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 let (tool_results, other_content): (Vec<_>, Vec<_>) = value
1016 .into_iter()
1017 .partition(|content| matches!(content, message::UserContent::ToolResult(_)));
1018
1019 if !tool_results.is_empty() {
1022 tool_results
1023 .into_iter()
1024 .map(|content| match content {
1025 message::UserContent::ToolResult(tool_result) => Ok(Message::ToolResult {
1026 tool_call_id: tool_result.call_id.unwrap_or(tool_result.id),
1029 content: openai::completion::ToolResultContentValue::String(
1030 tool_result
1031 .content
1032 .into_iter()
1033 .map(|c| match c {
1034 message::ToolResultContent::Text(message::Text {
1035 text, ..
1036 }) => text,
1037 message::ToolResultContent::Image(_) => {
1038 "[Image content not supported in tool results]".to_string()
1039 }
1040 })
1041 .collect::<Vec<_>>()
1042 .join("\n"),
1043 ),
1044 }),
1045 _ => Err(message::MessageError::ConversionError(
1046 "expected tool result content while converting OpenRouter input".into(),
1047 )),
1048 })
1049 .collect::<Result<Vec<_>, _>>()
1050 } else {
1051 let user_content: Vec<UserContent> = other_content
1052 .into_iter()
1053 .map(user_content_to_openai)
1054 .collect::<Result<Vec<_>, _>>()?;
1055
1056 let content = OneOrMany::many(user_content).map_err(|_| {
1057 message::MessageError::ConversionError(
1058 "OpenRouter user message did not contain any non-tool content".into(),
1059 )
1060 })?;
1061
1062 Ok(vec![Message::User {
1063 content,
1064 name: None,
1065 }])
1066 }
1067}
1068
1069#[derive(Clone, Debug, Deserialize, Serialize)]
1074pub struct Choice {
1075 pub index: usize,
1076 pub native_finish_reason: Option<String>,
1077 pub message: Message,
1078 pub finish_reason: Option<String>,
1079}
1080
1081#[derive(Debug, Deserialize, PartialEq, Clone)]
1082#[serde(untagged)]
1083enum ToolCallAdditionalParams {
1084 ReasoningDetails(ReasoningDetails),
1085 Minimal {
1086 id: Option<String>,
1087 format: Option<String>,
1088 },
1089}
1090
1091fn assistant_contents_to_messages(
1099 value: OneOrMany<message::AssistantContent>,
1100) -> Result<Vec<Message>, message::MessageError> {
1101 let mut text_content = Vec::new();
1102 let mut tool_calls = Vec::new();
1103 let mut reasoning = None;
1104 let mut reasoning_details = Vec::new();
1105
1106 for content in value.into_iter() {
1107 match content {
1108 message::AssistantContent::Text(text) => text_content.push(text),
1109 message::AssistantContent::ToolCall(tool_call) => {
1110 if let Some(additional_params) = &tool_call.additional_params
1116 && let Ok(additional_params) = serde_json::from_value::<ToolCallAdditionalParams>(
1117 additional_params.clone(),
1118 )
1119 {
1120 match additional_params {
1121 ToolCallAdditionalParams::ReasoningDetails(full) => {
1122 reasoning_details.push(full);
1123 }
1124 ToolCallAdditionalParams::Minimal { id, format } => {
1125 let id = id
1128 .or_else(|| tool_call.call_id.clone())
1129 .unwrap_or_else(|| tool_call.id.clone());
1130 if let Some(signature) = &tool_call.signature {
1131 reasoning_details.push(ReasoningDetails::Encrypted {
1132 id: Some(id),
1133 format,
1134 index: None,
1135 data: signature.clone(),
1136 })
1137 }
1138 }
1139 }
1140 } else if let Some(signature) = &tool_call.signature {
1141 reasoning_details.push(ReasoningDetails::Encrypted {
1142 id: Some(
1143 tool_call
1144 .call_id
1145 .clone()
1146 .unwrap_or_else(|| tool_call.id.clone()),
1147 ),
1148 format: None,
1149 index: None,
1150 data: signature.clone(),
1151 });
1152 }
1153 tool_calls.push(tool_call.into())
1154 }
1155 message::AssistantContent::Reasoning(r) => {
1156 if r.content.is_empty() {
1157 let display = r.display_text();
1158 if !display.is_empty() {
1159 reasoning = Some(display);
1160 }
1161 } else {
1162 for reasoning_block in &r.content {
1163 let index = Some(reasoning_details.len());
1164 match reasoning_block {
1165 message::ReasoningContent::Text { text, signature } => {
1166 reasoning_details.push(ReasoningDetails::Text {
1167 id: r.id.clone(),
1168 format: None,
1169 index,
1170 text: Some(text.clone()),
1171 signature: signature.clone(),
1172 });
1173 }
1174 message::ReasoningContent::Summary(summary) => {
1175 reasoning_details.push(ReasoningDetails::Summary {
1176 id: r.id.clone(),
1177 format: None,
1178 index,
1179 summary: summary.clone(),
1180 });
1181 }
1182 message::ReasoningContent::Encrypted(data)
1183 | message::ReasoningContent::Redacted { data } => {
1184 reasoning_details.push(ReasoningDetails::Encrypted {
1185 id: r.id.clone(),
1186 format: None,
1187 index,
1188 data: data.clone(),
1189 });
1190 }
1191 }
1192 }
1193 }
1194 }
1195 message::AssistantContent::Image(image) if is_openrouter_response_image(&image) => {
1196 }
1200 message::AssistantContent::Image(_) => {
1201 return Err(message::MessageError::ConversionError(
1202 "OpenRouter does not support assistant image content in request history; pass images as user image inputs instead".into(),
1203 ));
1204 }
1205 }
1206 }
1207
1208 if text_content.is_empty()
1209 && tool_calls.is_empty()
1210 && reasoning.is_none()
1211 && reasoning_details.is_empty()
1212 {
1213 return Ok(vec![]);
1214 }
1215
1216 Ok(vec![Message::Assistant {
1217 content: text_content
1218 .into_iter()
1219 .map(|content| content.text.into())
1220 .collect::<Vec<_>>(),
1221 refusal: None,
1222 audio: None,
1223 name: None,
1224 tool_calls,
1225 reasoning,
1226 reasoning_details,
1227 images: Vec::new(),
1228 }])
1229}
1230
1231pub fn messages_from_rig_message(
1238 message: message::Message,
1239) -> Result<Vec<Message>, message::MessageError> {
1240 match message {
1241 message::Message::System { content } => Ok(vec![Message::system(&content)]),
1242 message::Message::User { content } => user_contents_to_messages(content),
1243 message::Message::Assistant { content, .. } => assistant_contents_to_messages(content),
1244 }
1245}
1246
1247pub(super) fn apply_prompt_caching(body: &mut serde_json::Value) {
1259 let Some(obj) = body.as_object_mut() else {
1260 return;
1261 };
1262 let Some(messages) = obj.get_mut("messages").and_then(|v| v.as_array_mut()) else {
1263 return;
1264 };
1265
1266 let Some(system_msg) = messages
1267 .iter_mut()
1268 .find(|m| m.get("role").and_then(|v| v.as_str()) == Some("system"))
1269 else {
1270 return;
1271 };
1272
1273 match system_msg.get("content").cloned() {
1274 Some(serde_json::Value::String(s)) => {
1275 if let Some(obj) = system_msg.as_object_mut() {
1276 obj.insert(
1277 "content".to_string(),
1278 serde_json::json!([{
1279 "type": "text",
1280 "text": s,
1281 "cache_control": { "type": "ephemeral" }
1282 }]),
1283 );
1284 }
1285 }
1286 Some(serde_json::Value::Array(mut arr)) => {
1287 if let Some(last) = arr.last_mut()
1290 && let Some(obj) = last.as_object_mut()
1291 {
1292 obj.insert(
1293 "cache_control".to_string(),
1294 serde_json::json!({ "type": "ephemeral" }),
1295 );
1296 }
1297 if let Some(obj) = system_msg.as_object_mut() {
1298 obj.insert("content".to_string(), serde_json::Value::Array(arr));
1299 }
1300 }
1301 _ => {}
1302 }
1303}
1304
1305pub(super) fn finalize_openrouter_request_body(body: &mut serde_json::Value, prompt_caching: bool) {
1306 if prompt_caching {
1307 apply_prompt_caching(body);
1308 }
1309
1310 if let Some(messages) = body
1314 .get_mut("messages")
1315 .and_then(serde_json::Value::as_array_mut)
1316 {
1317 for message in messages {
1318 if let Some(message) = message.as_object_mut()
1319 && message.get("role").and_then(serde_json::Value::as_str) == Some("assistant")
1320 && let Some(reasoning) = message.remove("reasoning_content")
1321 {
1322 message.insert("reasoning".to_string(), reasoning);
1323 }
1324 }
1325 }
1326}
1327
1328#[cfg(test)]
1329pub(super) fn final_request_body(
1330 request: &OpenrouterCompletionRequest,
1331 prompt_caching: bool,
1332) -> Result<serde_json::Value, CompletionError> {
1333 let mut body = serde_json::to_value(request)?;
1334 finalize_openrouter_request_body(&mut body, prompt_caching);
1335 Ok(body)
1336}
1337
1338pub(super) type OpenrouterCompletionRequest = openai::completion::CompletionRequest;
1339
1340pub struct OpenRouterRequestParams<'a> {
1342 pub model: &'a str,
1343 pub request: CompletionRequest,
1344 pub strict_tools: bool,
1345}
1346
1347impl TryFrom<OpenRouterRequestParams<'_>> for OpenrouterCompletionRequest {
1348 type Error = CompletionError;
1349
1350 fn try_from(params: OpenRouterRequestParams) -> Result<Self, Self::Error> {
1351 let OpenRouterRequestParams {
1352 model,
1353 request: req,
1354 strict_tools,
1355 } = params;
1356 let chat_history = req.chat_history_with_documents();
1357 let model = req.model.clone().unwrap_or_else(|| model.to_string());
1358
1359 let mut full_history: Vec<Message> = match &req.preamble {
1360 Some(preamble) => vec![Message::system(preamble)],
1361 None => vec![],
1362 };
1363
1364 let chat_history: Vec<Message> = chat_history
1365 .into_iter()
1366 .map(messages_from_rig_message)
1367 .collect::<Result<Vec<Vec<Message>>, _>>()?
1368 .into_iter()
1369 .flatten()
1370 .collect();
1371
1372 full_history.extend(chat_history);
1373
1374 let tool_choice = req
1375 .tool_choice
1376 .clone()
1377 .map(crate::providers::openai::completion::ToolChoice::try_from)
1378 .transpose()?;
1379
1380 let tools: Vec<crate::providers::openai::completion::ToolDefinition> = req
1381 .tools
1382 .clone()
1383 .into_iter()
1384 .map(|tool| {
1385 let def = crate::providers::openai::completion::ToolDefinition::from(tool);
1386 if strict_tools { def.with_strict() } else { def }
1387 })
1388 .collect();
1389
1390 let additional_params = if let Some(schema) = req.output_schema {
1391 let name = schema
1392 .as_object()
1393 .and_then(|o| o.get("title"))
1394 .and_then(|v| v.as_str())
1395 .unwrap_or("response_schema")
1396 .to_string();
1397 let mut schema_value = schema.to_value();
1398 openai::sanitize_schema(&mut schema_value);
1399 let response_format = serde_json::json!({
1400 "response_format": {
1401 "type": "json_schema",
1402 "json_schema": {
1403 "name": name,
1404 "strict": true,
1405 "schema": schema_value
1406 }
1407 }
1408 });
1409 Some(match req.additional_params {
1410 Some(existing) => json_utils::merge(existing, response_format),
1411 None => response_format,
1412 })
1413 } else {
1414 req.additional_params
1415 };
1416
1417 Ok(Self {
1418 model,
1419 messages: full_history,
1420 temperature: req.temperature,
1421 max_tokens: None,
1422 tools,
1423 tool_choice,
1424 additional_params,
1425 })
1426 }
1427}
1428
1429impl TryFrom<(&str, CompletionRequest)> for OpenrouterCompletionRequest {
1430 type Error = CompletionError;
1431
1432 fn try_from((model, req): (&str, CompletionRequest)) -> Result<Self, Self::Error> {
1433 let model = req.model.clone().unwrap_or_else(|| model.to_string());
1434 OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1435 model: &model,
1436 request: req,
1437 strict_tools: false,
1438 })
1439 }
1440}
1441
1442impl openai::completion::OpenAICompatibleProvider for OpenRouterExt {
1443 const PROVIDER_NAME: &'static str = "openrouter";
1444
1445 type StreamingUsage = Usage;
1446 type Response = CompletionResponse;
1447
1448 const STREAM_INCLUDE_USAGE: bool = false;
1449
1450 fn build_completion_request(
1451 &self,
1452 model: String,
1453 request: CompletionRequest,
1454 options: openai::completion::CompletionModelOptions,
1455 ) -> Result<openai::completion::CompletionRequest, CompletionError> {
1456 OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1457 model: &model,
1458 request,
1459 strict_tools: options.strict_tools,
1460 })
1461 }
1462
1463 fn finalize_request_body_with_options(
1464 &self,
1465 body: &mut serde_json::Value,
1466 options: openai::completion::CompletionModelOptions,
1467 ) -> Result<(), CompletionError> {
1468 finalize_openrouter_request_body(body, options.prompt_caching);
1469 Ok(())
1470 }
1471
1472 fn decorate_streaming_tool_call(
1473 &self,
1474 detail: &serde_json::Value,
1475 tool_calls: &mut std::collections::HashMap<usize, crate::streaming::RawStreamingToolCall>,
1476 ) {
1477 let Ok(ReasoningDetails::Encrypted { id, data, .. }) =
1478 serde_json::from_value::<ReasoningDetails>(detail.clone())
1479 else {
1480 return;
1481 };
1482 let Some(id) = id else {
1483 return;
1484 };
1485 let Some(tool_call) = tool_calls
1486 .values_mut()
1487 .find(|tool_call| tool_call.id.eq(&id))
1488 else {
1489 return;
1490 };
1491
1492 tool_call.signature = Some(data);
1493 tool_call.additional_params = Some(detail.clone());
1494 }
1495}
1496
1497pub type CompletionModel<H = reqwest::Client> =
1499 openai::completion::GenericCompletionModel<OpenRouterExt, H>;
1500
1501pub type StreamingCompletionResponse =
1503 openai::completion::streaming::StreamingCompletionResponse<Usage>;
1504
1505impl<H> openai::completion::GenericCompletionModel<OpenRouterExt, H> {
1506 pub fn with_prompt_caching(mut self) -> Self {
1513 self.prompt_caching = true;
1514 self
1515 }
1516}
1517
1518#[cfg(test)]
1519mod tests {
1520 use super::*;
1521 use crate::message::{AudioMediaType, ImageDetail, VideoMediaType};
1522 use serde_json::json;
1523
1524 #[test]
1525 fn test_openrouter_request_uses_request_model_override() {
1526 let request = CompletionRequest {
1527 model: Some("google/gemini-2.5-flash".to_string()),
1528 preamble: None,
1529 chat_history: crate::OneOrMany::one("Hello".into()),
1530 documents: vec![],
1531 tools: vec![],
1532 temperature: None,
1533 max_tokens: None,
1534 tool_choice: None,
1535 additional_params: None,
1536 output_schema: None,
1537 };
1538
1539 let openrouter_request =
1540 OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1541 .expect("request conversion should succeed");
1542 let serialized =
1543 serde_json::to_value(openrouter_request).expect("serialization should succeed");
1544
1545 assert_eq!(serialized["model"], "google/gemini-2.5-flash");
1546 }
1547
1548 #[test]
1549 fn openrouter_params_include_direct_request_documents() {
1550 let request = CompletionRequest {
1551 model: None,
1552 preamble: None,
1553 chat_history: crate::OneOrMany::one(crate::message::Message::user(
1554 "What is glarb-glarb?",
1555 )),
1556 documents: vec![crate::completion::request::Document {
1557 id: "doc_1".to_string(),
1558 text: "Definition of glarb-glarb: an ancient tool.".to_string(),
1559 additional_props: Default::default(),
1560 }],
1561 tools: vec![],
1562 temperature: None,
1563 max_tokens: None,
1564 tool_choice: None,
1565 additional_params: None,
1566 output_schema: None,
1567 };
1568
1569 let request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1570 model: "openai/gpt-4o-mini",
1571 request,
1572 strict_tools: false,
1573 })
1574 .expect("request conversion should succeed");
1575 let serialized = serde_json::to_value(request).expect("serialization should succeed");
1576
1577 assert!(
1578 serialized["messages"].to_string().contains("glarb-glarb"),
1579 "direct request documents should be normalized through public params"
1580 );
1581 }
1582
1583 #[test]
1584 fn test_openrouter_request_uses_default_model_when_override_unset() {
1585 let request = CompletionRequest {
1586 model: None,
1587 preamble: None,
1588 chat_history: crate::OneOrMany::one("Hello".into()),
1589 documents: vec![],
1590 tools: vec![],
1591 temperature: None,
1592 max_tokens: None,
1593 tool_choice: None,
1594 additional_params: None,
1595 output_schema: None,
1596 };
1597
1598 let openrouter_request =
1599 OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1600 .expect("request conversion should succeed");
1601 let serialized =
1602 serde_json::to_value(openrouter_request).expect("serialization should succeed");
1603
1604 assert_eq!(serialized["model"], "openai/gpt-4o-mini");
1605 }
1606
1607 #[test]
1608 fn final_request_body_serializes_assistant_reasoning_under_openrouter_key() {
1609 let request = OpenrouterCompletionRequest {
1613 model: "openai/gpt-4o".to_string(),
1614 messages: vec![Message::Assistant {
1615 content: vec![],
1616 reasoning: Some("thinking it through".to_string()),
1617 refusal: None,
1618 audio: None,
1619 name: None,
1620 tool_calls: vec![],
1621 reasoning_details: vec![],
1622 images: vec![],
1623 }],
1624 temperature: None,
1625 max_tokens: None,
1626 tools: vec![],
1627 tool_choice: None,
1628 additional_params: None,
1629 };
1630
1631 let body = final_request_body(&request, false).expect("body should serialize");
1632
1633 assert_eq!(
1634 body["messages"][0]["reasoning"],
1635 serde_json::json!("thinking it through")
1636 );
1637 assert!(
1638 body["messages"][0].get("reasoning_content").is_none(),
1639 "OpenRouter's assistant reasoning key is `reasoning`, not `reasoning_content`"
1640 );
1641 }
1642
1643 #[test]
1644 fn test_openrouter_request_maps_output_schema_to_response_format() {
1645 let schema: schemars::Schema = serde_json::from_value(json!({
1646 "title": "WeatherResponse",
1647 "type": "object",
1648 "properties": {
1649 "city": { "type": "string" },
1650 "weather": { "type": "string" }
1651 }
1652 }))
1653 .expect("schema should deserialize");
1654
1655 let request = CompletionRequest {
1656 model: None,
1657 preamble: None,
1658 chat_history: crate::OneOrMany::one("Hello".into()),
1659 documents: vec![],
1660 tools: vec![],
1661 temperature: None,
1662 max_tokens: None,
1663 tool_choice: None,
1664 additional_params: None,
1665 output_schema: Some(schema),
1666 };
1667
1668 let openrouter_request =
1669 OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1670 .expect("request conversion should succeed");
1671 let serialized =
1672 serde_json::to_value(openrouter_request).expect("serialization should succeed");
1673
1674 assert_eq!(
1675 serialized["response_format"],
1676 json!({
1677 "type": "json_schema",
1678 "json_schema": {
1679 "name": "WeatherResponse",
1680 "strict": true,
1681 "schema": {
1682 "title": "WeatherResponse",
1683 "type": "object",
1684 "properties": {
1685 "city": { "type": "string" },
1686 "weather": { "type": "string" }
1687 },
1688 "additionalProperties": false,
1689 "required": ["city", "weather"]
1690 }
1691 }
1692 })
1693 );
1694 }
1695
1696 #[test]
1697 fn test_openrouter_request_merges_output_schema_with_provider_preferences() {
1698 let schema: schemars::Schema = serde_json::from_value(json!({
1699 "type": "object",
1700 "properties": {
1701 "answer": { "type": "string" }
1702 }
1703 }))
1704 .expect("schema should deserialize");
1705
1706 let request = CompletionRequest {
1707 model: None,
1708 preamble: None,
1709 chat_history: crate::OneOrMany::one("Hello".into()),
1710 documents: vec![],
1711 tools: vec![],
1712 temperature: None,
1713 max_tokens: None,
1714 tool_choice: None,
1715 additional_params: Some(
1716 ProviderPreferences::new()
1717 .require_parameters(true)
1718 .to_json(),
1719 ),
1720 output_schema: Some(schema),
1721 };
1722
1723 let openrouter_request =
1724 OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1725 .expect("request conversion should succeed");
1726 let serialized =
1727 serde_json::to_value(openrouter_request).expect("serialization should succeed");
1728
1729 assert_eq!(serialized["provider"]["require_parameters"], true);
1730 assert_eq!(serialized["response_format"]["type"], "json_schema");
1731 assert_eq!(
1732 serialized["response_format"]["json_schema"]["name"],
1733 "response_schema"
1734 );
1735 assert_eq!(
1736 serialized["response_format"]["json_schema"]["schema"]["additionalProperties"],
1737 false
1738 );
1739 }
1740
1741 #[test]
1742 fn test_completion_response_deserialization_gemini_flash() {
1743 let json = json!({
1745 "id": "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA",
1746 "provider": "Google",
1747 "model": "google/gemini-2.5-flash",
1748 "object": "chat.completion",
1749 "created": 1765971703u64,
1750 "choices": [{
1751 "logprobs": null,
1752 "finish_reason": "stop",
1753 "native_finish_reason": "STOP",
1754 "index": 0,
1755 "message": {
1756 "role": "assistant",
1757 "content": "CONTENT",
1758 "refusal": null,
1759 "reasoning": null
1760 }
1761 }],
1762 "usage": {
1763 "prompt_tokens": 669,
1764 "completion_tokens": 5,
1765 "total_tokens": 674
1766 }
1767 });
1768
1769 let response: CompletionResponse = serde_json::from_value(json).unwrap();
1770 assert_eq!(response.id, "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA");
1771 assert_eq!(response.model, "google/gemini-2.5-flash");
1772 assert_eq!(response.choices.len(), 1);
1773 assert_eq!(response.choices[0].finish_reason, Some("stop".to_string()));
1774 }
1775
1776 #[test]
1777 fn test_completion_response_usage_prefers_reported_completion_tokens() {
1778 let json = json!({
1779 "id": "gen-usage-divergent",
1780 "object": "chat.completion",
1781 "created": 1,
1782 "model": "anthropic/claude-3.5-sonnet",
1783 "choices": [{
1784 "index": 0,
1785 "message": {"role": "assistant", "content": "ok"},
1786 "finish_reason": "stop"
1787 }],
1788 "usage": {"prompt_tokens": 500, "completion_tokens": 10, "total_tokens": 505}
1790 });
1791
1792 let response: CompletionResponse = serde_json::from_value(json).unwrap();
1793 let converted = completion::CompletionResponse::try_from(response).unwrap();
1794 assert_eq!(converted.usage.output_tokens, 10);
1795 }
1796
1797 #[test]
1798 fn test_completion_response_usage_falls_back_when_completion_tokens_missing() {
1799 let json = json!({
1800 "id": "gen-usage-omitted",
1801 "object": "chat.completion",
1802 "created": 1,
1803 "model": "some/gateway-model",
1804 "choices": [{
1805 "index": 0,
1806 "message": {"role": "assistant", "content": "ok"},
1807 "finish_reason": "stop"
1808 }],
1809 "usage": {"prompt_tokens": 100, "total_tokens": 110}
1810 });
1811
1812 let response: CompletionResponse = serde_json::from_value(json).unwrap();
1813 let converted = completion::CompletionResponse::try_from(response).unwrap();
1814 assert_eq!(converted.usage.output_tokens, 10);
1815 }
1816
1817 #[test]
1818 fn test_completion_response_maps_cache_token_accounting() {
1819 let json = json!({
1820 "id": "gen-cache-test",
1821 "object": "chat.completion",
1822 "created": 1,
1823 "model": "anthropic/claude-3.5-sonnet",
1824 "choices": [{
1825 "index": 0,
1826 "finish_reason": "stop",
1827 "message": {
1828 "role": "assistant",
1829 "content": "Hi"
1830 }
1831 }],
1832 "usage": {
1833 "prompt_tokens": 500,
1834 "completion_tokens": 10,
1835 "total_tokens": 510,
1836 "prompt_tokens_details": {
1837 "cached_tokens": 400,
1838 "cache_write_tokens": 50
1839 }
1840 }
1841 });
1842
1843 let response: CompletionResponse = serde_json::from_value(json).unwrap();
1844 let converted: completion::CompletionResponse<CompletionResponse> =
1845 response.try_into().unwrap();
1846
1847 assert_eq!(converted.usage.input_tokens, 500);
1848 assert_eq!(converted.usage.output_tokens, 10);
1849 assert_eq!(converted.usage.cached_input_tokens, 400);
1850 assert_eq!(converted.usage.cache_creation_input_tokens, 50);
1851 }
1852
1853 #[test]
1854 fn test_completion_response_cache_tokens_absent_defaults_to_zero() {
1855 let json = json!({
1856 "id": "gen-no-cache",
1857 "object": "chat.completion",
1858 "created": 1,
1859 "model": "openai/gpt-4o",
1860 "choices": [{
1861 "index": 0,
1862 "finish_reason": "stop",
1863 "message": {
1864 "role": "assistant",
1865 "content": "Hi"
1866 }
1867 }],
1868 "usage": {
1869 "prompt_tokens": 100,
1870 "completion_tokens": 10,
1871 "total_tokens": 110
1872 }
1873 });
1874
1875 let response: CompletionResponse = serde_json::from_value(json).unwrap();
1876 let converted: completion::CompletionResponse<CompletionResponse> =
1877 response.try_into().unwrap();
1878
1879 assert_eq!(converted.usage.cached_input_tokens, 0);
1880 assert_eq!(converted.usage.cache_creation_input_tokens, 0);
1881 }
1882
1883 #[test]
1884 fn test_completion_response_deserialization_gemini_model_role() {
1885 let json = json!({
1886 "id": "gen-BBBBBBBBBB-BBBBBBBBBBBBBBBBBBBB",
1887 "provider": "Google",
1888 "model": "google/gemini-2.5-pro-exp-03-25:free",
1889 "object": "chat.completion",
1890 "created": 1743780565u64,
1891 "choices": [{
1892 "logprobs": null,
1893 "finish_reason": "stop",
1894 "native_finish_reason": "STOP",
1895 "index": 0,
1896 "message": {
1897 "role": "model",
1898 "content": "CONTENT",
1899 "refusal": null,
1900 "reasoning": null
1901 }
1902 }],
1903 "usage": {
1904 "prompt_tokens": 669,
1905 "completion_tokens": 5,
1906 "total_tokens": 674
1907 }
1908 });
1909
1910 let response: CompletionResponse = serde_json::from_value(json).unwrap();
1911 let converted: completion::CompletionResponse<CompletionResponse> =
1912 response.try_into().unwrap();
1913
1914 assert_eq!(
1915 converted.raw_response.model,
1916 "google/gemini-2.5-pro-exp-03-25:free"
1917 );
1918 assert!(matches!(
1919 converted.choice.first(),
1920 completion::AssistantContent::Text(text) if text.text == "CONTENT"
1921 ));
1922 }
1923
1924 #[test]
1925 fn test_message_assistant_without_reasoning_details() {
1926 let json = json!({
1928 "role": "assistant",
1929 "content": "Hello world",
1930 "refusal": null,
1931 "reasoning": null
1932 });
1933
1934 let message: Message = serde_json::from_value(json).unwrap();
1935 match message {
1936 Message::Assistant {
1937 content,
1938 reasoning_details,
1939 ..
1940 } => {
1941 assert_eq!(content.len(), 1);
1942 assert!(reasoning_details.is_empty());
1943 }
1944 _ => panic!("Expected Assistant message"),
1945 }
1946 }
1947
1948 #[test]
1949 fn test_data_collection_serialization() {
1950 assert_eq!(
1951 serde_json::to_string(&DataCollection::Allow).unwrap(),
1952 r#""allow""#
1953 );
1954 assert_eq!(
1955 serde_json::to_string(&DataCollection::Deny).unwrap(),
1956 r#""deny""#
1957 );
1958 }
1959
1960 #[test]
1961 fn test_data_collection_default() {
1962 assert_eq!(DataCollection::default(), DataCollection::Allow);
1963 }
1964
1965 #[test]
1966 fn test_quantization_serialization() {
1967 assert_eq!(
1968 serde_json::to_string(&Quantization::Int4).unwrap(),
1969 r#""int4""#
1970 );
1971 assert_eq!(
1972 serde_json::to_string(&Quantization::Int8).unwrap(),
1973 r#""int8""#
1974 );
1975 assert_eq!(
1976 serde_json::to_string(&Quantization::Fp16).unwrap(),
1977 r#""fp16""#
1978 );
1979 assert_eq!(
1980 serde_json::to_string(&Quantization::Bf16).unwrap(),
1981 r#""bf16""#
1982 );
1983 assert_eq!(
1984 serde_json::to_string(&Quantization::Fp32).unwrap(),
1985 r#""fp32""#
1986 );
1987 assert_eq!(
1988 serde_json::to_string(&Quantization::Fp8).unwrap(),
1989 r#""fp8""#
1990 );
1991 assert_eq!(
1992 serde_json::to_string(&Quantization::Unknown).unwrap(),
1993 r#""unknown""#
1994 );
1995 }
1996
1997 #[test]
1998 fn test_provider_sort_strategy_serialization() {
1999 assert_eq!(
2000 serde_json::to_string(&ProviderSortStrategy::Price).unwrap(),
2001 r#""price""#
2002 );
2003 assert_eq!(
2004 serde_json::to_string(&ProviderSortStrategy::Throughput).unwrap(),
2005 r#""throughput""#
2006 );
2007 assert_eq!(
2008 serde_json::to_string(&ProviderSortStrategy::Latency).unwrap(),
2009 r#""latency""#
2010 );
2011 }
2012
2013 #[test]
2014 fn test_sort_partition_serialization() {
2015 assert_eq!(
2016 serde_json::to_string(&SortPartition::Model).unwrap(),
2017 r#""model""#
2018 );
2019 assert_eq!(
2020 serde_json::to_string(&SortPartition::None).unwrap(),
2021 r#""none""#
2022 );
2023 }
2024
2025 #[test]
2026 fn test_provider_sort_simple() {
2027 let sort = ProviderSort::Simple(ProviderSortStrategy::Latency);
2028 let json = serde_json::to_value(&sort).unwrap();
2029 assert_eq!(json, "latency");
2030 }
2031
2032 #[test]
2033 fn test_provider_sort_complex() {
2034 let sort = ProviderSort::Complex(
2035 ProviderSortConfig::new(ProviderSortStrategy::Price).partition(SortPartition::None),
2036 );
2037 let json = serde_json::to_value(&sort).unwrap();
2038 assert_eq!(json["by"], "price");
2039 assert_eq!(json["partition"], "none");
2040 }
2041
2042 #[test]
2043 fn test_provider_sort_complex_without_partition() {
2044 let sort = ProviderSort::Complex(ProviderSortConfig::new(ProviderSortStrategy::Throughput));
2045 let json = serde_json::to_value(&sort).unwrap();
2046 assert_eq!(json["by"], "throughput");
2047 assert!(json.get("partition").is_none());
2048 }
2049
2050 #[test]
2051 fn test_provider_sort_from_strategy() {
2052 let sort: ProviderSort = ProviderSortStrategy::Price.into();
2053 assert_eq!(sort, ProviderSort::Simple(ProviderSortStrategy::Price));
2054 }
2055
2056 #[test]
2057 fn test_provider_sort_from_config() {
2058 let config = ProviderSortConfig::new(ProviderSortStrategy::Latency);
2059 let sort: ProviderSort = config.into();
2060 match sort {
2061 ProviderSort::Complex(c) => assert_eq!(c.by, ProviderSortStrategy::Latency),
2062 _ => panic!("Expected Complex variant"),
2063 }
2064 }
2065
2066 #[test]
2067 fn test_percentile_thresholds_builder() {
2068 let thresholds = PercentileThresholds::new()
2069 .p50(10.0)
2070 .p75(25.0)
2071 .p90(50.0)
2072 .p99(100.0);
2073
2074 assert_eq!(thresholds.p50, Some(10.0));
2075 assert_eq!(thresholds.p75, Some(25.0));
2076 assert_eq!(thresholds.p90, Some(50.0));
2077 assert_eq!(thresholds.p99, Some(100.0));
2078 }
2079
2080 #[test]
2081 fn test_percentile_thresholds_default() {
2082 let thresholds = PercentileThresholds::default();
2083 assert_eq!(thresholds.p50, None);
2084 assert_eq!(thresholds.p75, None);
2085 assert_eq!(thresholds.p90, None);
2086 assert_eq!(thresholds.p99, None);
2087 }
2088
2089 #[test]
2090 fn test_throughput_threshold_simple() {
2091 let threshold = ThroughputThreshold::Simple(50.0);
2092 let json = serde_json::to_value(&threshold).unwrap();
2093 assert_eq!(json, 50.0);
2094 }
2095
2096 #[test]
2097 fn test_throughput_threshold_percentile() {
2098 let threshold = ThroughputThreshold::Percentile(PercentileThresholds::new().p90(50.0));
2099 let json = serde_json::to_value(&threshold).unwrap();
2100 assert_eq!(json["p90"], 50.0);
2101 }
2102
2103 #[test]
2104 fn test_latency_threshold_simple() {
2105 let threshold = LatencyThreshold::Simple(0.5);
2106 let json = serde_json::to_value(&threshold).unwrap();
2107 assert_eq!(json, 0.5);
2108 }
2109
2110 #[test]
2111 fn test_latency_threshold_percentile() {
2112 let threshold = LatencyThreshold::Percentile(PercentileThresholds::new().p50(0.1).p99(1.0));
2113 let json = serde_json::to_value(&threshold).unwrap();
2114 assert_eq!(json["p50"], 0.1);
2115 assert_eq!(json["p99"], 1.0);
2116 }
2117
2118 #[test]
2119 fn test_max_price_builder() {
2120 let price = MaxPrice::new().prompt(0.001).completion(0.002);
2121
2122 assert_eq!(price.prompt, Some(0.001));
2123 assert_eq!(price.completion, Some(0.002));
2124 assert_eq!(price.request, None);
2125 assert_eq!(price.image, None);
2126 }
2127
2128 #[test]
2129 fn test_max_price_all_fields() {
2130 let price = MaxPrice::new()
2131 .prompt(0.001)
2132 .completion(0.002)
2133 .request(0.01)
2134 .image(0.05);
2135
2136 let json = serde_json::to_value(&price).unwrap();
2137 assert_eq!(json["prompt"], 0.001);
2138 assert_eq!(json["completion"], 0.002);
2139 assert_eq!(json["request"], 0.01);
2140 assert_eq!(json["image"], 0.05);
2141 }
2142
2143 #[test]
2144 fn test_max_price_default() {
2145 let price = MaxPrice::default();
2146 assert_eq!(price.prompt, None);
2147 assert_eq!(price.completion, None);
2148 assert_eq!(price.request, None);
2149 assert_eq!(price.image, None);
2150 }
2151
2152 #[test]
2153 fn test_provider_preferences_default() {
2154 let prefs = ProviderPreferences::default();
2155 assert!(prefs.order.is_none());
2156 assert!(prefs.only.is_none());
2157 assert!(prefs.ignore.is_none());
2158 assert!(prefs.allow_fallbacks.is_none());
2159 assert!(prefs.require_parameters.is_none());
2160 assert!(prefs.data_collection.is_none());
2161 assert!(prefs.zdr.is_none());
2162 assert!(prefs.sort.is_none());
2163 assert!(prefs.preferred_min_throughput.is_none());
2164 assert!(prefs.preferred_max_latency.is_none());
2165 assert!(prefs.max_price.is_none());
2166 assert!(prefs.quantizations.is_none());
2167 }
2168
2169 #[test]
2170 fn test_provider_preferences_order_with_fallbacks() {
2171 let prefs = ProviderPreferences::new()
2172 .order(["anthropic", "openai"])
2173 .allow_fallbacks(true);
2174
2175 let json = prefs.to_json();
2176 let provider = &json["provider"];
2177
2178 assert_eq!(provider["order"], json!(["anthropic", "openai"]));
2179 assert_eq!(provider["allow_fallbacks"], true);
2180 }
2181
2182 #[test]
2183 fn test_provider_preferences_only_allowlist() {
2184 let prefs = ProviderPreferences::new()
2185 .only(["azure", "together"])
2186 .allow_fallbacks(false);
2187
2188 let json = prefs.to_json();
2189 let provider = &json["provider"];
2190
2191 assert_eq!(provider["only"], json!(["azure", "together"]));
2192 assert_eq!(provider["allow_fallbacks"], false);
2193 }
2194
2195 #[test]
2196 fn test_provider_preferences_ignore() {
2197 let prefs = ProviderPreferences::new().ignore(["deepinfra"]);
2198
2199 let json = prefs.to_json();
2200 let provider = &json["provider"];
2201
2202 assert_eq!(provider["ignore"], json!(["deepinfra"]));
2203 }
2204
2205 #[test]
2206 fn test_provider_preferences_sort_latency() {
2207 let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Latency);
2208
2209 let json = prefs.to_json();
2210 let provider = &json["provider"];
2211
2212 assert_eq!(provider["sort"], "latency");
2213 }
2214
2215 #[test]
2216 fn test_provider_preferences_price_with_throughput() {
2217 let prefs = ProviderPreferences::new()
2218 .sort(ProviderSortStrategy::Price)
2219 .preferred_min_throughput(ThroughputThreshold::Percentile(
2220 PercentileThresholds::new().p90(50.0),
2221 ));
2222
2223 let json = prefs.to_json();
2224 let provider = &json["provider"];
2225
2226 assert_eq!(provider["sort"], "price");
2227 assert_eq!(provider["preferred_min_throughput"]["p90"], 50.0);
2228 }
2229
2230 #[test]
2231 fn test_provider_preferences_require_parameters() {
2232 let prefs = ProviderPreferences::new().require_parameters(true);
2233
2234 let json = prefs.to_json();
2235 let provider = &json["provider"];
2236
2237 assert_eq!(provider["require_parameters"], true);
2238 }
2239
2240 #[test]
2241 fn test_provider_preferences_data_policy_and_zdr() {
2242 let prefs = ProviderPreferences::new()
2243 .data_collection(DataCollection::Deny)
2244 .zdr(true);
2245
2246 let json = prefs.to_json();
2247 let provider = &json["provider"];
2248
2249 assert_eq!(provider["data_collection"], "deny");
2250 assert_eq!(provider["zdr"], true);
2251 }
2252
2253 #[test]
2254 fn test_provider_preferences_quantizations() {
2255 let prefs =
2256 ProviderPreferences::new().quantizations([Quantization::Int8, Quantization::Fp16]);
2257
2258 let json = prefs.to_json();
2259 let provider = &json["provider"];
2260
2261 assert_eq!(provider["quantizations"], json!(["int8", "fp16"]));
2262 }
2263
2264 #[test]
2265 fn test_provider_preferences_convenience_methods() {
2266 let prefs = ProviderPreferences::new().zero_data_retention().fastest();
2267
2268 assert_eq!(prefs.zdr, Some(true));
2269 assert_eq!(
2270 prefs.sort,
2271 Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
2272 );
2273
2274 let prefs2 = ProviderPreferences::new().cheapest();
2275 assert_eq!(
2276 prefs2.sort,
2277 Some(ProviderSort::Simple(ProviderSortStrategy::Price))
2278 );
2279
2280 let prefs3 = ProviderPreferences::new().lowest_latency();
2281 assert_eq!(
2282 prefs3.sort,
2283 Some(ProviderSort::Simple(ProviderSortStrategy::Latency))
2284 );
2285 }
2286
2287 #[test]
2288 fn test_provider_preferences_serialization_skips_none() {
2289 let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Price);
2290
2291 let json = serde_json::to_value(&prefs).unwrap();
2292
2293 assert_eq!(json["sort"], "price");
2294 assert!(json.get("order").is_none());
2295 assert!(json.get("only").is_none());
2296 assert!(json.get("ignore").is_none());
2297 assert!(json.get("zdr").is_none());
2298 }
2299
2300 #[test]
2301 fn test_provider_preferences_deserialization() {
2302 let json = json!({
2303 "order": ["anthropic", "openai"],
2304 "sort": "throughput",
2305 "data_collection": "deny",
2306 "zdr": true,
2307 "quantizations": ["int8", "fp16"]
2308 });
2309
2310 let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
2311
2312 assert_eq!(
2313 prefs.order,
2314 Some(vec!["anthropic".to_string(), "openai".to_string()])
2315 );
2316 assert_eq!(
2317 prefs.sort,
2318 Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
2319 );
2320 assert_eq!(prefs.data_collection, Some(DataCollection::Deny));
2321 assert_eq!(prefs.zdr, Some(true));
2322 assert_eq!(
2323 prefs.quantizations,
2324 Some(vec![Quantization::Int8, Quantization::Fp16])
2325 );
2326 }
2327
2328 #[test]
2329 fn test_provider_preferences_deserialization_complex_sort() {
2330 let json = json!({
2331 "sort": {
2332 "by": "latency",
2333 "partition": "model"
2334 }
2335 });
2336
2337 let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
2338
2339 match prefs.sort {
2340 Some(ProviderSort::Complex(config)) => {
2341 assert_eq!(config.by, ProviderSortStrategy::Latency);
2342 assert_eq!(config.partition, Some(SortPartition::Model));
2343 }
2344 _ => panic!("Expected Complex sort variant"),
2345 }
2346 }
2347
2348 #[test]
2349 fn test_provider_preferences_full_integration() {
2350 let prefs = ProviderPreferences::new()
2351 .order(["anthropic", "openai"])
2352 .only(["anthropic", "openai", "google"])
2353 .sort(ProviderSortStrategy::Throughput)
2354 .data_collection(DataCollection::Deny)
2355 .zdr(true)
2356 .quantizations([Quantization::Int8])
2357 .allow_fallbacks(false);
2358
2359 let json = prefs.to_json();
2360
2361 assert!(json.get("provider").is_some());
2362 let provider = &json["provider"];
2363 assert_eq!(provider["order"], json!(["anthropic", "openai"]));
2364 assert_eq!(provider["only"], json!(["anthropic", "openai", "google"]));
2365 assert_eq!(provider["sort"], "throughput");
2366 assert_eq!(provider["data_collection"], "deny");
2367 assert_eq!(provider["zdr"], true);
2368 assert_eq!(provider["quantizations"], json!(["int8"]));
2369 assert_eq!(provider["allow_fallbacks"], false);
2370 }
2371
2372 #[test]
2373 fn test_provider_preferences_max_price() {
2374 let prefs =
2375 ProviderPreferences::new().max_price(MaxPrice::new().prompt(0.001).completion(0.002));
2376
2377 let json = prefs.to_json();
2378 let provider = &json["provider"];
2379
2380 assert_eq!(provider["max_price"]["prompt"], 0.001);
2381 assert_eq!(provider["max_price"]["completion"], 0.002);
2382 }
2383
2384 #[test]
2385 fn test_provider_preferences_preferred_max_latency() {
2386 let prefs = ProviderPreferences::new().preferred_max_latency(LatencyThreshold::Simple(0.5));
2387
2388 let json = prefs.to_json();
2389 let provider = &json["provider"];
2390
2391 assert_eq!(provider["preferred_max_latency"], 0.5);
2392 }
2393
2394 #[test]
2395 fn test_provider_preferences_empty_arrays() {
2396 let prefs = ProviderPreferences::new()
2397 .order(Vec::<String>::new())
2398 .quantizations(Vec::<Quantization>::new());
2399
2400 let json = prefs.to_json();
2401 let provider = &json["provider"];
2402
2403 assert_eq!(provider["order"], json!([]));
2404 assert_eq!(provider["quantizations"], json!([]));
2405 }
2406
2407 #[test]
2412 fn test_user_content_text_serialization() {
2413 let content = UserContent::Text {
2414 text: "Hello, world!".to_string(),
2415 };
2416 let json = serde_json::to_value(&content).unwrap();
2417
2418 assert_eq!(json["type"], "text");
2419 assert_eq!(json["text"], "Hello, world!");
2420 }
2421
2422 #[test]
2423 fn test_user_content_image_url_serialization() {
2424 let content = UserContent::Image {
2425 image_url: ImageUrl {
2426 url: "https://example.com/image.png".to_string(),
2427 detail: None,
2428 },
2429 };
2430 let json = serde_json::to_value(&content).unwrap();
2431
2432 assert_eq!(json["type"], "image_url");
2433 assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
2434 assert!(json["image_url"].get("detail").is_none());
2435 }
2436
2437 #[test]
2438 fn test_user_content_image_url_with_detail_serialization() {
2439 let content = UserContent::Image {
2440 image_url: ImageUrl {
2441 url: "https://example.com/image.png".to_string(),
2442 detail: Some(ImageDetail::High),
2443 },
2444 };
2445 let json = serde_json::to_value(&content).unwrap();
2446
2447 assert_eq!(json["type"], "image_url");
2448 assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
2449 assert_eq!(json["image_url"]["detail"], "high");
2450 }
2451
2452 #[test]
2453 fn test_user_content_image_base64_serialization() {
2454 let content = UserContent::Image {
2455 image_url: ImageUrl {
2456 url: "data:image/png;base64,SGVsbG8=".to_string(),
2457 detail: Some(ImageDetail::Low),
2458 },
2459 };
2460 let json = serde_json::to_value(&content).unwrap();
2461
2462 assert_eq!(json["type"], "image_url");
2463 assert_eq!(json["image_url"]["url"], "data:image/png;base64,SGVsbG8=");
2464 assert_eq!(json["image_url"]["detail"], "low");
2465 }
2466
2467 #[test]
2468 fn test_user_content_file_url_serialization() {
2469 let content = UserContent::File {
2470 file: FileData {
2471 file_data: Some("https://example.com/doc.pdf".to_string()),
2472 file_id: None,
2473 filename: Some("document.pdf".to_string()),
2474 },
2475 };
2476 let json = serde_json::to_value(&content).unwrap();
2477
2478 assert_eq!(json["type"], "file");
2479 assert_eq!(json["file"]["file_data"], "https://example.com/doc.pdf");
2480 assert_eq!(json["file"]["filename"], "document.pdf");
2481 }
2482
2483 #[test]
2484 fn test_user_content_file_base64_serialization() {
2485 let content = UserContent::File {
2486 file: FileData {
2487 file_data: Some("data:application/pdf;base64,JVBERi0xLjQ=".to_string()),
2488 file_id: None,
2489 filename: Some("report.pdf".to_string()),
2490 },
2491 };
2492 let json = serde_json::to_value(&content).unwrap();
2493
2494 assert_eq!(json["type"], "file");
2495 assert_eq!(
2496 json["file"]["file_data"],
2497 "data:application/pdf;base64,JVBERi0xLjQ="
2498 );
2499 assert_eq!(json["file"]["filename"], "report.pdf");
2500 }
2501
2502 #[test]
2503 fn test_user_content_text_deserialization() {
2504 let json = json!({
2505 "type": "text",
2506 "text": "Hello!"
2507 });
2508
2509 let content: UserContent = serde_json::from_value(json).unwrap();
2510 assert_eq!(
2511 content,
2512 UserContent::Text {
2513 text: "Hello!".to_string()
2514 }
2515 );
2516 }
2517
2518 #[test]
2519 fn test_user_content_image_url_deserialization() {
2520 let json = json!({
2521 "type": "image_url",
2522 "image_url": {
2523 "url": "https://example.com/img.jpg",
2524 "detail": "high"
2525 }
2526 });
2527
2528 let content: UserContent = serde_json::from_value(json).unwrap();
2529 match content {
2530 UserContent::Image { image_url } => {
2531 assert_eq!(image_url.url, "https://example.com/img.jpg");
2532 assert_eq!(image_url.detail, Some(ImageDetail::High));
2533 }
2534 _ => panic!("Expected Image variant"),
2535 }
2536 }
2537
2538 #[test]
2539 fn test_user_content_file_deserialization() {
2540 let json = json!({
2541 "type": "file",
2542 "file": {
2543 "filename": "doc.pdf",
2544 "file_data": "https://example.com/doc.pdf"
2545 }
2546 });
2547
2548 let content: UserContent = serde_json::from_value(json).unwrap();
2549 match content {
2550 UserContent::File { file } => {
2551 assert_eq!(file.filename, Some("doc.pdf".to_string()));
2552 assert_eq!(
2553 file.file_data,
2554 Some("https://example.com/doc.pdf".to_string())
2555 );
2556 }
2557 _ => panic!("Expected File variant"),
2558 }
2559 }
2560
2561 #[test]
2562 fn test_message_user_with_text_serialization() {
2563 let message = Message::User {
2564 content: OneOrMany::one(UserContent::Text {
2565 text: "Hello".to_string(),
2566 }),
2567 name: None,
2568 };
2569 let json = serde_json::to_value(&message).unwrap();
2570
2571 assert_eq!(json["role"], "user");
2573 assert_eq!(json["content"], "Hello");
2574 }
2575
2576 #[test]
2577 fn test_message_user_with_mixed_content_serialization() {
2578 let message = Message::User {
2579 content: OneOrMany::many(vec![
2580 UserContent::Text {
2581 text: "Check this image:".to_string(),
2582 },
2583 UserContent::Image {
2584 image_url: ImageUrl {
2585 url: "https://example.com/img.png".to_string(),
2586 detail: None,
2587 },
2588 },
2589 ])
2590 .unwrap(),
2591 name: None,
2592 };
2593 let json = serde_json::to_value(&message).unwrap();
2594
2595 assert_eq!(json["role"], "user");
2596 let content = json["content"].as_array().unwrap();
2597 assert_eq!(content.len(), 2);
2598 assert_eq!(content[0]["type"], "text");
2599 assert_eq!(content[1]["type"], "image_url");
2600 }
2601
2602 #[test]
2603 fn test_message_user_with_file_serialization() {
2604 let message = Message::User {
2605 content: OneOrMany::many(vec![
2606 UserContent::Text {
2607 text: "Analyze this PDF:".to_string(),
2608 },
2609 UserContent::File {
2610 file: FileData {
2611 file_data: Some("https://example.com/doc.pdf".to_string()),
2612 file_id: None,
2613 filename: Some("document.pdf".to_string()),
2614 },
2615 },
2616 ])
2617 .unwrap(),
2618 name: None,
2619 };
2620 let json = serde_json::to_value(&message).unwrap();
2621
2622 assert_eq!(json["role"], "user");
2623 let content = json["content"].as_array().unwrap();
2624 assert_eq!(content.len(), 2);
2625 assert_eq!(content[0]["type"], "text");
2626 assert_eq!(content[1]["type"], "file");
2627 assert_eq!(
2628 content[1]["file"]["file_data"],
2629 "https://example.com/doc.pdf"
2630 );
2631 }
2632
2633 #[test]
2634 fn test_user_content_from_rig_text() {
2635 let rig_content = message::UserContent::Text(message::Text::new("Hello".to_string()));
2636 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2637
2638 assert_eq!(
2639 openrouter_content,
2640 UserContent::Text {
2641 text: "Hello".to_string()
2642 }
2643 );
2644 }
2645
2646 #[test]
2647 fn test_user_content_from_rig_image_url() {
2648 let rig_content = message::UserContent::Image(message::Image {
2649 data: DocumentSourceKind::Url("https://example.com/img.png".to_string()),
2650 media_type: Some(message::ImageMediaType::PNG),
2651 detail: Some(ImageDetail::High),
2652 additional_params: None,
2653 });
2654 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2655
2656 match openrouter_content {
2657 UserContent::Image { image_url } => {
2658 assert_eq!(image_url.url, "https://example.com/img.png");
2659 assert_eq!(image_url.detail, Some(ImageDetail::High));
2660 }
2661 _ => panic!("Expected Image variant"),
2662 }
2663 }
2664
2665 #[test]
2666 fn test_user_content_from_rig_image_base64() {
2667 let rig_content = message::UserContent::Image(message::Image {
2668 data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
2669 media_type: Some(message::ImageMediaType::JPEG),
2670 detail: Some(ImageDetail::Low),
2671 additional_params: None,
2672 });
2673 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2674
2675 match openrouter_content {
2676 UserContent::Image { image_url } => {
2677 assert_eq!(image_url.url, "data:image/jpeg;base64,SGVsbG8=");
2678 assert_eq!(image_url.detail, Some(ImageDetail::Low));
2679 }
2680 _ => panic!("Expected Image variant"),
2681 }
2682 }
2683
2684 #[test]
2685 fn test_user_content_from_rig_document_url() {
2686 let rig_content = message::UserContent::Document(message::Document {
2687 data: DocumentSourceKind::Url("https://example.com/doc.pdf".to_string()),
2688 media_type: Some(DocumentMediaType::PDF),
2689 additional_params: None,
2690 });
2691 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2692
2693 match openrouter_content {
2694 UserContent::File { file } => {
2695 assert_eq!(
2696 file.file_data,
2697 Some("https://example.com/doc.pdf".to_string())
2698 );
2699 assert_eq!(file.filename, Some("document.pdf".to_string()));
2700 }
2701 _ => panic!("Expected File variant"),
2702 }
2703 }
2704
2705 #[test]
2706 fn test_user_content_from_rig_document_base64() {
2707 let rig_content = message::UserContent::Document(message::Document {
2708 data: DocumentSourceKind::Base64("JVBERi0xLjQ=".to_string()),
2709 media_type: Some(DocumentMediaType::PDF),
2710 additional_params: None,
2711 });
2712 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2713
2714 match openrouter_content {
2715 UserContent::File { file } => {
2716 assert_eq!(
2717 file.file_data,
2718 Some("data:application/pdf;base64,JVBERi0xLjQ=".to_string())
2719 );
2720 assert_eq!(file.filename, Some("document.pdf".to_string()));
2721 }
2722 _ => panic!("Expected File variant"),
2723 }
2724 }
2725
2726 #[test]
2727 fn test_user_content_from_rig_document_file_id() {
2728 let rig_content = message::UserContent::Document(message::Document {
2729 data: DocumentSourceKind::FileId("file_abc".to_string()),
2730 media_type: None,
2731 additional_params: None,
2732 });
2733
2734 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
2735 assert!(matches!(
2736 result,
2737 Err(message::MessageError::ConversionError(message))
2738 if message.contains("Provider file IDs are not supported")
2739 ));
2740 }
2741
2742 #[test]
2743 fn test_openai_file_id_content_round_trips_through_rig_to_openrouter_error() {
2744 let openai_content = openai::UserContent::File {
2745 file: openai::FileData {
2746 file_data: None,
2747 file_id: Some("file_abc".to_string()),
2748 filename: None,
2749 },
2750 };
2751 let rig_content: message::UserContent = openai_content.into();
2752
2753 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
2754 assert!(matches!(
2755 result,
2756 Err(message::MessageError::ConversionError(message))
2757 if message.contains("Provider file IDs are not supported")
2758 ));
2759 }
2760
2761 #[test]
2762 fn test_user_content_from_rig_document_string_becomes_text() {
2763 let rig_content = message::UserContent::Document(message::Document {
2764 data: DocumentSourceKind::String("Plain text document content".to_string()),
2765 media_type: Some(DocumentMediaType::TXT),
2766 additional_params: None,
2767 });
2768 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2769
2770 assert_eq!(
2771 openrouter_content,
2772 UserContent::Text {
2773 text: "Plain text document content".to_string()
2774 }
2775 );
2776 }
2777
2778 #[test]
2779 fn test_completion_response_with_reasoning_details_maps_to_typed_reasoning() {
2780 let json = json!({
2781 "id": "resp_123",
2782 "object": "chat.completion",
2783 "created": 1,
2784 "model": "openrouter/test-model",
2785 "choices": [{
2786 "index": 0,
2787 "finish_reason": "stop",
2788 "message": {
2789 "role": "assistant",
2790 "content": "hello",
2791 "reasoning": null,
2792 "reasoning_details": [
2793 {"type":"reasoning.summary","id":"rs_1","summary":"s1"},
2794 {"type":"reasoning.text","id":"rs_1","text":"t1","signature":"sig_1"},
2795 {"type":"reasoning.encrypted","id":"rs_1","data":"enc_1"}
2796 ]
2797 }
2798 }]
2799 });
2800
2801 let response: CompletionResponse = serde_json::from_value(json).unwrap();
2802 let converted: completion::CompletionResponse<CompletionResponse> =
2803 response.try_into().unwrap();
2804 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
2805
2806 assert!(items.iter().any(|item| matches!(
2807 item,
2808 completion::AssistantContent::Reasoning(message::Reasoning { id: Some(id), content })
2809 if id == "rs_1" && content.len() == 3
2810 )));
2811 }
2812
2813 #[test]
2814 fn test_assistant_reasoning_emits_openrouter_reasoning_details() {
2815 let reasoning = message::Reasoning {
2816 id: Some("rs_2".to_string()),
2817 content: vec![
2818 message::ReasoningContent::Text {
2819 text: "step".to_string(),
2820 signature: Some("sig_step".to_string()),
2821 },
2822 message::ReasoningContent::Summary("summary".to_string()),
2823 message::ReasoningContent::Encrypted("enc_blob".to_string()),
2824 ],
2825 };
2826
2827 let messages = assistant_contents_to_messages(OneOrMany::one(
2828 message::AssistantContent::Reasoning(reasoning),
2829 ))
2830 .unwrap();
2831 let Message::Assistant {
2832 reasoning,
2833 reasoning_details,
2834 ..
2835 } = messages.first().expect("assistant message")
2836 else {
2837 panic!("Expected assistant message");
2838 };
2839
2840 assert!(reasoning.is_none());
2841 assert_eq!(reasoning_details.len(), 3);
2842 assert!(matches!(
2843 reasoning_details.first(),
2844 Some(ReasoningDetails::Text {
2845 id: Some(id),
2846 text: Some(text),
2847 signature: Some(signature),
2848 ..
2849 }) if id == "rs_2" && text == "step" && signature == "sig_step"
2850 ));
2851 }
2852
2853 #[test]
2854 fn test_tool_call_signature_without_params_uses_wire_id_for_encrypted_detail() {
2855 let tool_call = message::ToolCall {
2856 id: "call_wire".to_string(),
2857 call_id: None,
2858 function: message::ToolFunction {
2859 name: "lookup".to_string(),
2860 arguments: json!({}),
2861 },
2862 signature: Some("sig-data".to_string()),
2863 additional_params: None,
2864 };
2865
2866 let messages = assistant_contents_to_messages(OneOrMany::one(
2867 message::AssistantContent::ToolCall(tool_call),
2868 ))
2869 .unwrap();
2870
2871 let Message::Assistant {
2872 reasoning_details, ..
2873 } = messages.first().expect("assistant message")
2874 else {
2875 panic!("Expected assistant message");
2876 };
2877
2878 assert!(matches!(
2879 reasoning_details.first(),
2880 Some(ReasoningDetails::Encrypted {
2881 id: Some(id),
2882 data,
2883 ..
2884 }) if id == "call_wire" && data == "sig-data"
2885 ));
2886 }
2887
2888 #[test]
2889 fn test_tool_call_minimal_params_fall_back_to_wire_id() {
2890 let tool_call = message::ToolCall {
2891 id: "call_wire".to_string(),
2892 call_id: None,
2893 function: message::ToolFunction {
2894 name: "lookup".to_string(),
2895 arguments: json!({}),
2896 },
2897 signature: Some("sig-data".to_string()),
2898 additional_params: Some(json!({"format": "anthropic"})),
2901 };
2902
2903 let messages = assistant_contents_to_messages(OneOrMany::one(
2904 message::AssistantContent::ToolCall(tool_call),
2905 ))
2906 .unwrap();
2907
2908 let Message::Assistant {
2909 reasoning_details, ..
2910 } = messages.first().expect("assistant message")
2911 else {
2912 panic!("Expected assistant message");
2913 };
2914
2915 assert!(matches!(
2916 reasoning_details.first(),
2917 Some(ReasoningDetails::Encrypted {
2918 id: Some(id),
2919 format,
2920 data,
2921 ..
2922 }) if id == "call_wire" && data == "sig-data" && format.as_deref() == Some("anthropic")
2923 ));
2924 }
2925
2926 #[test]
2927 fn test_assistant_redacted_reasoning_emits_encrypted_detail_not_text() {
2928 let reasoning = message::Reasoning {
2929 id: Some("rs_redacted".to_string()),
2930 content: vec![message::ReasoningContent::Redacted {
2931 data: "opaque-redacted-data".to_string(),
2932 }],
2933 };
2934
2935 let messages = assistant_contents_to_messages(OneOrMany::one(
2936 message::AssistantContent::Reasoning(reasoning),
2937 ))
2938 .unwrap();
2939
2940 let Message::Assistant {
2941 reasoning_details,
2942 reasoning,
2943 ..
2944 } = messages.first().expect("assistant message")
2945 else {
2946 panic!("Expected assistant message");
2947 };
2948
2949 assert!(reasoning.is_none());
2950 assert_eq!(reasoning_details.len(), 1);
2951 assert!(matches!(
2952 reasoning_details.first(),
2953 Some(ReasoningDetails::Encrypted {
2954 id: Some(id),
2955 data,
2956 ..
2957 }) if id == "rs_redacted" && data == "opaque-redacted-data"
2958 ));
2959 }
2960
2961 #[test]
2962 fn test_completion_response_reasoning_details_respects_index_ordering() {
2963 let json = json!({
2964 "id": "resp_ordering",
2965 "object": "chat.completion",
2966 "created": 1,
2967 "model": "openrouter/test-model",
2968 "choices": [{
2969 "index": 0,
2970 "finish_reason": "stop",
2971 "message": {
2972 "role": "assistant",
2973 "content": "hello",
2974 "reasoning": null,
2975 "reasoning_details": [
2976 {"type":"reasoning.summary","id":"rs_order","index":1,"summary":"second"},
2977 {"type":"reasoning.summary","id":"rs_order","index":0,"summary":"first"}
2978 ]
2979 }
2980 }]
2981 });
2982
2983 let response: CompletionResponse = serde_json::from_value(json).unwrap();
2984 let converted: completion::CompletionResponse<CompletionResponse> =
2985 response.try_into().unwrap();
2986 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
2987 let reasoning_blocks: Vec<_> = items
2988 .into_iter()
2989 .filter_map(|item| match item {
2990 completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
2991 _ => None,
2992 })
2993 .collect();
2994
2995 assert_eq!(reasoning_blocks.len(), 1);
2996 assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_order"));
2997 assert_eq!(
2998 reasoning_blocks[0].content,
2999 vec![
3000 message::ReasoningContent::Summary("first".to_string()),
3001 message::ReasoningContent::Summary("second".to_string()),
3002 ]
3003 );
3004 }
3005
3006 #[test]
3007 fn test_user_content_from_rig_image_missing_media_type_error() {
3008 let rig_content = message::UserContent::Image(message::Image {
3009 data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3010 media_type: None, detail: None,
3012 additional_params: None,
3013 });
3014 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3015
3016 assert!(result.is_err());
3017 let err = result.unwrap_err();
3018 assert!(err.to_string().contains("media type required"));
3019 }
3020
3021 #[test]
3022 fn test_user_content_from_rig_image_raw_bytes_error() {
3023 let rig_content = message::UserContent::Image(message::Image {
3024 data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3025 media_type: Some(message::ImageMediaType::PNG),
3026 detail: None,
3027 additional_params: None,
3028 });
3029 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3030
3031 assert!(result.is_err());
3032 let err = result.unwrap_err();
3033 assert!(err.to_string().contains("base64"));
3034 }
3035
3036 #[test]
3037 fn test_user_content_from_rig_video_url() {
3038 let rig_content = message::UserContent::Video(message::Video {
3039 data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
3040 media_type: Some(message::VideoMediaType::MP4),
3041 additional_params: None,
3042 });
3043 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3044
3045 match openrouter_content {
3046 UserContent::Video { video_url } => {
3047 assert_eq!(video_url.url, "https://example.com/video.mp4");
3048 }
3049 _ => panic!("Expected Video variant"),
3050 }
3051 }
3052
3053 #[test]
3054 fn test_user_content_from_rig_video_base64() {
3055 let rig_content = message::UserContent::Video(message::Video {
3056 data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3057 media_type: Some(message::VideoMediaType::MP4),
3058 additional_params: None,
3059 });
3060 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3061
3062 match openrouter_content {
3063 UserContent::Video { video_url } => {
3064 assert_eq!(video_url.url, "data:video/mp4;base64,SGVsbG8=");
3065 }
3066 _ => panic!("Expected Video variant"),
3067 }
3068 }
3069
3070 #[test]
3071 fn test_user_content_from_rig_video_base64_missing_media_type_error() {
3072 let rig_content = message::UserContent::Video(message::Video {
3073 data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3074 media_type: None,
3075 additional_params: None,
3076 });
3077 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3078
3079 assert!(result.is_err());
3080 let err = result.unwrap_err();
3081 assert!(err.to_string().contains("media type"));
3082 }
3083
3084 #[test]
3085 fn test_user_content_from_rig_video_raw_bytes_error() {
3086 let rig_content = message::UserContent::Video(message::Video {
3087 data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3088 media_type: Some(message::VideoMediaType::MP4),
3089 additional_params: None,
3090 });
3091 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3092
3093 assert!(result.is_err());
3094 let err = result.unwrap_err();
3095 assert!(err.to_string().contains("base64"));
3096 }
3097
3098 #[test]
3099 fn test_user_content_from_rig_audio_base64() {
3100 let rig_content = message::UserContent::Audio(message::Audio {
3101 data: DocumentSourceKind::Base64("audiodata".to_string()),
3102 media_type: Some(message::AudioMediaType::MP3),
3103 additional_params: None,
3104 });
3105 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3106
3107 match openrouter_content {
3108 UserContent::Audio { input_audio } => {
3109 assert_eq!(input_audio.data, "audiodata");
3110 assert_eq!(input_audio.format, message::AudioMediaType::MP3);
3111 }
3112 _ => panic!("Expected Audio variant"),
3113 }
3114 }
3115
3116 #[test]
3117 fn test_user_content_from_rig_audio_missing_media_type_error() {
3118 let rig_content = message::UserContent::Audio(message::Audio {
3119 data: DocumentSourceKind::Base64("audiodata".to_string()),
3120 media_type: None, additional_params: None,
3122 });
3123 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3124
3125 assert!(result.is_err());
3126 let err = result.unwrap_err();
3127 assert!(err.to_string().contains("media type required"));
3128 }
3129
3130 #[test]
3131 fn test_user_content_from_rig_audio_url_error() {
3132 let rig_content = message::UserContent::Audio(message::Audio {
3133 data: DocumentSourceKind::Url("https://example.com/audio.wav".to_string()),
3134 media_type: Some(message::AudioMediaType::WAV),
3135 additional_params: None,
3136 });
3137 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3138
3139 assert!(result.is_err());
3140 let err = result.unwrap_err();
3141 assert!(err.to_string().contains("base64"));
3142 }
3143
3144 #[test]
3145 fn test_user_content_from_rig_audio_raw_bytes_error() {
3146 let rig_content = message::UserContent::Audio(message::Audio {
3147 data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3148 media_type: Some(message::AudioMediaType::WAV),
3149 additional_params: None,
3150 });
3151 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3152
3153 assert!(result.is_err());
3154 let err = result.unwrap_err();
3155 assert!(err.to_string().contains("base64"));
3156 }
3157
3158 #[test]
3159 fn test_user_content_from_rig_video_file_id_error() {
3160 let rig_content = message::UserContent::Video(message::Video {
3161 data: DocumentSourceKind::FileId("file-123".to_string()),
3162 media_type: Some(message::VideoMediaType::MP4),
3163 additional_params: None,
3164 });
3165 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3166
3167 assert!(result.is_err());
3168 let err = result.unwrap_err();
3169 assert!(
3170 err.to_string()
3171 .contains("File IDs are not supported for video")
3172 );
3173 }
3174
3175 #[test]
3176 fn test_user_content_from_rig_audio_file_id_error() {
3177 let rig_content = message::UserContent::Audio(message::Audio {
3178 data: DocumentSourceKind::FileId("file-123".to_string()),
3179 media_type: Some(message::AudioMediaType::MP3),
3180 additional_params: None,
3181 });
3182 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3183
3184 assert!(result.is_err());
3185 let err = result.unwrap_err();
3186 assert!(
3187 err.to_string()
3188 .contains("File IDs are not supported for audio")
3189 );
3190 }
3191
3192 #[test]
3193 fn test_video_helper_converts_to_data_uri() {
3194 let rig_content =
3197 message::UserContent::video("SGVsbG8=", Some(message::VideoMediaType::MP4));
3198 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3199
3200 match openrouter_content {
3201 UserContent::Video { video_url } => {
3202 assert_eq!(video_url.url, "data:video/mp4;base64,SGVsbG8=");
3203 }
3204 _ => panic!("Expected Video variant"),
3205 }
3206 }
3207
3208 #[test]
3209 fn test_video_url_helper_passes_url_through() {
3210 let rig_content = message::UserContent::video_url("https://example.com/video.mp4", None);
3213 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3214
3215 match openrouter_content {
3216 UserContent::Video { video_url } => {
3217 assert_eq!(video_url.url, "https://example.com/video.mp4");
3218 }
3219 _ => panic!("Expected Video variant"),
3220 }
3221 }
3222
3223 #[test]
3224 fn test_video_raw_helper_errors() {
3225 let rig_content =
3228 message::UserContent::video_raw(vec![1, 2, 3], Some(message::VideoMediaType::MP4));
3229 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3230
3231 assert!(result.is_err());
3232 let err = result.unwrap_err();
3233 assert!(err.to_string().contains("base64"));
3234 }
3235
3236 #[test]
3237 fn test_message_conversion_with_pdf() {
3238 let rig_message = message::Message::User {
3239 content: OneOrMany::many(vec![
3240 message::UserContent::Text(message::Text::new(
3241 "Summarize this document".to_string(),
3242 )),
3243 message::UserContent::Document(message::Document {
3244 data: DocumentSourceKind::Url("https://example.com/paper.pdf".to_string()),
3245 media_type: Some(DocumentMediaType::PDF),
3246 additional_params: None,
3247 }),
3248 ])
3249 .unwrap(),
3250 };
3251
3252 let openrouter_messages: Vec<Message> = messages_from_rig_message(rig_message).unwrap();
3253 assert_eq!(openrouter_messages.len(), 1);
3254
3255 match &openrouter_messages[0] {
3256 Message::User { content, .. } => {
3257 assert_eq!(content.len(), 2);
3258
3259 match content.first_ref() {
3261 UserContent::Text { text, .. } => assert_eq!(text, "Summarize this document"),
3262 _ => panic!("Expected Text"),
3263 }
3264 }
3265 _ => panic!("Expected User message"),
3266 }
3267 }
3268
3269 #[test]
3270 fn test_user_content_from_string() {
3271 let content: UserContent = "Hello".into();
3272 assert_eq!(
3273 content,
3274 UserContent::Text {
3275 text: "Hello".to_string()
3276 }
3277 );
3278
3279 let content: UserContent = String::from("World").into();
3280 assert_eq!(
3281 content,
3282 UserContent::Text {
3283 text: "World".to_string()
3284 }
3285 );
3286 }
3287
3288 #[test]
3289 fn test_completion_response_reasoning_details_with_multiple_ids_stay_separate() {
3290 let json = json!({
3291 "id": "resp_multi_id",
3292 "object": "chat.completion",
3293 "created": 1,
3294 "model": "openrouter/test-model",
3295 "choices": [{
3296 "index": 0,
3297 "finish_reason": "stop",
3298 "message": {
3299 "role": "assistant",
3300 "content": "hello",
3301 "reasoning": null,
3302 "reasoning_details": [
3303 {"type":"reasoning.summary","id":"rs_a","summary":"a1"},
3304 {"type":"reasoning.summary","id":"rs_b","summary":"b1"},
3305 {"type":"reasoning.summary","id":"rs_a","summary":"a2"}
3306 ]
3307 }
3308 }]
3309 });
3310
3311 let response: CompletionResponse = serde_json::from_value(json).unwrap();
3312 let converted: completion::CompletionResponse<CompletionResponse> =
3313 response.try_into().unwrap();
3314 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3315 let reasoning_blocks: Vec<_> = items
3316 .into_iter()
3317 .filter_map(|item| match item {
3318 completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
3319 _ => None,
3320 })
3321 .collect();
3322
3323 assert_eq!(reasoning_blocks.len(), 2);
3324 assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_a"));
3325 assert_eq!(
3326 reasoning_blocks[0].content,
3327 vec![
3328 message::ReasoningContent::Summary("a1".to_string()),
3329 message::ReasoningContent::Summary("a2".to_string()),
3330 ]
3331 );
3332 assert_eq!(reasoning_blocks[1].id.as_deref(), Some("rs_b"));
3333 assert_eq!(
3334 reasoning_blocks[1].content,
3335 vec![message::ReasoningContent::Summary("b1".to_string())]
3336 );
3337 }
3338
3339 #[test]
3340 fn test_user_content_audio_serialization() {
3341 let content = UserContent::Audio {
3342 input_audio: openai::InputAudio {
3343 data: "SGVsbG8=".to_string(),
3344 format: AudioMediaType::WAV,
3345 },
3346 };
3347 let json = serde_json::to_value(&content).unwrap();
3348
3349 assert_eq!(json["type"], "input_audio");
3350 assert_eq!(json["input_audio"]["data"], "SGVsbG8=");
3351 assert_eq!(json["input_audio"]["format"], "wav");
3352 }
3353
3354 #[test]
3355 fn test_user_content_audio_deserialization() {
3356 let json = json!({
3357 "type": "input_audio",
3358 "input_audio": {
3359 "data": "SGVsbG8=",
3360 "format": "wav"
3361 }
3362 });
3363
3364 let content: UserContent = serde_json::from_value(json).unwrap();
3365 match content {
3366 UserContent::Audio { input_audio } => {
3367 assert_eq!(input_audio.data, "SGVsbG8=");
3368 assert_eq!(input_audio.format, AudioMediaType::WAV);
3369 }
3370 _ => panic!("Expected Audio variant"),
3371 }
3372 }
3373
3374 #[test]
3375 fn test_message_user_with_audio_serialization() {
3376 let msg = Message::User {
3377 content: OneOrMany::many(vec![
3378 UserContent::Text {
3379 text: "Transcribe this audio:".to_string(),
3380 },
3381 UserContent::Audio {
3382 input_audio: openai::InputAudio {
3383 data: "SGVsbG8=".to_string(),
3384 format: AudioMediaType::MP3,
3385 },
3386 },
3387 ])
3388 .unwrap(),
3389 name: None,
3390 };
3391 let json = serde_json::to_value(&msg).unwrap();
3392
3393 assert_eq!(json["role"], "user");
3394 let content = json["content"].as_array().unwrap();
3395 assert_eq!(content.len(), 2);
3396 assert_eq!(content[0]["type"], "text");
3397 assert_eq!(content[1]["type"], "input_audio");
3398 assert_eq!(content[1]["input_audio"]["data"], "SGVsbG8=");
3399 assert_eq!(content[1]["input_audio"]["format"], "mp3");
3400 }
3401
3402 #[test]
3403 fn test_user_content_video_url_serialization() {
3404 let content = UserContent::Video {
3405 video_url: VideoUrl {
3406 url: "https://example.com/video.mp4".to_string(),
3407 },
3408 };
3409 let json = serde_json::to_value(&content).unwrap();
3410
3411 assert_eq!(json["type"], "video_url");
3412 assert_eq!(json["video_url"]["url"], "https://example.com/video.mp4");
3413 }
3414
3415 #[test]
3416 fn test_user_content_video_base64_serialization() {
3417 let content = UserContent::Video {
3418 video_url: VideoUrl {
3419 url: format!(
3420 "data:{};base64,SGVsbG8=",
3421 VideoMediaType::MP4.to_mime_type()
3422 ),
3423 },
3424 };
3425 let json = serde_json::to_value(&content).unwrap();
3426
3427 assert_eq!(json["type"], "video_url");
3428 assert_eq!(json["video_url"]["url"], "data:video/mp4;base64,SGVsbG8=");
3429 }
3430
3431 #[test]
3432 fn test_user_content_video_url_deserialization() {
3433 let json = json!({
3434 "type": "video_url",
3435 "video_url": {
3436 "url": "https://example.com/video.mp4"
3437 }
3438 });
3439
3440 let content: UserContent = serde_json::from_value(json).unwrap();
3441 match content {
3442 UserContent::Video { video_url } => {
3443 assert_eq!(video_url.url, "https://example.com/video.mp4");
3444 }
3445 _ => panic!("Expected Video variant"),
3446 }
3447 }
3448
3449 #[test]
3450 fn test_message_user_with_video_serialization() {
3451 let msg = Message::User {
3452 content: OneOrMany::many(vec![
3453 UserContent::Text {
3454 text: "Describe this video:".to_string(),
3455 },
3456 UserContent::Video {
3457 video_url: VideoUrl {
3458 url: "https://example.com/video.mp4".to_string(),
3459 },
3460 },
3461 ])
3462 .unwrap(),
3463 name: None,
3464 };
3465 let json = serde_json::to_value(&msg).unwrap();
3466
3467 assert_eq!(json["role"], "user");
3468 let content = json["content"].as_array().unwrap();
3469 assert_eq!(content.len(), 2);
3470 assert_eq!(content[0]["type"], "text");
3471 assert_eq!(content[1]["type"], "video_url");
3472 assert_eq!(
3473 content[1]["video_url"]["url"],
3474 "https://example.com/video.mp4"
3475 );
3476 }
3477
3478 #[test]
3479 fn test_user_content_video_url_no_media_type_needed() {
3480 let rig_content = message::UserContent::Video(message::Video {
3481 data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
3482 media_type: None,
3483 additional_params: None,
3484 });
3485 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3486
3487 match openrouter_content {
3488 UserContent::Video { video_url } => {
3489 assert_eq!(video_url.url, "https://example.com/video.mp4");
3490 }
3491 _ => panic!("Expected Video variant"),
3492 }
3493 }
3494
3495 fn prompt_caching_completion_request() -> CompletionRequest {
3496 CompletionRequest {
3497 model: None,
3498 preamble: Some("You are a helpful assistant.".to_string()),
3499 chat_history: crate::OneOrMany::one(crate::message::Message::user("Hello")),
3500 documents: vec![],
3501 tools: vec![],
3502 temperature: None,
3503 max_tokens: None,
3504 tool_choice: None,
3505 additional_params: None,
3506 output_schema: None,
3507 }
3508 }
3509
3510 #[test]
3511 fn test_final_request_body_applies_prompt_caching_to_converted_completion_request() {
3512 let request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
3513 model: "anthropic/claude-3.5-sonnet",
3514 request: prompt_caching_completion_request(),
3515 strict_tools: false,
3516 })
3517 .expect("request conversion should succeed");
3518
3519 let body = final_request_body(&request, true).expect("request body should serialize");
3520 let system_block = &body["messages"][0]["content"][0];
3521
3522 assert_eq!(system_block["type"], "text");
3523 assert_eq!(system_block["text"], "You are a helpful assistant.");
3524 assert_eq!(system_block["cache_control"]["type"], "ephemeral");
3525
3526 let body = final_request_body(&request, false).expect("request body should serialize");
3527 assert!(
3528 body["messages"][0]["content"][0]
3529 .get("cache_control")
3530 .is_none(),
3531 "prompt caching should be opt-in"
3532 );
3533 }
3534
3535 #[test]
3536 fn test_final_request_body_preserves_stream_flag_when_prompt_caching_enabled() {
3537 let mut request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
3538 model: "anthropic/claude-3.5-sonnet",
3539 request: prompt_caching_completion_request(),
3540 strict_tools: false,
3541 })
3542 .expect("request conversion should succeed");
3543 request.additional_params = Some(json!({ "stream": true }));
3544
3545 let body = final_request_body(&request, true).expect("request body should serialize");
3546
3547 assert_eq!(body["stream"], true);
3548 assert_eq!(
3549 body["messages"][0]["content"][0]["cache_control"]["type"],
3550 "ephemeral"
3551 );
3552 }
3553
3554 #[test]
3555 fn test_apply_prompt_caching_string_system_message() {
3556 let mut body = json!({
3557 "model": "anthropic/claude-3.5-sonnet",
3558 "messages": [
3559 {"role": "system", "content": "You are a helpful assistant."},
3560 {"role": "user", "content": "Hello"}
3561 ]
3562 });
3563
3564 apply_prompt_caching(&mut body);
3565
3566 let system_content = &body["messages"][0]["content"];
3567 assert!(
3568 system_content.is_array(),
3569 "system content should be an array after caching"
3570 );
3571 let block = &system_content[0];
3572 assert_eq!(block["type"], "text");
3573 assert_eq!(block["text"], "You are a helpful assistant.");
3574 assert_eq!(block["cache_control"]["type"], "ephemeral");
3575
3576 assert_eq!(body["messages"][1]["content"], "Hello");
3578 }
3579
3580 #[test]
3581 fn test_apply_prompt_caching_array_system_message_marks_last_block() {
3582 let mut body = json!({
3583 "model": "anthropic/claude-3.5-sonnet",
3584 "messages": [
3585 {
3586 "role": "system",
3587 "content": [
3588 {"type": "text", "text": "Part 1. "},
3589 {"type": "text", "text": "Part 2."}
3590 ]
3591 }
3592 ]
3593 });
3594
3595 apply_prompt_caching(&mut body);
3596
3597 let system_content = &body["messages"][0]["content"];
3598 assert!(system_content.is_array());
3599 assert_eq!(system_content.as_array().unwrap().len(), 2);
3601 assert_eq!(system_content[0]["text"], "Part 1. ");
3602 assert!(system_content[0].get("cache_control").is_none());
3603 assert_eq!(system_content[1]["text"], "Part 2.");
3604 assert_eq!(system_content[1]["cache_control"]["type"], "ephemeral");
3605 }
3606
3607 #[test]
3608 fn test_apply_prompt_caching_preserves_non_text_blocks() {
3609 let mut body = json!({
3610 "model": "anthropic/claude-3.5-sonnet",
3611 "messages": [
3612 {
3613 "role": "system",
3614 "content": [
3615 {"type": "image", "source": {"type": "url", "url": "https://example.com/img.png"}},
3616 {"type": "text", "text": "Describe the image."}
3617 ]
3618 }
3619 ]
3620 });
3621
3622 apply_prompt_caching(&mut body);
3623
3624 let system_content = &body["messages"][0]["content"];
3625 assert_eq!(system_content.as_array().unwrap().len(), 2);
3626 assert_eq!(system_content[0]["type"], "image");
3628 assert!(system_content[0].get("cache_control").is_none());
3629 assert_eq!(system_content[1]["type"], "text");
3631 assert_eq!(system_content[1]["cache_control"]["type"], "ephemeral");
3632 }
3633
3634 #[test]
3635 fn test_apply_prompt_caching_no_system_message_is_noop() {
3636 let mut body = json!({
3637 "model": "openai/gpt-4o",
3638 "messages": [
3639 {"role": "user", "content": "Hello"}
3640 ]
3641 });
3642
3643 let body_before = body.clone();
3644 apply_prompt_caching(&mut body);
3645 assert_eq!(
3646 body, body_before,
3647 "body should be unchanged when no system message exists"
3648 );
3649 }
3650
3651 #[test]
3652 fn test_completion_response_extracts_generated_images() {
3653 let json = json!({
3654 "id": "resp_img",
3655 "object": "chat.completion",
3656 "created": 1,
3657 "model": "google/gemini-flash-image-preview",
3658 "choices": [{
3659 "index": 0,
3660 "finish_reason": "stop",
3661 "message": {
3662 "role": "assistant",
3663 "content": "Here is your image.",
3664 "images": [
3665 {"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}
3666 ]
3667 }
3668 }]
3669 });
3670
3671 let response: CompletionResponse = serde_json::from_value(json).unwrap();
3672 let converted: completion::CompletionResponse<CompletionResponse> =
3673 response.try_into().unwrap();
3674 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3675 assert_eq!(items.len(), 2);
3676
3677 assert!(items.iter().any(|item| matches!(
3678 item,
3679 completion::AssistantContent::Text(t) if t.text == "Here is your image."
3680 )));
3681 assert!(items.iter().any(|item| matches!(
3682 item,
3683 completion::AssistantContent::Image(message::Image {
3684 data: message::DocumentSourceKind::Base64(b64),
3685 media_type: Some(message::ImageMediaType::PNG),
3686 additional_params: Some(_),
3687 ..
3688 }) if b64 == "iVBORw0KGgo="
3689 )));
3690 assert!(
3691 items.iter().any(|item| matches!(
3692 item,
3693 completion::AssistantContent::Image(image)
3694 if is_openrouter_response_image(image)
3695 )),
3696 "generated images should be marked as OpenRouter response-only artifacts"
3697 );
3698 }
3699
3700 #[test]
3701 fn test_completion_response_extracts_generated_images_url() {
3702 let json = json!({
3703 "id": "resp_img_url",
3704 "object": "chat.completion",
3705 "created": 1,
3706 "model": "google/gemini-flash-image-preview",
3707 "choices": [{
3708 "index": 0,
3709 "finish_reason": "stop",
3710 "message": {
3711 "role": "assistant",
3712 "content": "Here is your image.",
3713 "images": [
3714 {"type":"image_url","image_url":{"url":"https://example.com/generated.png"}}
3715 ]
3716 }
3717 }]
3718 });
3719
3720 let response: CompletionResponse = serde_json::from_value(json).unwrap();
3721 let converted: completion::CompletionResponse<CompletionResponse> =
3722 response.try_into().unwrap();
3723 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3724 assert_eq!(items.len(), 2);
3725
3726 assert!(items.iter().any(|item| matches!(
3727 item,
3728 completion::AssistantContent::Image(message::Image {
3729 data: message::DocumentSourceKind::Url(url),
3730 media_type: None,
3731 additional_params: Some(_),
3732 ..
3733 }) if url == "https://example.com/generated.png"
3734 )));
3735 assert!(
3736 items.iter().any(|item| matches!(
3737 item,
3738 completion::AssistantContent::Image(image)
3739 if is_openrouter_response_image(image)
3740 )),
3741 "generated URL images should be marked as OpenRouter response-only artifacts"
3742 );
3743 }
3744
3745 #[test]
3746 fn test_generated_images_do_not_break_assistant_history_conversion() {
3747 let generated_image = response_image_to_assistant_content(&ResponseImage {
3748 image_url: ImageUrl {
3749 url: "data:image/png;base64,abc".to_string(),
3750 detail: None,
3751 },
3752 });
3753
3754 let content = OneOrMany::many(vec![
3755 completion::AssistantContent::text("Here is your image."),
3756 generated_image,
3757 ])
3758 .unwrap();
3759 let messages = assistant_contents_to_messages(content).unwrap();
3760
3761 assert_eq!(messages.len(), 1);
3762 assert!(matches!(
3763 &messages[0],
3764 Message::Assistant { content, .. }
3765 if content == &vec![openai::AssistantContent::Text {
3766 text: "Here is your image.".to_string()
3767 }]
3768 ));
3769 }
3770
3771 #[test]
3772 fn test_image_only_assistant_history_is_omitted_for_openrouter() {
3773 let generated_image = response_image_to_assistant_content(&ResponseImage {
3774 image_url: ImageUrl {
3775 url: "data:image/png;base64,abc".to_string(),
3776 detail: None,
3777 },
3778 });
3779
3780 let messages = assistant_contents_to_messages(OneOrMany::one(generated_image)).unwrap();
3781
3782 assert!(
3783 messages.is_empty(),
3784 "response-only generated image turns should not be replayed as assistant content"
3785 );
3786 }
3787
3788 #[test]
3789 fn test_unmarked_assistant_image_history_errors_for_openrouter() {
3790 let image = completion::AssistantContent::image_base64(
3791 "abc",
3792 Some(message::ImageMediaType::PNG),
3793 None,
3794 );
3795
3796 let err = assistant_contents_to_messages(OneOrMany::one(image)).unwrap_err();
3797
3798 match err {
3799 message::MessageError::ConversionError(message) => assert!(
3800 message.contains("OpenRouter does not support assistant image content"),
3801 "unexpected error: {message}"
3802 ),
3803 }
3804 }
3805
3806 #[test]
3807 fn test_mixed_text_and_generated_image_replays_text_only_for_openrouter() {
3808 let generated_image = response_image_to_assistant_content(&ResponseImage {
3809 image_url: ImageUrl {
3810 url: "https://example.com/generated.png".to_string(),
3811 detail: None,
3812 },
3813 });
3814
3815 let messages = assistant_contents_to_messages(
3816 OneOrMany::many(vec![
3817 completion::AssistantContent::text("Keep this text."),
3818 generated_image,
3819 ])
3820 .unwrap(),
3821 )
3822 .unwrap();
3823
3824 let serialized = serde_json::to_value(&messages).unwrap();
3825 assert_eq!(
3826 serialized,
3827 json!([{
3828 "role": "assistant",
3829 "content": [{"type": "text", "text": "Keep this text."}]
3830 }])
3831 );
3832 }
3833
3834 #[test]
3835 fn test_assistant_images_not_serialized_in_request() {
3836 let msg = Message::Assistant {
3837 content: vec!["Hello".to_string().into()],
3838 refusal: None,
3839 audio: None,
3840 name: None,
3841 tool_calls: vec![],
3842 reasoning: None,
3843 reasoning_details: vec![],
3844 images: vec![ResponseImage {
3845 image_url: ImageUrl {
3846 url: "data:image/png;base64,abc".to_string(),
3847 detail: None,
3848 },
3849 }],
3850 };
3851 let serialized = serde_json::to_value(&msg).unwrap();
3852 assert!(
3853 serialized.get("images").is_none(),
3854 "images field must not appear in serialized assistant message"
3855 );
3856 }
3857}