diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 0daae55800c1..966e6c14cc46 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -70,6 +70,12 @@ export const Flag = { OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT: copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"), OPENCODE_ENABLE_EXA: truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA"), + OPENCODE_ENABLE_PERPLEXITY: + !falsy("OPENCODE_DISABLE_PERPLEXITY") && + !truthy("OPENCODE_DISABLE_PERPLEXITY") && + (truthy("OPENCODE_ENABLE_PERPLEXITY") || + !!process.env.PERPLEXITY_API_KEY || + !!process.env.PPLX_API_KEY), OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), OPENCODE_EXPERIMENTAL_OXFMT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT"), diff --git a/packages/opencode/src/tool/mcp-perplexity.ts b/packages/opencode/src/tool/mcp-perplexity.ts new file mode 100644 index 000000000000..4c1eb0c9cf84 --- /dev/null +++ b/packages/opencode/src/tool/mcp-perplexity.ts @@ -0,0 +1,76 @@ +import { Duration, Effect, Schema } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { InstallationVersion } from "@opencode-ai/core/installation/version" + +const PERPLEXITY_URL = "https://api.perplexity.ai/search" + +export const SearchArgs = Schema.Struct({ + query: Schema.String, + numResults: Schema.optional(Schema.Number), + contextMaxCharacters: Schema.optional(Schema.Number), +}) + +const PerplexityResultItem = Schema.Struct({ + title: Schema.String, + url: Schema.String, + snippet: Schema.optional(Schema.String), + date: Schema.optional(Schema.String), +}) + +const PerplexityResult = Schema.Struct({ + results: Schema.Array(PerplexityResultItem), +}) + +const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(PerplexityResult)) + +const PerplexityRequest = Schema.Struct({ + query: Schema.String, + max_results: Schema.optional(Schema.Number), +}) + +export const call = ( + http: HttpClient.HttpClient, + args: typeof SearchArgs.Type, + timeout: Duration.Input, +) => + Effect.gen(function* () { + const apiKey = process.env.PERPLEXITY_API_KEY ?? process.env.PPLX_API_KEY + if (!apiKey) return yield* Effect.die(new Error("PERPLEXITY_API_KEY is not set")) + + const request = yield* HttpClientRequest.post(PERPLEXITY_URL).pipe( + HttpClientRequest.bearerToken(apiKey), + HttpClientRequest.setHeader("X-Pplx-Integration", `opencode/${InstallationVersion}`), + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(PerplexityRequest)({ + query: args.query, + max_results: args.numResults ?? 8, + }), + ) + + const response = yield* HttpClient.filterStatusOk(http) + .execute(request) + .pipe( + Effect.timeoutOrElse({ + duration: timeout, + orElse: () => Effect.die(new Error("perplexity websearch request timed out")), + }), + ) + + const body = yield* response.text + const parsed = yield* decode(body) + + let output = parsed.results + .map((r, i) => { + const parts = [`${i + 1}. ${r.title}`, ` ${r.url}`] + if (r.snippet) parts.push(` ${r.snippet}`) + if (r.date) parts.push(` (${r.date})`) + return parts.join("\n") + }) + .join("\n\n") + + if (args.contextMaxCharacters && output.length > args.contextMaxCharacters) { + output = output.slice(0, args.contextMaxCharacters) + } + + return output || undefined + }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a4eb31acc747..8cb943c07f58 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -284,7 +284,11 @@ export const layer: Layer.Layer< const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { const filtered = (yield* all()).filter((tool) => { if (tool.id === WebSearchTool.id) { - return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA + return ( + input.providerID === ProviderID.opencode || + Flag.OPENCODE_ENABLE_EXA || + Flag.OPENCODE_ENABLE_PERPLEXITY + ) } const usePatch = diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index ff4c696a25e7..90208407d22e 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,7 +1,9 @@ import { Effect, Schema } from "effect" import { HttpClient } from "effect/unstable/http" +import { Flag } from "@opencode-ai/core/flag/flag" import * as Tool from "./tool" import * as McpExa from "./mcp-exa" +import * as McpPerplexity from "./mcp-perplexity" import DESCRIPTION from "./websearch.txt" export const Parameters = Schema.Struct({ @@ -11,16 +13,26 @@ export const Parameters = Schema.Struct({ }), livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({ description: - "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')", + "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback'). Exa-only; ignored when Perplexity backend is in use.", }), type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({ - description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", + description: + "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search. Exa-only; ignored when Perplexity backend is in use.", }), contextMaxCharacters: Schema.optional(Schema.Number).annotate({ description: "Maximum characters for context string optimized for LLMs (default: 10000)", }), }) +// Backend selection precedence: +// 1. Perplexity (default) — when PERPLEXITY_API_KEY (or PPLX_API_KEY) is set +// and OPENCODE_DISABLE_PERPLEXITY is not set. +// 2. Exa — when OPENCODE_ENABLE_EXA is set or the hosted opencode provider is in use. +// 3. Otherwise the websearch tool is not registered (see registry.ts). +function usePerplexityBackend() { + return Flag.OPENCODE_ENABLE_PERPLEXITY && !!(process.env.PERPLEXITY_API_KEY ?? process.env.PPLX_API_KEY) +} + export const WebSearchTool = Tool.define( "websearch", Effect.gen(function* () { @@ -46,19 +58,29 @@ export const WebSearchTool = Tool.define( }, }) - const result = yield* McpExa.call( - http, - "web_search_exa", - McpExa.SearchArgs, - { - query: params.query, - type: params.type || "auto", - numResults: params.numResults || 8, - livecrawl: params.livecrawl || "fallback", - contextMaxCharacters: params.contextMaxCharacters, - }, - "25 seconds", - ) + const result = usePerplexityBackend() + ? yield* McpPerplexity.call( + http, + { + query: params.query, + numResults: params.numResults || 8, + contextMaxCharacters: params.contextMaxCharacters, + }, + "25 seconds", + ) + : yield* McpExa.call( + http, + "web_search_exa", + McpExa.SearchArgs, + { + query: params.query, + type: params.type || "auto", + numResults: params.numResults || 8, + livecrawl: params.livecrawl || "fallback", + contextMaxCharacters: params.contextMaxCharacters, + }, + "25 seconds", + ) return { output: result ?? "No search results found. Please try a different query.", diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 601f07cb3a55..45e64ba28b12 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -427,7 +427,7 @@ exports[`tool parameters JSON Schema (wire shape) websearch 1`] = ` "type": "number", }, "livecrawl": { - "description": "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')", + "description": "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback'). Exa-only; ignored when Perplexity backend is in use.", "enum": [ "fallback", "preferred", @@ -443,7 +443,7 @@ exports[`tool parameters JSON Schema (wire shape) websearch 1`] = ` "type": "string", }, "type": { - "description": "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", + "description": "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search. Exa-only; ignored when Perplexity backend is in use.", "enum": [ "auto", "fast", diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 8410c549f292..33330c98847c 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1642,6 +1642,97 @@ OpenCode Zen is a list of tested and verified models provided by the OpenCode te --- +### Perplexity + +Perplexity exposes its [Agent API](https://docs.perplexity.ai/docs/agent-api/quickstart) (OpenAI Responses-compatible) and [Search API](https://docs.perplexity.ai/docs/search/quickstart) for grounded, citation-backed answers. + +1. Create an API key in the [Perplexity API key dashboard](https://www.perplexity.ai/account/api/keys). + +2. Run the `/connect` command and select **Other** (Perplexity is not yet in the built-in directory). + + ```txt + /connect + ``` + +3. Enter `perplexity` as the provider ID and paste your API key. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Add the following to your `opencode.json`. Because the Agent API is [OpenAI Responses-compatible](https://docs.perplexity.ai/docs/agent-api/openai-compatibility), use the `@ai-sdk/openai` package. The Agent API routes to many [third-party models](https://docs.perplexity.ai/docs/agent-api/models), so list the ones you want under `models`. + + ```json title="opencode.json" {5-9} + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "perplexity": { + "npm": "@ai-sdk/openai", + "name": "Perplexity", + "options": { + "baseURL": "https://api.perplexity.ai/v1", + "apiKey": "{env:PERPLEXITY_API_KEY}" + }, + "models": { + "openai/gpt-5.4": { + "name": "GPT-5.4 (via Perplexity)" + }, + "anthropic/claude-sonnet-4-6": { + "name": "Claude Sonnet 4.6 (via Perplexity)" + } + } + } + } + } + ``` + +5. Run `/models` and pick a model. See the [Agent API models](https://docs.perplexity.ai/docs/agent-api/models) page for the full list. [Presets](https://docs.perplexity.ai/docs/agent-api/presets) (for example `pro-search`) and the [Agent API reference](https://docs.perplexity.ai/api-reference/agent-post) cover advanced options like fallback model lists. + + ```txt + /models + ``` + +#### Search API + +For raw, ranked web search results (separate from chat completions), Perplexity also offers the [Search API](https://docs.perplexity.ai/docs/search/quickstart). Two ways to use it from opencode: + +**Direct HTTP** for scripts and one-off calls: + +```bash +curl -X POST https://api.perplexity.ai/search \ + -H "Authorization: Bearer $PERPLEXITY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "latest AI policy updates", + "max_results": 5, + "search_recency_filter": "month" + }' +``` + +**Via MCP** so the agent can call search as a tool. Perplexity ships an [official MCP server](https://github.com/perplexityai/modelcontextprotocol) on npm (`@perplexity-ai/mcp-server`); run it locally over stdio: + +```json title="opencode.json" {4-13} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "perplexity": { + "type": "local", + "command": ["npx", "-y", "@perplexity-ai/mcp-server"], + "environment": { + "PERPLEXITY_API_KEY": "{env:PERPLEXITY_API_KEY}" + } + } + } +} +``` + +The server exposes a `perplexity_search` tool (backed by the Search API) plus optional research and reasoning tools. See the [MCP server README](https://github.com/perplexityai/modelcontextprotocol#available-tools) for the tool list and the [Search API reference](https://docs.perplexity.ai/api-reference/search-post) for available filters and the response shape. + +--- + ### LLM Gateway 1. Head over to the [LLM Gateway dashboard](https://llmgateway.io/dashboard), click **Create API Key**, and copy the key. diff --git a/packages/web/src/content/docs/tools.mdx b/packages/web/src/content/docs/tools.mdx index e8d5e0963aeb..f6be0718aad1 100644 --- a/packages/web/src/content/docs/tools.mdx +++ b/packages/web/src/content/docs/tools.mdx @@ -256,12 +256,21 @@ Allows the LLM to fetch and read web pages. Useful for looking up documentation Search the web for information. +The `websearch` tool supports two backends: + +- **Perplexity Search API** (default when `PERPLEXITY_API_KEY` is set). Calls Perplexity's `/search` REST endpoint directly. +- **Exa**. Used when `OPENCODE_ENABLE_EXA=1` is set or when the hosted `opencode` provider is in use. + +Set `OPENCODE_DISABLE_PERPLEXITY=1` to force Exa even when a Perplexity key is present. + :::note -This tool is only available when using the OpenCode provider or when the `OPENCODE_ENABLE_EXA` environment variable is set to any truthy value (e.g., `true` or `1`). +This tool is only available when using the OpenCode provider, when `PERPLEXITY_API_KEY` is set, or when the `OPENCODE_ENABLE_EXA` environment variable is set to any truthy value (e.g., `true` or `1`). To enable when launching OpenCode: ```bash +PERPLEXITY_API_KEY=pplx-... opencode +# or OPENCODE_ENABLE_EXA=1 opencode ``` @@ -276,9 +285,9 @@ OPENCODE_ENABLE_EXA=1 opencode } ``` -Performs web searches using Exa AI to find relevant information online. Useful for researching topics, finding current events, or gathering information beyond the training data cutoff. +Performs web searches to find relevant information online. Useful for researching topics, finding current events, or gathering information beyond the training data cutoff. -No API key is required — the tool connects directly to Exa AI's hosted MCP service without authentication. +When the Perplexity backend is in use, the tool calls `https://api.perplexity.ai/search` with your `PERPLEXITY_API_KEY`. When the Exa backend is in use, the tool connects directly to Exa AI's hosted MCP service without authentication. :::tip Use `websearch` when you need to find information (discovery), and `webfetch` when you need to retrieve content from a specific URL (retrieval).