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