1use crate::client::BearerAuth;
15use crate::client::{self, DebugExt, Provider};
16use crate::completion::CompletionError;
17use crate::providers::openai;
18
19const 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 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 const SUPPORTS_TOOLS: bool = false;
47
48 const SUPPORTS_RESPONSE_FORMAT: bool = false;
51
52 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 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
95pub type CompletionModel<H = reqwest::Client> =
97 openai::completion::GenericCompletionModel<PerplexityExt, H>;
98
99pub type CompletionResponse = openai::CompletionResponse;
101
102client::impl_provider_client!(Client, input = String, api_key_env = "PERPLEXITY_API_KEY");
103
104pub 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 assert!(body["messages"][2]["content"].is_array());
154 }
155
156 #[test]
157 fn perplexity_drops_tool_choice_instead_of_erroring() {
158 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}