Skip to main content

construct/providers/
openrouter.rs

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    /// Raw reasoning content from thinking models; pass-through for providers
93    /// that require it in assistant tool-call history messages.
94    #[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    /// Reasoning/thinking models may return output in `reasoning_content`.
152    #[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    /// Override the HTTP request timeout for LLM API calls.
170    pub fn with_timeout_secs(mut self, secs: u64) -> Self {
171        self.timeout_secs = secs;
172        self
173    }
174
175    /// Set the maximum output tokens for API requests.
176    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        // Hit a lightweight endpoint to establish TLS + HTTP/2 connection pool.
377        // This prevents the first real chat request from timing out on cold start.
378        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        // Convert tool JSON values to NativeToolSpec
569        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        // Convert ChatMessage to NativeMessage, preserving structured assistant/tool entries
597        // when history contains native tool-call metadata.
598        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
643/// Check if a tool name is valid for OpenAI-compatible APIs.
644/// Must match `^[a-zA-Z0-9_-]{1,64}$`.
645fn 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    // ═══════════════════════════════════════════════════════════════════════
1027    // reasoning_content pass-through tests
1028    // ═══════════════════════════════════════════════════════════════════════
1029
1030    #[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    // ═══════════════════════════════════════════════════════════════════════
1150    // timeout_secs configuration tests
1151    // ═══════════════════════════════════════════════════════════════════════
1152
1153    #[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    // ═══════════════════════════════════════════════════════════════════════
1166    // tool name validation tests
1167    // ═══════════════════════════════════════════════════════════════════════
1168
1169    #[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}