1use 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
24const 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 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 const SUPPORTS_TOOLS: bool = false;
52
53 const SUPPORTS_RESPONSE_FORMAT: bool = false;
56
57 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 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
124pub type CompletionModel<H = reqwest::Client> =
126 openai::completion::GenericCompletionModel<PerplexityExt, H>;
127
128pub type CompletionResponse = openai::CompletionResponse;
130
131impl ProviderClient for Client {
132 type Input = String;
133 type Error = crate::client::ProviderClientError;
134
135 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
146pub 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 assert!(body["messages"][2]["content"].is_array());
196 }
197
198 #[test]
199 fn perplexity_drops_tool_choice_instead_of_erroring() {
200 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}