Skip to content

Commit 5dc0e3a

Browse files
committed
feat(ai-perplexity): add Perplexity Search integration
Adds a new @effect/ai-perplexity package providing Effect bindings for the Perplexity Search API. Exposes a PerplexitySearch service with a typed search method (returns {title, url, snippet, date?}) plus support for max_results, search_domain_filter (allow- or deny-list, validated to not mix), search_recency_filter, and date-range filters. Reads the API key from PERPLEXITY_API_KEY (with PPLX_API_KEY as a fallback) via Config.redacted. Includes unit tests (10 cases) covering body shape, filter forwarding, mixed-domain rejection, success decoding, HTTP errors, and malformed response handling.
1 parent 70ce155 commit 5dc0e3a

19 files changed

Lines changed: 993 additions & 10 deletions

.changeset/perplexity-search.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@effect/ai-perplexity": minor
3+
---
4+
5+
Add `@effect/ai-perplexity` package providing Effect bindings for the
6+
[Perplexity Search API](https://docs.perplexity.ai/api-reference/search-post).
7+
8+
The package exposes a `PerplexitySearch` service with a `search` method that
9+
returns typed `{ title, url, snippet, date? }` results. It supports the
10+
`max_results`, `search_domain_filter`, `search_recency_filter`, and date-range
11+
filters from the Search API. Authentication is read from the
12+
`PERPLEXITY_API_KEY` environment variable (with `PPLX_API_KEY` as a fallback).

packages/ai/perplexity/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2023 Effectful Technologies Inc
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.

packages/ai/perplexity/README.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# `@effect/ai-perplexity`
2+
3+
Effect bindings for the [Perplexity Search API](https://docs.perplexity.ai/api-reference/search-post).
4+
5+
## Installation
6+
7+
```sh
8+
pnpm add @effect/ai-perplexity @effect/ai @effect/platform effect
9+
```
10+
11+
You will also need a platform-specific HTTP client implementation, e.g.
12+
`@effect/platform-node` or `@effect/platform-bun`.
13+
14+
## Authentication
15+
16+
The client reads its API key from the `PERPLEXITY_API_KEY` environment
17+
variable, falling back to `PPLX_API_KEY` if the former is not set. You can
18+
obtain a key from the
19+
[Perplexity API key console](https://www.perplexity.ai/account/api/keys).
20+
21+
## Usage
22+
23+
```ts
24+
import { NodeHttpClient } from "@effect/platform-node"
25+
import { PerplexitySearch } from "@effect/ai-perplexity"
26+
import { Effect, Layer } from "effect"
27+
28+
const program = Effect.gen(function*() {
29+
const search = yield* PerplexitySearch.PerplexitySearch
30+
const response = yield* search.search({
31+
query: "latest research on attention mechanisms",
32+
maxResults: 5,
33+
recencyFilter: "month"
34+
})
35+
for (const result of response.results) {
36+
console.log(result.title, result.url)
37+
}
38+
})
39+
40+
const SearchLayer = PerplexitySearch.layerConfig().pipe(
41+
Layer.provide(NodeHttpClient.layerUndici)
42+
)
43+
44+
Effect.runPromise(program.pipe(Effect.provide(SearchLayer)))
45+
```
46+
47+
### Search options
48+
49+
`PerplexitySearch.search` accepts the following options (all but `query` are
50+
optional):
51+
52+
| Option | Type | Description |
53+
| ------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
54+
| `query` | `string` | The search query. |
55+
| `maxResults` | `number` | Maximum results to return. Server default is 10. |
56+
| `maxTokensPerPage` | `number` | Maximum tokens per result snippet. |
57+
| `domainFilter` | `ReadonlyArray<string>` | Allowlist (`"nytimes.com"`) **or** denylist (`"-pinterest.com"`). Cannot be mixed. |
58+
| `recencyFilter` | `"hour" \| "day" \| "week" \| "month" \| "year"` | Restrict results to a recency window. |
59+
| `afterDateFilter` | `string` | Only return results published on or after this date (`m/d/yyyy`). |
60+
| `beforeDateFilter` | `string` | Only return results published on or before this date (`m/d/yyyy`). |
61+
62+
The response is decoded into `SearchResponse`, which exposes a `results`
63+
array of `{ title, url, snippet, date? }` items.
64+
65+
### Domain filter caveat
66+
67+
The Perplexity API does not accept a `search_domain_filter` array that mixes
68+
allowed and excluded domains. `PerplexitySearch.search` enforces this by
69+
failing fast with an `AiError.MalformedInput` if you pass an array containing
70+
both positive (`"nytimes.com"`) and negative (`"-pinterest.com"`) entries.
71+
72+
### Manual layer composition
73+
74+
If you want to provide the API key explicitly:
75+
76+
```ts
77+
import { PerplexityClient, PerplexitySearch } from "@effect/ai-perplexity"
78+
import { NodeHttpClient } from "@effect/platform-node"
79+
import { Layer, Redacted } from "effect"
80+
81+
const SearchLayer = PerplexitySearch.layer.pipe(
82+
Layer.provide(PerplexityClient.layer({
83+
apiKey: Redacted.make(process.env.PERPLEXITY_API_KEY!)
84+
})),
85+
Layer.provide(NodeHttpClient.layerUndici)
86+
)
87+
```
88+
89+
## Documentation
90+
91+
- [Search API quickstart](https://docs.perplexity.ai/docs/search/quickstart)
92+
- [Search API reference](https://docs.perplexity.ai/api-reference/search-post)
93+
- [Domain filters](https://docs.perplexity.ai/docs/search/filters/domain-filter)
94+
- [Date / recency filters](https://docs.perplexity.ai/docs/search/filters/date-time-filters)

packages/ai/perplexity/docgen.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"$schema": "../../../node_modules/@effect/docgen/schema.json",
3+
"exclude": ["src/internal/**/*.ts"],
4+
"srcLink": "https://github.com/Effect-TS/effect/tree/main/packages/ai/perplexity/src/",
5+
"examplesCompilerOptions": {
6+
"noEmit": true,
7+
"strict": true,
8+
"skipLibCheck": true,
9+
"moduleResolution": "Bundler",
10+
"module": "ES2022",
11+
"target": "ES2022",
12+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
13+
"paths": {
14+
"effect": ["../../../../effect/src/index.js"],
15+
"effect/*": ["../../../../effect/src/*.js"],
16+
"@effect/platform": ["../../../../platform/src/index.js"],
17+
"@effect/platform/*": ["../../../../platform/src/*.js"],
18+
"@effect/ai": ["../../../ai/src/index.js"],
19+
"@effect/ai/*": ["../../../ai/src/*.js"],
20+
"@effect/ai-perplexity": ["../../../ai-perplexity/src/index.js"],
21+
"@effect/ai-perplexity/*": ["../../../ai-perplexity/src/*.js"]
22+
}
23+
}
24+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"name": "@effect/ai-perplexity",
3+
"type": "module",
4+
"version": "0.1.0",
5+
"license": "MIT",
6+
"description": "Effect modules for working with the Perplexity Search API",
7+
"homepage": "https://effect.website",
8+
"repository": {
9+
"type": "git",
10+
"url": "https://github.com/Effect-TS/effect.git",
11+
"directory": "packages/ai/perplexity"
12+
},
13+
"bugs": {
14+
"url": "https://github.com/Effect-TS/effect/issues"
15+
},
16+
"tags": [
17+
"typescript",
18+
"algebraic-data-types",
19+
"functional-programming"
20+
],
21+
"keywords": [
22+
"typescript",
23+
"algebraic-data-types",
24+
"functional-programming"
25+
],
26+
"publishConfig": {
27+
"access": "public",
28+
"provenance": true,
29+
"directory": "dist",
30+
"linkDirectory": false
31+
},
32+
"exports": {
33+
"./package.json": "./package.json",
34+
".": "./src/index.ts",
35+
"./*": "./src/*.ts",
36+
"./internal/*": null
37+
},
38+
"scripts": {
39+
"codegen": "build-utils prepare-v3",
40+
"build": "pnpm build-esm && pnpm build-annotate && pnpm build-cjs && build-utils pack-v3",
41+
"build-esm": "tsc -b tsconfig.build.json",
42+
"build-cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps",
43+
"build-annotate": "babel build/esm --plugins annotate-pure-calls --out-dir build/esm --source-maps",
44+
"check": "tsc -b tsconfig.json",
45+
"test": "vitest",
46+
"coverage": "vitest --coverage"
47+
},
48+
"peerDependencies": {
49+
"@effect/ai": "workspace:^",
50+
"@effect/platform": "workspace:^",
51+
"effect": "workspace:^"
52+
},
53+
"devDependencies": {
54+
"@effect/ai": "workspace:^",
55+
"@effect/platform": "workspace:^",
56+
"@effect/platform-node": "workspace:^",
57+
"effect": "workspace:^"
58+
}
59+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* @since 1.0.0
3+
*/
4+
import * as AiError from "@effect/ai/AiError"
5+
import * as Headers from "@effect/platform/Headers"
6+
import * as HttpClient from "@effect/platform/HttpClient"
7+
import * as HttpClientRequest from "@effect/platform/HttpClientRequest"
8+
import * as HttpClientResponse from "@effect/platform/HttpClientResponse"
9+
import * as Arr from "effect/Array"
10+
import * as Config from "effect/Config"
11+
import type { ConfigError } from "effect/ConfigError"
12+
import * as Context from "effect/Context"
13+
import * as Effect from "effect/Effect"
14+
import { identity } from "effect/Function"
15+
import * as Layer from "effect/Layer"
16+
import * as Redacted from "effect/Redacted"
17+
import type * as Schema from "effect/Schema"
18+
import type * as Scope from "effect/Scope"
19+
20+
/**
21+
* @since 1.0.0
22+
* @category Context
23+
*/
24+
export class PerplexityClient extends Context.Tag(
25+
"@effect/ai-perplexity/PerplexityClient"
26+
)<PerplexityClient, Service>() {}
27+
28+
/**
29+
* Represents the interface that the `PerplexityClient` service provides.
30+
*
31+
* This service abstracts the complexity of communicating with the Perplexity
32+
* Search API. It exposes the underlying HTTP client (already configured with
33+
* authentication and the Perplexity base URL) plus a high-level helper for
34+
* decoding JSON responses into a schema.
35+
*
36+
* @since 1.0.0
37+
* @category Models
38+
*/
39+
export interface Service {
40+
/**
41+
* The underlying HTTP client capable of communicating with the Perplexity
42+
* API. Pre-configured with authentication (`Authorization: Bearer ...`) and
43+
* the API base URL.
44+
*/
45+
readonly httpClient: HttpClient.HttpClient
46+
47+
/**
48+
* Execute a request and decode the JSON response body using the supplied
49+
* schema. Maps platform `HttpClient` errors to `@effect/ai` `AiError`s.
50+
*/
51+
readonly executeRequest: <A, I, R>(
52+
request: HttpClientRequest.HttpClientRequest,
53+
schema: Schema.Schema<A, I, R>,
54+
method: string
55+
) => Effect.Effect<A, AiError.AiError, R>
56+
}
57+
58+
/**
59+
* @since 1.0.0
60+
* @category Constructors
61+
*/
62+
export const make = (options: {
63+
/**
64+
* The API key used to authenticate with the Perplexity API.
65+
*
66+
* Wrapped in `Redacted` to avoid accidental logging. Sent as a Bearer token
67+
* in the `Authorization` header on every request.
68+
*/
69+
readonly apiKey?: Redacted.Redacted | undefined
70+
/**
71+
* The base URL of the Perplexity API. Defaults to `https://api.perplexity.ai`.
72+
*/
73+
readonly apiUrl?: string | undefined
74+
/**
75+
* Optional transform applied to the underlying HTTP client (e.g. to add
76+
* middleware, logging, retries).
77+
*/
78+
readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined
79+
}): Effect.Effect<Service, never, HttpClient.HttpClient | Scope.Scope> =>
80+
Effect.gen(function*() {
81+
const authHeader = "authorization"
82+
83+
yield* Effect.locallyScopedWith(Headers.currentRedactedNames, Arr.append(authHeader))
84+
85+
const httpClient = (yield* HttpClient.HttpClient).pipe(
86+
HttpClient.mapRequest((request) =>
87+
request.pipe(
88+
HttpClientRequest.prependUrl(options.apiUrl ?? "https://api.perplexity.ai"),
89+
options.apiKey
90+
? HttpClientRequest.setHeader(authHeader, `Bearer ${Redacted.value(options.apiKey)}`)
91+
: identity,
92+
HttpClientRequest.acceptJson
93+
)
94+
),
95+
options.transformClient ? options.transformClient : identity
96+
)
97+
98+
const httpClientOk = HttpClient.filterStatusOk(httpClient)
99+
100+
const executeRequest = <A, I, R>(
101+
request: HttpClientRequest.HttpClientRequest,
102+
schema: Schema.Schema<A, I, R>,
103+
method: string
104+
): Effect.Effect<A, AiError.AiError, R> =>
105+
httpClientOk.execute(request).pipe(
106+
Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
107+
Effect.catchTags({
108+
RequestError: (error) =>
109+
AiError.HttpRequestError.fromRequestError({
110+
module: "PerplexityClient",
111+
method,
112+
error
113+
}),
114+
ResponseError: (error) =>
115+
AiError.HttpResponseError.fromResponseError({
116+
module: "PerplexityClient",
117+
method,
118+
error
119+
}),
120+
ParseError: (error) =>
121+
Effect.fail(
122+
new AiError.MalformedOutput({
123+
module: "PerplexityClient",
124+
method,
125+
description: `Failed to decode response body: ${error.message}`,
126+
cause: error
127+
})
128+
)
129+
})
130+
)
131+
132+
return PerplexityClient.of({
133+
httpClient,
134+
executeRequest
135+
})
136+
})
137+
138+
/**
139+
* @since 1.0.0
140+
* @category Layers
141+
*/
142+
export const layer = (options: {
143+
readonly apiKey?: Redacted.Redacted | undefined
144+
readonly apiUrl?: string | undefined
145+
readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined
146+
}): Layer.Layer<PerplexityClient, never, HttpClient.HttpClient> => Layer.scoped(PerplexityClient, make(options))
147+
148+
/**
149+
* Build a `PerplexityClient` layer that reads its API key from environment
150+
* configuration. Looks for `PERPLEXITY_API_KEY` first and falls back to
151+
* `PPLX_API_KEY`. Either may be supplied; if neither is set the layer fails
152+
* with a `ConfigError`.
153+
*
154+
* @since 1.0.0
155+
* @category Layers
156+
*/
157+
export const layerConfig = (
158+
options?: {
159+
readonly apiKey?: Config.Config<Redacted.Redacted> | undefined
160+
readonly apiUrl?: Config.Config<string> | undefined
161+
}
162+
): Layer.Layer<PerplexityClient, ConfigError, HttpClient.HttpClient> =>
163+
Layer.scoped(
164+
PerplexityClient,
165+
Effect.flatMap(
166+
Config.all({
167+
apiKey: options?.apiKey ?? Config.redacted("PERPLEXITY_API_KEY").pipe(
168+
Config.orElse(() => Config.redacted("PPLX_API_KEY"))
169+
),
170+
apiUrl: options?.apiUrl ?? Config.string("PERPLEXITY_API_URL").pipe(
171+
Config.withDefault("https://api.perplexity.ai")
172+
)
173+
}),
174+
make
175+
)
176+
)

0 commit comments

Comments
 (0)