1use crate::multimodal;
2use crate::providers::traits::{
3 ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
4 Provider, ProviderCapabilities, TokenUsage, ToolCall as ProviderToolCall,
5};
6use crate::tools::ToolSpec;
7use async_trait::async_trait;
8use reqwest::Client;
9use serde::de::DeserializeOwned;
10use serde::{Deserialize, Serialize};
11
12pub struct OpenRouterProvider {
13 credential: Option<String>,
14 timeout_secs: u64,
15 max_tokens: Option<u32>,
16}
17
18const DEFAULT_OPENROUTER_TIMEOUT_SECS: u64 = 120;
19const OPENROUTER_CONNECT_TIMEOUT_SECS: u64 = 10;
20
21#[derive(Debug, Serialize)]
22struct ChatRequest {
23 model: String,
24 messages: Vec<Message>,
25 temperature: f64,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 max_tokens: Option<u32>,
28}
29
30#[derive(Debug, Serialize)]
31struct Message {
32 role: String,
33 content: MessageContent,
34}
35
36#[derive(Debug, Serialize)]
37#[serde(untagged)]
38enum MessageContent {
39 Text(String),
40 Parts(Vec<MessagePart>),
41}
42
43#[derive(Debug, Serialize)]
44#[serde(tag = "type", rename_all = "snake_case")]
45enum MessagePart {
46 Text { text: String },
47 ImageUrl { image_url: ImageUrlPart },
48}
49
50#[derive(Debug, Serialize)]
51struct ImageUrlPart {
52 url: String,
53}
54
55#[derive(Debug, Deserialize)]
56struct ApiChatResponse {
57 choices: Vec<Choice>,
58}
59
60#[derive(Debug, Deserialize)]
61struct Choice {
62 message: ResponseMessage,
63}
64
65#[derive(Debug, Deserialize)]
66struct ResponseMessage {
67 content: String,
68}
69
70#[derive(Debug, Serialize)]
71struct NativeChatRequest {
72 model: String,
73 messages: Vec<NativeMessage>,
74 temperature: f64,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 tools: Option<Vec<NativeToolSpec>>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 tool_choice: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 max_tokens: Option<u32>,
81}
82
83#[derive(Debug, Serialize)]
84struct NativeMessage {
85 role: String,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 content: Option<MessageContent>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 tool_call_id: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 tool_calls: Option<Vec<NativeToolCall>>,
92 #[serde(skip_serializing_if = "Option::is_none")]
95 reasoning_content: Option<String>,
96}
97
98#[derive(Debug, Serialize)]
99struct NativeToolSpec {
100 #[serde(rename = "type")]
101 kind: String,
102 function: NativeToolFunctionSpec,
103}
104
105#[derive(Debug, Serialize)]
106struct NativeToolFunctionSpec {
107 name: String,
108 description: String,
109 parameters: serde_json::Value,
110}
111
112#[derive(Debug, Serialize, Deserialize)]
113struct NativeToolCall {
114 #[serde(skip_serializing_if = "Option::is_none")]
115 id: Option<String>,
116 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
117 kind: Option<String>,
118 function: NativeFunctionCall,
119}
120
121#[derive(Debug, Serialize, Deserialize)]
122struct NativeFunctionCall {
123 name: String,
124 arguments: String,
125}
126
127#[derive(Debug, Deserialize)]
128struct NativeChatResponse {
129 choices: Vec<NativeChoice>,
130 #[serde(default)]
131 usage: Option<UsageInfo>,
132}
133
134#[derive(Debug, Deserialize)]
135struct UsageInfo {
136 #[serde(default)]
137 prompt_tokens: Option<u64>,
138 #[serde(default)]
139 completion_tokens: Option<u64>,
140}
141
142#[derive(Debug, Deserialize)]
143struct NativeChoice {
144 message: NativeResponseMessage,
145}
146
147#[derive(Debug, Deserialize)]
148struct NativeResponseMessage {
149 #[serde(default)]
150 content: Option<String>,
151 #[serde(default)]
153 reasoning_content: Option<String>,
154 #[serde(default)]
155 tool_calls: Option<Vec<NativeToolCall>>,
156}
157
158impl OpenRouterProvider {
159 pub fn new(credential: Option<&str>, timeout_secs: Option<u64>) -> Self {
160 Self {
161 credential: credential.map(ToString::to_string),
162 timeout_secs: timeout_secs
163 .filter(|secs| *secs > 0)
164 .unwrap_or(DEFAULT_OPENROUTER_TIMEOUT_SECS),
165 max_tokens: None,
166 }
167 }
168
169 pub fn with_timeout_secs(mut self, secs: u64) -> Self {
171 self.timeout_secs = secs;
172 self
173 }
174
175 pub fn with_max_tokens(mut self, max_tokens: Option<u32>) -> Self {
177 self.max_tokens = max_tokens;
178 self
179 }
180
181 fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
182 let items = tools?;
183 if items.is_empty() {
184 return None;
185 }
186 let valid: Vec<NativeToolSpec> = items
187 .iter()
188 .filter(|tool| is_valid_openai_tool_name(&tool.name))
189 .map(|tool| NativeToolSpec {
190 kind: "function".to_string(),
191 function: NativeToolFunctionSpec {
192 name: tool.name.clone(),
193 description: tool.description.clone(),
194 parameters: tool.parameters.clone(),
195 },
196 })
197 .collect();
198 if valid.is_empty() { None } else { Some(valid) }
199 }
200
201 fn convert_messages(messages: &[ChatMessage]) -> Vec<NativeMessage> {
202 messages
203 .iter()
204 .map(|m| {
205 if m.role == "assistant" {
206 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content) {
207 if let Some(tool_calls_value) = value.get("tool_calls") {
208 if let Ok(parsed_calls) =
209 serde_json::from_value::<Vec<ProviderToolCall>>(
210 tool_calls_value.clone(),
211 )
212 {
213 let tool_calls = parsed_calls
214 .into_iter()
215 .map(|tc| NativeToolCall {
216 id: Some(tc.id),
217 kind: Some("function".to_string()),
218 function: NativeFunctionCall {
219 name: tc.name,
220 arguments: tc.arguments,
221 },
222 })
223 .collect::<Vec<_>>();
224 let content = value
225 .get("content")
226 .and_then(serde_json::Value::as_str)
227 .map(|value| MessageContent::Text(value.to_string()));
228 let reasoning_content = value
229 .get("reasoning_content")
230 .and_then(serde_json::Value::as_str)
231 .map(ToString::to_string);
232 return NativeMessage {
233 role: "assistant".to_string(),
234 content,
235 tool_call_id: None,
236 tool_calls: Some(tool_calls),
237 reasoning_content,
238 };
239 }
240 }
241 }
242 }
243
244 if m.role == "tool" {
245 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content) {
246 let tool_call_id = value
247 .get("tool_call_id")
248 .and_then(serde_json::Value::as_str)
249 .map(ToString::to_string);
250 let content = value
251 .get("content")
252 .and_then(serde_json::Value::as_str)
253 .map(|value| MessageContent::Text(value.to_string()))
254 .or_else(|| Some(MessageContent::Text(m.content.clone())));
255 return NativeMessage {
256 role: "tool".to_string(),
257 content,
258 tool_call_id,
259 tool_calls: None,
260 reasoning_content: None,
261 };
262 }
263 }
264
265 NativeMessage {
266 role: m.role.clone(),
267 content: Some(Self::to_message_content(&m.role, &m.content)),
268 tool_call_id: None,
269 tool_calls: None,
270 reasoning_content: None,
271 }
272 })
273 .collect()
274 }
275
276 fn to_message_content(role: &str, content: &str) -> MessageContent {
277 if role != "user" {
278 return MessageContent::Text(content.to_string());
279 }
280
281 let (cleaned_text, image_refs) = multimodal::parse_image_markers(content);
282 if image_refs.is_empty() {
283 return MessageContent::Text(content.to_string());
284 }
285
286 let mut parts = Vec::with_capacity(image_refs.len() + 1);
287 let trimmed_text = cleaned_text.trim();
288 if !trimmed_text.is_empty() {
289 parts.push(MessagePart::Text {
290 text: trimmed_text.to_string(),
291 });
292 }
293
294 for image_ref in image_refs {
295 parts.push(MessagePart::ImageUrl {
296 image_url: ImageUrlPart { url: image_ref },
297 });
298 }
299
300 MessageContent::Parts(parts)
301 }
302
303 fn parse_native_response(message: NativeResponseMessage) -> ProviderChatResponse {
304 let reasoning_content = message.reasoning_content.clone();
305 let tool_calls = message
306 .tool_calls
307 .unwrap_or_default()
308 .into_iter()
309 .map(|tc| ProviderToolCall {
310 id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
311 name: tc.function.name,
312 arguments: tc.function.arguments,
313 })
314 .collect::<Vec<_>>();
315
316 ProviderChatResponse {
317 text: message.content,
318 tool_calls,
319 usage: None,
320 reasoning_content,
321 }
322 }
323
324 fn compact_sanitized_body_snippet(body: &str) -> String {
325 super::sanitize_api_error(body)
326 .split_whitespace()
327 .collect::<Vec<_>>()
328 .join(" ")
329 }
330
331 async fn read_response_body(
332 provider_name: &str,
333 response: reqwest::Response,
334 ) -> anyhow::Result<String> {
335 response.text().await.map_err(|error| {
336 let sanitized = super::sanitize_api_error(&error.to_string());
337 anyhow::anyhow!(
338 "{provider_name} transport error while reading response body: {sanitized}"
339 )
340 })
341 }
342
343 fn parse_response_body<T: DeserializeOwned>(
344 provider_name: &str,
345 body: &str,
346 kind: &str,
347 ) -> anyhow::Result<T> {
348 serde_json::from_str::<T>(body).map_err(|error| {
349 let snippet = Self::compact_sanitized_body_snippet(body);
350 anyhow::anyhow!(
351 "{provider_name} API returned an unexpected {kind} payload: {error}; body={snippet}"
352 )
353 })
354 }
355
356 fn http_client(&self) -> Client {
357 crate::config::build_runtime_proxy_client_with_timeouts(
358 "provider.openrouter",
359 self.timeout_secs,
360 OPENROUTER_CONNECT_TIMEOUT_SECS,
361 )
362 }
363}
364
365#[async_trait]
366impl Provider for OpenRouterProvider {
367 fn capabilities(&self) -> ProviderCapabilities {
368 ProviderCapabilities {
369 native_tool_calling: true,
370 vision: true,
371 prompt_caching: false,
372 }
373 }
374
375 async fn warmup(&self) -> anyhow::Result<()> {
376 if let Some(credential) = self.credential.as_ref() {
379 self.http_client()
380 .get("https://openrouter.ai/api/v1/auth/key")
381 .header("Authorization", format!("Bearer {credential}"))
382 .send()
383 .await?
384 .error_for_status()?;
385 }
386 Ok(())
387 }
388
389 async fn chat_with_system(
390 &self,
391 system_prompt: Option<&str>,
392 message: &str,
393 model: &str,
394 temperature: f64,
395 ) -> anyhow::Result<String> {
396 let credential = self.credential.as_ref()
397 .ok_or_else(|| anyhow::anyhow!("OpenRouter API key not set. Run `construct onboard` or set OPENROUTER_API_KEY env var."))?;
398
399 let mut messages = Vec::new();
400
401 if let Some(sys) = system_prompt {
402 messages.push(Message {
403 role: "system".to_string(),
404 content: MessageContent::Text(sys.to_string()),
405 });
406 }
407
408 messages.push(Message {
409 role: "user".to_string(),
410 content: Self::to_message_content("user", message),
411 });
412
413 let request = ChatRequest {
414 model: model.to_string(),
415 messages,
416 temperature,
417 max_tokens: self.max_tokens,
418 };
419
420 let response = self
421 .http_client()
422 .post("https://openrouter.ai/api/v1/chat/completions")
423 .header("Authorization", format!("Bearer {credential}"))
424 .header("HTTP-Referer", "https://github.com/KumihoIO/construct")
425 .header("X-Title", "Construct")
426 .json(&request)
427 .send()
428 .await?;
429
430 if !response.status().is_success() {
431 return Err(super::api_error("OpenRouter", response).await);
432 }
433
434 let body = Self::read_response_body("OpenRouter", response).await?;
435 let chat_response =
436 Self::parse_response_body::<ApiChatResponse>("OpenRouter", &body, "chat-completions")?;
437
438 chat_response
439 .choices
440 .into_iter()
441 .next()
442 .map(|c| c.message.content)
443 .ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))
444 }
445
446 async fn chat_with_history(
447 &self,
448 messages: &[ChatMessage],
449 model: &str,
450 temperature: f64,
451 ) -> anyhow::Result<String> {
452 let credential = self.credential.as_ref()
453 .ok_or_else(|| anyhow::anyhow!("OpenRouter API key not set. Run `construct onboard` or set OPENROUTER_API_KEY env var."))?;
454
455 let api_messages: Vec<Message> = messages
456 .iter()
457 .map(|m| Message {
458 role: m.role.clone(),
459 content: Self::to_message_content(&m.role, &m.content),
460 })
461 .collect();
462
463 let request = ChatRequest {
464 model: model.to_string(),
465 messages: api_messages,
466 temperature,
467 max_tokens: self.max_tokens,
468 };
469
470 let response = self
471 .http_client()
472 .post("https://openrouter.ai/api/v1/chat/completions")
473 .header("Authorization", format!("Bearer {credential}"))
474 .header("HTTP-Referer", "https://github.com/KumihoIO/construct")
475 .header("X-Title", "Construct")
476 .json(&request)
477 .send()
478 .await?;
479
480 if !response.status().is_success() {
481 return Err(super::api_error("OpenRouter", response).await);
482 }
483
484 let body = Self::read_response_body("OpenRouter", response).await?;
485 let chat_response =
486 Self::parse_response_body::<ApiChatResponse>("OpenRouter", &body, "chat-completions")?;
487
488 chat_response
489 .choices
490 .into_iter()
491 .next()
492 .map(|c| c.message.content)
493 .ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))
494 }
495
496 async fn chat(
497 &self,
498 request: ProviderChatRequest<'_>,
499 model: &str,
500 temperature: f64,
501 ) -> anyhow::Result<ProviderChatResponse> {
502 let credential = self.credential.as_ref().ok_or_else(|| {
503 anyhow::anyhow!(
504 "OpenRouter API key not set. Run `construct onboard` or set OPENROUTER_API_KEY env var."
505 )
506 })?;
507
508 let tools = Self::convert_tools(request.tools);
509 let native_request = NativeChatRequest {
510 model: model.to_string(),
511 messages: Self::convert_messages(request.messages),
512 temperature,
513 tool_choice: tools.as_ref().map(|_| "auto".to_string()),
514 tools,
515 max_tokens: self.max_tokens,
516 };
517
518 let response = self
519 .http_client()
520 .post("https://openrouter.ai/api/v1/chat/completions")
521 .header("Authorization", format!("Bearer {credential}"))
522 .header("HTTP-Referer", "https://github.com/KumihoIO/construct")
523 .header("X-Title", "Construct")
524 .json(&native_request)
525 .send()
526 .await?;
527
528 if !response.status().is_success() {
529 return Err(super::api_error("OpenRouter", response).await);
530 }
531
532 let body = Self::read_response_body("OpenRouter", response).await?;
533 let native_response =
534 Self::parse_response_body::<NativeChatResponse>("OpenRouter", &body, "native chat")?;
535 let usage = native_response.usage.map(|u| TokenUsage {
536 input_tokens: u.prompt_tokens,
537 output_tokens: u.completion_tokens,
538 cached_input_tokens: None,
539 });
540 let message = native_response
541 .choices
542 .into_iter()
543 .next()
544 .map(|c| c.message)
545 .ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))?;
546 let mut result = Self::parse_native_response(message);
547 result.usage = usage;
548 Ok(result)
549 }
550
551 fn supports_native_tools(&self) -> bool {
552 true
553 }
554
555 async fn chat_with_tools(
556 &self,
557 messages: &[ChatMessage],
558 tools: &[serde_json::Value],
559 model: &str,
560 temperature: f64,
561 ) -> anyhow::Result<ProviderChatResponse> {
562 let credential = self.credential.as_ref().ok_or_else(|| {
563 anyhow::anyhow!(
564 "OpenRouter API key not set. Run `construct onboard` or set OPENROUTER_API_KEY env var."
565 )
566 })?;
567
568 let native_tools: Option<Vec<NativeToolSpec>> = if tools.is_empty() {
570 None
571 } else {
572 let specs: Vec<NativeToolSpec> = tools
573 .iter()
574 .filter_map(|t| {
575 let func = t.get("function")?;
576 Some(NativeToolSpec {
577 kind: "function".to_string(),
578 function: NativeToolFunctionSpec {
579 name: func.get("name")?.as_str()?.to_string(),
580 description: func
581 .get("description")
582 .and_then(|d| d.as_str())
583 .unwrap_or("")
584 .to_string(),
585 parameters: func
586 .get("parameters")
587 .cloned()
588 .unwrap_or(serde_json::json!({})),
589 },
590 })
591 })
592 .collect();
593 if specs.is_empty() { None } else { Some(specs) }
594 };
595
596 let native_messages = Self::convert_messages(messages);
599
600 let native_request = NativeChatRequest {
601 model: model.to_string(),
602 messages: native_messages,
603 temperature,
604 tool_choice: native_tools.as_ref().map(|_| "auto".to_string()),
605 tools: native_tools,
606 max_tokens: self.max_tokens,
607 };
608
609 let response = self
610 .http_client()
611 .post("https://openrouter.ai/api/v1/chat/completions")
612 .header("Authorization", format!("Bearer {credential}"))
613 .header("HTTP-Referer", "https://github.com/KumihoIO/construct")
614 .header("X-Title", "Construct")
615 .json(&native_request)
616 .send()
617 .await?;
618
619 if !response.status().is_success() {
620 return Err(super::api_error("OpenRouter", response).await);
621 }
622
623 let body = Self::read_response_body("OpenRouter", response).await?;
624 let native_response =
625 Self::parse_response_body::<NativeChatResponse>("OpenRouter", &body, "native chat")?;
626 let usage = native_response.usage.map(|u| TokenUsage {
627 input_tokens: u.prompt_tokens,
628 output_tokens: u.completion_tokens,
629 cached_input_tokens: None,
630 });
631 let message = native_response
632 .choices
633 .into_iter()
634 .next()
635 .map(|c| c.message)
636 .ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))?;
637 let mut result = Self::parse_native_response(message);
638 result.usage = usage;
639 Ok(result)
640 }
641}
642
643fn is_valid_openai_tool_name(name: &str) -> bool {
646 !name.is_empty()
647 && name.len() <= 64
648 && name
649 .bytes()
650 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use crate::providers::traits::{ChatMessage, Provider};
657
658 #[test]
659 fn capabilities_report_vision_support() {
660 let provider = OpenRouterProvider::new(Some("openrouter-test-credential"), None);
661 let caps = <OpenRouterProvider as Provider>::capabilities(&provider);
662 assert!(caps.native_tool_calling);
663 assert!(caps.vision);
664 }
665
666 #[test]
667 fn creates_with_key() {
668 let provider = OpenRouterProvider::new(Some("openrouter-test-credential"), None);
669 assert_eq!(
670 provider.credential.as_deref(),
671 Some("openrouter-test-credential")
672 );
673 }
674
675 #[test]
676 fn creates_without_key() {
677 let provider = OpenRouterProvider::new(None, None);
678 assert!(provider.credential.is_none());
679 }
680
681 #[test]
682 fn uses_configured_timeout_when_provided() {
683 let provider = OpenRouterProvider::new(Some("openrouter-test-credential"), Some(1200));
684 assert_eq!(provider.timeout_secs, 1200);
685 }
686
687 #[test]
688 fn falls_back_to_default_timeout_for_zero() {
689 let provider = OpenRouterProvider::new(Some("openrouter-test-credential"), Some(0));
690 assert_eq!(provider.timeout_secs, DEFAULT_OPENROUTER_TIMEOUT_SECS);
691 }
692
693 #[tokio::test]
694 async fn warmup_without_key_is_noop() {
695 let provider = OpenRouterProvider::new(None, None);
696 let result = provider.warmup().await;
697 assert!(result.is_ok());
698 }
699
700 #[tokio::test]
701 async fn chat_with_system_fails_without_key() {
702 let provider = OpenRouterProvider::new(None, None);
703 let result = provider
704 .chat_with_system(Some("system"), "hello", "openai/gpt-4o", 0.2)
705 .await;
706
707 assert!(result.is_err());
708 assert!(result.unwrap_err().to_string().contains("API key not set"));
709 }
710
711 #[tokio::test]
712 async fn chat_with_history_fails_without_key() {
713 let provider = OpenRouterProvider::new(None, None);
714 let messages = vec![
715 ChatMessage {
716 role: "system".into(),
717 content: "be concise".into(),
718 },
719 ChatMessage {
720 role: "user".into(),
721 content: "hello".into(),
722 },
723 ];
724
725 let result = provider
726 .chat_with_history(&messages, "anthropic/claude-sonnet-4", 0.7)
727 .await;
728
729 assert!(result.is_err());
730 assert!(result.unwrap_err().to_string().contains("API key not set"));
731 }
732
733 #[test]
734 fn chat_request_serializes_with_system_and_user() {
735 let request = ChatRequest {
736 model: "anthropic/claude-sonnet-4".into(),
737 messages: vec![
738 Message {
739 role: "system".into(),
740 content: MessageContent::Text("You are helpful".into()),
741 },
742 Message {
743 role: "user".into(),
744 content: MessageContent::Text("Summarize this".into()),
745 },
746 ],
747 temperature: 0.5,
748 max_tokens: None,
749 };
750
751 let json = serde_json::to_string(&request).unwrap();
752
753 assert!(json.contains("anthropic/claude-sonnet-4"));
754 assert!(json.contains("\"role\":\"system\""));
755 assert!(json.contains("\"role\":\"user\""));
756 assert!(json.contains("\"temperature\":0.5"));
757 }
758
759 #[test]
760 fn chat_request_serializes_history_messages() {
761 let messages = [
762 ChatMessage {
763 role: "assistant".into(),
764 content: "Previous answer".into(),
765 },
766 ChatMessage {
767 role: "user".into(),
768 content: "Follow-up".into(),
769 },
770 ];
771
772 let request = ChatRequest {
773 model: "google/gemini-2.5-pro".into(),
774 messages: messages
775 .iter()
776 .map(|msg| Message {
777 role: msg.role.clone(),
778 content: MessageContent::Text(msg.content.clone()),
779 })
780 .collect(),
781 temperature: 0.0,
782 max_tokens: None,
783 };
784
785 let json = serde_json::to_string(&request).unwrap();
786 assert!(json.contains("\"role\":\"assistant\""));
787 assert!(json.contains("\"role\":\"user\""));
788 assert!(json.contains("google/gemini-2.5-pro"));
789 }
790
791 #[test]
792 fn response_deserializes_single_choice() {
793 let json = r#"{"choices":[{"message":{"content":"Hi from OpenRouter"}}]}"#;
794
795 let response: ApiChatResponse = serde_json::from_str(json).unwrap();
796
797 assert_eq!(response.choices.len(), 1);
798 assert_eq!(response.choices[0].message.content, "Hi from OpenRouter");
799 }
800
801 #[test]
802 fn response_deserializes_empty_choices() {
803 let json = r#"{"choices":[]}"#;
804
805 let response: ApiChatResponse = serde_json::from_str(json).unwrap();
806
807 assert!(response.choices.is_empty());
808 }
809
810 #[test]
811 fn parse_chat_response_body_reports_sanitized_snippet() {
812 let body = r#"{"choices":"invalid","api_key":"sk-test-secret-value"}"#;
813 let err = OpenRouterProvider::parse_response_body::<ApiChatResponse>(
814 "OpenRouter",
815 body,
816 "chat-completions",
817 )
818 .expect_err("payload should fail");
819 let msg = err.to_string();
820
821 assert!(msg.contains("OpenRouter API returned an unexpected chat-completions payload"));
822 assert!(msg.contains("body="));
823 assert!(msg.contains("[REDACTED]"));
824 assert!(!msg.contains("sk-test-secret-value"));
825 }
826
827 #[test]
828 fn parse_native_response_body_reports_sanitized_snippet() {
829 let body = r#"{"choices":123,"api_key":"sk-another-secret"}"#;
830 let err = OpenRouterProvider::parse_response_body::<NativeChatResponse>(
831 "OpenRouter",
832 body,
833 "native chat",
834 )
835 .expect_err("payload should fail");
836 let msg = err.to_string();
837
838 assert!(msg.contains("OpenRouter API returned an unexpected native chat payload"));
839 assert!(msg.contains("body="));
840 assert!(msg.contains("[REDACTED]"));
841 assert!(!msg.contains("sk-another-secret"));
842 }
843
844 #[tokio::test]
845 async fn chat_with_tools_fails_without_key() {
846 let provider = OpenRouterProvider::new(None, None);
847 let messages = vec![ChatMessage {
848 role: "user".into(),
849 content: "What is the date?".into(),
850 }];
851 let tools = vec![serde_json::json!({
852 "type": "function",
853 "function": {
854 "name": "shell",
855 "description": "Run a shell command",
856 "parameters": {"type": "object", "properties": {"command": {"type": "string"}}}
857 }
858 })];
859
860 let result = provider
861 .chat_with_tools(&messages, &tools, "deepseek/deepseek-chat", 0.5)
862 .await;
863
864 assert!(result.is_err());
865 assert!(result.unwrap_err().to_string().contains("API key not set"));
866 }
867
868 #[test]
869 fn native_response_deserializes_with_tool_calls() {
870 let json = r#"{
871 "choices":[{
872 "message":{
873 "content":null,
874 "tool_calls":[
875 {"id":"call_123","type":"function","function":{"name":"get_price","arguments":"{\"symbol\":\"BTC\"}"}}
876 ]
877 }
878 }]
879 }"#;
880
881 let response: NativeChatResponse = serde_json::from_str(json).unwrap();
882
883 assert_eq!(response.choices.len(), 1);
884 let message = &response.choices[0].message;
885 assert!(message.content.is_none());
886 let tool_calls = message.tool_calls.as_ref().unwrap();
887 assert_eq!(tool_calls.len(), 1);
888 assert_eq!(tool_calls[0].id.as_deref(), Some("call_123"));
889 assert_eq!(tool_calls[0].function.name, "get_price");
890 assert_eq!(tool_calls[0].function.arguments, "{\"symbol\":\"BTC\"}");
891 }
892
893 #[test]
894 fn native_response_deserializes_with_text_and_tool_calls() {
895 let json = r#"{
896 "choices":[{
897 "message":{
898 "content":"I'll get that for you.",
899 "tool_calls":[
900 {"id":"call_456","type":"function","function":{"name":"shell","arguments":"{\"command\":\"date\"}"}}
901 ]
902 }
903 }]
904 }"#;
905
906 let response: NativeChatResponse = serde_json::from_str(json).unwrap();
907
908 assert_eq!(response.choices.len(), 1);
909 let message = &response.choices[0].message;
910 assert_eq!(message.content.as_deref(), Some("I'll get that for you."));
911 let tool_calls = message.tool_calls.as_ref().unwrap();
912 assert_eq!(tool_calls.len(), 1);
913 assert_eq!(tool_calls[0].function.name, "shell");
914 }
915
916 #[test]
917 fn parse_native_response_converts_to_chat_response() {
918 let message = NativeResponseMessage {
919 content: Some("Here you go.".into()),
920 reasoning_content: None,
921 tool_calls: Some(vec![NativeToolCall {
922 id: Some("call_789".into()),
923 kind: Some("function".into()),
924 function: NativeFunctionCall {
925 name: "file_read".into(),
926 arguments: r#"{"path":"test.txt"}"#.into(),
927 },
928 }]),
929 };
930
931 let response = OpenRouterProvider::parse_native_response(message);
932
933 assert_eq!(response.text.as_deref(), Some("Here you go."));
934 assert_eq!(response.tool_calls.len(), 1);
935 assert_eq!(response.tool_calls[0].id, "call_789");
936 assert_eq!(response.tool_calls[0].name, "file_read");
937 }
938
939 #[test]
940 fn convert_messages_parses_assistant_tool_call_payload() {
941 let messages = vec![ChatMessage {
942 role: "assistant".into(),
943 content: r#"{"content":"Using tool","tool_calls":[{"id":"call_abc","name":"shell","arguments":"{\"command\":\"pwd\"}"}]}"#
944 .into(),
945 }];
946
947 let converted = OpenRouterProvider::convert_messages(&messages);
948 assert_eq!(converted.len(), 1);
949 assert_eq!(converted[0].role, "assistant");
950 assert_eq!(
951 converted[0]
952 .content
953 .as_ref()
954 .and_then(|content| match content {
955 MessageContent::Text(value) => Some(value.as_str()),
956 MessageContent::Parts(_) => None,
957 }),
958 Some("Using tool")
959 );
960
961 let tool_calls = converted[0].tool_calls.as_ref().unwrap();
962 assert_eq!(tool_calls.len(), 1);
963 assert_eq!(tool_calls[0].id.as_deref(), Some("call_abc"));
964 assert_eq!(tool_calls[0].function.name, "shell");
965 assert_eq!(tool_calls[0].function.arguments, r#"{"command":"pwd"}"#);
966 }
967
968 #[test]
969 fn convert_messages_parses_tool_result_payload() {
970 let messages = vec![ChatMessage {
971 role: "tool".into(),
972 content: r#"{"tool_call_id":"call_xyz","content":"done"}"#.into(),
973 }];
974
975 let converted = OpenRouterProvider::convert_messages(&messages);
976 assert_eq!(converted.len(), 1);
977 assert_eq!(converted[0].role, "tool");
978 assert_eq!(converted[0].tool_call_id.as_deref(), Some("call_xyz"));
979 assert_eq!(
980 converted[0]
981 .content
982 .as_ref()
983 .and_then(|content| match content {
984 MessageContent::Text(value) => Some(value.as_str()),
985 MessageContent::Parts(_) => None,
986 }),
987 Some("done")
988 );
989 assert!(converted[0].tool_calls.is_none());
990 }
991
992 #[test]
993 fn to_message_content_converts_image_markers_to_openai_parts() {
994 let content = "Describe this\n\n[IMAGE:data:image/png;base64,abcd]";
995 let value =
996 serde_json::to_value(OpenRouterProvider::to_message_content("user", content)).unwrap();
997 let parts = value
998 .as_array()
999 .expect("multimodal content should be an array");
1000 assert_eq!(parts.len(), 2);
1001 assert_eq!(parts[0]["type"], "text");
1002 assert_eq!(parts[0]["text"], "Describe this");
1003 assert_eq!(parts[1]["type"], "image_url");
1004 assert_eq!(parts[1]["image_url"]["url"], "data:image/png;base64,abcd");
1005 }
1006
1007 #[test]
1008 fn native_response_parses_usage() {
1009 let json = r#"{
1010 "choices": [{"message": {"content": "Hello"}}],
1011 "usage": {"prompt_tokens": 42, "completion_tokens": 15}
1012 }"#;
1013 let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
1014 let usage = resp.usage.unwrap();
1015 assert_eq!(usage.prompt_tokens, Some(42));
1016 assert_eq!(usage.completion_tokens, Some(15));
1017 }
1018
1019 #[test]
1020 fn native_response_parses_without_usage() {
1021 let json = r#"{"choices": [{"message": {"content": "Hello"}}]}"#;
1022 let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
1023 assert!(resp.usage.is_none());
1024 }
1025
1026 #[test]
1031 fn parse_native_response_captures_reasoning_content() {
1032 let message = NativeResponseMessage {
1033 content: Some("answer".into()),
1034 reasoning_content: Some("thinking step".into()),
1035 tool_calls: Some(vec![NativeToolCall {
1036 id: Some("call_1".into()),
1037 kind: Some("function".into()),
1038 function: NativeFunctionCall {
1039 name: "shell".into(),
1040 arguments: "{}".into(),
1041 },
1042 }]),
1043 };
1044 let parsed = OpenRouterProvider::parse_native_response(message);
1045 assert_eq!(parsed.reasoning_content.as_deref(), Some("thinking step"));
1046 assert_eq!(parsed.tool_calls.len(), 1);
1047 }
1048
1049 #[test]
1050 fn parse_native_response_none_reasoning_content_for_normal_model() {
1051 let message = NativeResponseMessage {
1052 content: Some("hello".into()),
1053 reasoning_content: None,
1054 tool_calls: None,
1055 };
1056 let parsed = OpenRouterProvider::parse_native_response(message);
1057 assert!(parsed.reasoning_content.is_none());
1058 }
1059
1060 #[test]
1061 fn native_response_deserializes_reasoning_content() {
1062 let json = r#"{
1063 "choices":[{
1064 "message":{
1065 "content":"answer",
1066 "reasoning_content":"deep thought",
1067 "tool_calls":[
1068 {"id":"call_r1","type":"function","function":{"name":"shell","arguments":"{}"}}
1069 ]
1070 }
1071 }]
1072 }"#;
1073 let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
1074 let message = &resp.choices[0].message;
1075 assert_eq!(message.reasoning_content.as_deref(), Some("deep thought"));
1076 }
1077
1078 #[test]
1079 fn convert_messages_round_trips_reasoning_content() {
1080 let history_json = serde_json::json!({
1081 "content": "I will check",
1082 "tool_calls": [{
1083 "id": "tc_1",
1084 "name": "shell",
1085 "arguments": "{}"
1086 }],
1087 "reasoning_content": "Let me think..."
1088 });
1089
1090 let messages = vec![ChatMessage {
1091 role: "assistant".into(),
1092 content: history_json.to_string(),
1093 }];
1094 let native = OpenRouterProvider::convert_messages(&messages);
1095 assert_eq!(native.len(), 1);
1096 assert_eq!(
1097 native[0].reasoning_content.as_deref(),
1098 Some("Let me think...")
1099 );
1100 }
1101
1102 #[test]
1103 fn convert_messages_no_reasoning_content_when_absent() {
1104 let history_json = serde_json::json!({
1105 "content": "I will check",
1106 "tool_calls": [{
1107 "id": "tc_1",
1108 "name": "shell",
1109 "arguments": "{}"
1110 }]
1111 });
1112
1113 let messages = vec![ChatMessage {
1114 role: "assistant".into(),
1115 content: history_json.to_string(),
1116 }];
1117 let native = OpenRouterProvider::convert_messages(&messages);
1118 assert_eq!(native.len(), 1);
1119 assert!(native[0].reasoning_content.is_none());
1120 }
1121
1122 #[test]
1123 fn native_message_omits_reasoning_content_when_none() {
1124 let msg = NativeMessage {
1125 role: "assistant".to_string(),
1126 content: Some(MessageContent::Text("hi".into())),
1127 tool_call_id: None,
1128 tool_calls: None,
1129 reasoning_content: None,
1130 };
1131 let json = serde_json::to_string(&msg).unwrap();
1132 assert!(!json.contains("reasoning_content"));
1133 }
1134
1135 #[test]
1136 fn native_message_includes_reasoning_content_when_some() {
1137 let msg = NativeMessage {
1138 role: "assistant".to_string(),
1139 content: Some(MessageContent::Text("hi".into())),
1140 tool_call_id: None,
1141 tool_calls: None,
1142 reasoning_content: Some("thinking...".to_string()),
1143 };
1144 let json = serde_json::to_string(&msg).unwrap();
1145 assert!(json.contains("reasoning_content"));
1146 assert!(json.contains("thinking..."));
1147 }
1148
1149 #[test]
1154 fn default_timeout_is_120() {
1155 let provider = OpenRouterProvider::new(Some("key"), None);
1156 assert_eq!(provider.timeout_secs, 120);
1157 }
1158
1159 #[test]
1160 fn with_timeout_secs_overrides_default() {
1161 let provider = OpenRouterProvider::new(Some("key"), None).with_timeout_secs(300);
1162 assert_eq!(provider.timeout_secs, 300);
1163 }
1164
1165 #[test]
1170 fn valid_openai_tool_names() {
1171 assert!(is_valid_openai_tool_name("shell"));
1172 assert!(is_valid_openai_tool_name("file_read"));
1173 assert!(is_valid_openai_tool_name("web-search"));
1174 assert!(is_valid_openai_tool_name("Tool123"));
1175 assert!(is_valid_openai_tool_name("a"));
1176 }
1177
1178 #[test]
1179 fn invalid_openai_tool_names() {
1180 assert!(!is_valid_openai_tool_name(""));
1181 assert!(!is_valid_openai_tool_name("mcp:server.tool"));
1182 assert!(!is_valid_openai_tool_name("node.js"));
1183 assert!(!is_valid_openai_tool_name("tool name"));
1184 assert!(!is_valid_openai_tool_name(
1185 "this_tool_name_is_way_too_long_and_exceeds_the_sixty_four_character_limit_xxxxx"
1186 ));
1187 }
1188
1189 #[test]
1190 fn convert_tools_skips_invalid_names() {
1191 use crate::tools::ToolSpec;
1192
1193 let tools = vec![
1194 ToolSpec {
1195 name: "valid_tool".into(),
1196 description: "A valid tool".into(),
1197 parameters: serde_json::json!({"type": "object"}),
1198 },
1199 ToolSpec {
1200 name: "mcp:server.bad".into(),
1201 description: "Invalid name".into(),
1202 parameters: serde_json::json!({"type": "object"}),
1203 },
1204 ToolSpec {
1205 name: "another-valid".into(),
1206 description: "Also valid".into(),
1207 parameters: serde_json::json!({"type": "object"}),
1208 },
1209 ];
1210
1211 let result = OpenRouterProvider::convert_tools(Some(&tools)).unwrap();
1212 assert_eq!(result.len(), 2);
1213 assert_eq!(result[0].function.name, "valid_tool");
1214 assert_eq!(result[1].function.name, "another-valid");
1215 }
1216
1217 #[test]
1218 fn convert_tools_returns_none_when_all_invalid() {
1219 use crate::tools::ToolSpec;
1220
1221 let tools = vec![ToolSpec {
1222 name: "mcp:bad.name".into(),
1223 description: "Invalid".into(),
1224 parameters: serde_json::json!({"type": "object"}),
1225 }];
1226
1227 assert!(OpenRouterProvider::convert_tools(Some(&tools)).is_none());
1228 }
1229}