From 23407a8c7b823786a1162cfec697f4eecf00d231 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 19 May 2026 15:18:05 +0000 Subject: [PATCH] feat(ai-perplexity): add Perplexity Search integration --- .changeset/perplexity-search.md | 5 + packages/ai/perplexity/README.md | 92 +++++ packages/ai/perplexity/package.json | 66 ++++ .../ai/perplexity/src/PerplexityClient.ts | 320 ++++++++++++++++++ .../ai/perplexity/src/PerplexityConfig.ts | 68 ++++ .../ai/perplexity/src/PerplexitySearch.ts | 208 ++++++++++++ packages/ai/perplexity/src/index.ts | 31 ++ .../perplexity/test/PerplexitySearch.test.ts | 286 ++++++++++++++++ packages/ai/perplexity/tsconfig.json | 8 + packages/ai/perplexity/vitest.config.ts | 6 + pnpm-lock.yaml | 6 + tsconfig.json | 2 + tsconfig.packages.json | 1 + 13 files changed, 1099 insertions(+) create mode 100644 .changeset/perplexity-search.md create mode 100644 packages/ai/perplexity/README.md create mode 100644 packages/ai/perplexity/package.json create mode 100644 packages/ai/perplexity/src/PerplexityClient.ts create mode 100644 packages/ai/perplexity/src/PerplexityConfig.ts create mode 100644 packages/ai/perplexity/src/PerplexitySearch.ts create mode 100644 packages/ai/perplexity/src/index.ts create mode 100644 packages/ai/perplexity/test/PerplexitySearch.test.ts create mode 100644 packages/ai/perplexity/tsconfig.json create mode 100644 packages/ai/perplexity/vitest.config.ts diff --git a/.changeset/perplexity-search.md b/.changeset/perplexity-search.md new file mode 100644 index 0000000000..73b353f585 --- /dev/null +++ b/.changeset/perplexity-search.md @@ -0,0 +1,5 @@ +--- +"@effect/ai-perplexity": minor +--- + +Add `@effect/ai-perplexity` package providing Effect bindings for the Perplexity Search API. diff --git a/packages/ai/perplexity/README.md b/packages/ai/perplexity/README.md new file mode 100644 index 0000000000..935708ca95 --- /dev/null +++ b/packages/ai/perplexity/README.md @@ -0,0 +1,92 @@ +# `@effect/ai-perplexity` + +Effect bindings for the [Perplexity Search API](https://docs.perplexity.ai/api-reference/search-post). + +## Installation + +```sh +pnpm add @effect/ai-perplexity effect +``` + +## Authentication + +The client reads its API key from the `PERPLEXITY_API_KEY` environment +variable, falling back to `PPLX_API_KEY` if the former is not set. You can +obtain a key from the +[Perplexity API key console](https://www.perplexity.ai/account/api/keys). + +## Usage + +```ts +import { PerplexitySearch } from "@effect/ai-perplexity" +import { Effect, Layer } from "effect" +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient" + +const program = Effect.gen(function*() { + const search = yield* PerplexitySearch.PerplexitySearch + const response = yield* search.search({ + query: "latest research on attention mechanisms", + maxResults: 5, + recencyFilter: "month" + }) + for (const result of response.results) { + console.log(result.title, result.url) + } +}) + +const SearchLayer = PerplexitySearch.layerConfig().pipe( + Layer.provide(FetchHttpClient.layer) +) + +Effect.runPromise(program.pipe(Effect.provide(SearchLayer))) +``` + +### Search Options + +`PerplexitySearch.search` accepts the following options. All options except +`query` are optional. + +| Option | Type | Description | +| ------------------ | ------------------------------------------------ | ------------------------------------------------------------------------------ | +| `query` | `string` | The search query. | +| `maxResults` | `number` | Maximum results to return. Server default is 10. | +| `maxTokensPerPage` | `number` | Maximum tokens per result snippet. | +| `domainFilter` | `ReadonlyArray` | Allowlist (`"nytimes.com"`) or denylist (`"-pinterest.com"`). Cannot be mixed. | +| `recencyFilter` | `"hour" \| "day" \| "week" \| "month" \| "year"` | Restrict results to a recency window. | +| `afterDateFilter` | `string` | Only return results published on or after this date (`m/d/yyyy`). | +| `beforeDateFilter` | `string` | Only return results published on or before this date (`m/d/yyyy`). | + +The response is decoded into `SearchResponse`, which exposes a `results` array +of `{ title, url, snippet, date? }` items. + +### Domain Filter Caveat + +The Perplexity API does not accept a `search_domain_filter` array that mixes +allowed and excluded domains. `PerplexitySearch.search` enforces this by +failing fast with an `AiError` whose reason is `InvalidRequestError` if you pass +an array containing both positive (`"nytimes.com"`) and negative +(`"-pinterest.com"`) entries. + +### Manual Layer Composition + +If you want to provide the API key explicitly: + +```ts +import { PerplexityClient, PerplexitySearch } from "@effect/ai-perplexity" +import { Layer, Redacted } from "effect" +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient" + +const SearchLayer = PerplexitySearch.layer.pipe( + Layer.provide(PerplexityClient.layer({ + apiKey: Redacted.make(process.env.PERPLEXITY_API_KEY!) + })), + Layer.provide(FetchHttpClient.layer) +) +``` + +## Documentation + +- [Search API quickstart](https://docs.perplexity.ai/docs/search/quickstart) +- [Search API reference](https://docs.perplexity.ai/api-reference/search-post) +- [Domain filters](https://docs.perplexity.ai/docs/search/filters/domain-filter) +- [Date / recency filters](https://docs.perplexity.ai/docs/search/filters/date-time-filters) diff --git a/packages/ai/perplexity/package.json b/packages/ai/perplexity/package.json new file mode 100644 index 0000000000..ad94f6ad38 --- /dev/null +++ b/packages/ai/perplexity/package.json @@ -0,0 +1,66 @@ +{ + "name": "@effect/ai-perplexity", + "version": "4.0.0-beta.1", + "type": "module", + "license": "MIT", + "description": "A Perplexity Search provider integration for Effect AI SDK", + "homepage": "https://effect.website", + "repository": { + "type": "git", + "url": "https://github.com/Effect-TS/effect-smol.git", + "directory": "packages/ai/perplexity" + }, + "bugs": { + "url": "https://github.com/Effect-TS/effect-smol/issues" + }, + "tags": [ + "typescript", + "ai", + "perplexity" + ], + "keywords": [ + "typescript", + "ai", + "perplexity" + ], + "sideEffects": [], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./*": "./src/*.ts", + "./internal/*": null, + "./*/index": null + }, + "files": [ + "src/**/*.ts", + "dist/**/*.js", + "dist/**/*.js.map", + "dist/**/*.d.ts", + "dist/**/*.d.ts.map" + ], + "publishConfig": { + "access": "public", + "provenance": true, + "exports": { + "./package.json": "./package.json", + ".": "./dist/index.js", + "./*": "./dist/*.js", + "./internal/*": null, + "./*/index": null + } + }, + "scripts": { + "build": "tsc -b tsconfig.json && pnpm babel", + "build:tsgo": "tsgo -b tsconfig.json && pnpm babel", + "babel": "babel dist --plugins annotate-pure-calls --out-dir dist --source-maps", + "check": "tsc -b tsconfig.json", + "test": "vitest", + "coverage": "vitest --coverage" + }, + "devDependencies": { + "effect": "workspace:^" + }, + "peerDependencies": { + "effect": "workspace:^" + } +} diff --git a/packages/ai/perplexity/src/PerplexityClient.ts b/packages/ai/perplexity/src/PerplexityClient.ts new file mode 100644 index 0000000000..a184c9945e --- /dev/null +++ b/packages/ai/perplexity/src/PerplexityClient.ts @@ -0,0 +1,320 @@ +/** + * Perplexity Client module for interacting with Perplexity's API. + * + * Provides a type-safe, Effect-based client for Perplexity operations. + * + * @since 4.0.0 + */ +import * as Array from "effect/Array" +import * as Config from "effect/Config" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import { identity } from "effect/Function" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as Predicate from "effect/Predicate" +import * as Redactable from "effect/Redactable" +import * as Redacted from "effect/Redacted" +import type * as Schema from "effect/Schema" +import * as AiError from "effect/unstable/ai/AiError" +import type * as Response from "effect/unstable/ai/Response" +import * as Headers from "effect/unstable/http/Headers" +import * as HttpClient from "effect/unstable/http/HttpClient" +import type * as HttpClientError from "effect/unstable/http/HttpClientError" +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" +import { PerplexityConfig } from "./PerplexityConfig.ts" + +// ============================================================================= +// Service Interface +// ============================================================================= + +/** + * The Perplexity client service interface. + * + * @category models + * @since 4.0.0 + */ +export interface Service { + /** + * The underlying HTTP client capable of communicating with the Perplexity + * API. Pre-configured with authentication and the API base URL. + */ + readonly httpClient: HttpClient.HttpClient + + /** + * Executes a request and decodes the JSON response body using the supplied + * schema. HTTP and schema errors are mapped to `AiError`. + */ + readonly executeRequest: ( + request: HttpClientRequest.HttpClientRequest, + schema: S, + method: string + ) => Effect.Effect +} + +// ============================================================================= +// Service Identifier +// ============================================================================= + +/** + * Service identifier for the Perplexity client. + * + * @category services + * @since 4.0.0 + */ +export class PerplexityClient extends Context.Service()( + "@effect/ai-perplexity/PerplexityClient" +) {} + +// ============================================================================= +// Options +// ============================================================================= + +/** + * Configuration options for creating a Perplexity client. + * + * @category models + * @since 4.0.0 + */ +export type Options = { + /** + * The Perplexity API key for authentication. + */ + readonly apiKey?: Redacted.Redacted | undefined + + /** + * The base URL for the Perplexity API. + * + * @default "https://api.perplexity.ai" + */ + readonly apiUrl?: string | undefined + + /** + * Optional transformer for the underlying HTTP client. + */ + readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined +} + +// ============================================================================= +// Constructor +// ============================================================================= + +const RedactedPerplexityHeaders = { + Authorization: "authorization" +} + +/** + * Creates a Perplexity client service with the given options. + * + * @category constructors + * @since 4.0.0 + */ +export const make = Effect.fnUntraced( + function*(options: Options): Effect.fn.Return { + const baseClient = yield* HttpClient.HttpClient + + const httpClient = baseClient.pipe( + HttpClient.mapRequest((request) => + request.pipe( + HttpClientRequest.prependUrl(options.apiUrl ?? "https://api.perplexity.ai"), + Predicate.isNotUndefined(options.apiKey) + ? HttpClientRequest.bearerToken(options.apiKey) + : identity, + HttpClientRequest.acceptJson + ) + ), + Predicate.isNotUndefined(options.transformClient) + ? options.transformClient + : identity + ) + + const executeRequest: Service["executeRequest"] = (request, schema, method) => + Effect.gen(function*() { + const config = yield* PerplexityConfig.getOrUndefined + const client = Predicate.isNotUndefined(config?.transformClient) + ? config.transformClient(httpClient) + : httpClient + return yield* HttpClient.filterStatusOk(client).execute(request).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), + Effect.catchTags({ + HttpClientError: (error) => mapHttpClientError(error, method), + SchemaError: (error) => Effect.fail(mapSchemaError(error, method)) + }) + ) + }) + + return PerplexityClient.of({ + httpClient, + executeRequest + }) + }, + Effect.updateService( + Headers.CurrentRedactedNames, + Array.appendAll(Object.values(RedactedPerplexityHeaders)) + ) +) + +// ============================================================================= +// Layers +// ============================================================================= + +/** + * Creates a layer for the Perplexity client with the given options. + * + * @category layers + * @since 4.0.0 + */ +export const layer = (options: Options): Layer.Layer => + Layer.effect(PerplexityClient, make(options)) + +/** + * Creates a layer for the Perplexity client, loading configuration via Effect's + * `Config` module. + * + * @category layers + * @since 4.0.0 + */ +export const layerConfig = (options?: { + /** + * The Perplexity API key config value. + * + * Defaults to `PERPLEXITY_API_KEY`, falling back to `PPLX_API_KEY`. + */ + readonly apiKey?: Config.Config | undefined> | undefined + + /** + * The Perplexity API URL config value. + * + * Defaults to `PERPLEXITY_API_URL`, falling back to + * `https://api.perplexity.ai`. + */ + readonly apiUrl?: Config.Config | undefined + + /** + * Optional transformer for the underlying HTTP client. + */ + readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined +}): Layer.Layer => + Layer.effect( + PerplexityClient, + Effect.gen(function*() { + const apiKey = Predicate.isNotUndefined(options?.apiKey) + ? yield* options.apiKey + : yield* Config.redacted("PERPLEXITY_API_KEY").pipe( + Config.orElse(() => Config.redacted("PPLX_API_KEY")) + ) + const apiUrl = Predicate.isNotUndefined(options?.apiUrl) + ? yield* options.apiUrl + : yield* Config.string("PERPLEXITY_API_URL").pipe( + Config.withDefault("https://api.perplexity.ai") + ) + return yield* make({ + apiKey, + apiUrl, + transformClient: options?.transformClient + }) + }) + ) + +// ============================================================================= +// Error Mapping +// ============================================================================= + +const mapSchemaError = (error: Schema.SchemaError, method: string): AiError.AiError => + AiError.make({ + module: "PerplexityClient", + method, + reason: AiError.InvalidOutputError.fromSchemaError(error) + }) + +const mapHttpClientError = ( + error: HttpClientError.HttpClientError, + method: string +): Effect.Effect => { + const reason = error.reason + switch (reason._tag) { + case "TransportError": + case "EncodeError": + case "InvalidUrlError": { + return Effect.fail(AiError.make({ + module: "PerplexityClient", + method, + reason: new AiError.NetworkError({ + reason: reason._tag, + description: reason.description, + request: buildHttpRequestDetails(reason.request) + }) + })) + } + case "StatusCodeError": { + return mapStatusCodeError(reason, method) + } + case "DecodeError": { + return Effect.fail(AiError.make({ + module: "PerplexityClient", + method, + reason: new AiError.InvalidOutputError({ + description: reason.description ?? "Failed to decode response" + }) + })) + } + case "EmptyBodyError": { + return Effect.fail(AiError.make({ + module: "PerplexityClient", + method, + reason: new AiError.InvalidOutputError({ + description: reason.description ?? "Response body was empty" + }) + })) + } + } +} + +const mapStatusCodeError = Effect.fnUntraced(function*( + error: HttpClientError.StatusCodeError, + method: string +) { + const body = yield* Effect.option(error.response.text) + const description = Option.isSome(body) && body.value.length > 0 + ? body.value + : error.description + return yield* AiError.make({ + module: "PerplexityClient", + method, + reason: AiError.reasonFromHttpStatus({ + status: error.response.status, + description, + http: buildHttpContext({ + request: error.request, + response: error.response, + body: description + }) + }) + }) +}) + +const buildHttpRequestDetails = ( + request: HttpClientRequest.HttpClientRequest +): typeof Response.HttpRequestDetails.Type => ({ + method: request.method, + url: request.url, + urlParams: globalThis.Array.from(request.urlParams), + hash: Option.getOrUndefined(request.hash), + headers: Redactable.redact(request.headers) as Record +}) + +const buildHttpContext = (params: { + readonly request: HttpClientRequest.HttpClientRequest + readonly response?: HttpClientResponse.HttpClientResponse | undefined + readonly body?: string | undefined +}): typeof AiError.HttpContext.Type => ({ + request: buildHttpRequestDetails(params.request), + response: Predicate.isNotUndefined(params.response) + ? { + status: params.response.status, + headers: Redactable.redact(params.response.headers) as Record + } + : undefined, + body: params.body +}) diff --git a/packages/ai/perplexity/src/PerplexityConfig.ts b/packages/ai/perplexity/src/PerplexityConfig.ts new file mode 100644 index 0000000000..09da336a8d --- /dev/null +++ b/packages/ai/perplexity/src/PerplexityConfig.ts @@ -0,0 +1,68 @@ +/** + * The `PerplexityConfig` module provides contextual configuration for the + * Perplexity AI provider integration. It is used to customize the HTTP client + * used by Perplexity requests without threading configuration through every + * call. + * + * @since 4.0.0 + */ +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import { dual } from "effect/Function" +import type { HttpClient } from "effect/unstable/http/HttpClient" + +/** + * Service tag for Perplexity client configuration overrides, such as transformations applied to the HTTP client. + * + * @category services + * @since 4.0.0 + */ +export class PerplexityConfig extends Context.Service< + PerplexityConfig, + PerplexityConfig.Service +>()("@effect/ai-perplexity/PerplexityConfig") { + /** + * Gets the configured Perplexity service from the current context when present. + * + * @since 4.0.0 + */ + static readonly getOrUndefined: Effect.Effect = Effect.map( + Effect.context(), + (services) => services.mapUnsafe.get(PerplexityConfig.key) + ) +} + +/** + * Namespace containing types associated with the `PerplexityConfig` service. + * + * @since 4.0.0 + */ +export declare namespace PerplexityConfig { + /** + * Configuration provided through `PerplexityConfig`. + * + * @category models + * @since 4.0.0 + */ + export interface Service { + readonly transformClient?: ((client: HttpClient) => HttpClient) | undefined + } +} + +/** + * Runs an effect with a `PerplexityConfig` override that transforms the underlying `HttpClient` used by Perplexity requests. + * + * @category configuration + * @since 4.0.0 + */ +export const withClientTransform: { + (transform: (client: HttpClient) => HttpClient): (self: Effect.Effect) => Effect.Effect + (self: Effect.Effect, transform: (client: HttpClient) => HttpClient): Effect.Effect +} = dual(2, ( + self: Effect.Effect, + transformClient: (client: HttpClient) => HttpClient +) => + Effect.flatMap( + PerplexityConfig.getOrUndefined, + (config) => Effect.provideService(self, PerplexityConfig, { ...config, transformClient }) + )) diff --git a/packages/ai/perplexity/src/PerplexitySearch.ts b/packages/ai/perplexity/src/PerplexitySearch.ts new file mode 100644 index 0000000000..dbf09246a1 --- /dev/null +++ b/packages/ai/perplexity/src/PerplexitySearch.ts @@ -0,0 +1,208 @@ +/** + * Perplexity Search module for calling the Perplexity Search API. + * + * @since 4.0.0 + */ +import type * as Config from "effect/Config" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Predicate from "effect/Predicate" +import type * as Redacted from "effect/Redacted" +import * as Schema from "effect/Schema" +import * as AiError from "effect/unstable/ai/AiError" +import * as HttpBody from "effect/unstable/http/HttpBody" +import type * as HttpClient from "effect/unstable/http/HttpClient" +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" +import * as PerplexityClient from "./PerplexityClient.ts" + +// ============================================================================= +// Schemas +// ============================================================================= + +/** + * Perplexity Search result. + * + * @category schemas + * @since 4.0.0 + */ +export class SearchResult extends Schema.Class( + "@effect/ai-perplexity/SearchResult" +)({ + title: Schema.String, + url: Schema.String, + snippet: Schema.String, + date: Schema.optional(Schema.NullOr(Schema.String)) +}) {} + +/** + * Perplexity Search response. + * + * @category schemas + * @since 4.0.0 + */ +export class SearchResponse extends Schema.Class( + "@effect/ai-perplexity/SearchResponse" +)({ + id: Schema.optional(Schema.String), + results: Schema.Array(SearchResult) +}) {} + +// ============================================================================= +// Models +// ============================================================================= + +/** + * Recency window for search results. + * + * @category models + * @since 4.0.0 + */ +export type RecencyFilter = "hour" | "day" | "week" | "month" | "year" + +/** + * Options accepted by `PerplexitySearch.search`. + * + * Matches the Perplexity Search API request body. + * + * @category models + * @since 4.0.0 + */ +export interface SearchOptions { + readonly query: string + readonly maxResults?: number | undefined + readonly maxTokensPerPage?: number | undefined + readonly domainFilter?: ReadonlyArray | undefined + readonly recencyFilter?: RecencyFilter | undefined + readonly afterDateFilter?: string | undefined + readonly beforeDateFilter?: string | undefined +} + +/** + * The Perplexity Search service interface. + * + * @category models + * @since 4.0.0 + */ +export interface Service { + /** + * Runs a Perplexity Search API query and returns decoded results. + */ + readonly search: (options: SearchOptions) => Effect.Effect +} + +// ============================================================================= +// Service Identifier +// ============================================================================= + +/** + * Service identifier for Perplexity Search. + * + * @category services + * @since 4.0.0 + */ +export class PerplexitySearch extends Context.Service()( + "@effect/ai-perplexity/PerplexitySearch" +) {} + +// ============================================================================= +// Utilities +// ============================================================================= + +const validateDomainFilter = (filter: ReadonlyArray): string | undefined => { + const hasAllow = filter.some((domain) => !domain.startsWith("-")) + const hasDeny = filter.some((domain) => domain.startsWith("-")) + return hasAllow && hasDeny + ? "domainFilter cannot mix allowlist and denylist entries. Use either positive entries (e.g. 'nytimes.com') or negative entries (e.g. '-pinterest.com'), not both." + : undefined +} + +const buildBody = (options: SearchOptions): Effect.Effect, AiError.AiError> => + Effect.gen(function*() { + if (Predicate.isNotUndefined(options.domainFilter) && options.domainFilter.length > 0) { + const validationError = validateDomainFilter(options.domainFilter) + if (Predicate.isNotUndefined(validationError)) { + return yield* AiError.make({ + module: "PerplexitySearch", + method: "search", + reason: new AiError.InvalidRequestError({ + parameter: "domainFilter", + constraint: "must contain either allowlist or denylist entries", + description: validationError + }) + }) + } + } + + const body: Record = { query: options.query } + if (Predicate.isNotUndefined(options.maxResults)) body.max_results = options.maxResults + if (Predicate.isNotUndefined(options.maxTokensPerPage)) body.max_tokens_per_page = options.maxTokensPerPage + if (Predicate.isNotUndefined(options.domainFilter)) body.search_domain_filter = options.domainFilter + if (Predicate.isNotUndefined(options.recencyFilter)) body.search_recency_filter = options.recencyFilter + if (Predicate.isNotUndefined(options.afterDateFilter)) body.search_after_date_filter = options.afterDateFilter + if (Predicate.isNotUndefined(options.beforeDateFilter)) body.search_before_date_filter = options.beforeDateFilter + return body + }) + +/** + * Builds the request body sent to the Perplexity Search API. + * + * @category utilities + * @since 4.0.0 + */ +export const buildRequestBody = (options: SearchOptions): Effect.Effect, AiError.AiError> => + buildBody(options) + +// ============================================================================= +// Constructor +// ============================================================================= + +/** + * Creates a Perplexity Search service from a `PerplexityClient`. + * + * @category constructors + * @since 4.0.0 + */ +export const make: Effect.Effect = Effect.gen(function*() { + const client = yield* PerplexityClient.PerplexityClient + + const search: Service["search"] = (options) => + Effect.gen(function*() { + const body = yield* buildBody(options) + const request = HttpClientRequest.post("/search", { + body: HttpBody.jsonUnsafe(body) + }) + return yield* client.executeRequest(request, SearchResponse, "search") + }) + + return PerplexitySearch.of({ search }) +}) + +// ============================================================================= +// Layers +// ============================================================================= + +/** + * Layer that builds the `PerplexitySearch` service from a `PerplexityClient`. + * + * @category layers + * @since 4.0.0 + */ +export const layer: Layer.Layer = Layer.effect( + PerplexitySearch, + make +) + +/** + * Convenience layer that wires the `PerplexitySearch` service together with a + * `PerplexityClient` configured from environment variables. + * + * @category layers + * @since 4.0.0 + */ +export const layerConfig = (options?: { + readonly apiKey?: Config.Config | undefined> | undefined + readonly apiUrl?: Config.Config | undefined + readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined +}): Layer.Layer => + layer.pipe(Layer.provide(PerplexityClient.layerConfig(options))) diff --git a/packages/ai/perplexity/src/index.ts b/packages/ai/perplexity/src/index.ts new file mode 100644 index 0000000000..062a7b907a --- /dev/null +++ b/packages/ai/perplexity/src/index.ts @@ -0,0 +1,31 @@ +/** + * @since 4.0.0 + */ + +// @barrel: Auto-generated exports. Do not edit manually. + +/** + * Perplexity Client module for interacting with Perplexity's API. + * + * Provides a type-safe, Effect-based client for Perplexity operations. + * + * @since 4.0.0 + */ +export * as PerplexityClient from "./PerplexityClient.ts" + +/** + * The `PerplexityConfig` module provides contextual configuration for the + * Perplexity AI provider integration. It is used to customize the HTTP client + * used by Perplexity requests without threading configuration through every + * call. + * + * @since 4.0.0 + */ +export * as PerplexityConfig from "./PerplexityConfig.ts" + +/** + * Perplexity Search module for calling the Perplexity Search API. + * + * @since 4.0.0 + */ +export * as PerplexitySearch from "./PerplexitySearch.ts" diff --git a/packages/ai/perplexity/test/PerplexitySearch.test.ts b/packages/ai/perplexity/test/PerplexitySearch.test.ts new file mode 100644 index 0000000000..ff348a195a --- /dev/null +++ b/packages/ai/perplexity/test/PerplexitySearch.test.ts @@ -0,0 +1,286 @@ +import { PerplexityClient, PerplexitySearch } from "@effect/ai-perplexity" +import { assert, describe, it } from "@effect/vitest" +import { Effect, Layer, Redacted, Ref } from "effect" +import * as HttpClient from "effect/unstable/http/HttpClient" +import type * as HttpClientError from "effect/unstable/http/HttpClientError" +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" + +interface CapturedRequest { + readonly method: string + readonly url: string + readonly body: unknown + readonly headers: Record +} + +const makeMockHttpClient = (responseBody: unknown, status = 200) => + Effect.gen(function*() { + const captured = yield* Ref.make(undefined) + const client = HttpClient.makeWith( + (requestEffect) => + Effect.flatMap(requestEffect, (request) => + Effect.gen(function*() { + const text = request.body._tag === "Uint8Array" + ? new TextDecoder().decode(request.body.body) + : request.body._tag === "Raw" + ? String(request.body.body) + : "" + const parsed = parseBody(text) + yield* Ref.set(captured, { + method: request.method, + url: request.url, + body: parsed, + headers: request.headers as Record + }) + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(responseBody), { + status, + headers: { "content-type": "application/json" } + }) + ) + })), + Effect.succeed + ) + return { client, captured } + }) + +const provideSearch = ( + effect: Effect.Effect, + responseBody: unknown, + status = 200 +) => + Effect.gen(function*() { + const { captured, client } = yield* makeMockHttpClient(responseBody, status) + const result = yield* effect.pipe( + Effect.provide( + PerplexitySearch.layer.pipe( + Layer.provide( + PerplexityClient.layer({ + apiKey: Redacted.make("test-api-key") + }) + ), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)) + ) + ) + ) + const captureValue = yield* Ref.get(captured) + return { result, captured: captureValue } + }) + +describe("PerplexitySearch", () => { + describe("buildRequestBody", () => { + it.effect("includes only the query when no options are set", () => + Effect.gen(function*() { + const body = yield* PerplexitySearch.buildRequestBody({ query: "hello" }) + assert.deepStrictEqual(body, { query: "hello" }) + })) + + it.effect("maps camelCase options to the API's snake_case fields", () => + Effect.gen(function*() { + const body = yield* PerplexitySearch.buildRequestBody({ + query: "hello", + maxResults: 5, + maxTokensPerPage: 256, + recencyFilter: "week", + afterDateFilter: "1/1/2025", + beforeDateFilter: "12/31/2025", + domainFilter: ["nytimes.com"] + }) + assert.deepStrictEqual(body, { + query: "hello", + max_results: 5, + max_tokens_per_page: 256, + search_domain_filter: ["nytimes.com"], + search_recency_filter: "week", + search_after_date_filter: "1/1/2025", + search_before_date_filter: "12/31/2025" + }) + })) + + it.effect("accepts an allowlist", () => + Effect.gen(function*() { + const body = yield* PerplexitySearch.buildRequestBody({ + query: "x", + domainFilter: ["nytimes.com", "wsj.com"] + }) + assert.deepStrictEqual(body.search_domain_filter, ["nytimes.com", "wsj.com"]) + })) + + it.effect("accepts a denylist", () => + Effect.gen(function*() { + const body = yield* PerplexitySearch.buildRequestBody({ + query: "x", + domainFilter: ["-pinterest.com", "-quora.com"] + }) + assert.deepStrictEqual(body.search_domain_filter, ["-pinterest.com", "-quora.com"]) + })) + + it.effect("rejects mixed allow/deny domain filter entries", () => + Effect.gen(function*() { + const error = yield* PerplexitySearch.buildRequestBody({ + query: "x", + domainFilter: ["nytimes.com", "-pinterest.com"] + }).pipe(Effect.flip) + assert.strictEqual(error.reason._tag, "InvalidRequestError") + assert.match(error.reason.description!, /cannot mix allowlist and denylist/) + })) + }) + + describe("search", () => { + it.effect("decodes a successful response", () => + Effect.gen(function*() { + const { captured, result } = yield* provideSearch( + Effect.gen(function*() { + const search = yield* PerplexitySearch.PerplexitySearch + return yield* search.search({ query: "effect typescript", maxResults: 2 }) + }), + { + id: "abc", + results: [ + { + title: "Effect TS", + url: "https://effect.website", + snippet: "Functional effects", + date: "2026-04-01" + }, + { + title: "Effect GitHub", + url: "https://github.com/Effect-TS/effect", + snippet: "Source" + } + ] + } + ) + assert.strictEqual(result.id, "abc") + assert.strictEqual(result.results.length, 2) + assert.strictEqual(result.results[0]!.title, "Effect TS") + assert.strictEqual(result.results[0]!.url, "https://effect.website") + assert.strictEqual(result.results[0]!.snippet, "Functional effects") + assert.strictEqual(result.results[0]!.date, "2026-04-01") + assert.strictEqual(result.results[1]!.title, "Effect GitHub") + + assert.isDefined(captured) + assert.strictEqual(captured!.method, "POST") + assert.match(captured!.url, /\/search$/) + assert.deepStrictEqual(captured!.body, { query: "effect typescript", max_results: 2 }) + assert.strictEqual(captured!.headers["authorization"], "Bearer test-api-key") + })) + + it.effect("forwards filter options to the request body", () => + Effect.gen(function*() { + const { captured } = yield* provideSearch( + Effect.gen(function*() { + const search = yield* PerplexitySearch.PerplexitySearch + yield* search.search({ + query: "climate news", + maxResults: 3, + maxTokensPerPage: 512, + recencyFilter: "day", + domainFilter: ["-pinterest.com"], + afterDateFilter: "1/1/2025", + beforeDateFilter: "12/31/2025" + }) + }), + { results: [] } + ) + assert.deepStrictEqual(captured!.body, { + query: "climate news", + max_results: 3, + max_tokens_per_page: 512, + search_recency_filter: "day", + search_domain_filter: ["-pinterest.com"], + search_after_date_filter: "1/1/2025", + search_before_date_filter: "12/31/2025" + }) + })) + + it.effect("fails with InvalidRequestError when domainFilter mixes allow and deny", () => + Effect.gen(function*() { + const { result } = yield* provideSearch( + Effect.gen(function*() { + const search = yield* PerplexitySearch.PerplexitySearch + return yield* search.search({ + query: "x", + domainFilter: ["nytimes.com", "-pinterest.com"] + }).pipe(Effect.flip) + }), + { results: [] } + ) + assert.strictEqual(result.reason._tag, "InvalidRequestError") + assert.match(result.reason.description!, /cannot mix allowlist and denylist/) + })) + + it.effect("fails with AuthenticationError on a 401 status", () => + Effect.gen(function*() { + const { result } = yield* provideSearch( + Effect.gen(function*() { + const search = yield* PerplexitySearch.PerplexitySearch + return yield* search.search({ query: "boom" }).pipe(Effect.flip) + }), + { error: "unauthorized" }, + 401 + ) + assert.strictEqual(result.reason._tag, "AuthenticationError") + })) + + it.effect("fails with InvalidOutputError when the response body cannot be decoded", () => + Effect.gen(function*() { + const { result } = yield* provideSearch( + Effect.gen(function*() { + const search = yield* PerplexitySearch.PerplexitySearch + return yield* search.search({ query: "x" }).pipe(Effect.flip) + }), + { results: "not-an-array" } + ) + assert.strictEqual(result.reason._tag, "InvalidOutputError") + })) + + it.effect("fails with InvalidOutputError when the response body is malformed JSON", () => + Effect.gen(function*() { + const request = yield* Ref.make(undefined) + const client = HttpClient.makeWith< + HttpClientError.HttpClientError, + never, + HttpClientError.HttpClientError, + never + >( + (requestEffect) => + Effect.flatMap(requestEffect, (capturedRequest) => + Effect.gen(function*() { + yield* Ref.set(request, capturedRequest) + return HttpClientResponse.fromWeb( + capturedRequest, + new Response("{not-json", { + status: 200, + headers: { "content-type": "application/json" } + }) + ) + })), + Effect.succeed + ) + const result = yield* Effect.gen(function*() { + const search = yield* PerplexitySearch.PerplexitySearch + return yield* search.search({ query: "x" }) + }).pipe( + Effect.provide( + PerplexitySearch.layer.pipe( + Layer.provide(PerplexityClient.layer({ apiKey: Redacted.make("test-api-key") })), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)) + ) + ), + Effect.flip + ) + assert.strictEqual(result.reason._tag, "InvalidOutputError") + assert.isDefined(yield* Ref.get(request)) + })) + }) +}) + +const parseBody = (text: string): unknown => { + try { + return text.length > 0 ? JSON.parse(text) : undefined + } catch { + return text + } +} diff --git a/packages/ai/perplexity/tsconfig.json b/packages/ai/perplexity/tsconfig.json new file mode 100644 index 0000000000..19a2f5dbca --- /dev/null +++ b/packages/ai/perplexity/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "references": [ + { "path": "../../effect" } + ] +} diff --git a/packages/ai/perplexity/vitest.config.ts b/packages/ai/perplexity/vitest.config.ts new file mode 100644 index 0000000000..c8a52c1826 --- /dev/null +++ b/packages/ai/perplexity/vitest.config.ts @@ -0,0 +1,6 @@ +import { mergeConfig, type ViteUserConfig } from "vitest/config" +import shared from "../../../vitest.shared.ts" + +const config: ViteUserConfig = {} + +export default mergeConfig(shared, config) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84931ce1a5..63ccc93927 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,6 +264,12 @@ importers: specifier: workspace:^ version: link:../../effect + packages/ai/perplexity: + devDependencies: + effect: + specifier: workspace:^ + version: link:../../effect + packages/atom/react: devDependencies: '@testing-library/dom': diff --git a/tsconfig.json b/tsconfig.json index 2d784dade0..3b92cabf8c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -43,6 +43,8 @@ "@effect/ai-openai-compat/*": ["./packages/ai/openai-compat/src/*.ts"], "@effect/ai-openai": ["./packages/ai/openai/src/index.ts"], "@effect/ai-openai/*": ["./packages/ai/openai/src/*.ts"], + "@effect/ai-perplexity": ["./packages/ai/perplexity/src/index.ts"], + "@effect/ai-perplexity/*": ["./packages/ai/perplexity/src/*.ts"], "@effect/atom-react": ["./packages/atom/react/src/index.ts"], "@effect/atom-react/*": ["./packages/atom/react/src/*.ts"], "@effect/atom-vue": ["./packages/atom/vue/src/index.ts"], diff --git a/tsconfig.packages.json b/tsconfig.packages.json index e8bc8d84f9..da9806d74b 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -9,6 +9,7 @@ { "path": "packages/ai/openai-compat" }, { "path": "packages/ai/openai" }, { "path": "packages/ai/openrouter" }, + { "path": "packages/ai/perplexity" }, { "path": "packages/atom/react" }, { "path": "packages/atom/vue" }, { "path": "packages/atom/solid" },