Skip to main content

rig_core/providers/
perplexity.rs

1//! Perplexity API client and Rig integration
2//!
3//! # Example
4//! ```no_run
5//! use rig_core::{client::CompletionClient, providers::perplexity};
6//!
7//! # fn run() -> Result<(), Box<dyn std::error::Error>> {
8//! let client = perplexity::Client::new("YOUR_API_KEY")?;
9//!
10//! let sonar = client.completion_model(perplexity::SONAR);
11//! # Ok(())
12//! # }
13//! ```
14use crate::client::BearerAuth;
15use crate::completion::CompletionError;
16use crate::providers::openai;
17use crate::{
18    client::{
19        self, Capabilities, Capable, DebugExt, Nothing, Provider, ProviderBuilder, ProviderClient,
20    },
21    http_client::{self, HttpClientExt},
22};
23
24// ================================================================
25// Main Perplexity Client
26// ================================================================
27const PERPLEXITY_API_BASE_URL: &str = "https://api.perplexity.ai";
28
29#[derive(Debug, Default, Clone, Copy)]
30pub struct PerplexityExt;
31
32#[derive(Debug, Default, Clone, Copy)]
33pub struct PerplexityBuilder;
34
35type PerplexityApiKey = BearerAuth;
36
37impl Provider for PerplexityExt {
38    type Builder = PerplexityBuilder;
39
40    // There is currently no way to verify a perplexity api key without consuming tokens
41    const VERIFY_PATH: &'static str = "";
42}
43
44impl openai::completion::OpenAICompatibleProvider for PerplexityExt {
45    const PROVIDER_NAME: &'static str = "perplexity";
46
47    type StreamingUsage = openai::Usage;
48
49    // Perplexity has no tool-calling support; `tools`/`tool_choice` are
50    // dropped with a warning during request conversion.
51    const SUPPORTS_TOOLS: bool = false;
52
53    // Perplexity's structured-output support predates rig's `output_schema`
54    // mapping; keep the pre-migration behavior of dropping it with a warning.
55    const SUPPORTS_RESPONSE_FORMAT: bool = false;
56
57    // The pre-migration streaming request sent `stream: true` with no
58    // `stream_options`.
59    const STREAM_INCLUDE_USAGE: bool = false;
60
61    type Response = openai::CompletionResponse;
62
63    fn finalize_request_body(&self, body: &mut serde_json::Value) -> Result<(), CompletionError> {
64        // Perplexity historically only accepted plain `{role, content: String}`
65        // messages, and its API accepts only system/user/assistant roles
66        // with strict user/assistant alternation. Strip tool-exchange
67        // remnants from shared histories and flatten text-only content-part
68        // arrays; arrays with non-text parts (e.g. images on sonar models)
69        // are left for the API's multimodal handling.
70        if let Some(messages) = body
71            .get_mut("messages")
72            .and_then(serde_json::Value::as_array_mut)
73        {
74            openai::completion::sanitize_plain_text_history(
75                messages,
76                Some(("\n", true)),
77                false,
78                true,
79            );
80        }
81
82        Ok(())
83    }
84}
85
86impl<H> Capabilities<H> for PerplexityExt {
87    type Completion = Capable<CompletionModel<H>>;
88    type Transcription = Nothing;
89    type Embeddings = Nothing;
90    type ModelListing = Nothing;
91    #[cfg(feature = "image")]
92    type ImageGeneration = Nothing;
93
94    #[cfg(feature = "audio")]
95    type AudioGeneration = Nothing;
96    type Rerank = Nothing;
97}
98
99impl DebugExt for PerplexityExt {}
100
101impl ProviderBuilder for PerplexityBuilder {
102    type Extension<H>
103        = PerplexityExt
104    where
105        H: HttpClientExt;
106    type ApiKey = PerplexityApiKey;
107
108    const BASE_URL: &'static str = PERPLEXITY_API_BASE_URL;
109
110    fn build<H>(
111        _builder: &crate::client::ClientBuilder<Self, Self::ApiKey, H>,
112    ) -> http_client::Result<Self::Extension<H>>
113    where
114        H: HttpClientExt,
115    {
116        Ok(PerplexityExt)
117    }
118}
119
120pub type Client<H = reqwest::Client> = client::Client<PerplexityExt, H>;
121pub type ClientBuilder<H = crate::markers::Missing> =
122    client::ClientBuilder<PerplexityBuilder, PerplexityApiKey, H>;
123
124/// Perplexity completion model, driven by the shared OpenAI Chat Completions path.
125pub type CompletionModel<H = reqwest::Client> =
126    openai::completion::GenericCompletionModel<PerplexityExt, H>;
127
128/// Raw completion payload, shared with the OpenAI Chat Completions path.
129pub type CompletionResponse = openai::CompletionResponse;
130
131impl ProviderClient for Client {
132    type Input = String;
133    type Error = crate::client::ProviderClientError;
134
135    /// Create a new Perplexity client from the `PERPLEXITY_API_KEY` environment variable.
136    fn from_env() -> Result<Self, Self::Error> {
137        let api_key = crate::client::required_env_var("PERPLEXITY_API_KEY")?;
138        Self::new(&api_key).map_err(Into::into)
139    }
140
141    fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
142        Self::new(&input).map_err(Into::into)
143    }
144}
145
146// ================================================================
147// Perplexity Completion API
148// ================================================================
149
150pub const SONAR_PRO: &str = "sonar_pro";
151pub const SONAR: &str = "sonar";
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::providers::openai::completion::{
157        CompletionRequest as OpenAICompletionRequest, OpenAICompatibleProvider, OpenAIRequestParams,
158    };
159    use crate::test_utils::MockCompletionModel;
160
161    #[test]
162    fn test_client_initialization() {
163        let _client =
164            crate::providers::perplexity::Client::new("dummy-key").expect("Client::new() failed");
165        let _client_from_builder = crate::providers::perplexity::Client::builder()
166            .api_key("dummy-key")
167            .build()
168            .expect("Client::builder() failed");
169    }
170
171    #[test]
172    fn perplexity_finalize_flattens_text_only_content_arrays() {
173        let mut body = serde_json::json!({
174            "model": SONAR,
175            "messages": [
176                {"role": "system", "content": [{"type": "text", "text": "Be brief."}]},
177                {"role": "user", "content": [
178                    {"type": "text", "text": "First."},
179                    {"type": "text", "text": "Second."}
180                ]},
181                {"role": "user", "content": [
182                    {"type": "text", "text": "Look:"},
183                    {"type": "image_url", "image_url": {"url": "https://example.com/i.png"}}
184                ]}
185            ]
186        });
187
188        PerplexityExt
189            .finalize_request_body(&mut body)
190            .expect("finalize should succeed");
191
192        assert_eq!(body["messages"][0]["content"], "Be brief.");
193        assert_eq!(body["messages"][1]["content"], "First.\nSecond.");
194        // Mixed content stays an array for the API's multimodal handling.
195        assert!(body["messages"][2]["content"].is_array());
196    }
197
198    #[test]
199    fn perplexity_drops_tool_choice_instead_of_erroring() {
200        // Multi-name Specific errors on tool-supporting providers; with
201        // SUPPORTS_TOOLS = false it must be dropped before that validation.
202        let mut request = crate::completion::CompletionRequest {
203            model: None,
204            preamble: None,
205            chat_history: crate::OneOrMany::one("Hello!".into()),
206            documents: vec![],
207            max_tokens: None,
208            temperature: None,
209            tools: vec![],
210            tool_choice: Some(crate::message::ToolChoice::Specific {
211                function_names: vec!["a".to_string(), "b".to_string()],
212            }),
213            additional_params: None,
214            output_schema: None,
215        };
216        request.tools = vec![crate::completion::ToolDefinition {
217            name: "lookup".to_string(),
218            description: String::new(),
219            parameters: serde_json::json!({}),
220        }];
221
222        let converted = OpenAICompletionRequest::try_from(OpenAIRequestParams {
223            model: SONAR.to_string(),
224            request,
225            strict_tools: false,
226            tool_result_array_content: false,
227            supports_response_format: PerplexityExt::SUPPORTS_RESPONSE_FORMAT,
228            supports_tools: PerplexityExt::SUPPORTS_TOOLS,
229        })
230        .expect("unsupported tools should be dropped, not an error");
231
232        let json = serde_json::to_value(converted).expect("request should serialize");
233        assert!(
234            json.get("tools")
235                .is_none_or(|tools| tools.as_array().is_none_or(|tools| tools.is_empty()))
236        );
237        assert!(json.get("tool_choice").is_none());
238    }
239
240    #[test]
241    fn perplexity_finalize_strips_tool_history_and_preserves_alternation() {
242        let mut body = serde_json::json!({
243            "model": SONAR,
244            "messages": [
245                {"role": "user", "content": "Look it up."},
246                {"role": "assistant", "tool_calls": [
247                    {"id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}}
248                ]},
249                {"role": "tool", "tool_call_id": "call_1", "content": "result"},
250                {"role": "assistant", "content": "It is crimson.", "reasoning_content": "hmm"},
251                {"role": "user", "content": "Thanks!"}
252            ]
253        });
254
255        PerplexityExt
256            .finalize_request_body(&mut body)
257            .expect("finalize should succeed");
258
259        let messages = body["messages"].as_array().expect("messages array");
260        let roles = messages
261            .iter()
262            .map(|m| m["role"].as_str().unwrap_or_default())
263            .collect::<Vec<_>>();
264        assert_eq!(roles, ["user", "assistant", "user"]);
265        assert_eq!(messages[1]["content"], "It is crimson.");
266        assert!(messages[1].get("reasoning_content").is_none());
267        assert!(messages[1].get("tool_calls").is_none());
268    }
269
270    #[test]
271    fn perplexity_prepare_request_drops_tools() {
272        let request = crate::completion::CompletionRequestBuilder::new(
273            MockCompletionModel::default(),
274            "What's new today?",
275        )
276        .tool(crate::completion::ToolDefinition {
277            name: "lookup".to_string(),
278            description: "Lookup".to_string(),
279            parameters: serde_json::json!({"type":"object","properties":{},"required":[]}),
280        })
281        .tool_choice(crate::message::ToolChoice::Required)
282        .build();
283
284        let mut request = OpenAICompletionRequest::try_from(OpenAIRequestParams {
285            model: SONAR.to_string(),
286            request,
287            strict_tools: false,
288            tool_result_array_content: false,
289            supports_response_format: PerplexityExt::SUPPORTS_RESPONSE_FORMAT,
290            supports_tools: false,
291        })
292        .expect("request should convert");
293        PerplexityExt
294            .prepare_request(&mut request)
295            .expect("prepare_request should succeed");
296
297        let body = serde_json::to_value(request).expect("request should serialize");
298        assert!(body.get("tools").is_none());
299        assert!(body.get("tool_choice").is_none());
300    }
301}