1use super::client::{OpenRouterExt, Usage};
2use crate::message::{self, DocumentMediaType, DocumentSourceKind, MimeType};
3use crate::telemetry::ProviderResponseExt;
4use crate::{
5 OneOrMany,
6 completion::{self, CompletionError, CompletionRequest},
7 json_utils,
8 providers::openai,
9};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13pub const QWEN_QWQ_32B: &str = "qwen/qwq-32b";
19pub const CLAUDE_3_7_SONNET: &str = "anthropic/claude-3.7-sonnet";
21pub const PERPLEXITY_SONAR_PRO: &str = "perplexity/sonar-pro";
23pub const GEMINI_FLASH_2_0: &str = "google/gemini-2.0-flash-001";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
35#[serde(rename_all = "lowercase")]
36pub enum DataCollection {
37 #[default]
39 Allow,
40 Deny,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum Quantization {
50 #[serde(rename = "int4")]
52 Int4,
53 #[serde(rename = "int8")]
55 Int8,
56 #[serde(rename = "fp16")]
58 Fp16,
59 #[serde(rename = "bf16")]
61 Bf16,
62 #[serde(rename = "fp32")]
64 Fp32,
65 #[serde(rename = "fp8")]
67 Fp8,
68 #[serde(rename = "unknown")]
70 Unknown,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum ProviderSortStrategy {
81 Price,
83 Throughput,
85 Latency,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "lowercase")]
92pub enum SortPartition {
93 Model,
95 None,
97}
98
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103pub struct ProviderSortConfig {
104 pub by: ProviderSortStrategy,
106
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub partition: Option<SortPartition>,
110}
111
112impl ProviderSortConfig {
113 pub fn new(by: ProviderSortStrategy) -> Self {
115 Self {
116 by,
117 partition: None,
118 }
119 }
120
121 pub fn partition(mut self, partition: SortPartition) -> Self {
123 self.partition = Some(partition);
124 self
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133#[serde(untagged)]
134pub enum ProviderSort {
135 Simple(ProviderSortStrategy),
137 Complex(ProviderSortConfig),
139}
140
141impl From<ProviderSortStrategy> for ProviderSort {
142 fn from(strategy: ProviderSortStrategy) -> Self {
143 ProviderSort::Simple(strategy)
144 }
145}
146
147impl From<ProviderSortConfig> for ProviderSort {
148 fn from(config: ProviderSortConfig) -> Self {
149 ProviderSort::Complex(config)
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157#[serde(untagged)]
158pub enum ThroughputThreshold {
159 Simple(f64),
161 Percentile(PercentileThresholds),
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169#[serde(untagged)]
170pub enum LatencyThreshold {
171 Simple(f64),
173 Percentile(PercentileThresholds),
175}
176
177#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
179pub struct PercentileThresholds {
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub p50: Option<f64>,
183 #[serde(skip_serializing_if = "Option::is_none")]
185 pub p75: Option<f64>,
186 #[serde(skip_serializing_if = "Option::is_none")]
188 pub p90: Option<f64>,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub p99: Option<f64>,
192}
193
194impl PercentileThresholds {
195 pub fn new() -> Self {
197 Self::default()
198 }
199
200 pub fn p50(mut self, value: f64) -> Self {
202 self.p50 = Some(value);
203 self
204 }
205
206 pub fn p75(mut self, value: f64) -> Self {
208 self.p75 = Some(value);
209 self
210 }
211
212 pub fn p90(mut self, value: f64) -> Self {
214 self.p90 = Some(value);
215 self
216 }
217
218 pub fn p99(mut self, value: f64) -> Self {
220 self.p99 = Some(value);
221 self
222 }
223}
224
225#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
230pub struct MaxPrice {
231 #[serde(skip_serializing_if = "Option::is_none")]
233 pub prompt: Option<f64>,
234 #[serde(skip_serializing_if = "Option::is_none")]
236 pub completion: Option<f64>,
237 #[serde(skip_serializing_if = "Option::is_none")]
239 pub request: Option<f64>,
240 #[serde(skip_serializing_if = "Option::is_none")]
242 pub image: Option<f64>,
243}
244
245impl MaxPrice {
246 pub fn new() -> Self {
248 Self::default()
249 }
250
251 pub fn prompt(mut self, price: f64) -> Self {
253 self.prompt = Some(price);
254 self
255 }
256
257 pub fn completion(mut self, price: f64) -> Self {
259 self.completion = Some(price);
260 self
261 }
262
263 pub fn request(mut self, price: f64) -> Self {
265 self.request = Some(price);
266 self
267 }
268
269 pub fn image(mut self, price: f64) -> Self {
271 self.image = Some(price);
272 self
273 }
274}
275
276#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
296pub struct ProviderPreferences {
297 #[serde(skip_serializing_if = "Option::is_none")]
301 pub order: Option<Vec<String>>,
302
303 #[serde(skip_serializing_if = "Option::is_none")]
305 pub only: Option<Vec<String>>,
306
307 #[serde(skip_serializing_if = "Option::is_none")]
309 pub ignore: Option<Vec<String>>,
310
311 #[serde(skip_serializing_if = "Option::is_none")]
314 pub allow_fallbacks: Option<bool>,
315
316 #[serde(skip_serializing_if = "Option::is_none")]
323 pub require_parameters: Option<bool>,
324
325 #[serde(skip_serializing_if = "Option::is_none")]
328 pub data_collection: Option<DataCollection>,
329
330 #[serde(skip_serializing_if = "Option::is_none")]
332 pub zdr: Option<bool>,
333
334 #[serde(skip_serializing_if = "Option::is_none")]
338 pub sort: Option<ProviderSort>,
339
340 #[serde(skip_serializing_if = "Option::is_none")]
342 pub preferred_min_throughput: Option<ThroughputThreshold>,
343
344 #[serde(skip_serializing_if = "Option::is_none")]
346 pub preferred_max_latency: Option<LatencyThreshold>,
347
348 #[serde(skip_serializing_if = "Option::is_none")]
350 pub max_price: Option<MaxPrice>,
351
352 #[serde(skip_serializing_if = "Option::is_none")]
355 pub quantizations: Option<Vec<Quantization>>,
356}
357
358impl ProviderPreferences {
359 pub fn new() -> Self {
361 Self::default()
362 }
363
364 pub fn order(mut self, providers: impl IntoIterator<Item = impl Into<String>>) -> Self {
380 self.order = Some(providers.into_iter().map(|p| p.into()).collect());
381 self
382 }
383
384 pub fn only(mut self, providers: impl IntoIterator<Item = impl Into<String>>) -> Self {
396 self.only = Some(providers.into_iter().map(|p| p.into()).collect());
397 self
398 }
399
400 pub fn ignore(mut self, providers: impl IntoIterator<Item = impl Into<String>>) -> Self {
411 self.ignore = Some(providers.into_iter().map(|p| p.into()).collect());
412 self
413 }
414
415 pub fn allow_fallbacks(mut self, allow: bool) -> Self {
420 self.allow_fallbacks = Some(allow);
421 self
422 }
423
424 pub fn require_parameters(mut self, require: bool) -> Self {
430 self.require_parameters = Some(require);
431 self
432 }
433
434 pub fn data_collection(mut self, policy: DataCollection) -> Self {
438 self.data_collection = Some(policy);
439 self
440 }
441
442 pub fn zdr(mut self, enable: bool) -> Self {
453 self.zdr = Some(enable);
454 self
455 }
456
457 pub fn sort(mut self, sort: impl Into<ProviderSort>) -> Self {
473 self.sort = Some(sort.into());
474 self
475 }
476
477 pub fn preferred_min_throughput(mut self, threshold: ThroughputThreshold) -> Self {
497 self.preferred_min_throughput = Some(threshold);
498 self
499 }
500
501 pub fn preferred_max_latency(mut self, threshold: LatencyThreshold) -> Self {
505 self.preferred_max_latency = Some(threshold);
506 self
507 }
508
509 pub fn max_price(mut self, price: MaxPrice) -> Self {
513 self.max_price = Some(price);
514 self
515 }
516
517 pub fn quantizations(mut self, quantizations: impl IntoIterator<Item = Quantization>) -> Self {
530 self.quantizations = Some(quantizations.into_iter().collect());
531 self
532 }
533
534 pub fn zero_data_retention(self) -> Self {
538 self.zdr(true)
539 }
540
541 pub fn fastest(self) -> Self {
543 self.sort(ProviderSortStrategy::Throughput)
544 }
545
546 pub fn cheapest(self) -> Self {
548 self.sort(ProviderSortStrategy::Price)
549 }
550
551 pub fn lowest_latency(self) -> Self {
553 self.sort(ProviderSortStrategy::Latency)
554 }
555
556 pub fn to_json(&self) -> serde_json::Value {
558 serde_json::json!({
559 "provider": self
560 })
561 }
562}
563
564#[derive(Clone, Debug, Serialize, Deserialize)]
568pub struct CompletionResponse {
569 pub id: String,
570 pub object: String,
571 pub created: u64,
572 pub model: String,
573 pub choices: Vec<Choice>,
574 pub system_fingerprint: Option<String>,
575 pub usage: Option<Usage>,
576}
577
578impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionResponse> {
579 type Error = CompletionError;
580
581 fn try_from(response: CompletionResponse) -> Result<Self, Self::Error> {
582 let choice = response.choices.first().ok_or_else(|| {
583 CompletionError::ResponseError("Response contained no choices".to_owned())
584 })?;
585
586 let content = match &choice.message {
587 Message::Assistant {
588 content,
589 tool_calls,
590 reasoning,
591 reasoning_details,
592 images,
593 ..
594 } => {
595 let mut content = content
596 .iter()
597 .map(|c| match c {
598 openai::AssistantContent::Text { text, .. } => {
599 completion::AssistantContent::text(text)
600 }
601 openai::AssistantContent::Refusal { refusal } => {
602 completion::AssistantContent::text(refusal)
603 }
604 })
605 .collect::<Vec<_>>();
606
607 content.extend(tool_calls.iter().map(|call| {
608 completion::AssistantContent::tool_call(
609 &call.id,
610 &call.function.name,
611 call.function.arguments.clone(),
612 )
613 }));
614
615 let mut grouped_reasoning: HashMap<
616 Option<String>,
617 Vec<(usize, usize, message::ReasoningContent)>,
618 > = HashMap::new();
619 let mut reasoning_order: Vec<Option<String>> = Vec::new();
620 for (position, detail) in reasoning_details.iter().enumerate() {
621 let (reasoning_id, sort_index, parsed_content) = match detail {
622 ReasoningDetails::Summary {
623 id, index, summary, ..
624 } => (
625 id.clone(),
626 *index,
627 Some(message::ReasoningContent::Summary(summary.clone())),
628 ),
629 ReasoningDetails::Encrypted {
630 id, index, data, ..
631 } => (
632 id.clone(),
633 *index,
634 Some(message::ReasoningContent::Encrypted(data.clone())),
635 ),
636 ReasoningDetails::Text {
637 id,
638 index,
639 text,
640 signature,
641 ..
642 } => (
643 id.clone(),
644 *index,
645 text.as_ref().map(|text| message::ReasoningContent::Text {
646 text: text.clone(),
647 signature: signature.clone(),
648 }),
649 ),
650 };
651
652 let Some(parsed_content) = parsed_content else {
653 continue;
654 };
655 let sort_index = sort_index.unwrap_or(position);
656
657 let entry = grouped_reasoning.entry(reasoning_id.clone());
658 if matches!(entry, std::collections::hash_map::Entry::Vacant(_)) {
659 reasoning_order.push(reasoning_id);
660 }
661 entry
662 .or_default()
663 .push((sort_index, position, parsed_content));
664 }
665
666 if grouped_reasoning.is_empty() {
667 if let Some(reasoning) = reasoning {
668 content.push(completion::AssistantContent::reasoning(reasoning));
669 }
670 } else {
671 for reasoning_id in reasoning_order {
672 let Some(mut blocks) = grouped_reasoning.remove(&reasoning_id) else {
673 continue;
674 };
675 blocks.sort_by_key(|(index, position, _)| (*index, *position));
676 content.push(completion::AssistantContent::Reasoning(
677 message::Reasoning {
678 id: reasoning_id,
679 content: blocks
680 .into_iter()
681 .map(|(_, _, content)| content)
682 .collect::<Vec<_>>(),
683 },
684 ));
685 }
686 }
687
688 content.extend(images.iter().map(response_image_to_assistant_content));
689
690 Ok(content)
691 }
692 _ => Err(CompletionError::ResponseError(
693 "Response did not contain a valid message or tool call".into(),
694 )),
695 }?;
696
697 let choice = OneOrMany::many(content).map_err(|_| {
698 CompletionError::ResponseError(
699 "Response contained no message or tool call (empty)".to_owned(),
700 )
701 })?;
702
703 let usage = response
704 .usage
705 .as_ref()
706 .map(|usage| {
707 let (cached_input, cache_creation) = usage
708 .prompt_tokens_details
709 .as_ref()
710 .map(|d| (d.cached_tokens as u64, d.cache_write_tokens as u64))
711 .unwrap_or((0, 0));
712 completion::Usage {
713 input_tokens: usage.prompt_tokens as u64,
714 output_tokens: if usage.completion_tokens > 0 {
718 usage.completion_tokens as u64
719 } else {
720 usage.total_tokens.saturating_sub(usage.prompt_tokens) as u64
721 },
722 total_tokens: usage.total_tokens as u64,
723 cached_input_tokens: cached_input,
724 cache_creation_input_tokens: cache_creation,
725 tool_use_prompt_tokens: 0,
726 reasoning_tokens: 0,
727 }
728 })
729 .unwrap_or_default();
730
731 Ok(completion::CompletionResponse {
732 choice,
733 usage,
734 raw_response: response,
735 message_id: None,
736 })
737 }
738}
739
740impl ProviderResponseExt for CompletionResponse {
741 type OutputMessage = Choice;
742 type Usage = Usage;
743
744 fn get_response_id(&self) -> Option<String> {
745 Some(self.id.clone())
746 }
747
748 fn get_response_model_name(&self) -> Option<String> {
749 Some(self.model.clone())
750 }
751
752 fn get_output_messages(&self) -> Vec<Self::OutputMessage> {
753 self.choices.clone()
754 }
755
756 fn get_text_response(&self) -> Option<String> {
757 let response = self
758 .choices
759 .iter()
760 .filter_map(|choice| assistant_message_text_response(&choice.message))
761 .collect::<Vec<_>>()
762 .join("\n");
763
764 (!response.is_empty()).then_some(response)
765 }
766
767 fn get_usage(&self) -> Option<Self::Usage> {
768 self.usage.clone()
769 }
770}
771
772fn assistant_message_text_response(message: &Message) -> Option<String> {
773 let Message::Assistant {
774 content, refusal, ..
775 } = message
776 else {
777 return None;
778 };
779
780 let mut segments = content
781 .iter()
782 .filter_map(|content| match content {
783 openai::AssistantContent::Text { text, .. } => (!text.is_empty()).then(|| text.clone()),
784 openai::AssistantContent::Refusal { refusal } => {
785 (!refusal.is_empty()).then(|| refusal.clone())
786 }
787 })
788 .collect::<Vec<_>>();
789
790 if let Some(refusal) = refusal
791 && !refusal.is_empty()
792 {
793 segments.push(refusal.clone());
794 }
795
796 (!segments.is_empty()).then(|| segments.join("\n"))
797}
798
799pub use crate::providers::openai::completion::{
804 FileData, ImageUrl, Message, ReasoningDetails, ResponseImage, UserContent, VideoUrl,
805};
806
807const OPENROUTER_RESPONSE_ONLY_KEY: &str = "response_only";
808const OPENROUTER_RESPONSE_IMAGE_SOURCE_KEY: &str = "source";
809const OPENROUTER_ASSISTANT_IMAGES_SOURCE: &str = "assistant.images";
810
811fn parse_data_uri(url: &str) -> Option<(&str, &str)> {
814 url.strip_prefix("data:")?.split_once(";base64,")
815}
816
817fn openrouter_response_image_params() -> serde_json::Value {
818 serde_json::json!({
819 "openrouter": {
820 OPENROUTER_RESPONSE_ONLY_KEY: true,
821 OPENROUTER_RESPONSE_IMAGE_SOURCE_KEY: OPENROUTER_ASSISTANT_IMAGES_SOURCE,
822 }
823 })
824}
825
826fn response_image_to_assistant_content(image: &ResponseImage) -> completion::AssistantContent {
827 let url = &image.image_url.url;
828 if let Some((mime, b64)) = parse_data_uri(url) {
829 completion::AssistantContent::Image(message::Image {
830 data: message::DocumentSourceKind::Base64(b64.to_string()),
831 media_type: message::ImageMediaType::from_mime_type(mime),
832 detail: None,
833 additional_params: Some(openrouter_response_image_params()),
834 })
835 } else {
836 completion::AssistantContent::Image(message::Image {
837 data: message::DocumentSourceKind::Url(url.clone()),
838 media_type: None,
839 detail: None,
840 additional_params: Some(openrouter_response_image_params()),
841 })
842 }
843}
844
845fn is_openrouter_response_image(image: &message::Image) -> bool {
846 image
847 .additional_params
848 .as_ref()
849 .and_then(|params| params.get("openrouter"))
850 .is_some_and(|params| {
851 params
852 .get(OPENROUTER_RESPONSE_ONLY_KEY)
853 .and_then(|value| value.as_bool())
854 .unwrap_or(false)
855 && params
856 .get(OPENROUTER_RESPONSE_IMAGE_SOURCE_KEY)
857 .and_then(|value| value.as_str())
858 == Some(OPENROUTER_ASSISTANT_IMAGES_SOURCE)
859 })
860}
861
862fn user_content_to_openai(
874 value: message::UserContent,
875) -> Result<UserContent, message::MessageError> {
876 match value {
877 message::UserContent::Image(message::Image {
878 data,
879 detail,
880 media_type,
881 ..
882 }) => {
883 let url = match data {
884 DocumentSourceKind::Url(url) => url,
885 DocumentSourceKind::Base64(data) => {
886 let mime = media_type
887 .ok_or_else(|| {
888 message::MessageError::ConversionError(
889 "Image media type required for base64 encoding".into(),
890 )
891 })?
892 .to_mime_type();
893 format!("data:{mime};base64,{data}")
894 }
895 DocumentSourceKind::Raw(_) => {
896 return Err(message::MessageError::ConversionError(
897 "Raw bytes not supported, encode as base64 first".into(),
898 ));
899 }
900 DocumentSourceKind::FileId(_) => {
901 return Err(message::MessageError::ConversionError(
902 "File IDs are not supported for images".into(),
903 ));
904 }
905 DocumentSourceKind::String(_) => {
906 return Err(message::MessageError::ConversionError(
907 "String source not supported for images".into(),
908 ));
909 }
910 DocumentSourceKind::Unknown => {
911 return Err(message::MessageError::ConversionError(
912 "Image has no data".into(),
913 ));
914 }
915 };
916 Ok(UserContent::Image {
917 image_url: ImageUrl { url, detail },
918 })
919 }
920
921 message::UserContent::Document(message::Document {
922 data, media_type, ..
923 }) => match data {
924 DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
925 "Provider file IDs are not supported for OpenRouter document inputs".into(),
926 )),
927 DocumentSourceKind::Url(url) => Ok(UserContent::File {
928 file: FileData {
929 file_data: Some(url),
930 file_id: None,
931 filename: document_filename(media_type.as_ref()),
932 },
933 }),
934 DocumentSourceKind::Base64(data) => {
935 let mime = media_type
936 .as_ref()
937 .map(|m| m.to_mime_type())
938 .unwrap_or("application/pdf");
939 let data_uri = format!("data:{mime};base64,{data}");
940
941 Ok(UserContent::File {
942 file: FileData {
943 file_data: Some(data_uri),
944 file_id: None,
945 filename: document_filename(media_type.as_ref()),
946 },
947 })
948 }
949 DocumentSourceKind::String(text) => Ok(UserContent::Text { text }),
950 DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
951 "Raw bytes not supported for documents, encode as base64 first".into(),
952 )),
953 DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
954 "Document has no data".into(),
955 )),
956 },
957
958 message::UserContent::Audio(message::Audio {
959 data, media_type, ..
960 }) => match data {
961 DocumentSourceKind::Base64(data) => {
962 let format = media_type.ok_or_else(|| {
963 message::MessageError::ConversionError(
964 "Audio media type required for base64 encoding".into(),
965 )
966 })?;
967 Ok(UserContent::Audio {
968 input_audio: openai::InputAudio { data, format },
969 })
970 }
971 DocumentSourceKind::Url(_) => Err(message::MessageError::ConversionError(
972 "OpenRouter does not support audio URLs, encode as base64 first".into(),
973 )),
974 DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
975 "Raw bytes not supported for audio, encode as base64 first".into(),
976 )),
977 DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
978 "File IDs are not supported for audio".into(),
979 )),
980 DocumentSourceKind::String(_) => Err(message::MessageError::ConversionError(
981 "String source not supported for audio".into(),
982 )),
983 DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
984 "Audio has no data".into(),
985 )),
986 },
987
988 message::UserContent::ToolResult(_) => Err(message::MessageError::ConversionError(
989 "Tool results should be handled as separate messages".into(),
990 )),
991
992 value => UserContent::try_from(value),
994 }
995}
996
997fn document_filename(media_type: Option<&DocumentMediaType>) -> Option<String> {
998 media_type.map(|mt| {
999 match mt {
1000 DocumentMediaType::PDF => "document.pdf",
1001 DocumentMediaType::TXT => "document.txt",
1002 DocumentMediaType::HTML => "document.html",
1003 DocumentMediaType::MARKDOWN => "document.md",
1004 DocumentMediaType::CSV => "document.csv",
1005 DocumentMediaType::XML => "document.xml",
1006 _ => "document",
1007 }
1008 .to_string()
1009 })
1010}
1011
1012fn user_contents_to_messages(
1013 value: OneOrMany<message::UserContent>,
1014) -> Result<Vec<Message>, message::MessageError> {
1015 fn flush_user_content(
1016 messages: &mut Vec<Message>,
1017 pending: &mut Vec<UserContent>,
1018 ) -> Result<(), message::MessageError> {
1019 if pending.is_empty() {
1020 return Ok(());
1021 }
1022
1023 let content = OneOrMany::many(std::mem::take(pending)).map_err(|_| {
1024 message::MessageError::ConversionError(
1025 "OpenRouter user message did not contain any non-tool content".into(),
1026 )
1027 })?;
1028 messages.push(Message::User {
1029 content,
1030 name: None,
1031 });
1032 Ok(())
1033 }
1034
1035 let mut messages = Vec::new();
1036 let mut pending = Vec::new();
1037
1038 for content in value {
1039 match content {
1040 message::UserContent::ToolResult(tool_result) => {
1041 flush_user_content(&mut messages, &mut pending)?;
1042 let content = tool_result
1043 .content
1044 .into_iter()
1045 .map(|content| match content {
1046 message::ToolResultContent::Text(message::Text { text, .. }) => Ok(text),
1047 message::ToolResultContent::Json { value } => Ok(value.to_string()),
1048 message::ToolResultContent::Image(_) => {
1049 Err(message::MessageError::ConversionError(
1050 "OpenRouter does not support images in tool results".into(),
1051 ))
1052 }
1053 })
1054 .collect::<Result<Vec<_>, _>>()?
1055 .join("\n");
1056 messages.push(Message::ToolResult {
1057 tool_call_id: tool_result.call_id.unwrap_or(tool_result.id),
1060 content: openai::completion::ToolResultContentValue::String(content),
1061 });
1062 }
1063 content => pending.push(user_content_to_openai(content)?),
1064 }
1065 }
1066
1067 flush_user_content(&mut messages, &mut pending)?;
1068 Ok(messages)
1069}
1070
1071#[derive(Clone, Debug, Deserialize, Serialize)]
1076pub struct Choice {
1077 pub index: usize,
1078 pub native_finish_reason: Option<String>,
1079 pub message: Message,
1080 pub finish_reason: Option<String>,
1081}
1082
1083#[derive(Debug, Deserialize, PartialEq, Clone)]
1084#[serde(untagged)]
1085enum ToolCallAdditionalParams {
1086 ReasoningDetails(ReasoningDetails),
1087 Minimal {
1088 id: Option<String>,
1089 format: Option<String>,
1090 },
1091}
1092
1093fn assistant_contents_to_messages(
1101 value: OneOrMany<message::AssistantContent>,
1102) -> Result<Vec<Message>, message::MessageError> {
1103 let mut text_content = Vec::new();
1104 let mut tool_calls = Vec::new();
1105 let mut reasoning = None;
1106 let mut reasoning_details = Vec::new();
1107
1108 for content in value.into_iter() {
1109 match content {
1110 message::AssistantContent::Text(text) => text_content.push(text),
1111 message::AssistantContent::ToolCall(tool_call) => {
1112 if let Some(additional_params) = &tool_call.additional_params
1118 && let Ok(additional_params) = serde_json::from_value::<ToolCallAdditionalParams>(
1119 additional_params.clone(),
1120 )
1121 {
1122 match additional_params {
1123 ToolCallAdditionalParams::ReasoningDetails(full) => {
1124 reasoning_details.push(full);
1125 }
1126 ToolCallAdditionalParams::Minimal { id, format } => {
1127 let id = id
1130 .or_else(|| tool_call.call_id.clone())
1131 .unwrap_or_else(|| tool_call.id.clone());
1132 if let Some(signature) = &tool_call.signature {
1133 reasoning_details.push(ReasoningDetails::Encrypted {
1134 id: Some(id),
1135 format,
1136 index: None,
1137 data: signature.clone(),
1138 })
1139 }
1140 }
1141 }
1142 } else if let Some(signature) = &tool_call.signature {
1143 reasoning_details.push(ReasoningDetails::Encrypted {
1144 id: Some(
1145 tool_call
1146 .call_id
1147 .clone()
1148 .unwrap_or_else(|| tool_call.id.clone()),
1149 ),
1150 format: None,
1151 index: None,
1152 data: signature.clone(),
1153 });
1154 }
1155 tool_calls.push(tool_call.into())
1156 }
1157 message::AssistantContent::Reasoning(r) => {
1158 if r.content.is_empty() {
1159 let display = r.display_text();
1160 if !display.is_empty() {
1161 reasoning = Some(display);
1162 }
1163 } else {
1164 for reasoning_block in &r.content {
1165 let index = Some(reasoning_details.len());
1166 match reasoning_block {
1167 message::ReasoningContent::Text { text, signature } => {
1168 reasoning_details.push(ReasoningDetails::Text {
1169 id: r.id.clone(),
1170 format: None,
1171 index,
1172 text: Some(text.clone()),
1173 signature: signature.clone(),
1174 });
1175 }
1176 message::ReasoningContent::Summary(summary) => {
1177 reasoning_details.push(ReasoningDetails::Summary {
1178 id: r.id.clone(),
1179 format: None,
1180 index,
1181 summary: summary.clone(),
1182 });
1183 }
1184 message::ReasoningContent::Encrypted(data)
1185 | message::ReasoningContent::Redacted { data } => {
1186 reasoning_details.push(ReasoningDetails::Encrypted {
1187 id: r.id.clone(),
1188 format: None,
1189 index,
1190 data: data.clone(),
1191 });
1192 }
1193 }
1194 }
1195 }
1196 }
1197 message::AssistantContent::Image(image) if is_openrouter_response_image(&image) => {
1198 }
1202 message::AssistantContent::Image(_) => {
1203 return Err(message::MessageError::ConversionError(
1204 "OpenRouter does not support assistant image content in request history; pass images as user image inputs instead".into(),
1205 ));
1206 }
1207 }
1208 }
1209
1210 if text_content.is_empty()
1211 && tool_calls.is_empty()
1212 && reasoning.is_none()
1213 && reasoning_details.is_empty()
1214 {
1215 return Ok(vec![]);
1216 }
1217
1218 Ok(vec![Message::Assistant {
1219 content: text_content
1220 .into_iter()
1221 .map(|content| content.text.into())
1222 .collect::<Vec<_>>(),
1223 refusal: None,
1224 audio: None,
1225 name: None,
1226 tool_calls,
1227 reasoning,
1228 reasoning_details,
1229 images: Vec::new(),
1230 }])
1231}
1232
1233pub fn messages_from_rig_message(
1240 message: message::Message,
1241) -> Result<Vec<Message>, message::MessageError> {
1242 match message {
1243 message::Message::System { content } => Ok(vec![Message::system(&content)]),
1244 message::Message::User { content } => user_contents_to_messages(content),
1245 message::Message::Assistant { content, .. } => assistant_contents_to_messages(content),
1246 }
1247}
1248
1249pub(super) fn apply_prompt_caching(body: &mut serde_json::Value) {
1261 let Some(obj) = body.as_object_mut() else {
1262 return;
1263 };
1264 let Some(messages) = obj.get_mut("messages").and_then(|v| v.as_array_mut()) else {
1265 return;
1266 };
1267
1268 let Some(system_msg) = messages
1269 .iter_mut()
1270 .find(|m| m.get("role").and_then(|v| v.as_str()) == Some("system"))
1271 else {
1272 return;
1273 };
1274
1275 match system_msg.get("content").cloned() {
1276 Some(serde_json::Value::String(s)) => {
1277 if let Some(obj) = system_msg.as_object_mut() {
1278 obj.insert(
1279 "content".to_string(),
1280 serde_json::json!([{
1281 "type": "text",
1282 "text": s,
1283 "cache_control": { "type": "ephemeral" }
1284 }]),
1285 );
1286 }
1287 }
1288 Some(serde_json::Value::Array(mut arr)) => {
1289 if let Some(last) = arr.last_mut()
1292 && let Some(obj) = last.as_object_mut()
1293 {
1294 obj.insert(
1295 "cache_control".to_string(),
1296 serde_json::json!({ "type": "ephemeral" }),
1297 );
1298 }
1299 if let Some(obj) = system_msg.as_object_mut() {
1300 obj.insert("content".to_string(), serde_json::Value::Array(arr));
1301 }
1302 }
1303 _ => {}
1304 }
1305}
1306
1307pub(super) fn finalize_openrouter_request_body(body: &mut serde_json::Value, prompt_caching: bool) {
1308 if prompt_caching {
1309 apply_prompt_caching(body);
1310 }
1311
1312 if let Some(messages) = body
1316 .get_mut("messages")
1317 .and_then(serde_json::Value::as_array_mut)
1318 {
1319 for message in messages {
1320 if let Some(message) = message.as_object_mut()
1321 && message.get("role").and_then(serde_json::Value::as_str) == Some("assistant")
1322 && let Some(reasoning) = message.remove("reasoning_content")
1323 {
1324 message.insert("reasoning".to_string(), reasoning);
1325 }
1326 }
1327 }
1328}
1329
1330#[cfg(test)]
1331pub(super) fn final_request_body(
1332 request: &OpenrouterCompletionRequest,
1333 prompt_caching: bool,
1334) -> Result<serde_json::Value, CompletionError> {
1335 let mut body = serde_json::to_value(request)?;
1336 finalize_openrouter_request_body(&mut body, prompt_caching);
1337 Ok(body)
1338}
1339
1340pub(super) type OpenrouterCompletionRequest = openai::completion::CompletionRequest;
1341
1342pub struct OpenRouterRequestParams<'a> {
1344 pub model: &'a str,
1345 pub request: CompletionRequest,
1346 pub strict_tools: bool,
1347}
1348
1349impl TryFrom<OpenRouterRequestParams<'_>> for OpenrouterCompletionRequest {
1350 type Error = CompletionError;
1351
1352 fn try_from(params: OpenRouterRequestParams) -> Result<Self, Self::Error> {
1353 let OpenRouterRequestParams {
1354 model,
1355 request: req,
1356 strict_tools,
1357 } = params;
1358 let chat_history = req.chat_history_with_documents();
1359 let model = req.model.clone().unwrap_or_else(|| model.to_string());
1360
1361 let mut full_history: Vec<Message> = match &req.preamble {
1362 Some(preamble) => vec![Message::system(preamble)],
1363 None => vec![],
1364 };
1365
1366 let chat_history: Vec<Message> = chat_history
1367 .into_iter()
1368 .map(messages_from_rig_message)
1369 .collect::<Result<Vec<Vec<Message>>, _>>()?
1370 .into_iter()
1371 .flatten()
1372 .collect();
1373
1374 full_history.extend(chat_history);
1375
1376 let tool_choice = req
1377 .tool_choice
1378 .clone()
1379 .map(crate::providers::openai::completion::ToolChoice::try_from)
1380 .transpose()?;
1381
1382 let tools: Vec<crate::providers::openai::completion::ToolDefinition> = req
1383 .tools
1384 .clone()
1385 .into_iter()
1386 .map(|tool| {
1387 let def = crate::providers::openai::completion::ToolDefinition::from(tool);
1388 if strict_tools { def.with_strict() } else { def }
1389 })
1390 .collect();
1391
1392 let additional_params = if let Some(schema) = req.output_schema {
1393 let name = schema
1394 .as_object()
1395 .and_then(|o| o.get("title"))
1396 .and_then(|v| v.as_str())
1397 .unwrap_or("response_schema")
1398 .to_string();
1399 let mut schema_value = schema.to_value();
1400 openai::sanitize_schema(&mut schema_value);
1401 let response_format = serde_json::json!({
1402 "response_format": {
1403 "type": "json_schema",
1404 "json_schema": {
1405 "name": name,
1406 "strict": true,
1407 "schema": schema_value
1408 }
1409 }
1410 });
1411 Some(match req.additional_params {
1412 Some(existing) => json_utils::merge(existing, response_format),
1413 None => response_format,
1414 })
1415 } else {
1416 req.additional_params
1417 };
1418
1419 Ok(Self {
1420 model,
1421 messages: full_history,
1422 temperature: req.temperature,
1423 max_tokens: None,
1424 tools,
1425 tool_choice,
1426 additional_params,
1427 })
1428 }
1429}
1430
1431impl TryFrom<(&str, CompletionRequest)> for OpenrouterCompletionRequest {
1432 type Error = CompletionError;
1433
1434 fn try_from((model, req): (&str, CompletionRequest)) -> Result<Self, Self::Error> {
1435 let model = req.model.clone().unwrap_or_else(|| model.to_string());
1436 OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1437 model: &model,
1438 request: req,
1439 strict_tools: false,
1440 })
1441 }
1442}
1443
1444impl openai::completion::OpenAICompatibleProvider for OpenRouterExt {
1445 const PROVIDER_NAME: &'static str = "openrouter";
1446
1447 type StreamingUsage = Usage;
1448 type Response = CompletionResponse;
1449
1450 const STREAM_INCLUDE_USAGE: bool = false;
1451
1452 fn build_completion_request(
1453 &self,
1454 model: String,
1455 request: CompletionRequest,
1456 options: openai::completion::CompletionModelOptions,
1457 ) -> Result<openai::completion::CompletionRequest, CompletionError> {
1458 OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1459 model: &model,
1460 request,
1461 strict_tools: options.strict_tools,
1462 })
1463 }
1464
1465 fn finalize_request_body_with_options(
1466 &self,
1467 body: &mut serde_json::Value,
1468 options: openai::completion::CompletionModelOptions,
1469 ) -> Result<(), CompletionError> {
1470 finalize_openrouter_request_body(body, options.prompt_caching);
1471 Ok(())
1472 }
1473
1474 fn decorate_streaming_tool_call(
1475 &self,
1476 detail: &serde_json::Value,
1477 tool_calls: &mut std::collections::HashMap<usize, crate::streaming::RawStreamingToolCall>,
1478 ) {
1479 let Ok(ReasoningDetails::Encrypted { id, data, .. }) =
1480 serde_json::from_value::<ReasoningDetails>(detail.clone())
1481 else {
1482 return;
1483 };
1484 let Some(id) = id else {
1485 return;
1486 };
1487 let Some(tool_call) = tool_calls
1488 .values_mut()
1489 .find(|tool_call| tool_call.id.eq(&id))
1490 else {
1491 return;
1492 };
1493
1494 tool_call.signature = Some(data);
1495 tool_call.additional_params = Some(detail.clone());
1496 }
1497}
1498
1499pub type CompletionModel<H = reqwest::Client> =
1501 openai::completion::GenericCompletionModel<OpenRouterExt, H>;
1502
1503pub type StreamingCompletionResponse =
1505 openai::completion::streaming::StreamingCompletionResponse<Usage>;
1506
1507impl<H> openai::completion::GenericCompletionModel<OpenRouterExt, H> {
1508 pub fn with_prompt_caching(mut self) -> Self {
1515 self.prompt_caching = true;
1516 self
1517 }
1518}
1519
1520#[cfg(test)]
1521mod tests {
1522 use super::*;
1523 use crate::message::{AudioMediaType, ImageDetail, VideoMediaType};
1524 use serde_json::json;
1525
1526 #[test]
1527 fn mixed_user_content_preserves_order_around_tool_results() {
1528 let content = OneOrMany::many(vec![
1529 message::UserContent::text("before"),
1530 message::UserContent::tool_result_with_call_id(
1531 "result-id",
1532 "call-id".to_string(),
1533 OneOrMany::one(message::ToolResultContent::text("tool output")),
1534 ),
1535 message::UserContent::text("after"),
1536 ])
1537 .expect("mixed content should be non-empty");
1538
1539 let messages = user_contents_to_messages(content).expect("message conversion");
1540
1541 assert!(matches!(
1542 messages.as_slice(),
1543 [
1544 Message::User { content: before, .. },
1545 Message::ToolResult { tool_call_id, .. },
1546 Message::User { content: after, .. },
1547 ] if matches!(before.first(), UserContent::Text { text } if text == "before")
1548 && tool_call_id == "call-id"
1549 && matches!(after.first(), UserContent::Text { text } if text == "after")
1550 ));
1551 }
1552
1553 #[test]
1554 fn test_openrouter_request_uses_request_model_override() {
1555 let request = CompletionRequest {
1556 model: Some("google/gemini-2.5-flash".to_string()),
1557 preamble: None,
1558 chat_history: crate::OneOrMany::one("Hello".into()),
1559 documents: vec![],
1560 tools: vec![],
1561 temperature: None,
1562 max_tokens: None,
1563 tool_choice: None,
1564 additional_params: None,
1565 output_schema: None,
1566 record_telemetry_content: false,
1567 };
1568
1569 let openrouter_request =
1570 OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1571 .expect("request conversion should succeed");
1572 let serialized =
1573 serde_json::to_value(openrouter_request).expect("serialization should succeed");
1574
1575 assert_eq!(serialized["model"], "google/gemini-2.5-flash");
1576 }
1577
1578 #[test]
1579 fn openrouter_params_include_direct_request_documents() {
1580 let request = CompletionRequest {
1581 model: None,
1582 preamble: None,
1583 chat_history: crate::OneOrMany::one(crate::message::Message::user(
1584 "What is glarb-glarb?",
1585 )),
1586 documents: vec![crate::completion::request::Document {
1587 id: "doc_1".to_string(),
1588 text: "Definition of glarb-glarb: an ancient tool.".to_string(),
1589 additional_props: Default::default(),
1590 }],
1591 tools: vec![],
1592 temperature: None,
1593 max_tokens: None,
1594 tool_choice: None,
1595 additional_params: None,
1596 output_schema: None,
1597 record_telemetry_content: false,
1598 };
1599
1600 let request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1601 model: "openai/gpt-4o-mini",
1602 request,
1603 strict_tools: false,
1604 })
1605 .expect("request conversion should succeed");
1606 let serialized = serde_json::to_value(request).expect("serialization should succeed");
1607
1608 assert!(
1609 serialized["messages"].to_string().contains("glarb-glarb"),
1610 "direct request documents should be normalized through public params"
1611 );
1612 }
1613
1614 #[test]
1615 fn test_openrouter_request_uses_default_model_when_override_unset() {
1616 let request = CompletionRequest {
1617 model: None,
1618 preamble: None,
1619 chat_history: crate::OneOrMany::one("Hello".into()),
1620 documents: vec![],
1621 tools: vec![],
1622 temperature: None,
1623 max_tokens: None,
1624 tool_choice: None,
1625 additional_params: None,
1626 output_schema: None,
1627 record_telemetry_content: false,
1628 };
1629
1630 let openrouter_request =
1631 OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1632 .expect("request conversion should succeed");
1633 let serialized =
1634 serde_json::to_value(openrouter_request).expect("serialization should succeed");
1635
1636 assert_eq!(serialized["model"], "openai/gpt-4o-mini");
1637 }
1638
1639 #[test]
1640 fn final_request_body_serializes_assistant_reasoning_under_openrouter_key() {
1641 let request = OpenrouterCompletionRequest {
1645 model: "openai/gpt-4o".to_string(),
1646 messages: vec![Message::Assistant {
1647 content: vec![],
1648 reasoning: Some("thinking it through".to_string()),
1649 refusal: None,
1650 audio: None,
1651 name: None,
1652 tool_calls: vec![],
1653 reasoning_details: vec![],
1654 images: vec![],
1655 }],
1656 temperature: None,
1657 max_tokens: None,
1658 tools: vec![],
1659 tool_choice: None,
1660 additional_params: None,
1661 };
1662
1663 let body = final_request_body(&request, false).expect("body should serialize");
1664
1665 assert_eq!(
1666 body["messages"][0]["reasoning"],
1667 serde_json::json!("thinking it through")
1668 );
1669 assert!(
1670 body["messages"][0].get("reasoning_content").is_none(),
1671 "OpenRouter's assistant reasoning key is `reasoning`, not `reasoning_content`"
1672 );
1673 }
1674
1675 #[test]
1676 fn test_openrouter_request_maps_output_schema_to_response_format() {
1677 let schema: schemars::Schema = serde_json::from_value(json!({
1678 "title": "WeatherResponse",
1679 "type": "object",
1680 "properties": {
1681 "city": { "type": "string" },
1682 "weather": { "type": "string" }
1683 }
1684 }))
1685 .expect("schema should deserialize");
1686
1687 let request = CompletionRequest {
1688 model: None,
1689 preamble: None,
1690 chat_history: crate::OneOrMany::one("Hello".into()),
1691 documents: vec![],
1692 tools: vec![],
1693 temperature: None,
1694 max_tokens: None,
1695 tool_choice: None,
1696 additional_params: None,
1697 output_schema: Some(schema),
1698 record_telemetry_content: false,
1699 };
1700
1701 let openrouter_request =
1702 OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1703 .expect("request conversion should succeed");
1704 let serialized =
1705 serde_json::to_value(openrouter_request).expect("serialization should succeed");
1706
1707 assert_eq!(
1708 serialized["response_format"],
1709 json!({
1710 "type": "json_schema",
1711 "json_schema": {
1712 "name": "WeatherResponse",
1713 "strict": true,
1714 "schema": {
1715 "title": "WeatherResponse",
1716 "type": "object",
1717 "properties": {
1718 "city": { "type": "string" },
1719 "weather": { "type": "string" }
1720 },
1721 "additionalProperties": false,
1722 "required": ["city", "weather"]
1723 }
1724 }
1725 })
1726 );
1727 }
1728
1729 #[test]
1730 fn test_openrouter_request_merges_output_schema_with_provider_preferences() {
1731 let schema: schemars::Schema = serde_json::from_value(json!({
1732 "type": "object",
1733 "properties": {
1734 "answer": { "type": "string" }
1735 }
1736 }))
1737 .expect("schema should deserialize");
1738
1739 let request = CompletionRequest {
1740 model: None,
1741 preamble: None,
1742 chat_history: crate::OneOrMany::one("Hello".into()),
1743 documents: vec![],
1744 tools: vec![],
1745 temperature: None,
1746 max_tokens: None,
1747 tool_choice: None,
1748 additional_params: Some(
1749 ProviderPreferences::new()
1750 .require_parameters(true)
1751 .to_json(),
1752 ),
1753 output_schema: Some(schema),
1754 record_telemetry_content: false,
1755 };
1756
1757 let openrouter_request =
1758 OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1759 .expect("request conversion should succeed");
1760 let serialized =
1761 serde_json::to_value(openrouter_request).expect("serialization should succeed");
1762
1763 assert_eq!(serialized["provider"]["require_parameters"], true);
1764 assert_eq!(serialized["response_format"]["type"], "json_schema");
1765 assert_eq!(
1766 serialized["response_format"]["json_schema"]["name"],
1767 "response_schema"
1768 );
1769 assert_eq!(
1770 serialized["response_format"]["json_schema"]["schema"]["additionalProperties"],
1771 false
1772 );
1773 }
1774
1775 #[test]
1776 fn test_completion_response_deserialization_gemini_flash() {
1777 let json = json!({
1779 "id": "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA",
1780 "provider": "Google",
1781 "model": "google/gemini-2.5-flash",
1782 "object": "chat.completion",
1783 "created": 1765971703u64,
1784 "choices": [{
1785 "logprobs": null,
1786 "finish_reason": "stop",
1787 "native_finish_reason": "STOP",
1788 "index": 0,
1789 "message": {
1790 "role": "assistant",
1791 "content": "CONTENT",
1792 "refusal": null,
1793 "reasoning": null
1794 }
1795 }],
1796 "usage": {
1797 "prompt_tokens": 669,
1798 "completion_tokens": 5,
1799 "total_tokens": 674
1800 }
1801 });
1802
1803 let response: CompletionResponse = serde_json::from_value(json).unwrap();
1804 assert_eq!(response.id, "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA");
1805 assert_eq!(response.model, "google/gemini-2.5-flash");
1806 assert_eq!(response.choices.len(), 1);
1807 assert_eq!(response.choices[0].finish_reason, Some("stop".to_string()));
1808 }
1809
1810 #[test]
1811 fn test_completion_response_usage_prefers_reported_completion_tokens() {
1812 let json = json!({
1813 "id": "gen-usage-divergent",
1814 "object": "chat.completion",
1815 "created": 1,
1816 "model": "anthropic/claude-3.5-sonnet",
1817 "choices": [{
1818 "index": 0,
1819 "message": {"role": "assistant", "content": "ok"},
1820 "finish_reason": "stop"
1821 }],
1822 "usage": {"prompt_tokens": 500, "completion_tokens": 10, "total_tokens": 505}
1824 });
1825
1826 let response: CompletionResponse = serde_json::from_value(json).unwrap();
1827 let converted = completion::CompletionResponse::try_from(response).unwrap();
1828 assert_eq!(converted.usage.output_tokens, 10);
1829 }
1830
1831 #[test]
1832 fn test_completion_response_usage_falls_back_when_completion_tokens_missing() {
1833 let json = json!({
1834 "id": "gen-usage-omitted",
1835 "object": "chat.completion",
1836 "created": 1,
1837 "model": "some/gateway-model",
1838 "choices": [{
1839 "index": 0,
1840 "message": {"role": "assistant", "content": "ok"},
1841 "finish_reason": "stop"
1842 }],
1843 "usage": {"prompt_tokens": 100, "total_tokens": 110}
1844 });
1845
1846 let response: CompletionResponse = serde_json::from_value(json).unwrap();
1847 let converted = completion::CompletionResponse::try_from(response).unwrap();
1848 assert_eq!(converted.usage.output_tokens, 10);
1849 }
1850
1851 #[test]
1852 fn test_completion_response_maps_cache_token_accounting() {
1853 let json = json!({
1854 "id": "gen-cache-test",
1855 "object": "chat.completion",
1856 "created": 1,
1857 "model": "anthropic/claude-3.5-sonnet",
1858 "choices": [{
1859 "index": 0,
1860 "finish_reason": "stop",
1861 "message": {
1862 "role": "assistant",
1863 "content": "Hi"
1864 }
1865 }],
1866 "usage": {
1867 "prompt_tokens": 500,
1868 "completion_tokens": 10,
1869 "total_tokens": 510,
1870 "prompt_tokens_details": {
1871 "cached_tokens": 400,
1872 "cache_write_tokens": 50
1873 }
1874 }
1875 });
1876
1877 let response: CompletionResponse = serde_json::from_value(json).unwrap();
1878 let converted: completion::CompletionResponse<CompletionResponse> =
1879 response.try_into().unwrap();
1880
1881 assert_eq!(converted.usage.input_tokens, 500);
1882 assert_eq!(converted.usage.output_tokens, 10);
1883 assert_eq!(converted.usage.cached_input_tokens, 400);
1884 assert_eq!(converted.usage.cache_creation_input_tokens, 50);
1885 }
1886
1887 #[test]
1888 fn test_completion_response_cache_tokens_absent_defaults_to_zero() {
1889 let json = json!({
1890 "id": "gen-no-cache",
1891 "object": "chat.completion",
1892 "created": 1,
1893 "model": "openai/gpt-4o",
1894 "choices": [{
1895 "index": 0,
1896 "finish_reason": "stop",
1897 "message": {
1898 "role": "assistant",
1899 "content": "Hi"
1900 }
1901 }],
1902 "usage": {
1903 "prompt_tokens": 100,
1904 "completion_tokens": 10,
1905 "total_tokens": 110
1906 }
1907 });
1908
1909 let response: CompletionResponse = serde_json::from_value(json).unwrap();
1910 let converted: completion::CompletionResponse<CompletionResponse> =
1911 response.try_into().unwrap();
1912
1913 assert_eq!(converted.usage.cached_input_tokens, 0);
1914 assert_eq!(converted.usage.cache_creation_input_tokens, 0);
1915 }
1916
1917 #[test]
1918 fn test_completion_response_deserialization_gemini_model_role() {
1919 let json = json!({
1920 "id": "gen-BBBBBBBBBB-BBBBBBBBBBBBBBBBBBBB",
1921 "provider": "Google",
1922 "model": "google/gemini-2.5-pro-exp-03-25:free",
1923 "object": "chat.completion",
1924 "created": 1743780565u64,
1925 "choices": [{
1926 "logprobs": null,
1927 "finish_reason": "stop",
1928 "native_finish_reason": "STOP",
1929 "index": 0,
1930 "message": {
1931 "role": "model",
1932 "content": "CONTENT",
1933 "refusal": null,
1934 "reasoning": null
1935 }
1936 }],
1937 "usage": {
1938 "prompt_tokens": 669,
1939 "completion_tokens": 5,
1940 "total_tokens": 674
1941 }
1942 });
1943
1944 let response: CompletionResponse = serde_json::from_value(json).unwrap();
1945 let converted: completion::CompletionResponse<CompletionResponse> =
1946 response.try_into().unwrap();
1947
1948 assert_eq!(
1949 converted.raw_response.model,
1950 "google/gemini-2.5-pro-exp-03-25:free"
1951 );
1952 assert!(matches!(
1953 converted.choice.first(),
1954 completion::AssistantContent::Text(text) if text.text == "CONTENT"
1955 ));
1956 }
1957
1958 #[test]
1959 fn test_message_assistant_without_reasoning_details() {
1960 let json = json!({
1962 "role": "assistant",
1963 "content": "Hello world",
1964 "refusal": null,
1965 "reasoning": null
1966 });
1967
1968 let message: Message = serde_json::from_value(json).unwrap();
1969 match message {
1970 Message::Assistant {
1971 content,
1972 reasoning_details,
1973 ..
1974 } => {
1975 assert_eq!(content.len(), 1);
1976 assert!(reasoning_details.is_empty());
1977 }
1978 _ => panic!("Expected Assistant message"),
1979 }
1980 }
1981
1982 #[test]
1983 fn test_data_collection_serialization() {
1984 assert_eq!(
1985 serde_json::to_string(&DataCollection::Allow).unwrap(),
1986 r#""allow""#
1987 );
1988 assert_eq!(
1989 serde_json::to_string(&DataCollection::Deny).unwrap(),
1990 r#""deny""#
1991 );
1992 }
1993
1994 #[test]
1995 fn test_data_collection_default() {
1996 assert_eq!(DataCollection::default(), DataCollection::Allow);
1997 }
1998
1999 #[test]
2000 fn test_quantization_serialization() {
2001 assert_eq!(
2002 serde_json::to_string(&Quantization::Int4).unwrap(),
2003 r#""int4""#
2004 );
2005 assert_eq!(
2006 serde_json::to_string(&Quantization::Int8).unwrap(),
2007 r#""int8""#
2008 );
2009 assert_eq!(
2010 serde_json::to_string(&Quantization::Fp16).unwrap(),
2011 r#""fp16""#
2012 );
2013 assert_eq!(
2014 serde_json::to_string(&Quantization::Bf16).unwrap(),
2015 r#""bf16""#
2016 );
2017 assert_eq!(
2018 serde_json::to_string(&Quantization::Fp32).unwrap(),
2019 r#""fp32""#
2020 );
2021 assert_eq!(
2022 serde_json::to_string(&Quantization::Fp8).unwrap(),
2023 r#""fp8""#
2024 );
2025 assert_eq!(
2026 serde_json::to_string(&Quantization::Unknown).unwrap(),
2027 r#""unknown""#
2028 );
2029 }
2030
2031 #[test]
2032 fn test_provider_sort_strategy_serialization() {
2033 assert_eq!(
2034 serde_json::to_string(&ProviderSortStrategy::Price).unwrap(),
2035 r#""price""#
2036 );
2037 assert_eq!(
2038 serde_json::to_string(&ProviderSortStrategy::Throughput).unwrap(),
2039 r#""throughput""#
2040 );
2041 assert_eq!(
2042 serde_json::to_string(&ProviderSortStrategy::Latency).unwrap(),
2043 r#""latency""#
2044 );
2045 }
2046
2047 #[test]
2048 fn test_sort_partition_serialization() {
2049 assert_eq!(
2050 serde_json::to_string(&SortPartition::Model).unwrap(),
2051 r#""model""#
2052 );
2053 assert_eq!(
2054 serde_json::to_string(&SortPartition::None).unwrap(),
2055 r#""none""#
2056 );
2057 }
2058
2059 #[test]
2060 fn test_provider_sort_simple() {
2061 let sort = ProviderSort::Simple(ProviderSortStrategy::Latency);
2062 let json = serde_json::to_value(&sort).unwrap();
2063 assert_eq!(json, "latency");
2064 }
2065
2066 #[test]
2067 fn test_provider_sort_complex() {
2068 let sort = ProviderSort::Complex(
2069 ProviderSortConfig::new(ProviderSortStrategy::Price).partition(SortPartition::None),
2070 );
2071 let json = serde_json::to_value(&sort).unwrap();
2072 assert_eq!(json["by"], "price");
2073 assert_eq!(json["partition"], "none");
2074 }
2075
2076 #[test]
2077 fn test_provider_sort_complex_without_partition() {
2078 let sort = ProviderSort::Complex(ProviderSortConfig::new(ProviderSortStrategy::Throughput));
2079 let json = serde_json::to_value(&sort).unwrap();
2080 assert_eq!(json["by"], "throughput");
2081 assert!(json.get("partition").is_none());
2082 }
2083
2084 #[test]
2085 fn test_provider_sort_from_strategy() {
2086 let sort: ProviderSort = ProviderSortStrategy::Price.into();
2087 assert_eq!(sort, ProviderSort::Simple(ProviderSortStrategy::Price));
2088 }
2089
2090 #[test]
2091 fn test_provider_sort_from_config() {
2092 let config = ProviderSortConfig::new(ProviderSortStrategy::Latency);
2093 let sort: ProviderSort = config.into();
2094 match sort {
2095 ProviderSort::Complex(c) => assert_eq!(c.by, ProviderSortStrategy::Latency),
2096 _ => panic!("Expected Complex variant"),
2097 }
2098 }
2099
2100 #[test]
2101 fn test_percentile_thresholds_builder() {
2102 let thresholds = PercentileThresholds::new()
2103 .p50(10.0)
2104 .p75(25.0)
2105 .p90(50.0)
2106 .p99(100.0);
2107
2108 assert_eq!(thresholds.p50, Some(10.0));
2109 assert_eq!(thresholds.p75, Some(25.0));
2110 assert_eq!(thresholds.p90, Some(50.0));
2111 assert_eq!(thresholds.p99, Some(100.0));
2112 }
2113
2114 #[test]
2115 fn test_percentile_thresholds_default() {
2116 let thresholds = PercentileThresholds::default();
2117 assert_eq!(thresholds.p50, None);
2118 assert_eq!(thresholds.p75, None);
2119 assert_eq!(thresholds.p90, None);
2120 assert_eq!(thresholds.p99, None);
2121 }
2122
2123 #[test]
2124 fn test_throughput_threshold_simple() {
2125 let threshold = ThroughputThreshold::Simple(50.0);
2126 let json = serde_json::to_value(&threshold).unwrap();
2127 assert_eq!(json, 50.0);
2128 }
2129
2130 #[test]
2131 fn test_throughput_threshold_percentile() {
2132 let threshold = ThroughputThreshold::Percentile(PercentileThresholds::new().p90(50.0));
2133 let json = serde_json::to_value(&threshold).unwrap();
2134 assert_eq!(json["p90"], 50.0);
2135 }
2136
2137 #[test]
2138 fn test_latency_threshold_simple() {
2139 let threshold = LatencyThreshold::Simple(0.5);
2140 let json = serde_json::to_value(&threshold).unwrap();
2141 assert_eq!(json, 0.5);
2142 }
2143
2144 #[test]
2145 fn test_latency_threshold_percentile() {
2146 let threshold = LatencyThreshold::Percentile(PercentileThresholds::new().p50(0.1).p99(1.0));
2147 let json = serde_json::to_value(&threshold).unwrap();
2148 assert_eq!(json["p50"], 0.1);
2149 assert_eq!(json["p99"], 1.0);
2150 }
2151
2152 #[test]
2153 fn test_max_price_builder() {
2154 let price = MaxPrice::new().prompt(0.001).completion(0.002);
2155
2156 assert_eq!(price.prompt, Some(0.001));
2157 assert_eq!(price.completion, Some(0.002));
2158 assert_eq!(price.request, None);
2159 assert_eq!(price.image, None);
2160 }
2161
2162 #[test]
2163 fn test_max_price_all_fields() {
2164 let price = MaxPrice::new()
2165 .prompt(0.001)
2166 .completion(0.002)
2167 .request(0.01)
2168 .image(0.05);
2169
2170 let json = serde_json::to_value(&price).unwrap();
2171 assert_eq!(json["prompt"], 0.001);
2172 assert_eq!(json["completion"], 0.002);
2173 assert_eq!(json["request"], 0.01);
2174 assert_eq!(json["image"], 0.05);
2175 }
2176
2177 #[test]
2178 fn test_max_price_default() {
2179 let price = MaxPrice::default();
2180 assert_eq!(price.prompt, None);
2181 assert_eq!(price.completion, None);
2182 assert_eq!(price.request, None);
2183 assert_eq!(price.image, None);
2184 }
2185
2186 #[test]
2187 fn test_provider_preferences_default() {
2188 let prefs = ProviderPreferences::default();
2189 assert!(prefs.order.is_none());
2190 assert!(prefs.only.is_none());
2191 assert!(prefs.ignore.is_none());
2192 assert!(prefs.allow_fallbacks.is_none());
2193 assert!(prefs.require_parameters.is_none());
2194 assert!(prefs.data_collection.is_none());
2195 assert!(prefs.zdr.is_none());
2196 assert!(prefs.sort.is_none());
2197 assert!(prefs.preferred_min_throughput.is_none());
2198 assert!(prefs.preferred_max_latency.is_none());
2199 assert!(prefs.max_price.is_none());
2200 assert!(prefs.quantizations.is_none());
2201 }
2202
2203 #[test]
2204 fn test_provider_preferences_order_with_fallbacks() {
2205 let prefs = ProviderPreferences::new()
2206 .order(["anthropic", "openai"])
2207 .allow_fallbacks(true);
2208
2209 let json = prefs.to_json();
2210 let provider = &json["provider"];
2211
2212 assert_eq!(provider["order"], json!(["anthropic", "openai"]));
2213 assert_eq!(provider["allow_fallbacks"], true);
2214 }
2215
2216 #[test]
2217 fn test_provider_preferences_only_allowlist() {
2218 let prefs = ProviderPreferences::new()
2219 .only(["azure", "together"])
2220 .allow_fallbacks(false);
2221
2222 let json = prefs.to_json();
2223 let provider = &json["provider"];
2224
2225 assert_eq!(provider["only"], json!(["azure", "together"]));
2226 assert_eq!(provider["allow_fallbacks"], false);
2227 }
2228
2229 #[test]
2230 fn test_provider_preferences_ignore() {
2231 let prefs = ProviderPreferences::new().ignore(["deepinfra"]);
2232
2233 let json = prefs.to_json();
2234 let provider = &json["provider"];
2235
2236 assert_eq!(provider["ignore"], json!(["deepinfra"]));
2237 }
2238
2239 #[test]
2240 fn test_provider_preferences_sort_latency() {
2241 let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Latency);
2242
2243 let json = prefs.to_json();
2244 let provider = &json["provider"];
2245
2246 assert_eq!(provider["sort"], "latency");
2247 }
2248
2249 #[test]
2250 fn test_provider_preferences_price_with_throughput() {
2251 let prefs = ProviderPreferences::new()
2252 .sort(ProviderSortStrategy::Price)
2253 .preferred_min_throughput(ThroughputThreshold::Percentile(
2254 PercentileThresholds::new().p90(50.0),
2255 ));
2256
2257 let json = prefs.to_json();
2258 let provider = &json["provider"];
2259
2260 assert_eq!(provider["sort"], "price");
2261 assert_eq!(provider["preferred_min_throughput"]["p90"], 50.0);
2262 }
2263
2264 #[test]
2265 fn test_provider_preferences_require_parameters() {
2266 let prefs = ProviderPreferences::new().require_parameters(true);
2267
2268 let json = prefs.to_json();
2269 let provider = &json["provider"];
2270
2271 assert_eq!(provider["require_parameters"], true);
2272 }
2273
2274 #[test]
2275 fn test_provider_preferences_data_policy_and_zdr() {
2276 let prefs = ProviderPreferences::new()
2277 .data_collection(DataCollection::Deny)
2278 .zdr(true);
2279
2280 let json = prefs.to_json();
2281 let provider = &json["provider"];
2282
2283 assert_eq!(provider["data_collection"], "deny");
2284 assert_eq!(provider["zdr"], true);
2285 }
2286
2287 #[test]
2288 fn test_provider_preferences_quantizations() {
2289 let prefs =
2290 ProviderPreferences::new().quantizations([Quantization::Int8, Quantization::Fp16]);
2291
2292 let json = prefs.to_json();
2293 let provider = &json["provider"];
2294
2295 assert_eq!(provider["quantizations"], json!(["int8", "fp16"]));
2296 }
2297
2298 #[test]
2299 fn test_provider_preferences_convenience_methods() {
2300 let prefs = ProviderPreferences::new().zero_data_retention().fastest();
2301
2302 assert_eq!(prefs.zdr, Some(true));
2303 assert_eq!(
2304 prefs.sort,
2305 Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
2306 );
2307
2308 let prefs2 = ProviderPreferences::new().cheapest();
2309 assert_eq!(
2310 prefs2.sort,
2311 Some(ProviderSort::Simple(ProviderSortStrategy::Price))
2312 );
2313
2314 let prefs3 = ProviderPreferences::new().lowest_latency();
2315 assert_eq!(
2316 prefs3.sort,
2317 Some(ProviderSort::Simple(ProviderSortStrategy::Latency))
2318 );
2319 }
2320
2321 #[test]
2322 fn test_provider_preferences_serialization_skips_none() {
2323 let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Price);
2324
2325 let json = serde_json::to_value(&prefs).unwrap();
2326
2327 assert_eq!(json["sort"], "price");
2328 assert!(json.get("order").is_none());
2329 assert!(json.get("only").is_none());
2330 assert!(json.get("ignore").is_none());
2331 assert!(json.get("zdr").is_none());
2332 }
2333
2334 #[test]
2335 fn test_provider_preferences_deserialization() {
2336 let json = json!({
2337 "order": ["anthropic", "openai"],
2338 "sort": "throughput",
2339 "data_collection": "deny",
2340 "zdr": true,
2341 "quantizations": ["int8", "fp16"]
2342 });
2343
2344 let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
2345
2346 assert_eq!(
2347 prefs.order,
2348 Some(vec!["anthropic".to_string(), "openai".to_string()])
2349 );
2350 assert_eq!(
2351 prefs.sort,
2352 Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
2353 );
2354 assert_eq!(prefs.data_collection, Some(DataCollection::Deny));
2355 assert_eq!(prefs.zdr, Some(true));
2356 assert_eq!(
2357 prefs.quantizations,
2358 Some(vec![Quantization::Int8, Quantization::Fp16])
2359 );
2360 }
2361
2362 #[test]
2363 fn test_provider_preferences_deserialization_complex_sort() {
2364 let json = json!({
2365 "sort": {
2366 "by": "latency",
2367 "partition": "model"
2368 }
2369 });
2370
2371 let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
2372
2373 match prefs.sort {
2374 Some(ProviderSort::Complex(config)) => {
2375 assert_eq!(config.by, ProviderSortStrategy::Latency);
2376 assert_eq!(config.partition, Some(SortPartition::Model));
2377 }
2378 _ => panic!("Expected Complex sort variant"),
2379 }
2380 }
2381
2382 #[test]
2383 fn test_provider_preferences_full_integration() {
2384 let prefs = ProviderPreferences::new()
2385 .order(["anthropic", "openai"])
2386 .only(["anthropic", "openai", "google"])
2387 .sort(ProviderSortStrategy::Throughput)
2388 .data_collection(DataCollection::Deny)
2389 .zdr(true)
2390 .quantizations([Quantization::Int8])
2391 .allow_fallbacks(false);
2392
2393 let json = prefs.to_json();
2394
2395 assert!(json.get("provider").is_some());
2396 let provider = &json["provider"];
2397 assert_eq!(provider["order"], json!(["anthropic", "openai"]));
2398 assert_eq!(provider["only"], json!(["anthropic", "openai", "google"]));
2399 assert_eq!(provider["sort"], "throughput");
2400 assert_eq!(provider["data_collection"], "deny");
2401 assert_eq!(provider["zdr"], true);
2402 assert_eq!(provider["quantizations"], json!(["int8"]));
2403 assert_eq!(provider["allow_fallbacks"], false);
2404 }
2405
2406 #[test]
2407 fn test_provider_preferences_max_price() {
2408 let prefs =
2409 ProviderPreferences::new().max_price(MaxPrice::new().prompt(0.001).completion(0.002));
2410
2411 let json = prefs.to_json();
2412 let provider = &json["provider"];
2413
2414 assert_eq!(provider["max_price"]["prompt"], 0.001);
2415 assert_eq!(provider["max_price"]["completion"], 0.002);
2416 }
2417
2418 #[test]
2419 fn test_provider_preferences_preferred_max_latency() {
2420 let prefs = ProviderPreferences::new().preferred_max_latency(LatencyThreshold::Simple(0.5));
2421
2422 let json = prefs.to_json();
2423 let provider = &json["provider"];
2424
2425 assert_eq!(provider["preferred_max_latency"], 0.5);
2426 }
2427
2428 #[test]
2429 fn test_provider_preferences_empty_arrays() {
2430 let prefs = ProviderPreferences::new()
2431 .order(Vec::<String>::new())
2432 .quantizations(Vec::<Quantization>::new());
2433
2434 let json = prefs.to_json();
2435 let provider = &json["provider"];
2436
2437 assert_eq!(provider["order"], json!([]));
2438 assert_eq!(provider["quantizations"], json!([]));
2439 }
2440
2441 #[test]
2446 fn test_user_content_text_serialization() {
2447 let content = UserContent::Text {
2448 text: "Hello, world!".to_string(),
2449 };
2450 let json = serde_json::to_value(&content).unwrap();
2451
2452 assert_eq!(json["type"], "text");
2453 assert_eq!(json["text"], "Hello, world!");
2454 }
2455
2456 #[test]
2457 fn test_user_content_image_url_serialization() {
2458 let content = UserContent::Image {
2459 image_url: ImageUrl {
2460 url: "https://example.com/image.png".to_string(),
2461 detail: None,
2462 },
2463 };
2464 let json = serde_json::to_value(&content).unwrap();
2465
2466 assert_eq!(json["type"], "image_url");
2467 assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
2468 assert!(json["image_url"].get("detail").is_none());
2469 }
2470
2471 #[test]
2472 fn test_user_content_image_url_with_detail_serialization() {
2473 let content = UserContent::Image {
2474 image_url: ImageUrl {
2475 url: "https://example.com/image.png".to_string(),
2476 detail: Some(ImageDetail::High),
2477 },
2478 };
2479 let json = serde_json::to_value(&content).unwrap();
2480
2481 assert_eq!(json["type"], "image_url");
2482 assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
2483 assert_eq!(json["image_url"]["detail"], "high");
2484 }
2485
2486 #[test]
2487 fn test_user_content_image_base64_serialization() {
2488 let content = UserContent::Image {
2489 image_url: ImageUrl {
2490 url: "data:image/png;base64,SGVsbG8=".to_string(),
2491 detail: Some(ImageDetail::Low),
2492 },
2493 };
2494 let json = serde_json::to_value(&content).unwrap();
2495
2496 assert_eq!(json["type"], "image_url");
2497 assert_eq!(json["image_url"]["url"], "data:image/png;base64,SGVsbG8=");
2498 assert_eq!(json["image_url"]["detail"], "low");
2499 }
2500
2501 #[test]
2502 fn test_user_content_file_url_serialization() {
2503 let content = UserContent::File {
2504 file: FileData {
2505 file_data: Some("https://example.com/doc.pdf".to_string()),
2506 file_id: None,
2507 filename: Some("document.pdf".to_string()),
2508 },
2509 };
2510 let json = serde_json::to_value(&content).unwrap();
2511
2512 assert_eq!(json["type"], "file");
2513 assert_eq!(json["file"]["file_data"], "https://example.com/doc.pdf");
2514 assert_eq!(json["file"]["filename"], "document.pdf");
2515 }
2516
2517 #[test]
2518 fn test_user_content_file_base64_serialization() {
2519 let content = UserContent::File {
2520 file: FileData {
2521 file_data: Some("data:application/pdf;base64,JVBERi0xLjQ=".to_string()),
2522 file_id: None,
2523 filename: Some("report.pdf".to_string()),
2524 },
2525 };
2526 let json = serde_json::to_value(&content).unwrap();
2527
2528 assert_eq!(json["type"], "file");
2529 assert_eq!(
2530 json["file"]["file_data"],
2531 "data:application/pdf;base64,JVBERi0xLjQ="
2532 );
2533 assert_eq!(json["file"]["filename"], "report.pdf");
2534 }
2535
2536 #[test]
2537 fn test_user_content_text_deserialization() {
2538 let json = json!({
2539 "type": "text",
2540 "text": "Hello!"
2541 });
2542
2543 let content: UserContent = serde_json::from_value(json).unwrap();
2544 assert_eq!(
2545 content,
2546 UserContent::Text {
2547 text: "Hello!".to_string()
2548 }
2549 );
2550 }
2551
2552 #[test]
2553 fn test_user_content_image_url_deserialization() {
2554 let json = json!({
2555 "type": "image_url",
2556 "image_url": {
2557 "url": "https://example.com/img.jpg",
2558 "detail": "high"
2559 }
2560 });
2561
2562 let content: UserContent = serde_json::from_value(json).unwrap();
2563 match content {
2564 UserContent::Image { image_url } => {
2565 assert_eq!(image_url.url, "https://example.com/img.jpg");
2566 assert_eq!(image_url.detail, Some(ImageDetail::High));
2567 }
2568 _ => panic!("Expected Image variant"),
2569 }
2570 }
2571
2572 #[test]
2573 fn test_user_content_file_deserialization() {
2574 let json = json!({
2575 "type": "file",
2576 "file": {
2577 "filename": "doc.pdf",
2578 "file_data": "https://example.com/doc.pdf"
2579 }
2580 });
2581
2582 let content: UserContent = serde_json::from_value(json).unwrap();
2583 match content {
2584 UserContent::File { file } => {
2585 assert_eq!(file.filename, Some("doc.pdf".to_string()));
2586 assert_eq!(
2587 file.file_data,
2588 Some("https://example.com/doc.pdf".to_string())
2589 );
2590 }
2591 _ => panic!("Expected File variant"),
2592 }
2593 }
2594
2595 #[test]
2596 fn test_message_user_with_text_serialization() {
2597 let message = Message::User {
2598 content: OneOrMany::one(UserContent::Text {
2599 text: "Hello".to_string(),
2600 }),
2601 name: None,
2602 };
2603 let json = serde_json::to_value(&message).unwrap();
2604
2605 assert_eq!(json["role"], "user");
2607 assert_eq!(json["content"], "Hello");
2608 }
2609
2610 #[test]
2611 fn test_message_user_with_mixed_content_serialization() {
2612 let message = Message::User {
2613 content: OneOrMany::many(vec![
2614 UserContent::Text {
2615 text: "Check this image:".to_string(),
2616 },
2617 UserContent::Image {
2618 image_url: ImageUrl {
2619 url: "https://example.com/img.png".to_string(),
2620 detail: None,
2621 },
2622 },
2623 ])
2624 .unwrap(),
2625 name: None,
2626 };
2627 let json = serde_json::to_value(&message).unwrap();
2628
2629 assert_eq!(json["role"], "user");
2630 let content = json["content"].as_array().unwrap();
2631 assert_eq!(content.len(), 2);
2632 assert_eq!(content[0]["type"], "text");
2633 assert_eq!(content[1]["type"], "image_url");
2634 }
2635
2636 #[test]
2637 fn test_message_user_with_file_serialization() {
2638 let message = Message::User {
2639 content: OneOrMany::many(vec![
2640 UserContent::Text {
2641 text: "Analyze this PDF:".to_string(),
2642 },
2643 UserContent::File {
2644 file: FileData {
2645 file_data: Some("https://example.com/doc.pdf".to_string()),
2646 file_id: None,
2647 filename: Some("document.pdf".to_string()),
2648 },
2649 },
2650 ])
2651 .unwrap(),
2652 name: None,
2653 };
2654 let json = serde_json::to_value(&message).unwrap();
2655
2656 assert_eq!(json["role"], "user");
2657 let content = json["content"].as_array().unwrap();
2658 assert_eq!(content.len(), 2);
2659 assert_eq!(content[0]["type"], "text");
2660 assert_eq!(content[1]["type"], "file");
2661 assert_eq!(
2662 content[1]["file"]["file_data"],
2663 "https://example.com/doc.pdf"
2664 );
2665 }
2666
2667 #[test]
2668 fn test_user_content_from_rig_text() {
2669 let rig_content = message::UserContent::Text(message::Text::new("Hello".to_string()));
2670 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2671
2672 assert_eq!(
2673 openrouter_content,
2674 UserContent::Text {
2675 text: "Hello".to_string()
2676 }
2677 );
2678 }
2679
2680 #[test]
2681 fn test_user_content_from_rig_image_url() {
2682 let rig_content = message::UserContent::Image(message::Image {
2683 data: DocumentSourceKind::Url("https://example.com/img.png".to_string()),
2684 media_type: Some(message::ImageMediaType::PNG),
2685 detail: Some(ImageDetail::High),
2686 additional_params: None,
2687 });
2688 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2689
2690 match openrouter_content {
2691 UserContent::Image { image_url } => {
2692 assert_eq!(image_url.url, "https://example.com/img.png");
2693 assert_eq!(image_url.detail, Some(ImageDetail::High));
2694 }
2695 _ => panic!("Expected Image variant"),
2696 }
2697 }
2698
2699 #[test]
2700 fn test_user_content_from_rig_image_base64() {
2701 let rig_content = message::UserContent::Image(message::Image {
2702 data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
2703 media_type: Some(message::ImageMediaType::JPEG),
2704 detail: Some(ImageDetail::Low),
2705 additional_params: None,
2706 });
2707 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2708
2709 match openrouter_content {
2710 UserContent::Image { image_url } => {
2711 assert_eq!(image_url.url, "data:image/jpeg;base64,SGVsbG8=");
2712 assert_eq!(image_url.detail, Some(ImageDetail::Low));
2713 }
2714 _ => panic!("Expected Image variant"),
2715 }
2716 }
2717
2718 #[test]
2719 fn test_user_content_from_rig_document_url() {
2720 let rig_content = message::UserContent::Document(message::Document {
2721 data: DocumentSourceKind::Url("https://example.com/doc.pdf".to_string()),
2722 media_type: Some(DocumentMediaType::PDF),
2723 additional_params: None,
2724 });
2725 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2726
2727 match openrouter_content {
2728 UserContent::File { file } => {
2729 assert_eq!(
2730 file.file_data,
2731 Some("https://example.com/doc.pdf".to_string())
2732 );
2733 assert_eq!(file.filename, Some("document.pdf".to_string()));
2734 }
2735 _ => panic!("Expected File variant"),
2736 }
2737 }
2738
2739 #[test]
2740 fn test_user_content_from_rig_document_base64() {
2741 let rig_content = message::UserContent::Document(message::Document {
2742 data: DocumentSourceKind::Base64("JVBERi0xLjQ=".to_string()),
2743 media_type: Some(DocumentMediaType::PDF),
2744 additional_params: None,
2745 });
2746 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2747
2748 match openrouter_content {
2749 UserContent::File { file } => {
2750 assert_eq!(
2751 file.file_data,
2752 Some("data:application/pdf;base64,JVBERi0xLjQ=".to_string())
2753 );
2754 assert_eq!(file.filename, Some("document.pdf".to_string()));
2755 }
2756 _ => panic!("Expected File variant"),
2757 }
2758 }
2759
2760 #[test]
2761 fn test_user_content_from_rig_document_file_id() {
2762 let rig_content = message::UserContent::Document(message::Document {
2763 data: DocumentSourceKind::FileId("file_abc".to_string()),
2764 media_type: None,
2765 additional_params: None,
2766 });
2767
2768 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
2769 assert!(matches!(
2770 result,
2771 Err(message::MessageError::ConversionError(message))
2772 if message.contains("Provider file IDs are not supported")
2773 ));
2774 }
2775
2776 #[test]
2777 fn test_openai_file_id_content_round_trips_through_rig_to_openrouter_error() {
2778 let openai_content = openai::UserContent::File {
2779 file: openai::FileData {
2780 file_data: None,
2781 file_id: Some("file_abc".to_string()),
2782 filename: None,
2783 },
2784 };
2785 let rig_content: message::UserContent = openai_content.into();
2786
2787 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
2788 assert!(matches!(
2789 result,
2790 Err(message::MessageError::ConversionError(message))
2791 if message.contains("Provider file IDs are not supported")
2792 ));
2793 }
2794
2795 #[test]
2796 fn test_user_content_from_rig_document_string_becomes_text() {
2797 let rig_content = message::UserContent::Document(message::Document {
2798 data: DocumentSourceKind::String("Plain text document content".to_string()),
2799 media_type: Some(DocumentMediaType::TXT),
2800 additional_params: None,
2801 });
2802 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2803
2804 assert_eq!(
2805 openrouter_content,
2806 UserContent::Text {
2807 text: "Plain text document content".to_string()
2808 }
2809 );
2810 }
2811
2812 #[test]
2813 fn test_completion_response_with_reasoning_details_maps_to_typed_reasoning() {
2814 let json = json!({
2815 "id": "resp_123",
2816 "object": "chat.completion",
2817 "created": 1,
2818 "model": "openrouter/test-model",
2819 "choices": [{
2820 "index": 0,
2821 "finish_reason": "stop",
2822 "message": {
2823 "role": "assistant",
2824 "content": "hello",
2825 "reasoning": null,
2826 "reasoning_details": [
2827 {"type":"reasoning.summary","id":"rs_1","summary":"s1"},
2828 {"type":"reasoning.text","id":"rs_1","text":"t1","signature":"sig_1"},
2829 {"type":"reasoning.encrypted","id":"rs_1","data":"enc_1"}
2830 ]
2831 }
2832 }]
2833 });
2834
2835 let response: CompletionResponse = serde_json::from_value(json).unwrap();
2836 let converted: completion::CompletionResponse<CompletionResponse> =
2837 response.try_into().unwrap();
2838 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
2839
2840 assert!(items.iter().any(|item| matches!(
2841 item,
2842 completion::AssistantContent::Reasoning(message::Reasoning { id: Some(id), content })
2843 if id == "rs_1" && content.len() == 3
2844 )));
2845 }
2846
2847 #[test]
2848 fn test_assistant_reasoning_emits_openrouter_reasoning_details() {
2849 let reasoning = message::Reasoning {
2850 id: Some("rs_2".to_string()),
2851 content: vec![
2852 message::ReasoningContent::Text {
2853 text: "step".to_string(),
2854 signature: Some("sig_step".to_string()),
2855 },
2856 message::ReasoningContent::Summary("summary".to_string()),
2857 message::ReasoningContent::Encrypted("enc_blob".to_string()),
2858 ],
2859 };
2860
2861 let messages = assistant_contents_to_messages(OneOrMany::one(
2862 message::AssistantContent::Reasoning(reasoning),
2863 ))
2864 .unwrap();
2865 let Message::Assistant {
2866 reasoning,
2867 reasoning_details,
2868 ..
2869 } = messages.first().expect("assistant message")
2870 else {
2871 panic!("Expected assistant message");
2872 };
2873
2874 assert!(reasoning.is_none());
2875 assert_eq!(reasoning_details.len(), 3);
2876 assert!(matches!(
2877 reasoning_details.first(),
2878 Some(ReasoningDetails::Text {
2879 id: Some(id),
2880 text: Some(text),
2881 signature: Some(signature),
2882 ..
2883 }) if id == "rs_2" && text == "step" && signature == "sig_step"
2884 ));
2885 }
2886
2887 #[test]
2888 fn test_tool_call_signature_without_params_uses_wire_id_for_encrypted_detail() {
2889 let tool_call = message::ToolCall {
2890 id: "call_wire".to_string(),
2891 call_id: None,
2892 function: message::ToolFunction {
2893 name: "lookup".to_string(),
2894 arguments: json!({}),
2895 },
2896 signature: Some("sig-data".to_string()),
2897 additional_params: None,
2898 };
2899
2900 let messages = assistant_contents_to_messages(OneOrMany::one(
2901 message::AssistantContent::ToolCall(tool_call),
2902 ))
2903 .unwrap();
2904
2905 let Message::Assistant {
2906 reasoning_details, ..
2907 } = messages.first().expect("assistant message")
2908 else {
2909 panic!("Expected assistant message");
2910 };
2911
2912 assert!(matches!(
2913 reasoning_details.first(),
2914 Some(ReasoningDetails::Encrypted {
2915 id: Some(id),
2916 data,
2917 ..
2918 }) if id == "call_wire" && data == "sig-data"
2919 ));
2920 }
2921
2922 #[test]
2923 fn test_tool_call_minimal_params_fall_back_to_wire_id() {
2924 let tool_call = message::ToolCall {
2925 id: "call_wire".to_string(),
2926 call_id: None,
2927 function: message::ToolFunction {
2928 name: "lookup".to_string(),
2929 arguments: json!({}),
2930 },
2931 signature: Some("sig-data".to_string()),
2932 additional_params: Some(json!({"format": "anthropic"})),
2935 };
2936
2937 let messages = assistant_contents_to_messages(OneOrMany::one(
2938 message::AssistantContent::ToolCall(tool_call),
2939 ))
2940 .unwrap();
2941
2942 let Message::Assistant {
2943 reasoning_details, ..
2944 } = messages.first().expect("assistant message")
2945 else {
2946 panic!("Expected assistant message");
2947 };
2948
2949 assert!(matches!(
2950 reasoning_details.first(),
2951 Some(ReasoningDetails::Encrypted {
2952 id: Some(id),
2953 format,
2954 data,
2955 ..
2956 }) if id == "call_wire" && data == "sig-data" && format.as_deref() == Some("anthropic")
2957 ));
2958 }
2959
2960 #[test]
2961 fn test_assistant_redacted_reasoning_emits_encrypted_detail_not_text() {
2962 let reasoning = message::Reasoning {
2963 id: Some("rs_redacted".to_string()),
2964 content: vec![message::ReasoningContent::Redacted {
2965 data: "opaque-redacted-data".to_string(),
2966 }],
2967 };
2968
2969 let messages = assistant_contents_to_messages(OneOrMany::one(
2970 message::AssistantContent::Reasoning(reasoning),
2971 ))
2972 .unwrap();
2973
2974 let Message::Assistant {
2975 reasoning_details,
2976 reasoning,
2977 ..
2978 } = messages.first().expect("assistant message")
2979 else {
2980 panic!("Expected assistant message");
2981 };
2982
2983 assert!(reasoning.is_none());
2984 assert_eq!(reasoning_details.len(), 1);
2985 assert!(matches!(
2986 reasoning_details.first(),
2987 Some(ReasoningDetails::Encrypted {
2988 id: Some(id),
2989 data,
2990 ..
2991 }) if id == "rs_redacted" && data == "opaque-redacted-data"
2992 ));
2993 }
2994
2995 #[test]
2996 fn test_completion_response_reasoning_details_respects_index_ordering() {
2997 let json = json!({
2998 "id": "resp_ordering",
2999 "object": "chat.completion",
3000 "created": 1,
3001 "model": "openrouter/test-model",
3002 "choices": [{
3003 "index": 0,
3004 "finish_reason": "stop",
3005 "message": {
3006 "role": "assistant",
3007 "content": "hello",
3008 "reasoning": null,
3009 "reasoning_details": [
3010 {"type":"reasoning.summary","id":"rs_order","index":1,"summary":"second"},
3011 {"type":"reasoning.summary","id":"rs_order","index":0,"summary":"first"}
3012 ]
3013 }
3014 }]
3015 });
3016
3017 let response: CompletionResponse = serde_json::from_value(json).unwrap();
3018 let converted: completion::CompletionResponse<CompletionResponse> =
3019 response.try_into().unwrap();
3020 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3021 let reasoning_blocks: Vec<_> = items
3022 .into_iter()
3023 .filter_map(|item| match item {
3024 completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
3025 _ => None,
3026 })
3027 .collect();
3028
3029 assert_eq!(reasoning_blocks.len(), 1);
3030 assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_order"));
3031 assert_eq!(
3032 reasoning_blocks[0].content,
3033 vec![
3034 message::ReasoningContent::Summary("first".to_string()),
3035 message::ReasoningContent::Summary("second".to_string()),
3036 ]
3037 );
3038 }
3039
3040 #[test]
3041 fn test_user_content_from_rig_image_missing_media_type_error() {
3042 let rig_content = message::UserContent::Image(message::Image {
3043 data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3044 media_type: None, detail: None,
3046 additional_params: None,
3047 });
3048 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3049
3050 assert!(result.is_err());
3051 let err = result.unwrap_err();
3052 assert!(err.to_string().contains("media type required"));
3053 }
3054
3055 #[test]
3056 fn test_user_content_from_rig_image_raw_bytes_error() {
3057 let rig_content = message::UserContent::Image(message::Image {
3058 data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3059 media_type: Some(message::ImageMediaType::PNG),
3060 detail: None,
3061 additional_params: None,
3062 });
3063 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3064
3065 assert!(result.is_err());
3066 let err = result.unwrap_err();
3067 assert!(err.to_string().contains("base64"));
3068 }
3069
3070 #[test]
3071 fn test_user_content_from_rig_video_url() {
3072 let rig_content = message::UserContent::Video(message::Video {
3073 data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
3074 media_type: Some(message::VideoMediaType::MP4),
3075 additional_params: None,
3076 });
3077 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3078
3079 match openrouter_content {
3080 UserContent::Video { video_url } => {
3081 assert_eq!(video_url.url, "https://example.com/video.mp4");
3082 }
3083 _ => panic!("Expected Video variant"),
3084 }
3085 }
3086
3087 #[test]
3088 fn test_user_content_from_rig_video_base64() {
3089 let rig_content = message::UserContent::Video(message::Video {
3090 data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3091 media_type: Some(message::VideoMediaType::MP4),
3092 additional_params: None,
3093 });
3094 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3095
3096 match openrouter_content {
3097 UserContent::Video { video_url } => {
3098 assert_eq!(video_url.url, "data:video/mp4;base64,SGVsbG8=");
3099 }
3100 _ => panic!("Expected Video variant"),
3101 }
3102 }
3103
3104 #[test]
3105 fn test_user_content_from_rig_video_base64_missing_media_type_error() {
3106 let rig_content = message::UserContent::Video(message::Video {
3107 data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3108 media_type: None,
3109 additional_params: None,
3110 });
3111 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3112
3113 assert!(result.is_err());
3114 let err = result.unwrap_err();
3115 assert!(err.to_string().contains("media type"));
3116 }
3117
3118 #[test]
3119 fn test_user_content_from_rig_video_raw_bytes_error() {
3120 let rig_content = message::UserContent::Video(message::Video {
3121 data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3122 media_type: Some(message::VideoMediaType::MP4),
3123 additional_params: None,
3124 });
3125 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3126
3127 assert!(result.is_err());
3128 let err = result.unwrap_err();
3129 assert!(err.to_string().contains("base64"));
3130 }
3131
3132 #[test]
3133 fn test_user_content_from_rig_audio_base64() {
3134 let rig_content = message::UserContent::Audio(message::Audio {
3135 data: DocumentSourceKind::Base64("audiodata".to_string()),
3136 media_type: Some(message::AudioMediaType::MP3),
3137 additional_params: None,
3138 });
3139 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3140
3141 match openrouter_content {
3142 UserContent::Audio { input_audio } => {
3143 assert_eq!(input_audio.data, "audiodata");
3144 assert_eq!(input_audio.format, message::AudioMediaType::MP3);
3145 }
3146 _ => panic!("Expected Audio variant"),
3147 }
3148 }
3149
3150 #[test]
3151 fn test_user_content_from_rig_audio_missing_media_type_error() {
3152 let rig_content = message::UserContent::Audio(message::Audio {
3153 data: DocumentSourceKind::Base64("audiodata".to_string()),
3154 media_type: None, additional_params: None,
3156 });
3157 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3158
3159 assert!(result.is_err());
3160 let err = result.unwrap_err();
3161 assert!(err.to_string().contains("media type required"));
3162 }
3163
3164 #[test]
3165 fn test_user_content_from_rig_audio_url_error() {
3166 let rig_content = message::UserContent::Audio(message::Audio {
3167 data: DocumentSourceKind::Url("https://example.com/audio.wav".to_string()),
3168 media_type: Some(message::AudioMediaType::WAV),
3169 additional_params: None,
3170 });
3171 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3172
3173 assert!(result.is_err());
3174 let err = result.unwrap_err();
3175 assert!(err.to_string().contains("base64"));
3176 }
3177
3178 #[test]
3179 fn test_user_content_from_rig_audio_raw_bytes_error() {
3180 let rig_content = message::UserContent::Audio(message::Audio {
3181 data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3182 media_type: Some(message::AudioMediaType::WAV),
3183 additional_params: None,
3184 });
3185 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3186
3187 assert!(result.is_err());
3188 let err = result.unwrap_err();
3189 assert!(err.to_string().contains("base64"));
3190 }
3191
3192 #[test]
3193 fn test_user_content_from_rig_video_file_id_error() {
3194 let rig_content = message::UserContent::Video(message::Video {
3195 data: DocumentSourceKind::FileId("file-123".to_string()),
3196 media_type: Some(message::VideoMediaType::MP4),
3197 additional_params: None,
3198 });
3199 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3200
3201 assert!(result.is_err());
3202 let err = result.unwrap_err();
3203 assert!(
3204 err.to_string()
3205 .contains("File IDs are not supported for video")
3206 );
3207 }
3208
3209 #[test]
3210 fn test_user_content_from_rig_audio_file_id_error() {
3211 let rig_content = message::UserContent::Audio(message::Audio {
3212 data: DocumentSourceKind::FileId("file-123".to_string()),
3213 media_type: Some(message::AudioMediaType::MP3),
3214 additional_params: None,
3215 });
3216 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3217
3218 assert!(result.is_err());
3219 let err = result.unwrap_err();
3220 assert!(
3221 err.to_string()
3222 .contains("File IDs are not supported for audio")
3223 );
3224 }
3225
3226 #[test]
3227 fn test_video_helper_converts_to_data_uri() {
3228 let rig_content =
3231 message::UserContent::video("SGVsbG8=", Some(message::VideoMediaType::MP4));
3232 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3233
3234 match openrouter_content {
3235 UserContent::Video { video_url } => {
3236 assert_eq!(video_url.url, "data:video/mp4;base64,SGVsbG8=");
3237 }
3238 _ => panic!("Expected Video variant"),
3239 }
3240 }
3241
3242 #[test]
3243 fn test_video_url_helper_passes_url_through() {
3244 let rig_content = message::UserContent::video_url("https://example.com/video.mp4", None);
3247 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3248
3249 match openrouter_content {
3250 UserContent::Video { video_url } => {
3251 assert_eq!(video_url.url, "https://example.com/video.mp4");
3252 }
3253 _ => panic!("Expected Video variant"),
3254 }
3255 }
3256
3257 #[test]
3258 fn test_video_raw_helper_errors() {
3259 let rig_content =
3262 message::UserContent::video_raw(vec![1, 2, 3], Some(message::VideoMediaType::MP4));
3263 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3264
3265 assert!(result.is_err());
3266 let err = result.unwrap_err();
3267 assert!(err.to_string().contains("base64"));
3268 }
3269
3270 #[test]
3271 fn test_message_conversion_with_pdf() {
3272 let rig_message = message::Message::User {
3273 content: OneOrMany::many(vec![
3274 message::UserContent::Text(message::Text::new(
3275 "Summarize this document".to_string(),
3276 )),
3277 message::UserContent::Document(message::Document {
3278 data: DocumentSourceKind::Url("https://example.com/paper.pdf".to_string()),
3279 media_type: Some(DocumentMediaType::PDF),
3280 additional_params: None,
3281 }),
3282 ])
3283 .unwrap(),
3284 };
3285
3286 let openrouter_messages: Vec<Message> = messages_from_rig_message(rig_message).unwrap();
3287 assert_eq!(openrouter_messages.len(), 1);
3288
3289 match &openrouter_messages[0] {
3290 Message::User { content, .. } => {
3291 assert_eq!(content.len(), 2);
3292
3293 match content.first_ref() {
3295 UserContent::Text { text, .. } => assert_eq!(text, "Summarize this document"),
3296 _ => panic!("Expected Text"),
3297 }
3298 }
3299 _ => panic!("Expected User message"),
3300 }
3301 }
3302
3303 #[test]
3304 fn test_user_content_from_string() {
3305 let content: UserContent = "Hello".into();
3306 assert_eq!(
3307 content,
3308 UserContent::Text {
3309 text: "Hello".to_string()
3310 }
3311 );
3312
3313 let content: UserContent = String::from("World").into();
3314 assert_eq!(
3315 content,
3316 UserContent::Text {
3317 text: "World".to_string()
3318 }
3319 );
3320 }
3321
3322 #[test]
3323 fn test_completion_response_reasoning_details_with_multiple_ids_stay_separate() {
3324 let json = json!({
3325 "id": "resp_multi_id",
3326 "object": "chat.completion",
3327 "created": 1,
3328 "model": "openrouter/test-model",
3329 "choices": [{
3330 "index": 0,
3331 "finish_reason": "stop",
3332 "message": {
3333 "role": "assistant",
3334 "content": "hello",
3335 "reasoning": null,
3336 "reasoning_details": [
3337 {"type":"reasoning.summary","id":"rs_a","summary":"a1"},
3338 {"type":"reasoning.summary","id":"rs_b","summary":"b1"},
3339 {"type":"reasoning.summary","id":"rs_a","summary":"a2"}
3340 ]
3341 }
3342 }]
3343 });
3344
3345 let response: CompletionResponse = serde_json::from_value(json).unwrap();
3346 let converted: completion::CompletionResponse<CompletionResponse> =
3347 response.try_into().unwrap();
3348 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3349 let reasoning_blocks: Vec<_> = items
3350 .into_iter()
3351 .filter_map(|item| match item {
3352 completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
3353 _ => None,
3354 })
3355 .collect();
3356
3357 assert_eq!(reasoning_blocks.len(), 2);
3358 assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_a"));
3359 assert_eq!(
3360 reasoning_blocks[0].content,
3361 vec![
3362 message::ReasoningContent::Summary("a1".to_string()),
3363 message::ReasoningContent::Summary("a2".to_string()),
3364 ]
3365 );
3366 assert_eq!(reasoning_blocks[1].id.as_deref(), Some("rs_b"));
3367 assert_eq!(
3368 reasoning_blocks[1].content,
3369 vec![message::ReasoningContent::Summary("b1".to_string())]
3370 );
3371 }
3372
3373 #[test]
3374 fn test_user_content_audio_serialization() {
3375 let content = UserContent::Audio {
3376 input_audio: openai::InputAudio {
3377 data: "SGVsbG8=".to_string(),
3378 format: AudioMediaType::WAV,
3379 },
3380 };
3381 let json = serde_json::to_value(&content).unwrap();
3382
3383 assert_eq!(json["type"], "input_audio");
3384 assert_eq!(json["input_audio"]["data"], "SGVsbG8=");
3385 assert_eq!(json["input_audio"]["format"], "wav");
3386 }
3387
3388 #[test]
3389 fn test_user_content_audio_deserialization() {
3390 let json = json!({
3391 "type": "input_audio",
3392 "input_audio": {
3393 "data": "SGVsbG8=",
3394 "format": "wav"
3395 }
3396 });
3397
3398 let content: UserContent = serde_json::from_value(json).unwrap();
3399 match content {
3400 UserContent::Audio { input_audio } => {
3401 assert_eq!(input_audio.data, "SGVsbG8=");
3402 assert_eq!(input_audio.format, AudioMediaType::WAV);
3403 }
3404 _ => panic!("Expected Audio variant"),
3405 }
3406 }
3407
3408 #[test]
3409 fn test_message_user_with_audio_serialization() {
3410 let msg = Message::User {
3411 content: OneOrMany::many(vec![
3412 UserContent::Text {
3413 text: "Transcribe this audio:".to_string(),
3414 },
3415 UserContent::Audio {
3416 input_audio: openai::InputAudio {
3417 data: "SGVsbG8=".to_string(),
3418 format: AudioMediaType::MP3,
3419 },
3420 },
3421 ])
3422 .unwrap(),
3423 name: None,
3424 };
3425 let json = serde_json::to_value(&msg).unwrap();
3426
3427 assert_eq!(json["role"], "user");
3428 let content = json["content"].as_array().unwrap();
3429 assert_eq!(content.len(), 2);
3430 assert_eq!(content[0]["type"], "text");
3431 assert_eq!(content[1]["type"], "input_audio");
3432 assert_eq!(content[1]["input_audio"]["data"], "SGVsbG8=");
3433 assert_eq!(content[1]["input_audio"]["format"], "mp3");
3434 }
3435
3436 #[test]
3437 fn test_user_content_video_url_serialization() {
3438 let content = UserContent::Video {
3439 video_url: VideoUrl {
3440 url: "https://example.com/video.mp4".to_string(),
3441 },
3442 };
3443 let json = serde_json::to_value(&content).unwrap();
3444
3445 assert_eq!(json["type"], "video_url");
3446 assert_eq!(json["video_url"]["url"], "https://example.com/video.mp4");
3447 }
3448
3449 #[test]
3450 fn test_user_content_video_base64_serialization() {
3451 let content = UserContent::Video {
3452 video_url: VideoUrl {
3453 url: format!(
3454 "data:{};base64,SGVsbG8=",
3455 VideoMediaType::MP4.to_mime_type()
3456 ),
3457 },
3458 };
3459 let json = serde_json::to_value(&content).unwrap();
3460
3461 assert_eq!(json["type"], "video_url");
3462 assert_eq!(json["video_url"]["url"], "data:video/mp4;base64,SGVsbG8=");
3463 }
3464
3465 #[test]
3466 fn test_user_content_video_url_deserialization() {
3467 let json = json!({
3468 "type": "video_url",
3469 "video_url": {
3470 "url": "https://example.com/video.mp4"
3471 }
3472 });
3473
3474 let content: UserContent = serde_json::from_value(json).unwrap();
3475 match content {
3476 UserContent::Video { video_url } => {
3477 assert_eq!(video_url.url, "https://example.com/video.mp4");
3478 }
3479 _ => panic!("Expected Video variant"),
3480 }
3481 }
3482
3483 #[test]
3484 fn test_message_user_with_video_serialization() {
3485 let msg = Message::User {
3486 content: OneOrMany::many(vec![
3487 UserContent::Text {
3488 text: "Describe this video:".to_string(),
3489 },
3490 UserContent::Video {
3491 video_url: VideoUrl {
3492 url: "https://example.com/video.mp4".to_string(),
3493 },
3494 },
3495 ])
3496 .unwrap(),
3497 name: None,
3498 };
3499 let json = serde_json::to_value(&msg).unwrap();
3500
3501 assert_eq!(json["role"], "user");
3502 let content = json["content"].as_array().unwrap();
3503 assert_eq!(content.len(), 2);
3504 assert_eq!(content[0]["type"], "text");
3505 assert_eq!(content[1]["type"], "video_url");
3506 assert_eq!(
3507 content[1]["video_url"]["url"],
3508 "https://example.com/video.mp4"
3509 );
3510 }
3511
3512 #[test]
3513 fn test_user_content_video_url_no_media_type_needed() {
3514 let rig_content = message::UserContent::Video(message::Video {
3515 data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
3516 media_type: None,
3517 additional_params: None,
3518 });
3519 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3520
3521 match openrouter_content {
3522 UserContent::Video { video_url } => {
3523 assert_eq!(video_url.url, "https://example.com/video.mp4");
3524 }
3525 _ => panic!("Expected Video variant"),
3526 }
3527 }
3528
3529 fn prompt_caching_completion_request() -> CompletionRequest {
3530 CompletionRequest {
3531 model: None,
3532 preamble: Some("You are a helpful assistant.".to_string()),
3533 chat_history: crate::OneOrMany::one(crate::message::Message::user("Hello")),
3534 documents: vec![],
3535 tools: vec![],
3536 temperature: None,
3537 max_tokens: None,
3538 tool_choice: None,
3539 additional_params: None,
3540 output_schema: None,
3541 record_telemetry_content: false,
3542 }
3543 }
3544
3545 #[test]
3546 fn test_final_request_body_applies_prompt_caching_to_converted_completion_request() {
3547 let request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
3548 model: "anthropic/claude-3.5-sonnet",
3549 request: prompt_caching_completion_request(),
3550 strict_tools: false,
3551 })
3552 .expect("request conversion should succeed");
3553
3554 let body = final_request_body(&request, true).expect("request body should serialize");
3555 let system_block = &body["messages"][0]["content"][0];
3556
3557 assert_eq!(system_block["type"], "text");
3558 assert_eq!(system_block["text"], "You are a helpful assistant.");
3559 assert_eq!(system_block["cache_control"]["type"], "ephemeral");
3560
3561 let body = final_request_body(&request, false).expect("request body should serialize");
3562 assert!(
3563 body["messages"][0]["content"][0]
3564 .get("cache_control")
3565 .is_none(),
3566 "prompt caching should be opt-in"
3567 );
3568 }
3569
3570 #[test]
3571 fn test_final_request_body_preserves_stream_flag_when_prompt_caching_enabled() {
3572 let mut request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
3573 model: "anthropic/claude-3.5-sonnet",
3574 request: prompt_caching_completion_request(),
3575 strict_tools: false,
3576 })
3577 .expect("request conversion should succeed");
3578 request.additional_params = Some(json!({ "stream": true }));
3579
3580 let body = final_request_body(&request, true).expect("request body should serialize");
3581
3582 assert_eq!(body["stream"], true);
3583 assert_eq!(
3584 body["messages"][0]["content"][0]["cache_control"]["type"],
3585 "ephemeral"
3586 );
3587 }
3588
3589 #[test]
3590 fn test_apply_prompt_caching_string_system_message() {
3591 let mut body = json!({
3592 "model": "anthropic/claude-3.5-sonnet",
3593 "messages": [
3594 {"role": "system", "content": "You are a helpful assistant."},
3595 {"role": "user", "content": "Hello"}
3596 ]
3597 });
3598
3599 apply_prompt_caching(&mut body);
3600
3601 let system_content = &body["messages"][0]["content"];
3602 assert!(
3603 system_content.is_array(),
3604 "system content should be an array after caching"
3605 );
3606 let block = &system_content[0];
3607 assert_eq!(block["type"], "text");
3608 assert_eq!(block["text"], "You are a helpful assistant.");
3609 assert_eq!(block["cache_control"]["type"], "ephemeral");
3610
3611 assert_eq!(body["messages"][1]["content"], "Hello");
3613 }
3614
3615 #[test]
3616 fn test_apply_prompt_caching_array_system_message_marks_last_block() {
3617 let mut body = json!({
3618 "model": "anthropic/claude-3.5-sonnet",
3619 "messages": [
3620 {
3621 "role": "system",
3622 "content": [
3623 {"type": "text", "text": "Part 1. "},
3624 {"type": "text", "text": "Part 2."}
3625 ]
3626 }
3627 ]
3628 });
3629
3630 apply_prompt_caching(&mut body);
3631
3632 let system_content = &body["messages"][0]["content"];
3633 assert!(system_content.is_array());
3634 assert_eq!(system_content.as_array().unwrap().len(), 2);
3636 assert_eq!(system_content[0]["text"], "Part 1. ");
3637 assert!(system_content[0].get("cache_control").is_none());
3638 assert_eq!(system_content[1]["text"], "Part 2.");
3639 assert_eq!(system_content[1]["cache_control"]["type"], "ephemeral");
3640 }
3641
3642 #[test]
3643 fn test_apply_prompt_caching_preserves_non_text_blocks() {
3644 let mut body = json!({
3645 "model": "anthropic/claude-3.5-sonnet",
3646 "messages": [
3647 {
3648 "role": "system",
3649 "content": [
3650 {"type": "image", "source": {"type": "url", "url": "https://example.com/img.png"}},
3651 {"type": "text", "text": "Describe the image."}
3652 ]
3653 }
3654 ]
3655 });
3656
3657 apply_prompt_caching(&mut body);
3658
3659 let system_content = &body["messages"][0]["content"];
3660 assert_eq!(system_content.as_array().unwrap().len(), 2);
3661 assert_eq!(system_content[0]["type"], "image");
3663 assert!(system_content[0].get("cache_control").is_none());
3664 assert_eq!(system_content[1]["type"], "text");
3666 assert_eq!(system_content[1]["cache_control"]["type"], "ephemeral");
3667 }
3668
3669 #[test]
3670 fn test_apply_prompt_caching_no_system_message_is_noop() {
3671 let mut body = json!({
3672 "model": "openai/gpt-4o",
3673 "messages": [
3674 {"role": "user", "content": "Hello"}
3675 ]
3676 });
3677
3678 let body_before = body.clone();
3679 apply_prompt_caching(&mut body);
3680 assert_eq!(
3681 body, body_before,
3682 "body should be unchanged when no system message exists"
3683 );
3684 }
3685
3686 #[test]
3687 fn test_completion_response_extracts_generated_images() {
3688 let json = json!({
3689 "id": "resp_img",
3690 "object": "chat.completion",
3691 "created": 1,
3692 "model": "google/gemini-flash-image-preview",
3693 "choices": [{
3694 "index": 0,
3695 "finish_reason": "stop",
3696 "message": {
3697 "role": "assistant",
3698 "content": "Here is your image.",
3699 "images": [
3700 {"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}
3701 ]
3702 }
3703 }]
3704 });
3705
3706 let response: CompletionResponse = serde_json::from_value(json).unwrap();
3707 let converted: completion::CompletionResponse<CompletionResponse> =
3708 response.try_into().unwrap();
3709 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3710 assert_eq!(items.len(), 2);
3711
3712 assert!(items.iter().any(|item| matches!(
3713 item,
3714 completion::AssistantContent::Text(t) if t.text == "Here is your image."
3715 )));
3716 assert!(items.iter().any(|item| matches!(
3717 item,
3718 completion::AssistantContent::Image(message::Image {
3719 data: message::DocumentSourceKind::Base64(b64),
3720 media_type: Some(message::ImageMediaType::PNG),
3721 additional_params: Some(_),
3722 ..
3723 }) if b64 == "iVBORw0KGgo="
3724 )));
3725 assert!(
3726 items.iter().any(|item| matches!(
3727 item,
3728 completion::AssistantContent::Image(image)
3729 if is_openrouter_response_image(image)
3730 )),
3731 "generated images should be marked as OpenRouter response-only artifacts"
3732 );
3733 }
3734
3735 #[test]
3736 fn test_completion_response_extracts_generated_images_url() {
3737 let json = json!({
3738 "id": "resp_img_url",
3739 "object": "chat.completion",
3740 "created": 1,
3741 "model": "google/gemini-flash-image-preview",
3742 "choices": [{
3743 "index": 0,
3744 "finish_reason": "stop",
3745 "message": {
3746 "role": "assistant",
3747 "content": "Here is your image.",
3748 "images": [
3749 {"type":"image_url","image_url":{"url":"https://example.com/generated.png"}}
3750 ]
3751 }
3752 }]
3753 });
3754
3755 let response: CompletionResponse = serde_json::from_value(json).unwrap();
3756 let converted: completion::CompletionResponse<CompletionResponse> =
3757 response.try_into().unwrap();
3758 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3759 assert_eq!(items.len(), 2);
3760
3761 assert!(items.iter().any(|item| matches!(
3762 item,
3763 completion::AssistantContent::Image(message::Image {
3764 data: message::DocumentSourceKind::Url(url),
3765 media_type: None,
3766 additional_params: Some(_),
3767 ..
3768 }) if url == "https://example.com/generated.png"
3769 )));
3770 assert!(
3771 items.iter().any(|item| matches!(
3772 item,
3773 completion::AssistantContent::Image(image)
3774 if is_openrouter_response_image(image)
3775 )),
3776 "generated URL images should be marked as OpenRouter response-only artifacts"
3777 );
3778 }
3779
3780 #[test]
3781 fn test_generated_images_do_not_break_assistant_history_conversion() {
3782 let generated_image = response_image_to_assistant_content(&ResponseImage {
3783 image_url: ImageUrl {
3784 url: "data:image/png;base64,abc".to_string(),
3785 detail: None,
3786 },
3787 });
3788
3789 let content = OneOrMany::many(vec![
3790 completion::AssistantContent::text("Here is your image."),
3791 generated_image,
3792 ])
3793 .unwrap();
3794 let messages = assistant_contents_to_messages(content).unwrap();
3795
3796 assert_eq!(messages.len(), 1);
3797 assert!(matches!(
3798 &messages[0],
3799 Message::Assistant { content, .. }
3800 if content == &vec![openai::AssistantContent::Text {
3801 text: "Here is your image.".to_string()
3802 }]
3803 ));
3804 }
3805
3806 #[test]
3807 fn test_image_only_assistant_history_is_omitted_for_openrouter() {
3808 let generated_image = response_image_to_assistant_content(&ResponseImage {
3809 image_url: ImageUrl {
3810 url: "data:image/png;base64,abc".to_string(),
3811 detail: None,
3812 },
3813 });
3814
3815 let messages = assistant_contents_to_messages(OneOrMany::one(generated_image)).unwrap();
3816
3817 assert!(
3818 messages.is_empty(),
3819 "response-only generated image turns should not be replayed as assistant content"
3820 );
3821 }
3822
3823 #[test]
3824 fn test_unmarked_assistant_image_history_errors_for_openrouter() {
3825 let image = completion::AssistantContent::image_base64(
3826 "abc",
3827 Some(message::ImageMediaType::PNG),
3828 None,
3829 );
3830
3831 let err = assistant_contents_to_messages(OneOrMany::one(image)).unwrap_err();
3832
3833 match err {
3834 message::MessageError::ConversionError(message) => assert!(
3835 message.contains("OpenRouter does not support assistant image content"),
3836 "unexpected error: {message}"
3837 ),
3838 }
3839 }
3840
3841 #[test]
3842 fn test_mixed_text_and_generated_image_replays_text_only_for_openrouter() {
3843 let generated_image = response_image_to_assistant_content(&ResponseImage {
3844 image_url: ImageUrl {
3845 url: "https://example.com/generated.png".to_string(),
3846 detail: None,
3847 },
3848 });
3849
3850 let messages = assistant_contents_to_messages(
3851 OneOrMany::many(vec![
3852 completion::AssistantContent::text("Keep this text."),
3853 generated_image,
3854 ])
3855 .unwrap(),
3856 )
3857 .unwrap();
3858
3859 let serialized = serde_json::to_value(&messages).unwrap();
3860 assert_eq!(
3861 serialized,
3862 json!([{
3863 "role": "assistant",
3864 "content": [{"type": "text", "text": "Keep this text."}]
3865 }])
3866 );
3867 }
3868
3869 #[test]
3870 fn test_assistant_images_not_serialized_in_request() {
3871 let msg = Message::Assistant {
3872 content: vec!["Hello".to_string().into()],
3873 refusal: None,
3874 audio: None,
3875 name: None,
3876 tool_calls: vec![],
3877 reasoning: None,
3878 reasoning_details: vec![],
3879 images: vec![ResponseImage {
3880 image_url: ImageUrl {
3881 url: "data:image/png;base64,abc".to_string(),
3882 detail: None,
3883 },
3884 }],
3885 };
3886 let serialized = serde_json::to_value(&msg).unwrap();
3887 assert!(
3888 serialized.get("images").is_none(),
3889 "images field must not appear in serialized assistant message"
3890 );
3891 }
3892}