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