@tanstack/ai-perplexity 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/search/client.d.ts +55 -0
- package/dist/esm/search/client.js +110 -0
- package/dist/esm/search/client.js.map +1 -0
- package/dist/esm/search/index.d.ts +2 -0
- package/dist/esm/search/index.js +3 -0
- package/dist/esm/search/tool.d.ts +72 -0
- package/dist/esm/search/tool.js +76 -0
- package/dist/esm/search/tool.js.map +1 -0
- package/dist/esm/utils/api-key.d.ts +7 -0
- package/dist/esm/utils/api-key.js +24 -0
- package/dist/esm/utils/api-key.js.map +1 -0
- package/dist/esm/utils/attribution.d.ts +11 -0
- package/dist/esm/utils/attribution.js +18 -0
- package/dist/esm/utils/attribution.js.map +1 -0
- package/package.json +64 -0
- package/src/index.ts +15 -0
- package/src/search/client.ts +231 -0
- package/src/search/index.ts +8 -0
- package/src/search/tool.ts +129 -0
- package/src/utils/api-key.ts +38 -0
- package/src/utils/attribution.ts +18 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Tanner Linsley
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# @tanstack/ai-perplexity
|
|
2
|
+
|
|
3
|
+
[Perplexity](https://www.perplexity.ai) Search API for [TanStack AI](https://tanstack.com/ai).
|
|
4
|
+
|
|
5
|
+
Wraps `POST https://api.perplexity.ai/search` as a tool (and a low-level HTTP client) so an agent can fetch ranked web results (`title`, `url`, `snippet`, `date?`, `last_updated?`) for grounding.
|
|
6
|
+
|
|
7
|
+
This package does **not** ship a TanStack text adapter. Pair the search tool with a function-calling adapter such as `openaiText`. For Sonar `chat()`, use [`openaiCompatible`](https://tanstack.com/ai/latest/docs/adapters/openai-compatible) from `@tanstack/ai-openai/compatible` — Sonar already searches the web and does not accept custom tools.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add @tanstack/ai @tanstack/ai-openai @tanstack/ai-perplexity
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Set your API key (get one at <https://console.perplexity.ai/group/keys>):
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
export PERPLEXITY_API_KEY=...
|
|
19
|
+
# PPLX_API_KEY is also accepted
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Search tool
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { chat } from '@tanstack/ai'
|
|
26
|
+
import { openaiText } from '@tanstack/ai-openai'
|
|
27
|
+
import { perplexitySearchTool } from '@tanstack/ai-perplexity'
|
|
28
|
+
|
|
29
|
+
const search = perplexitySearchTool({
|
|
30
|
+
defaultMaxResults: 5,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const stream = chat({
|
|
34
|
+
adapter: openaiText('gpt-5.2'),
|
|
35
|
+
tools: [search],
|
|
36
|
+
messages: [
|
|
37
|
+
{ role: 'user', content: 'What were the top AI papers this week?' },
|
|
38
|
+
],
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The tool input schema accepts:
|
|
43
|
+
|
|
44
|
+
| field | type | notes |
|
|
45
|
+
| --------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------- |
|
|
46
|
+
| `query` | `string` (required) | The search query. |
|
|
47
|
+
| `max_results` | `integer` (1–20) | Defaults to `defaultMaxResults` when set, otherwise the API default (10). |
|
|
48
|
+
| `search_domain_filter` | `string[]` | Max 20. Allowlist (`"nytimes.com"`) **or** denylist (`"-pinterest.com"`) — never both. |
|
|
49
|
+
| `search_recency_filter` | `"hour" \| "day" \| "week" \| "month" \| "year"` | Recency window. |
|
|
50
|
+
| `search_after_date_filter` | `string` | `m/d/yyyy` — only results on/after this date. |
|
|
51
|
+
| `search_before_date_filter` | `string` | `m/d/yyyy` — only results on/before this date. |
|
|
52
|
+
|
|
53
|
+
Output: `{ results: Array<{ title, url, snippet, date?, last_updated? }> }`.
|
|
54
|
+
|
|
55
|
+
### Direct client usage
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { PerplexitySearchClient } from '@tanstack/ai-perplexity'
|
|
59
|
+
|
|
60
|
+
const client = new PerplexitySearchClient()
|
|
61
|
+
const { results } = await client.search({
|
|
62
|
+
query: 'mars sample return mission',
|
|
63
|
+
max_results: 5,
|
|
64
|
+
search_recency_filter: 'month',
|
|
65
|
+
})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Chat (Sonar)
|
|
69
|
+
|
|
70
|
+
Use `openaiCompatible` from `@tanstack/ai-openai/compatible`. Do not pass `perplexitySearchTool` here. Pass `getPerplexityIntegrationHeaders()` if you want the same `X-Pplx-Integration` attribution header the Search client sends automatically.
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { chat } from '@tanstack/ai'
|
|
74
|
+
import { openaiCompatible } from '@tanstack/ai-openai/compatible'
|
|
75
|
+
import { getPerplexityIntegrationHeaders } from '@tanstack/ai-perplexity'
|
|
76
|
+
|
|
77
|
+
const perplexity = openaiCompatible({
|
|
78
|
+
name: 'perplexity',
|
|
79
|
+
baseURL: 'https://api.perplexity.ai',
|
|
80
|
+
apiKey: process.env.PERPLEXITY_API_KEY!,
|
|
81
|
+
models: ['sonar', 'sonar-pro'],
|
|
82
|
+
defaultHeaders: getPerplexityIntegrationHeaders(),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const stream = chat({
|
|
86
|
+
adapter: perplexity('sonar'),
|
|
87
|
+
messages: [
|
|
88
|
+
{ role: 'user', content: 'What is the latest on the Mars rover?' },
|
|
89
|
+
],
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Docs
|
|
94
|
+
|
|
95
|
+
- Search quickstart: <https://docs.perplexity.ai/docs/search/quickstart>
|
|
96
|
+
- Search API reference: <https://docs.perplexity.ai/api-reference/search-post>
|
|
97
|
+
- Domain filters: <https://docs.perplexity.ai/docs/search/filters/domain-filter>
|
|
98
|
+
- Date / recency filters: <https://docs.perplexity.ai/docs/search/filters/date-time-filters>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { PerplexitySearchClient, perplexitySearchTool, type PerplexitySearchClientConfig, type PerplexitySearchRequest, type PerplexitySearchResponse, type PerplexitySearchResult, } from './search/index.js';
|
|
2
|
+
export { getPerplexityApiKeyFromEnv } from './utils/api-key.js';
|
|
3
|
+
export { getPerplexityIntegrationHeaders, PERPLEXITY_INTEGRATION_HEADER, PERPLEXITY_INTEGRATION_HEADER_VALUE, } from './utils/attribution.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { getPerplexityApiKeyFromEnv } from "./utils/api-key.js";
|
|
2
|
+
import { PERPLEXITY_INTEGRATION_HEADER, PERPLEXITY_INTEGRATION_HEADER_VALUE, getPerplexityIntegrationHeaders } from "./utils/attribution.js";
|
|
3
|
+
import { PerplexitySearchClient } from "./search/client.js";
|
|
4
|
+
import { perplexitySearchTool } from "./search/tool.js";
|
|
5
|
+
import "./search/index.js";
|
|
6
|
+
export { PERPLEXITY_INTEGRATION_HEADER, PERPLEXITY_INTEGRATION_HEADER_VALUE, PerplexitySearchClient, getPerplexityApiKeyFromEnv, getPerplexityIntegrationHeaders, perplexitySearchTool };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface PerplexitySearchClientConfig {
|
|
2
|
+
/** Perplexity API key. Falls back to `PERPLEXITY_API_KEY` / `PPLX_API_KEY` env vars. */
|
|
3
|
+
apiKey?: string;
|
|
4
|
+
/** Override the API base URL (defaults to https://api.perplexity.ai). */
|
|
5
|
+
baseURL?: string;
|
|
6
|
+
/** Optional `fetch` implementation; defaults to globalThis.fetch. */
|
|
7
|
+
fetch?: typeof fetch;
|
|
8
|
+
}
|
|
9
|
+
export interface PerplexitySearchRequest {
|
|
10
|
+
/** The search query, or up to 5 queries. */
|
|
11
|
+
query: string | ReadonlyArray<string>;
|
|
12
|
+
/** Maximum number of results to return (1–20). Defaults to the API default (10). */
|
|
13
|
+
max_results?: number;
|
|
14
|
+
/** Maximum tokens of content to return per page. */
|
|
15
|
+
max_tokens_per_page?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Restrict (or exclude) results by domain (max 20 entries).
|
|
18
|
+
*
|
|
19
|
+
* Hostnames, optional paths, or TLDs. Use bare entries to allowlist
|
|
20
|
+
* (`["nytimes.com"]`) or `-` prefixed entries to denylist
|
|
21
|
+
* (`["-pinterest.com"]`). Allow and deny entries must NOT be mixed.
|
|
22
|
+
*/
|
|
23
|
+
search_domain_filter?: Array<string>;
|
|
24
|
+
/** Restrict results by recency: `hour | day | week | month | year`. */
|
|
25
|
+
search_recency_filter?: 'hour' | 'day' | 'week' | 'month' | 'year';
|
|
26
|
+
/** Only include results published on or after this date (m/d/yyyy). */
|
|
27
|
+
search_after_date_filter?: string;
|
|
28
|
+
/** Only include results published on or before this date (m/d/yyyy). */
|
|
29
|
+
search_before_date_filter?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface PerplexitySearchResult {
|
|
32
|
+
title: string;
|
|
33
|
+
url: string;
|
|
34
|
+
snippet: string;
|
|
35
|
+
date?: string;
|
|
36
|
+
last_updated?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface PerplexitySearchResponse {
|
|
39
|
+
id?: string;
|
|
40
|
+
results: Array<PerplexitySearchResult>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Low-level HTTP client for the Perplexity Search API.
|
|
44
|
+
*
|
|
45
|
+
* Calls `POST {baseURL}/search` with bearer auth.
|
|
46
|
+
*/
|
|
47
|
+
export declare class PerplexitySearchClient {
|
|
48
|
+
private readonly apiKey;
|
|
49
|
+
private readonly baseURL;
|
|
50
|
+
private readonly fetchImpl;
|
|
51
|
+
constructor(config?: PerplexitySearchClientConfig);
|
|
52
|
+
search(request: PerplexitySearchRequest, init?: {
|
|
53
|
+
signal?: AbortSignal;
|
|
54
|
+
}): Promise<PerplexitySearchResponse>;
|
|
55
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { getPerplexityApiKeyFromEnv } from "../utils/api-key.js";
|
|
2
|
+
import { getPerplexityIntegrationHeaders } from "../utils/attribution.js";
|
|
3
|
+
//#region src/search/client.ts
|
|
4
|
+
var DEFAULT_BASE_URL = "https://api.perplexity.ai";
|
|
5
|
+
var MAX_QUERY_BATCH = 5;
|
|
6
|
+
var MAX_DOMAIN_FILTER = 20;
|
|
7
|
+
/**
|
|
8
|
+
* Low-level HTTP client for the Perplexity Search API.
|
|
9
|
+
*
|
|
10
|
+
* Calls `POST {baseURL}/search` with bearer auth.
|
|
11
|
+
*/
|
|
12
|
+
var PerplexitySearchClient = class {
|
|
13
|
+
apiKey;
|
|
14
|
+
baseURL;
|
|
15
|
+
fetchImpl;
|
|
16
|
+
constructor(config = {}) {
|
|
17
|
+
const { apiKey } = config;
|
|
18
|
+
const resolvedApiKey = typeof apiKey === "string" && apiKey.trim().length > 0 ? apiKey : getPerplexityApiKeyFromEnv();
|
|
19
|
+
this.apiKey = resolvedApiKey;
|
|
20
|
+
this.baseURL = (config.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
21
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch;
|
|
22
|
+
}
|
|
23
|
+
async search(request, init = {}) {
|
|
24
|
+
const query = normalizeQuery(request.query);
|
|
25
|
+
validateDomainFilter(request.search_domain_filter);
|
|
26
|
+
const body = { query };
|
|
27
|
+
if (request.max_results !== void 0) body.max_results = requireMaxResults(request.max_results);
|
|
28
|
+
if (request.max_tokens_per_page !== void 0) body.max_tokens_per_page = request.max_tokens_per_page;
|
|
29
|
+
if (request.search_domain_filter) body.search_domain_filter = request.search_domain_filter;
|
|
30
|
+
if (request.search_recency_filter) body.search_recency_filter = request.search_recency_filter;
|
|
31
|
+
if (request.search_after_date_filter) body.search_after_date_filter = request.search_after_date_filter;
|
|
32
|
+
if (request.search_before_date_filter) body.search_before_date_filter = request.search_before_date_filter;
|
|
33
|
+
const response = await this.fetchImpl(`${this.baseURL}/search`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: {
|
|
36
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
Accept: "application/json",
|
|
39
|
+
...getPerplexityIntegrationHeaders()
|
|
40
|
+
},
|
|
41
|
+
body: JSON.stringify(body),
|
|
42
|
+
signal: init.signal
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
const text = await safeReadText(response);
|
|
46
|
+
throw new Error(`Perplexity Search API request failed: ${response.status} ${response.statusText}${text ? ` — ${text}` : ""}`);
|
|
47
|
+
}
|
|
48
|
+
return parseSearchResponse(await response.json());
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
function normalizeQuery(query) {
|
|
52
|
+
if (typeof query === "string") {
|
|
53
|
+
const trimmed = query.trim();
|
|
54
|
+
if (trimmed.length === 0) throw new Error("PerplexitySearchClient.search requires a non-empty `query`.");
|
|
55
|
+
return trimmed;
|
|
56
|
+
}
|
|
57
|
+
if (query.length === 0) throw new Error("PerplexitySearchClient.search requires a non-empty `query`.");
|
|
58
|
+
if (query.length > MAX_QUERY_BATCH) throw new Error(`query array must contain at most ${MAX_QUERY_BATCH} entries.`);
|
|
59
|
+
return query.map((entry) => {
|
|
60
|
+
if (typeof entry !== "string" || entry.trim().length === 0) throw new Error("PerplexitySearchClient.search requires a non-empty `query`.");
|
|
61
|
+
return entry.trim();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function requireMaxResults(maxResults) {
|
|
65
|
+
if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 20) throw new Error("max_results must be an integer between 1 and 20.");
|
|
66
|
+
return maxResults;
|
|
67
|
+
}
|
|
68
|
+
function validateDomainFilter(filter) {
|
|
69
|
+
if (!filter || filter.length === 0) return;
|
|
70
|
+
if (filter.length > MAX_DOMAIN_FILTER) throw new Error(`search_domain_filter must contain at most ${MAX_DOMAIN_FILTER} entries.`);
|
|
71
|
+
let hasAllow = false;
|
|
72
|
+
let hasDeny = false;
|
|
73
|
+
for (const entry of filter) {
|
|
74
|
+
if (typeof entry !== "string" || entry.length === 0) continue;
|
|
75
|
+
if (entry.startsWith("-")) hasDeny = true;
|
|
76
|
+
else hasAllow = true;
|
|
77
|
+
}
|
|
78
|
+
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.");
|
|
79
|
+
}
|
|
80
|
+
function isSearchResult(value) {
|
|
81
|
+
if (typeof value !== "object" || value === null) return false;
|
|
82
|
+
const result = value;
|
|
83
|
+
return typeof result.title === "string" && typeof result.url === "string" && typeof result.snippet === "string" && (result.date === void 0 || result.date === null || typeof result.date === "string") && (result.last_updated === void 0 || result.last_updated === null || typeof result.last_updated === "string");
|
|
84
|
+
}
|
|
85
|
+
function parseSearchResponse(value) {
|
|
86
|
+
if (typeof value !== "object" || value === null) throw new Error("Perplexity Search API returned an invalid response.");
|
|
87
|
+
const data = value;
|
|
88
|
+
if (!Array.isArray(data.results) || !data.results.every(isSearchResult)) throw new Error("Perplexity Search API returned an invalid response.");
|
|
89
|
+
return {
|
|
90
|
+
...typeof data.id === "string" ? { id: data.id } : {},
|
|
91
|
+
results: data.results.map((result) => ({
|
|
92
|
+
title: result.title,
|
|
93
|
+
url: result.url,
|
|
94
|
+
snippet: result.snippet,
|
|
95
|
+
...result.date ? { date: result.date } : {},
|
|
96
|
+
...result.last_updated ? { last_updated: result.last_updated } : {}
|
|
97
|
+
}))
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
async function safeReadText(response) {
|
|
101
|
+
try {
|
|
102
|
+
return await response.text();
|
|
103
|
+
} catch {
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
export { PerplexitySearchClient };
|
|
109
|
+
|
|
110
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","names":[],"sources":["../../../src/search/client.ts"],"sourcesContent":["import { getPerplexityApiKeyFromEnv } from '../utils/api-key'\nimport { getPerplexityIntegrationHeaders } from '../utils/attribution'\n\nexport interface PerplexitySearchClientConfig {\n /** Perplexity API key. Falls back to `PERPLEXITY_API_KEY` / `PPLX_API_KEY` env vars. */\n apiKey?: string\n /** Override the API base URL (defaults to https://api.perplexity.ai). */\n baseURL?: string\n /** Optional `fetch` implementation; defaults to globalThis.fetch. */\n fetch?: typeof fetch\n}\n\nexport interface PerplexitySearchRequest {\n /** The search query, or up to 5 queries. */\n query: string | ReadonlyArray<string>\n /** Maximum number of results to return (1–20). Defaults to the API default (10). */\n max_results?: number\n /** Maximum tokens of content to return per page. */\n max_tokens_per_page?: number\n /**\n * Restrict (or exclude) results by domain (max 20 entries).\n *\n * Hostnames, optional paths, or TLDs. Use bare entries to allowlist\n * (`[\"nytimes.com\"]`) or `-` prefixed entries to denylist\n * (`[\"-pinterest.com\"]`). Allow and deny entries must NOT be mixed.\n */\n search_domain_filter?: Array<string>\n /** Restrict results by recency: `hour | day | week | month | year`. */\n search_recency_filter?: 'hour' | 'day' | 'week' | 'month' | 'year'\n /** Only include results published on or after this date (m/d/yyyy). */\n search_after_date_filter?: string\n /** Only include results published on or before this date (m/d/yyyy). */\n search_before_date_filter?: string\n}\n\nexport interface PerplexitySearchResult {\n title: string\n url: string\n snippet: string\n date?: string\n last_updated?: string\n}\n\nexport interface PerplexitySearchResponse {\n id?: string\n results: Array<PerplexitySearchResult>\n}\n\nconst DEFAULT_BASE_URL = 'https://api.perplexity.ai'\nconst MAX_QUERY_BATCH = 5\nconst MAX_DOMAIN_FILTER = 20\n\n/**\n * Low-level HTTP client for the Perplexity Search API.\n *\n * Calls `POST {baseURL}/search` with bearer auth.\n */\nexport class PerplexitySearchClient {\n private readonly apiKey: string\n private readonly baseURL: string\n private readonly fetchImpl: typeof fetch\n\n constructor(config: PerplexitySearchClientConfig = {}) {\n const { apiKey } = config\n const resolvedApiKey =\n typeof apiKey === 'string' && apiKey.trim().length > 0\n ? apiKey\n : getPerplexityApiKeyFromEnv()\n\n this.apiKey = resolvedApiKey\n this.baseURL = (config.baseURL ?? DEFAULT_BASE_URL).replace(/\\/$/, '')\n this.fetchImpl = config.fetch ?? globalThis.fetch\n }\n\n async search(\n request: PerplexitySearchRequest,\n init: { signal?: AbortSignal } = {},\n ): Promise<PerplexitySearchResponse> {\n const query = normalizeQuery(request.query)\n validateDomainFilter(request.search_domain_filter)\n\n const body: Record<string, unknown> = { query }\n if (request.max_results !== undefined)\n body.max_results = requireMaxResults(request.max_results)\n if (request.max_tokens_per_page !== undefined)\n body.max_tokens_per_page = request.max_tokens_per_page\n if (request.search_domain_filter)\n body.search_domain_filter = request.search_domain_filter\n if (request.search_recency_filter)\n body.search_recency_filter = request.search_recency_filter\n if (request.search_after_date_filter)\n body.search_after_date_filter = request.search_after_date_filter\n if (request.search_before_date_filter)\n body.search_before_date_filter = request.search_before_date_filter\n\n const response = await this.fetchImpl(`${this.baseURL}/search`, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${this.apiKey}`,\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n ...getPerplexityIntegrationHeaders(),\n },\n body: JSON.stringify(body),\n signal: init.signal,\n })\n\n if (!response.ok) {\n const text = await safeReadText(response)\n throw new Error(\n `Perplexity Search API request failed: ${response.status} ${response.statusText}${\n text ? ` — ${text}` : ''\n }`,\n )\n }\n\n return parseSearchResponse(await response.json())\n }\n}\n\nfunction normalizeQuery(\n query: string | ReadonlyArray<string>,\n): string | Array<string> {\n if (typeof query === 'string') {\n const trimmed = query.trim()\n if (trimmed.length === 0) {\n throw new Error(\n 'PerplexitySearchClient.search requires a non-empty `query`.',\n )\n }\n return trimmed\n }\n\n if (query.length === 0) {\n throw new Error(\n 'PerplexitySearchClient.search requires a non-empty `query`.',\n )\n }\n if (query.length > MAX_QUERY_BATCH) {\n throw new Error(\n `query array must contain at most ${MAX_QUERY_BATCH} entries.`,\n )\n }\n\n return query.map((entry) => {\n if (typeof entry !== 'string' || entry.trim().length === 0) {\n throw new Error(\n 'PerplexitySearchClient.search requires a non-empty `query`.',\n )\n }\n return entry.trim()\n })\n}\n\nfunction requireMaxResults(maxResults: number): number {\n if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 20) {\n throw new Error('max_results must be an integer between 1 and 20.')\n }\n return maxResults\n}\n\nfunction validateDomainFilter(filter: Array<string> | undefined): void {\n if (!filter || filter.length === 0) return\n if (filter.length > MAX_DOMAIN_FILTER) {\n throw new Error(\n `search_domain_filter must contain at most ${MAX_DOMAIN_FILTER} entries.`,\n )\n }\n let hasAllow = false\n let hasDeny = false\n for (const entry of filter) {\n if (typeof entry !== 'string' || entry.length === 0) continue\n if (entry.startsWith('-')) hasDeny = true\n else hasAllow = true\n }\n if (hasAllow && hasDeny) {\n throw new Error(\n 'search_domain_filter cannot mix allowlist and denylist entries. Use only `-domain.com` for negation, or only bare domains for allowlist.',\n )\n }\n}\n\nfunction isSearchResult(value: unknown): value is PerplexitySearchResult {\n if (typeof value !== 'object' || value === null) return false\n const result = value as {\n title?: unknown\n url?: unknown\n snippet?: unknown\n date?: unknown\n last_updated?: unknown\n }\n return (\n typeof result.title === 'string' &&\n typeof result.url === 'string' &&\n typeof result.snippet === 'string' &&\n (result.date === undefined ||\n result.date === null ||\n typeof result.date === 'string') &&\n (result.last_updated === undefined ||\n result.last_updated === null ||\n typeof result.last_updated === 'string')\n )\n}\n\nfunction parseSearchResponse(value: unknown): PerplexitySearchResponse {\n if (typeof value !== 'object' || value === null) {\n throw new Error('Perplexity Search API returned an invalid response.')\n }\n const data = value as { id?: unknown; results?: unknown }\n if (!Array.isArray(data.results) || !data.results.every(isSearchResult)) {\n throw new Error('Perplexity Search API returned an invalid response.')\n }\n return {\n ...(typeof data.id === 'string' ? { id: data.id } : {}),\n results: data.results.map((result) => ({\n title: result.title,\n url: result.url,\n snippet: result.snippet,\n ...(result.date ? { date: result.date } : {}),\n ...(result.last_updated ? { last_updated: result.last_updated } : {}),\n })),\n }\n}\n\nasync function safeReadText(response: Response): Promise<string> {\n try {\n return await response.text()\n } catch {\n return ''\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { PerplexitySearchClientConfig } from './client.js';
|
|
3
|
+
/**
|
|
4
|
+
* Build a TanStack AI tool that performs real-time web search via Perplexity.
|
|
5
|
+
*
|
|
6
|
+
* Returns `{ results: Array<{ title, url, snippet, date?, last_updated? }> }`
|
|
7
|
+
* for citation/grounding in an LLM agent loop.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { chat } from '@tanstack/ai'
|
|
12
|
+
* import { openaiText } from '@tanstack/ai-openai'
|
|
13
|
+
* import { perplexitySearchTool } from '@tanstack/ai-perplexity'
|
|
14
|
+
*
|
|
15
|
+
* const search = perplexitySearchTool({ defaultMaxResults: 5 })
|
|
16
|
+
* chat({ adapter: openaiText('gpt-5.2'), tools: [search], messages })
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export declare function perplexitySearchTool(config?: PerplexitySearchClientConfig & {
|
|
20
|
+
/** Override the tool name (defaults to `perplexity_search`). */
|
|
21
|
+
name?: string;
|
|
22
|
+
/** Override the tool description shown to the model. */
|
|
23
|
+
description?: string;
|
|
24
|
+
/** Default max_results applied when the model does not provide one. */
|
|
25
|
+
defaultMaxResults?: number;
|
|
26
|
+
}): import('@tanstack/ai').ServerTool<z.ZodObject<{
|
|
27
|
+
query: z.ZodString;
|
|
28
|
+
max_results: z.ZodOptional<z.ZodNumber>;
|
|
29
|
+
search_domain_filter: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
30
|
+
search_recency_filter: z.ZodOptional<z.ZodEnum<{
|
|
31
|
+
hour: "hour";
|
|
32
|
+
day: "day";
|
|
33
|
+
week: "week";
|
|
34
|
+
month: "month";
|
|
35
|
+
year: "year";
|
|
36
|
+
}>>;
|
|
37
|
+
search_after_date_filter: z.ZodOptional<z.ZodString>;
|
|
38
|
+
search_before_date_filter: z.ZodOptional<z.ZodString>;
|
|
39
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
40
|
+
results: z.ZodArray<z.ZodObject<{
|
|
41
|
+
title: z.ZodString;
|
|
42
|
+
url: z.ZodString;
|
|
43
|
+
snippet: z.ZodString;
|
|
44
|
+
date: z.ZodOptional<z.ZodString>;
|
|
45
|
+
last_updated: z.ZodOptional<z.ZodString>;
|
|
46
|
+
}, z.core.$strip>>;
|
|
47
|
+
}, z.core.$strip>, string, unknown, false, undefined> & {
|
|
48
|
+
inputSchema: z.ZodObject<{
|
|
49
|
+
query: z.ZodString;
|
|
50
|
+
max_results: z.ZodOptional<z.ZodNumber>;
|
|
51
|
+
search_domain_filter: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
52
|
+
search_recency_filter: z.ZodOptional<z.ZodEnum<{
|
|
53
|
+
hour: "hour";
|
|
54
|
+
day: "day";
|
|
55
|
+
week: "week";
|
|
56
|
+
month: "month";
|
|
57
|
+
year: "year";
|
|
58
|
+
}>>;
|
|
59
|
+
search_after_date_filter: z.ZodOptional<z.ZodString>;
|
|
60
|
+
search_before_date_filter: z.ZodOptional<z.ZodString>;
|
|
61
|
+
}, z.core.$strip>;
|
|
62
|
+
outputSchema: z.ZodObject<{
|
|
63
|
+
results: z.ZodArray<z.ZodObject<{
|
|
64
|
+
title: z.ZodString;
|
|
65
|
+
url: z.ZodString;
|
|
66
|
+
snippet: z.ZodString;
|
|
67
|
+
date: z.ZodOptional<z.ZodString>;
|
|
68
|
+
last_updated: z.ZodOptional<z.ZodString>;
|
|
69
|
+
}, z.core.$strip>>;
|
|
70
|
+
}, z.core.$strip>;
|
|
71
|
+
approvalSchema: undefined;
|
|
72
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { PerplexitySearchClient } from "./client.js";
|
|
2
|
+
import { toolDefinition } from "@tanstack/ai";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region src/search/tool.ts
|
|
5
|
+
var searchRecency = z.enum([
|
|
6
|
+
"hour",
|
|
7
|
+
"day",
|
|
8
|
+
"week",
|
|
9
|
+
"month",
|
|
10
|
+
"year"
|
|
11
|
+
]);
|
|
12
|
+
var inputSchema = z.object({
|
|
13
|
+
query: z.string().min(1).describe("The search query string."),
|
|
14
|
+
max_results: z.number().int().min(1).max(20).optional().describe("Maximum number of results to return. Defaults to defaultMaxResults when configured, otherwise the API default (10)."),
|
|
15
|
+
search_domain_filter: z.array(z.string()).max(20).optional().describe("Restrict results by domain (max 20). Use bare hostnames to allowlist (e.g. [\"nytimes.com\"]) or \"-domain.com\" to denylist. Allow and deny entries must NOT be mixed."),
|
|
16
|
+
search_recency_filter: searchRecency.optional().describe("Only include results from the given recency window."),
|
|
17
|
+
search_after_date_filter: z.string().optional().describe("Only include results published on or after this date (m/d/yyyy)."),
|
|
18
|
+
search_before_date_filter: z.string().optional().describe("Only include results published on or before this date (m/d/yyyy).")
|
|
19
|
+
});
|
|
20
|
+
var outputSchema = z.object({ results: z.array(z.object({
|
|
21
|
+
title: z.string(),
|
|
22
|
+
url: z.string(),
|
|
23
|
+
snippet: z.string(),
|
|
24
|
+
date: z.string().optional(),
|
|
25
|
+
last_updated: z.string().optional()
|
|
26
|
+
})) });
|
|
27
|
+
/**
|
|
28
|
+
* Build a TanStack AI tool that performs real-time web search via Perplexity.
|
|
29
|
+
*
|
|
30
|
+
* Returns `{ results: Array<{ title, url, snippet, date?, last_updated? }> }`
|
|
31
|
+
* for citation/grounding in an LLM agent loop.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* import { chat } from '@tanstack/ai'
|
|
36
|
+
* import { openaiText } from '@tanstack/ai-openai'
|
|
37
|
+
* import { perplexitySearchTool } from '@tanstack/ai-perplexity'
|
|
38
|
+
*
|
|
39
|
+
* const search = perplexitySearchTool({ defaultMaxResults: 5 })
|
|
40
|
+
* chat({ adapter: openaiText('gpt-5.2'), tools: [search], messages })
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
function perplexitySearchTool(config = {}) {
|
|
44
|
+
const { name, description, defaultMaxResults, ...clientConfig } = config;
|
|
45
|
+
if (defaultMaxResults !== void 0 && (!Number.isInteger(defaultMaxResults) || defaultMaxResults < 1 || defaultMaxResults > 20)) throw new Error("defaultMaxResults must be an integer between 1 and 20.");
|
|
46
|
+
let client = null;
|
|
47
|
+
const getClient = () => {
|
|
48
|
+
if (!client) client = new PerplexitySearchClient(clientConfig);
|
|
49
|
+
return client;
|
|
50
|
+
};
|
|
51
|
+
return toolDefinition({
|
|
52
|
+
name: name ?? "perplexity_search",
|
|
53
|
+
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 optional publication dates.",
|
|
54
|
+
inputSchema,
|
|
55
|
+
outputSchema
|
|
56
|
+
}).server(async (args, ctx) => {
|
|
57
|
+
return { results: (await getClient().search({
|
|
58
|
+
query: args.query,
|
|
59
|
+
max_results: args.max_results ?? defaultMaxResults,
|
|
60
|
+
search_domain_filter: args.search_domain_filter,
|
|
61
|
+
search_recency_filter: args.search_recency_filter,
|
|
62
|
+
search_after_date_filter: args.search_after_date_filter,
|
|
63
|
+
search_before_date_filter: args.search_before_date_filter
|
|
64
|
+
}, { signal: ctx?.abortSignal })).results.map((result) => ({
|
|
65
|
+
title: result.title,
|
|
66
|
+
url: result.url,
|
|
67
|
+
snippet: result.snippet,
|
|
68
|
+
...result.date ? { date: result.date } : {},
|
|
69
|
+
...result.last_updated ? { last_updated: result.last_updated } : {}
|
|
70
|
+
})) };
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
export { perplexitySearchTool };
|
|
75
|
+
|
|
76
|
+
//# sourceMappingURL=tool.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool.js","names":[],"sources":["../../../src/search/tool.ts"],"sourcesContent":["import { toolDefinition } from '@tanstack/ai'\nimport { z } from 'zod'\nimport { PerplexitySearchClient } from './client'\nimport type { PerplexitySearchClientConfig } from './client'\n\nconst searchRecency = z.enum(['hour', 'day', 'week', 'month', 'year'])\n\nconst inputSchema = z.object({\n query: z.string().min(1).describe('The search query string.'),\n max_results: z\n .number()\n .int()\n .min(1)\n .max(20)\n .optional()\n .describe(\n 'Maximum number of results to return. Defaults to defaultMaxResults when configured, otherwise the API default (10).',\n ),\n search_domain_filter: z\n .array(z.string())\n .max(20)\n .optional()\n .describe(\n 'Restrict results by domain (max 20). Use bare hostnames to allowlist (e.g. [\"nytimes.com\"]) or \"-domain.com\" to denylist. Allow and deny entries must NOT be mixed.',\n ),\n search_recency_filter: searchRecency\n .optional()\n .describe('Only include results from the given recency window.'),\n search_after_date_filter: z\n .string()\n .optional()\n .describe(\n 'Only include results published on or after this date (m/d/yyyy).',\n ),\n search_before_date_filter: z\n .string()\n .optional()\n .describe(\n 'Only include results published on or before this date (m/d/yyyy).',\n ),\n})\n\nconst outputSchema = z.object({\n results: z.array(\n z.object({\n title: z.string(),\n url: z.string(),\n snippet: z.string(),\n date: z.string().optional(),\n last_updated: z.string().optional(),\n }),\n ),\n})\n\n/**\n * Build a TanStack AI tool that performs real-time web search via Perplexity.\n *\n * Returns `{ results: Array<{ title, url, snippet, date?, last_updated? }> }`\n * for citation/grounding in an LLM agent loop.\n *\n * @example\n * ```ts\n * import { chat } from '@tanstack/ai'\n * import { openaiText } from '@tanstack/ai-openai'\n * import { perplexitySearchTool } from '@tanstack/ai-perplexity'\n *\n * const search = perplexitySearchTool({ defaultMaxResults: 5 })\n * chat({ adapter: openaiText('gpt-5.2'), tools: [search], messages })\n * ```\n */\nexport function perplexitySearchTool(\n config: PerplexitySearchClientConfig & {\n /** Override the tool name (defaults to `perplexity_search`). */\n name?: string\n /** Override the tool description shown to the model. */\n description?: string\n /** Default max_results applied when the model does not provide one. */\n defaultMaxResults?: number\n } = {},\n) {\n const { name, description, defaultMaxResults, ...clientConfig } = config\n if (\n defaultMaxResults !== undefined &&\n (!Number.isInteger(defaultMaxResults) ||\n defaultMaxResults < 1 ||\n defaultMaxResults > 20)\n ) {\n throw new Error('defaultMaxResults must be an integer between 1 and 20.')\n }\n\n // Lazily construct the client so missing API keys don't blow up at import\n // time (e.g. on bundlers that statically evaluate module top-level).\n let client: PerplexitySearchClient | null = null\n const getClient = () => {\n if (!client) client = new PerplexitySearchClient(clientConfig)\n return client\n }\n\n return toolDefinition({\n name: name ?? 'perplexity_search',\n description:\n description ??\n '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 optional publication dates.',\n inputSchema,\n outputSchema,\n }).server(async (args, ctx) => {\n const response = await getClient().search(\n {\n query: args.query,\n max_results: args.max_results ?? defaultMaxResults,\n search_domain_filter: args.search_domain_filter,\n search_recency_filter: args.search_recency_filter,\n search_after_date_filter: args.search_after_date_filter,\n search_before_date_filter: args.search_before_date_filter,\n },\n { signal: ctx?.abortSignal },\n )\n\n return {\n results: response.results.map((result) => ({\n title: result.title,\n url: result.url,\n snippet: result.snippet,\n ...(result.date ? { date: result.date } : {}),\n ...(result.last_updated ? { last_updated: result.last_updated } : {}),\n })),\n }\n })\n}\n"],"mappings":";;;;AAKA,IAAM,gBAAgB,EAAE,KAAK;CAAC;CAAQ;CAAO;CAAQ;CAAS;AAAM,CAAC;AAErE,IAAM,cAAc,EAAE,OAAO;CAC3B,OAAO,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,0BAA0B;CAC5D,aAAa,EACV,OAAO,CAAC,CACR,IAAI,CAAC,CACL,IAAI,CAAC,CAAC,CACN,IAAI,EAAE,CAAC,CACP,SAAS,CAAC,CACV,SACC,qHACF;CACF,sBAAsB,EACnB,MAAM,EAAE,OAAO,CAAC,CAAC,CACjB,IAAI,EAAE,CAAC,CACP,SAAS,CAAC,CACV,SACC,yKACF;CACF,uBAAuB,cACpB,SAAS,CAAC,CACV,SAAS,qDAAqD;CACjE,0BAA0B,EACvB,OAAO,CAAC,CACR,SAAS,CAAC,CACV,SACC,kEACF;CACF,2BAA2B,EACxB,OAAO,CAAC,CACR,SAAS,CAAC,CACV,SACC,mEACF;AACJ,CAAC;AAED,IAAM,eAAe,EAAE,OAAO,EAC5B,SAAS,EAAE,MACT,EAAE,OAAO;CACP,OAAO,EAAE,OAAO;CAChB,KAAK,EAAE,OAAO;CACd,SAAS,EAAE,OAAO;CAClB,MAAM,EAAE,OAAO,CAAC,CAAC,SAAS;CAC1B,cAAc,EAAE,OAAO,CAAC,CAAC,SAAS;AACpC,CAAC,CACH,EACF,CAAC;;;;;;;;;;;;;;;;;AAkBD,SAAgB,qBACd,SAOI,CAAC,GACL;CACA,MAAM,EAAE,MAAM,aAAa,mBAAmB,GAAG,iBAAiB;CAClE,IACE,sBAAsB,KAAA,MACrB,CAAC,OAAO,UAAU,iBAAiB,KAClC,oBAAoB,KACpB,oBAAoB,KAEtB,MAAM,IAAI,MAAM,wDAAwD;CAK1E,IAAI,SAAwC;CAC5C,MAAM,kBAAkB;EACtB,IAAI,CAAC,QAAQ,SAAS,IAAI,uBAAuB,YAAY;EAC7D,OAAO;CACT;CAEA,OAAO,eAAe;EACpB,MAAM,QAAQ;EACd,aACE,eACA;EACF;EACA;CACF,CAAC,CAAC,CAAC,OAAO,OAAO,MAAM,QAAQ;EAa7B,OAAO,EACL,UAAS,MAbY,UAAU,CAAC,CAAC,OACjC;GACE,OAAO,KAAK;GACZ,aAAa,KAAK,eAAe;GACjC,sBAAsB,KAAK;GAC3B,uBAAuB,KAAK;GAC5B,0BAA0B,KAAK;GAC/B,2BAA2B,KAAK;EAClC,GACA,EAAE,QAAQ,KAAK,YAAY,CAC7B,EAAA,CAGoB,QAAQ,KAAK,YAAY;GACzC,OAAO,OAAO;GACd,KAAK,OAAO;GACZ,SAAS,OAAO;GAChB,GAAI,OAAO,OAAO,EAAE,MAAM,OAAO,KAAK,IAAI,CAAC;GAC3C,GAAI,OAAO,eAAe,EAAE,cAAc,OAAO,aAAa,IAAI,CAAC;EACrE,EAAE,EACJ;CACF,CAAC;AACH"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/utils/api-key.ts
|
|
2
|
+
/**
|
|
3
|
+
* Resolve a Perplexity API key from environment variables.
|
|
4
|
+
*
|
|
5
|
+
* Honors `PERPLEXITY_API_KEY` first, then falls back to `PPLX_API_KEY`.
|
|
6
|
+
* Throws if neither is set.
|
|
7
|
+
*/
|
|
8
|
+
function getPerplexityApiKeyFromEnv() {
|
|
9
|
+
const env = getEnvironment();
|
|
10
|
+
const key = [env?.PERPLEXITY_API_KEY, env?.PPLX_API_KEY].find((value) => typeof value === "string" && value.trim().length > 0)?.trim();
|
|
11
|
+
if (!key) throw new Error("PERPLEXITY_API_KEY (or PPLX_API_KEY) is required. Set it in your environment or pass an explicit apiKey.");
|
|
12
|
+
return key;
|
|
13
|
+
}
|
|
14
|
+
function getEnvironment() {
|
|
15
|
+
if (typeof globalThis !== "undefined") {
|
|
16
|
+
const win = globalThis.window;
|
|
17
|
+
if (win?.env) return win.env;
|
|
18
|
+
}
|
|
19
|
+
if (typeof process !== "undefined") return process.env;
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
export { getPerplexityApiKeyFromEnv };
|
|
23
|
+
|
|
24
|
+
//# sourceMappingURL=api-key.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-key.js","names":[],"sources":["../../../src/utils/api-key.ts"],"sourcesContent":["/**\n * Resolve a Perplexity API key from environment variables.\n *\n * Honors `PERPLEXITY_API_KEY` first, then falls back to `PPLX_API_KEY`.\n * Throws if neither is set.\n */\nexport function getPerplexityApiKeyFromEnv(): string {\n const env = getEnvironment()\n const key = [env?.PERPLEXITY_API_KEY, env?.PPLX_API_KEY]\n .find(\n (value): value is string =>\n typeof value === 'string' && value.trim().length > 0,\n )\n ?.trim()\n\n if (!key) {\n throw new Error(\n 'PERPLEXITY_API_KEY (or PPLX_API_KEY) is required. Set it in your environment or pass an explicit apiKey.',\n )\n }\n\n return key\n}\n\ninterface WindowWithEnv {\n env?: Record<string, string | undefined>\n}\n\nfunction getEnvironment(): Record<string, string | undefined> | undefined {\n if (typeof globalThis !== 'undefined') {\n const win = (globalThis as { window?: WindowWithEnv }).window\n if (win?.env) return win.env\n }\n if (typeof process !== 'undefined') {\n return process.env\n }\n return undefined\n}\n"],"mappings":";;;;;;;AAMA,SAAgB,6BAAqC;CACnD,MAAM,MAAM,eAAe;CAC3B,MAAM,MAAM,CAAC,KAAK,oBAAoB,KAAK,YAAY,CAAC,CACrD,MACE,UACC,OAAO,UAAU,YAAY,MAAM,KAAK,CAAC,CAAC,SAAS,CACvD,CAAC,EACC,KAAK;CAET,IAAI,CAAC,KACH,MAAM,IAAI,MACR,0GACF;CAGF,OAAO;AACT;AAMA,SAAS,iBAAiE;CACxE,IAAI,OAAO,eAAe,aAAa;EACrC,MAAM,MAAO,WAA0C;EACvD,IAAI,KAAK,KAAK,OAAO,IAAI;CAC3B;CACA,IAAI,OAAO,YAAY,aACrB,OAAO,QAAQ;AAGnB"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const PERPLEXITY_INTEGRATION_HEADER = "X-Pplx-Integration";
|
|
2
|
+
export declare const PERPLEXITY_INTEGRATION_HEADER_VALUE: string;
|
|
3
|
+
/**
|
|
4
|
+
* Attribution header Perplexity uses to identify TanStack AI traffic
|
|
5
|
+
* (`X-Pplx-Integration: tanstack/<package-version>`).
|
|
6
|
+
*
|
|
7
|
+
* The Search client sends this automatically. Pass it as
|
|
8
|
+
* `openaiCompatible({ defaultHeaders })` if you want the same header on
|
|
9
|
+
* Sonar chat requests.
|
|
10
|
+
*/
|
|
11
|
+
export declare function getPerplexityIntegrationHeaders(): Record<string, string>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/utils/attribution.ts
|
|
2
|
+
var PERPLEXITY_INTEGRATION_HEADER = "X-Pplx-Integration";
|
|
3
|
+
var PERPLEXITY_INTEGRATION_HEADER_VALUE = `tanstack/0.1.0`;
|
|
4
|
+
/**
|
|
5
|
+
* Attribution header Perplexity uses to identify TanStack AI traffic
|
|
6
|
+
* (`X-Pplx-Integration: tanstack/<package-version>`).
|
|
7
|
+
*
|
|
8
|
+
* The Search client sends this automatically. Pass it as
|
|
9
|
+
* `openaiCompatible({ defaultHeaders })` if you want the same header on
|
|
10
|
+
* Sonar chat requests.
|
|
11
|
+
*/
|
|
12
|
+
function getPerplexityIntegrationHeaders() {
|
|
13
|
+
return { [PERPLEXITY_INTEGRATION_HEADER]: PERPLEXITY_INTEGRATION_HEADER_VALUE };
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
export { PERPLEXITY_INTEGRATION_HEADER, PERPLEXITY_INTEGRATION_HEADER_VALUE, getPerplexityIntegrationHeaders };
|
|
17
|
+
|
|
18
|
+
//# sourceMappingURL=attribution.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attribution.js","names":[],"sources":["../../../src/utils/attribution.ts"],"sourcesContent":["export const PERPLEXITY_INTEGRATION_HEADER = 'X-Pplx-Integration'\nexport const PERPLEXITY_INTEGRATION_HEADER_VALUE = `tanstack/${__PACKAGE_VERSION__}`\n\n/**\n * Attribution header Perplexity uses to identify TanStack AI traffic\n * (`X-Pplx-Integration: tanstack/<package-version>`).\n *\n * The Search client sends this automatically. Pass it as\n * `openaiCompatible({ defaultHeaders })` if you want the same header on\n * Sonar chat requests.\n */\nexport function getPerplexityIntegrationHeaders(): Record<string, string> {\n return {\n [PERPLEXITY_INTEGRATION_HEADER]: PERPLEXITY_INTEGRATION_HEADER_VALUE,\n }\n}\n\ndeclare const __PACKAGE_VERSION__: string\n"],"mappings":";AAAA,IAAa,gCAAgC;AAC7C,IAAa,sCAAsC;;;;;;;;;AAUnD,SAAgB,kCAA0D;CACxE,OAAO,GACJ,gCAAgC,oCACnC;AACF"}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tanstack/ai-perplexity",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Perplexity Search API client and tool for TanStack AI",
|
|
5
|
+
"author": "Tanner Linsley",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://tanstack.com/ai",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/TanStack/ai.git",
|
|
11
|
+
"directory": "packages/ai-perplexity"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/TanStack/ai/issues"
|
|
15
|
+
},
|
|
16
|
+
"funding": {
|
|
17
|
+
"type": "github",
|
|
18
|
+
"url": "https://github.com/sponsors/tannerlinsley"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"module": "./dist/esm/index.js",
|
|
22
|
+
"types": "./dist/esm/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/esm/index.d.ts",
|
|
26
|
+
"import": "./dist/esm/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./search": {
|
|
29
|
+
"types": "./dist/esm/search/index.d.ts",
|
|
30
|
+
"import": "./dist/esm/search/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"src"
|
|
36
|
+
],
|
|
37
|
+
"keywords": [
|
|
38
|
+
"ai",
|
|
39
|
+
"ai-sdk",
|
|
40
|
+
"typescript",
|
|
41
|
+
"tanstack",
|
|
42
|
+
"perplexity",
|
|
43
|
+
"search"
|
|
44
|
+
],
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@vitest/coverage-v8": "4.0.14",
|
|
47
|
+
"vite": "^8.1.4",
|
|
48
|
+
"zod": "^4.2.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"zod": "^4.0.0",
|
|
52
|
+
"@tanstack/ai": "^0.44.0"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "vite build",
|
|
56
|
+
"clean": "premove ./build ./dist",
|
|
57
|
+
"lint:fix": "oxlint src --type-aware --fix",
|
|
58
|
+
"test:build": "publint --strict",
|
|
59
|
+
"test:oxlint": "oxlint src --type-aware",
|
|
60
|
+
"test:lib": "vitest run",
|
|
61
|
+
"test:lib:dev": "pnpm test:lib --watch",
|
|
62
|
+
"test:types": "tsc"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
PerplexitySearchClient,
|
|
3
|
+
perplexitySearchTool,
|
|
4
|
+
type PerplexitySearchClientConfig,
|
|
5
|
+
type PerplexitySearchRequest,
|
|
6
|
+
type PerplexitySearchResponse,
|
|
7
|
+
type PerplexitySearchResult,
|
|
8
|
+
} from './search/index'
|
|
9
|
+
|
|
10
|
+
export { getPerplexityApiKeyFromEnv } from './utils/api-key'
|
|
11
|
+
export {
|
|
12
|
+
getPerplexityIntegrationHeaders,
|
|
13
|
+
PERPLEXITY_INTEGRATION_HEADER,
|
|
14
|
+
PERPLEXITY_INTEGRATION_HEADER_VALUE,
|
|
15
|
+
} from './utils/attribution'
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { getPerplexityApiKeyFromEnv } from '../utils/api-key'
|
|
2
|
+
import { getPerplexityIntegrationHeaders } from '../utils/attribution'
|
|
3
|
+
|
|
4
|
+
export interface PerplexitySearchClientConfig {
|
|
5
|
+
/** Perplexity API key. Falls back to `PERPLEXITY_API_KEY` / `PPLX_API_KEY` env vars. */
|
|
6
|
+
apiKey?: string
|
|
7
|
+
/** Override the API base URL (defaults to https://api.perplexity.ai). */
|
|
8
|
+
baseURL?: string
|
|
9
|
+
/** Optional `fetch` implementation; defaults to globalThis.fetch. */
|
|
10
|
+
fetch?: typeof fetch
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PerplexitySearchRequest {
|
|
14
|
+
/** The search query, or up to 5 queries. */
|
|
15
|
+
query: string | ReadonlyArray<string>
|
|
16
|
+
/** Maximum number of results to return (1–20). Defaults to the API default (10). */
|
|
17
|
+
max_results?: number
|
|
18
|
+
/** Maximum tokens of content to return per page. */
|
|
19
|
+
max_tokens_per_page?: number
|
|
20
|
+
/**
|
|
21
|
+
* Restrict (or exclude) results by domain (max 20 entries).
|
|
22
|
+
*
|
|
23
|
+
* Hostnames, optional paths, or TLDs. Use bare entries to allowlist
|
|
24
|
+
* (`["nytimes.com"]`) or `-` prefixed entries to denylist
|
|
25
|
+
* (`["-pinterest.com"]`). Allow and deny entries must NOT be mixed.
|
|
26
|
+
*/
|
|
27
|
+
search_domain_filter?: Array<string>
|
|
28
|
+
/** Restrict results by recency: `hour | day | week | month | year`. */
|
|
29
|
+
search_recency_filter?: 'hour' | 'day' | 'week' | 'month' | 'year'
|
|
30
|
+
/** Only include results published on or after this date (m/d/yyyy). */
|
|
31
|
+
search_after_date_filter?: string
|
|
32
|
+
/** Only include results published on or before this date (m/d/yyyy). */
|
|
33
|
+
search_before_date_filter?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PerplexitySearchResult {
|
|
37
|
+
title: string
|
|
38
|
+
url: string
|
|
39
|
+
snippet: string
|
|
40
|
+
date?: string
|
|
41
|
+
last_updated?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PerplexitySearchResponse {
|
|
45
|
+
id?: string
|
|
46
|
+
results: Array<PerplexitySearchResult>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEFAULT_BASE_URL = 'https://api.perplexity.ai'
|
|
50
|
+
const MAX_QUERY_BATCH = 5
|
|
51
|
+
const MAX_DOMAIN_FILTER = 20
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Low-level HTTP client for the Perplexity Search API.
|
|
55
|
+
*
|
|
56
|
+
* Calls `POST {baseURL}/search` with bearer auth.
|
|
57
|
+
*/
|
|
58
|
+
export class PerplexitySearchClient {
|
|
59
|
+
private readonly apiKey: string
|
|
60
|
+
private readonly baseURL: string
|
|
61
|
+
private readonly fetchImpl: typeof fetch
|
|
62
|
+
|
|
63
|
+
constructor(config: PerplexitySearchClientConfig = {}) {
|
|
64
|
+
const { apiKey } = config
|
|
65
|
+
const resolvedApiKey =
|
|
66
|
+
typeof apiKey === 'string' && apiKey.trim().length > 0
|
|
67
|
+
? apiKey
|
|
68
|
+
: getPerplexityApiKeyFromEnv()
|
|
69
|
+
|
|
70
|
+
this.apiKey = resolvedApiKey
|
|
71
|
+
this.baseURL = (config.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, '')
|
|
72
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async search(
|
|
76
|
+
request: PerplexitySearchRequest,
|
|
77
|
+
init: { signal?: AbortSignal } = {},
|
|
78
|
+
): Promise<PerplexitySearchResponse> {
|
|
79
|
+
const query = normalizeQuery(request.query)
|
|
80
|
+
validateDomainFilter(request.search_domain_filter)
|
|
81
|
+
|
|
82
|
+
const body: Record<string, unknown> = { query }
|
|
83
|
+
if (request.max_results !== undefined)
|
|
84
|
+
body.max_results = requireMaxResults(request.max_results)
|
|
85
|
+
if (request.max_tokens_per_page !== undefined)
|
|
86
|
+
body.max_tokens_per_page = request.max_tokens_per_page
|
|
87
|
+
if (request.search_domain_filter)
|
|
88
|
+
body.search_domain_filter = request.search_domain_filter
|
|
89
|
+
if (request.search_recency_filter)
|
|
90
|
+
body.search_recency_filter = request.search_recency_filter
|
|
91
|
+
if (request.search_after_date_filter)
|
|
92
|
+
body.search_after_date_filter = request.search_after_date_filter
|
|
93
|
+
if (request.search_before_date_filter)
|
|
94
|
+
body.search_before_date_filter = request.search_before_date_filter
|
|
95
|
+
|
|
96
|
+
const response = await this.fetchImpl(`${this.baseURL}/search`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: {
|
|
99
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
100
|
+
'Content-Type': 'application/json',
|
|
101
|
+
Accept: 'application/json',
|
|
102
|
+
...getPerplexityIntegrationHeaders(),
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify(body),
|
|
105
|
+
signal: init.signal,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
const text = await safeReadText(response)
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Perplexity Search API request failed: ${response.status} ${response.statusText}${
|
|
112
|
+
text ? ` — ${text}` : ''
|
|
113
|
+
}`,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return parseSearchResponse(await response.json())
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeQuery(
|
|
122
|
+
query: string | ReadonlyArray<string>,
|
|
123
|
+
): string | Array<string> {
|
|
124
|
+
if (typeof query === 'string') {
|
|
125
|
+
const trimmed = query.trim()
|
|
126
|
+
if (trimmed.length === 0) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
'PerplexitySearchClient.search requires a non-empty `query`.',
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
return trimmed
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (query.length === 0) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
'PerplexitySearchClient.search requires a non-empty `query`.',
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
if (query.length > MAX_QUERY_BATCH) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`query array must contain at most ${MAX_QUERY_BATCH} entries.`,
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return query.map((entry) => {
|
|
146
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
'PerplexitySearchClient.search requires a non-empty `query`.',
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
return entry.trim()
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function requireMaxResults(maxResults: number): number {
|
|
156
|
+
if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 20) {
|
|
157
|
+
throw new Error('max_results must be an integer between 1 and 20.')
|
|
158
|
+
}
|
|
159
|
+
return maxResults
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validateDomainFilter(filter: Array<string> | undefined): void {
|
|
163
|
+
if (!filter || filter.length === 0) return
|
|
164
|
+
if (filter.length > MAX_DOMAIN_FILTER) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`search_domain_filter must contain at most ${MAX_DOMAIN_FILTER} entries.`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
let hasAllow = false
|
|
170
|
+
let hasDeny = false
|
|
171
|
+
for (const entry of filter) {
|
|
172
|
+
if (typeof entry !== 'string' || entry.length === 0) continue
|
|
173
|
+
if (entry.startsWith('-')) hasDeny = true
|
|
174
|
+
else hasAllow = true
|
|
175
|
+
}
|
|
176
|
+
if (hasAllow && hasDeny) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
'search_domain_filter cannot mix allowlist and denylist entries. Use only `-domain.com` for negation, or only bare domains for allowlist.',
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isSearchResult(value: unknown): value is PerplexitySearchResult {
|
|
184
|
+
if (typeof value !== 'object' || value === null) return false
|
|
185
|
+
const result = value as {
|
|
186
|
+
title?: unknown
|
|
187
|
+
url?: unknown
|
|
188
|
+
snippet?: unknown
|
|
189
|
+
date?: unknown
|
|
190
|
+
last_updated?: unknown
|
|
191
|
+
}
|
|
192
|
+
return (
|
|
193
|
+
typeof result.title === 'string' &&
|
|
194
|
+
typeof result.url === 'string' &&
|
|
195
|
+
typeof result.snippet === 'string' &&
|
|
196
|
+
(result.date === undefined ||
|
|
197
|
+
result.date === null ||
|
|
198
|
+
typeof result.date === 'string') &&
|
|
199
|
+
(result.last_updated === undefined ||
|
|
200
|
+
result.last_updated === null ||
|
|
201
|
+
typeof result.last_updated === 'string')
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function parseSearchResponse(value: unknown): PerplexitySearchResponse {
|
|
206
|
+
if (typeof value !== 'object' || value === null) {
|
|
207
|
+
throw new Error('Perplexity Search API returned an invalid response.')
|
|
208
|
+
}
|
|
209
|
+
const data = value as { id?: unknown; results?: unknown }
|
|
210
|
+
if (!Array.isArray(data.results) || !data.results.every(isSearchResult)) {
|
|
211
|
+
throw new Error('Perplexity Search API returned an invalid response.')
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
...(typeof data.id === 'string' ? { id: data.id } : {}),
|
|
215
|
+
results: data.results.map((result) => ({
|
|
216
|
+
title: result.title,
|
|
217
|
+
url: result.url,
|
|
218
|
+
snippet: result.snippet,
|
|
219
|
+
...(result.date ? { date: result.date } : {}),
|
|
220
|
+
...(result.last_updated ? { last_updated: result.last_updated } : {}),
|
|
221
|
+
})),
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function safeReadText(response: Response): Promise<string> {
|
|
226
|
+
try {
|
|
227
|
+
return await response.text()
|
|
228
|
+
} catch {
|
|
229
|
+
return ''
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { toolDefinition } from '@tanstack/ai'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { PerplexitySearchClient } from './client'
|
|
4
|
+
import type { PerplexitySearchClientConfig } from './client'
|
|
5
|
+
|
|
6
|
+
const searchRecency = z.enum(['hour', 'day', 'week', 'month', 'year'])
|
|
7
|
+
|
|
8
|
+
const inputSchema = z.object({
|
|
9
|
+
query: z.string().min(1).describe('The search query string.'),
|
|
10
|
+
max_results: z
|
|
11
|
+
.number()
|
|
12
|
+
.int()
|
|
13
|
+
.min(1)
|
|
14
|
+
.max(20)
|
|
15
|
+
.optional()
|
|
16
|
+
.describe(
|
|
17
|
+
'Maximum number of results to return. Defaults to defaultMaxResults when configured, otherwise the API default (10).',
|
|
18
|
+
),
|
|
19
|
+
search_domain_filter: z
|
|
20
|
+
.array(z.string())
|
|
21
|
+
.max(20)
|
|
22
|
+
.optional()
|
|
23
|
+
.describe(
|
|
24
|
+
'Restrict results by domain (max 20). Use bare hostnames to allowlist (e.g. ["nytimes.com"]) or "-domain.com" to denylist. Allow and deny entries must NOT be mixed.',
|
|
25
|
+
),
|
|
26
|
+
search_recency_filter: searchRecency
|
|
27
|
+
.optional()
|
|
28
|
+
.describe('Only include results from the given recency window.'),
|
|
29
|
+
search_after_date_filter: z
|
|
30
|
+
.string()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe(
|
|
33
|
+
'Only include results published on or after this date (m/d/yyyy).',
|
|
34
|
+
),
|
|
35
|
+
search_before_date_filter: z
|
|
36
|
+
.string()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe(
|
|
39
|
+
'Only include results published on or before this date (m/d/yyyy).',
|
|
40
|
+
),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const outputSchema = z.object({
|
|
44
|
+
results: z.array(
|
|
45
|
+
z.object({
|
|
46
|
+
title: z.string(),
|
|
47
|
+
url: z.string(),
|
|
48
|
+
snippet: z.string(),
|
|
49
|
+
date: z.string().optional(),
|
|
50
|
+
last_updated: z.string().optional(),
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build a TanStack AI tool that performs real-time web search via Perplexity.
|
|
57
|
+
*
|
|
58
|
+
* Returns `{ results: Array<{ title, url, snippet, date?, last_updated? }> }`
|
|
59
|
+
* for citation/grounding in an LLM agent loop.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* import { chat } from '@tanstack/ai'
|
|
64
|
+
* import { openaiText } from '@tanstack/ai-openai'
|
|
65
|
+
* import { perplexitySearchTool } from '@tanstack/ai-perplexity'
|
|
66
|
+
*
|
|
67
|
+
* const search = perplexitySearchTool({ defaultMaxResults: 5 })
|
|
68
|
+
* chat({ adapter: openaiText('gpt-5.2'), tools: [search], messages })
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export function perplexitySearchTool(
|
|
72
|
+
config: PerplexitySearchClientConfig & {
|
|
73
|
+
/** Override the tool name (defaults to `perplexity_search`). */
|
|
74
|
+
name?: string
|
|
75
|
+
/** Override the tool description shown to the model. */
|
|
76
|
+
description?: string
|
|
77
|
+
/** Default max_results applied when the model does not provide one. */
|
|
78
|
+
defaultMaxResults?: number
|
|
79
|
+
} = {},
|
|
80
|
+
) {
|
|
81
|
+
const { name, description, defaultMaxResults, ...clientConfig } = config
|
|
82
|
+
if (
|
|
83
|
+
defaultMaxResults !== undefined &&
|
|
84
|
+
(!Number.isInteger(defaultMaxResults) ||
|
|
85
|
+
defaultMaxResults < 1 ||
|
|
86
|
+
defaultMaxResults > 20)
|
|
87
|
+
) {
|
|
88
|
+
throw new Error('defaultMaxResults must be an integer between 1 and 20.')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Lazily construct the client so missing API keys don't blow up at import
|
|
92
|
+
// time (e.g. on bundlers that statically evaluate module top-level).
|
|
93
|
+
let client: PerplexitySearchClient | null = null
|
|
94
|
+
const getClient = () => {
|
|
95
|
+
if (!client) client = new PerplexitySearchClient(clientConfig)
|
|
96
|
+
return client
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return toolDefinition({
|
|
100
|
+
name: name ?? 'perplexity_search',
|
|
101
|
+
description:
|
|
102
|
+
description ??
|
|
103
|
+
'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 optional publication dates.',
|
|
104
|
+
inputSchema,
|
|
105
|
+
outputSchema,
|
|
106
|
+
}).server(async (args, ctx) => {
|
|
107
|
+
const response = await getClient().search(
|
|
108
|
+
{
|
|
109
|
+
query: args.query,
|
|
110
|
+
max_results: args.max_results ?? defaultMaxResults,
|
|
111
|
+
search_domain_filter: args.search_domain_filter,
|
|
112
|
+
search_recency_filter: args.search_recency_filter,
|
|
113
|
+
search_after_date_filter: args.search_after_date_filter,
|
|
114
|
+
search_before_date_filter: args.search_before_date_filter,
|
|
115
|
+
},
|
|
116
|
+
{ signal: ctx?.abortSignal },
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
results: response.results.map((result) => ({
|
|
121
|
+
title: result.title,
|
|
122
|
+
url: result.url,
|
|
123
|
+
snippet: result.snippet,
|
|
124
|
+
...(result.date ? { date: result.date } : {}),
|
|
125
|
+
...(result.last_updated ? { last_updated: result.last_updated } : {}),
|
|
126
|
+
})),
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a Perplexity API key from environment variables.
|
|
3
|
+
*
|
|
4
|
+
* Honors `PERPLEXITY_API_KEY` first, then falls back to `PPLX_API_KEY`.
|
|
5
|
+
* Throws if neither is set.
|
|
6
|
+
*/
|
|
7
|
+
export function getPerplexityApiKeyFromEnv(): string {
|
|
8
|
+
const env = getEnvironment()
|
|
9
|
+
const key = [env?.PERPLEXITY_API_KEY, env?.PPLX_API_KEY]
|
|
10
|
+
.find(
|
|
11
|
+
(value): value is string =>
|
|
12
|
+
typeof value === 'string' && value.trim().length > 0,
|
|
13
|
+
)
|
|
14
|
+
?.trim()
|
|
15
|
+
|
|
16
|
+
if (!key) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
'PERPLEXITY_API_KEY (or PPLX_API_KEY) is required. Set it in your environment or pass an explicit apiKey.',
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return key
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface WindowWithEnv {
|
|
26
|
+
env?: Record<string, string | undefined>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getEnvironment(): Record<string, string | undefined> | undefined {
|
|
30
|
+
if (typeof globalThis !== 'undefined') {
|
|
31
|
+
const win = (globalThis as { window?: WindowWithEnv }).window
|
|
32
|
+
if (win?.env) return win.env
|
|
33
|
+
}
|
|
34
|
+
if (typeof process !== 'undefined') {
|
|
35
|
+
return process.env
|
|
36
|
+
}
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const PERPLEXITY_INTEGRATION_HEADER = 'X-Pplx-Integration'
|
|
2
|
+
export const PERPLEXITY_INTEGRATION_HEADER_VALUE = `tanstack/${__PACKAGE_VERSION__}`
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Attribution header Perplexity uses to identify TanStack AI traffic
|
|
6
|
+
* (`X-Pplx-Integration: tanstack/<package-version>`).
|
|
7
|
+
*
|
|
8
|
+
* The Search client sends this automatically. Pass it as
|
|
9
|
+
* `openaiCompatible({ defaultHeaders })` if you want the same header on
|
|
10
|
+
* Sonar chat requests.
|
|
11
|
+
*/
|
|
12
|
+
export function getPerplexityIntegrationHeaders(): Record<string, string> {
|
|
13
|
+
return {
|
|
14
|
+
[PERPLEXITY_INTEGRATION_HEADER]: PERPLEXITY_INTEGRATION_HEADER_VALUE,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
declare const __PACKAGE_VERSION__: string
|