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 model = req.model.clone().unwrap_or_else(|| model.to_string());
1798
1799 let mut full_history: Vec<Message> = match &req.preamble {
1800 Some(preamble) => vec![Message::system(preamble)],
1801 None => vec![],
1802 };
1803 if let Some(docs) = req.normalized_documents() {
1804 let docs: Vec<Message> = docs.try_into()?;
1805 full_history.extend(docs);
1806 }
1807
1808 let chat_history: Vec<Message> = req
1809 .chat_history
1810 .clone()
1811 .into_iter()
1812 .map(|message| message.try_into())
1813 .collect::<Result<Vec<Vec<Message>>, _>>()?
1814 .into_iter()
1815 .flatten()
1816 .collect();
1817
1818 full_history.extend(chat_history);
1819
1820 let tool_choice = req
1821 .tool_choice
1822 .clone()
1823 .map(crate::providers::openai::completion::ToolChoice::try_from)
1824 .transpose()?;
1825
1826 let tools: Vec<crate::providers::openai::completion::ToolDefinition> = req
1827 .tools
1828 .clone()
1829 .into_iter()
1830 .map(|tool| {
1831 let def = crate::providers::openai::completion::ToolDefinition::from(tool);
1832 if strict_tools { def.with_strict() } else { def }
1833 })
1834 .collect();
1835
1836 let additional_params = if let Some(schema) = req.output_schema {
1837 let name = schema
1838 .as_object()
1839 .and_then(|o| o.get("title"))
1840 .and_then(|v| v.as_str())
1841 .unwrap_or("response_schema")
1842 .to_string();
1843 let mut schema_value = schema.to_value();
1844 openai::sanitize_schema(&mut schema_value);
1845 let response_format = serde_json::json!({
1846 "response_format": {
1847 "type": "json_schema",
1848 "json_schema": {
1849 "name": name,
1850 "strict": true,
1851 "schema": schema_value
1852 }
1853 }
1854 });
1855 Some(match req.additional_params {
1856 Some(existing) => json_utils::merge(existing, response_format),
1857 None => response_format,
1858 })
1859 } else {
1860 req.additional_params
1861 };
1862
1863 Ok(Self {
1864 model,
1865 messages: full_history,
1866 temperature: req.temperature,
1867 tools,
1868 tool_choice,
1869 additional_params,
1870 })
1871 }
1872}
1873
1874impl TryFrom<(&str, CompletionRequest)> for OpenrouterCompletionRequest {
1875 type Error = CompletionError;
1876
1877 fn try_from((model, req): (&str, CompletionRequest)) -> Result<Self, Self::Error> {
1878 let model = req.model.clone().unwrap_or_else(|| model.to_string());
1879 OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1880 model: &model,
1881 request: req,
1882 strict_tools: false,
1883 })
1884 }
1885}
1886
1887#[derive(Clone)]
1888pub struct CompletionModel<T = reqwest::Client> {
1889 pub(crate) client: Client<T>,
1890 pub model: String,
1891 pub strict_tools: bool,
1894 pub prompt_caching: bool,
1901}
1902
1903impl<T> CompletionModel<T> {
1904 pub fn new(client: Client<T>, model: impl Into<String>) -> Self {
1905 Self {
1906 client,
1907 model: model.into(),
1908 strict_tools: false,
1909 prompt_caching: false,
1910 }
1911 }
1912
1913 pub fn with_prompt_caching(mut self) -> Self {
1920 self.prompt_caching = true;
1921 self
1922 }
1923
1924 pub fn with_strict_tools(mut self) -> Self {
1933 self.strict_tools = true;
1934 self
1935 }
1936}
1937
1938impl<T> completion::CompletionModel for CompletionModel<T>
1939where
1940 T: HttpClientExt + Clone + std::fmt::Debug + Default + 'static,
1941{
1942 type Response = CompletionResponse;
1943 type StreamingResponse = StreamingCompletionResponse;
1944
1945 type Client = Client<T>;
1946
1947 fn make(client: &Self::Client, model: impl Into<String>) -> Self {
1948 Self::new(client.clone(), model)
1949 }
1950
1951 async fn completion(
1952 &self,
1953 completion_request: CompletionRequest,
1954 ) -> Result<completion::CompletionResponse<CompletionResponse>, CompletionError> {
1955 let request_model = completion_request
1956 .model
1957 .clone()
1958 .unwrap_or_else(|| self.model.clone());
1959 let preamble = completion_request.preamble.clone();
1960 let request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1961 model: request_model.as_ref(),
1962 request: completion_request,
1963 strict_tools: self.strict_tools,
1964 })?;
1965
1966 let body = final_request_body(&request, self.prompt_caching)?;
1967
1968 if enabled!(Level::TRACE) {
1969 tracing::trace!(
1970 target: "rig::completions",
1971 "OpenRouter completion request: {}",
1972 serde_json::to_string_pretty(&body)?
1973 );
1974 }
1975
1976 let span = if tracing::Span::current().is_disabled() {
1977 info_span!(
1978 target: "rig::completions",
1979 "chat",
1980 gen_ai.operation.name = "chat",
1981 gen_ai.provider.name = "openrouter",
1982 gen_ai.request.model = &request_model,
1983 gen_ai.system_instructions = preamble,
1984 gen_ai.response.id = tracing::field::Empty,
1985 gen_ai.response.model = tracing::field::Empty,
1986 gen_ai.usage.output_tokens = tracing::field::Empty,
1987 gen_ai.usage.input_tokens = tracing::field::Empty,
1988 gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
1989 )
1990 } else {
1991 tracing::Span::current()
1992 };
1993
1994 let body = serde_json::to_vec(&body)?;
1995
1996 let req = self
1997 .client
1998 .post("/chat/completions")?
1999 .body(body)
2000 .map_err(|x| CompletionError::HttpError(x.into()))?;
2001
2002 async move {
2003 let response = self.client.send::<_, Bytes>(req).await?;
2004 let status = response.status();
2005 let response_body = response.into_body().into_future().await?.to_vec();
2006
2007 if status.is_success() {
2008 let parsed: ApiResponse<CompletionResponse> =
2009 serde_json::from_slice(&response_body).map_err(|e| {
2010 CompletionError::ResponseError(format!(
2011 "Failed to parse OpenRouter completion response: {}, response body: {}",
2012 e,
2013 String::from_utf8_lossy(&response_body)
2014 ))
2015 })?;
2016 match parsed {
2017 ApiResponse::Ok(response) => {
2018 let span = tracing::Span::current();
2019 span.record_token_usage(&response.usage);
2020 span.record("gen_ai.response.id", &response.id);
2021 span.record("gen_ai.response.model", &response.model);
2022
2023 tracing::debug!(target: "rig::completions",
2024 "OpenRouter response: {response:?}");
2025 response.try_into()
2026 }
2027 ApiResponse::Err(err) => Err(CompletionError::ProviderError(err.message)),
2028 }
2029 } else {
2030 Err(CompletionError::ProviderError(
2031 String::from_utf8_lossy(&response_body).to_string(),
2032 ))
2033 }
2034 }
2035 .instrument(span)
2036 .await
2037 }
2038
2039 async fn stream(
2040 &self,
2041 completion_request: CompletionRequest,
2042 ) -> Result<
2043 crate::streaming::StreamingCompletionResponse<Self::StreamingResponse>,
2044 CompletionError,
2045 > {
2046 CompletionModel::stream(self, completion_request).await
2047 }
2048}
2049
2050#[cfg(test)]
2051mod tests {
2052 use super::*;
2053 use serde_json::json;
2054
2055 #[test]
2056 fn test_openrouter_request_uses_request_model_override() {
2057 let request = CompletionRequest {
2058 model: Some("google/gemini-2.5-flash".to_string()),
2059 preamble: None,
2060 chat_history: crate::OneOrMany::one("Hello".into()),
2061 documents: vec![],
2062 tools: vec![],
2063 temperature: None,
2064 max_tokens: None,
2065 tool_choice: None,
2066 additional_params: None,
2067 output_schema: None,
2068 };
2069
2070 let openrouter_request =
2071 OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
2072 .expect("request conversion should succeed");
2073 let serialized =
2074 serde_json::to_value(openrouter_request).expect("serialization should succeed");
2075
2076 assert_eq!(serialized["model"], "google/gemini-2.5-flash");
2077 }
2078
2079 #[test]
2080 fn test_openrouter_request_uses_default_model_when_override_unset() {
2081 let request = CompletionRequest {
2082 model: None,
2083 preamble: None,
2084 chat_history: crate::OneOrMany::one("Hello".into()),
2085 documents: vec![],
2086 tools: vec![],
2087 temperature: None,
2088 max_tokens: None,
2089 tool_choice: None,
2090 additional_params: None,
2091 output_schema: None,
2092 };
2093
2094 let openrouter_request =
2095 OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
2096 .expect("request conversion should succeed");
2097 let serialized =
2098 serde_json::to_value(openrouter_request).expect("serialization should succeed");
2099
2100 assert_eq!(serialized["model"], "openai/gpt-4o-mini");
2101 }
2102
2103 #[test]
2104 fn test_openrouter_request_maps_output_schema_to_response_format() {
2105 let schema: schemars::Schema = serde_json::from_value(json!({
2106 "title": "WeatherResponse",
2107 "type": "object",
2108 "properties": {
2109 "city": { "type": "string" },
2110 "weather": { "type": "string" }
2111 }
2112 }))
2113 .expect("schema should deserialize");
2114
2115 let request = CompletionRequest {
2116 model: None,
2117 preamble: None,
2118 chat_history: crate::OneOrMany::one("Hello".into()),
2119 documents: vec![],
2120 tools: vec![],
2121 temperature: None,
2122 max_tokens: None,
2123 tool_choice: None,
2124 additional_params: None,
2125 output_schema: Some(schema),
2126 };
2127
2128 let openrouter_request =
2129 OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
2130 .expect("request conversion should succeed");
2131 let serialized =
2132 serde_json::to_value(openrouter_request).expect("serialization should succeed");
2133
2134 assert_eq!(
2135 serialized["response_format"],
2136 json!({
2137 "type": "json_schema",
2138 "json_schema": {
2139 "name": "WeatherResponse",
2140 "strict": true,
2141 "schema": {
2142 "title": "WeatherResponse",
2143 "type": "object",
2144 "properties": {
2145 "city": { "type": "string" },
2146 "weather": { "type": "string" }
2147 },
2148 "additionalProperties": false,
2149 "required": ["city", "weather"]
2150 }
2151 }
2152 })
2153 );
2154 }
2155
2156 #[test]
2157 fn test_openrouter_request_merges_output_schema_with_provider_preferences() {
2158 let schema: schemars::Schema = serde_json::from_value(json!({
2159 "type": "object",
2160 "properties": {
2161 "answer": { "type": "string" }
2162 }
2163 }))
2164 .expect("schema should deserialize");
2165
2166 let request = CompletionRequest {
2167 model: None,
2168 preamble: None,
2169 chat_history: crate::OneOrMany::one("Hello".into()),
2170 documents: vec![],
2171 tools: vec![],
2172 temperature: None,
2173 max_tokens: None,
2174 tool_choice: None,
2175 additional_params: Some(
2176 ProviderPreferences::new()
2177 .require_parameters(true)
2178 .to_json(),
2179 ),
2180 output_schema: Some(schema),
2181 };
2182
2183 let openrouter_request =
2184 OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
2185 .expect("request conversion should succeed");
2186 let serialized =
2187 serde_json::to_value(openrouter_request).expect("serialization should succeed");
2188
2189 assert_eq!(serialized["provider"]["require_parameters"], true);
2190 assert_eq!(serialized["response_format"]["type"], "json_schema");
2191 assert_eq!(
2192 serialized["response_format"]["json_schema"]["name"],
2193 "response_schema"
2194 );
2195 assert_eq!(
2196 serialized["response_format"]["json_schema"]["schema"]["additionalProperties"],
2197 false
2198 );
2199 }
2200
2201 #[test]
2202 fn test_completion_response_deserialization_gemini_flash() {
2203 let json = json!({
2205 "id": "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA",
2206 "provider": "Google",
2207 "model": "google/gemini-2.5-flash",
2208 "object": "chat.completion",
2209 "created": 1765971703u64,
2210 "choices": [{
2211 "logprobs": null,
2212 "finish_reason": "stop",
2213 "native_finish_reason": "STOP",
2214 "index": 0,
2215 "message": {
2216 "role": "assistant",
2217 "content": "CONTENT",
2218 "refusal": null,
2219 "reasoning": null
2220 }
2221 }],
2222 "usage": {
2223 "prompt_tokens": 669,
2224 "completion_tokens": 5,
2225 "total_tokens": 674
2226 }
2227 });
2228
2229 let response: CompletionResponse = serde_json::from_value(json).unwrap();
2230 assert_eq!(response.id, "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA");
2231 assert_eq!(response.model, "google/gemini-2.5-flash");
2232 assert_eq!(response.choices.len(), 1);
2233 assert_eq!(response.choices[0].finish_reason, Some("stop".to_string()));
2234 }
2235
2236 #[test]
2237 fn test_completion_response_maps_cache_token_accounting() {
2238 let json = json!({
2239 "id": "gen-cache-test",
2240 "object": "chat.completion",
2241 "created": 1,
2242 "model": "anthropic/claude-3.5-sonnet",
2243 "choices": [{
2244 "index": 0,
2245 "finish_reason": "stop",
2246 "message": {
2247 "role": "assistant",
2248 "content": "Hi"
2249 }
2250 }],
2251 "usage": {
2252 "prompt_tokens": 500,
2253 "completion_tokens": 10,
2254 "total_tokens": 510,
2255 "prompt_tokens_details": {
2256 "cached_tokens": 400,
2257 "cache_write_tokens": 50
2258 }
2259 }
2260 });
2261
2262 let response: CompletionResponse = serde_json::from_value(json).unwrap();
2263 let converted: completion::CompletionResponse<CompletionResponse> =
2264 response.try_into().unwrap();
2265
2266 assert_eq!(converted.usage.input_tokens, 500);
2267 assert_eq!(converted.usage.output_tokens, 10);
2268 assert_eq!(converted.usage.cached_input_tokens, 400);
2269 assert_eq!(converted.usage.cache_creation_input_tokens, 50);
2270 }
2271
2272 #[test]
2273 fn test_completion_response_cache_tokens_absent_defaults_to_zero() {
2274 let json = json!({
2275 "id": "gen-no-cache",
2276 "object": "chat.completion",
2277 "created": 1,
2278 "model": "openai/gpt-4o",
2279 "choices": [{
2280 "index": 0,
2281 "finish_reason": "stop",
2282 "message": {
2283 "role": "assistant",
2284 "content": "Hi"
2285 }
2286 }],
2287 "usage": {
2288 "prompt_tokens": 100,
2289 "completion_tokens": 10,
2290 "total_tokens": 110
2291 }
2292 });
2293
2294 let response: CompletionResponse = serde_json::from_value(json).unwrap();
2295 let converted: completion::CompletionResponse<CompletionResponse> =
2296 response.try_into().unwrap();
2297
2298 assert_eq!(converted.usage.cached_input_tokens, 0);
2299 assert_eq!(converted.usage.cache_creation_input_tokens, 0);
2300 }
2301
2302 #[test]
2303 fn test_completion_response_deserialization_gemini_model_role() {
2304 let json = json!({
2305 "id": "gen-BBBBBBBBBB-BBBBBBBBBBBBBBBBBBBB",
2306 "provider": "Google",
2307 "model": "google/gemini-2.5-pro-exp-03-25:free",
2308 "object": "chat.completion",
2309 "created": 1743780565u64,
2310 "choices": [{
2311 "logprobs": null,
2312 "finish_reason": "stop",
2313 "native_finish_reason": "STOP",
2314 "index": 0,
2315 "message": {
2316 "role": "model",
2317 "content": "CONTENT",
2318 "refusal": null,
2319 "reasoning": null
2320 }
2321 }],
2322 "usage": {
2323 "prompt_tokens": 669,
2324 "completion_tokens": 5,
2325 "total_tokens": 674
2326 }
2327 });
2328
2329 let response: CompletionResponse = serde_json::from_value(json).unwrap();
2330 let converted: completion::CompletionResponse<CompletionResponse> =
2331 response.try_into().unwrap();
2332
2333 assert_eq!(
2334 converted.raw_response.model,
2335 "google/gemini-2.5-pro-exp-03-25:free"
2336 );
2337 assert!(matches!(
2338 converted.choice.first(),
2339 completion::AssistantContent::Text(text) if text.text == "CONTENT"
2340 ));
2341 }
2342
2343 #[test]
2344 fn test_message_assistant_without_reasoning_details() {
2345 let json = json!({
2347 "role": "assistant",
2348 "content": "Hello world",
2349 "refusal": null,
2350 "reasoning": null
2351 });
2352
2353 let message: Message = serde_json::from_value(json).unwrap();
2354 match message {
2355 Message::Assistant {
2356 content,
2357 reasoning_details,
2358 ..
2359 } => {
2360 assert_eq!(content.len(), 1);
2361 assert!(reasoning_details.is_empty());
2362 }
2363 _ => panic!("Expected Assistant message"),
2364 }
2365 }
2366
2367 #[test]
2368 fn test_data_collection_serialization() {
2369 assert_eq!(
2370 serde_json::to_string(&DataCollection::Allow).unwrap(),
2371 r#""allow""#
2372 );
2373 assert_eq!(
2374 serde_json::to_string(&DataCollection::Deny).unwrap(),
2375 r#""deny""#
2376 );
2377 }
2378
2379 #[test]
2380 fn test_data_collection_default() {
2381 assert_eq!(DataCollection::default(), DataCollection::Allow);
2382 }
2383
2384 #[test]
2385 fn test_quantization_serialization() {
2386 assert_eq!(
2387 serde_json::to_string(&Quantization::Int4).unwrap(),
2388 r#""int4""#
2389 );
2390 assert_eq!(
2391 serde_json::to_string(&Quantization::Int8).unwrap(),
2392 r#""int8""#
2393 );
2394 assert_eq!(
2395 serde_json::to_string(&Quantization::Fp16).unwrap(),
2396 r#""fp16""#
2397 );
2398 assert_eq!(
2399 serde_json::to_string(&Quantization::Bf16).unwrap(),
2400 r#""bf16""#
2401 );
2402 assert_eq!(
2403 serde_json::to_string(&Quantization::Fp32).unwrap(),
2404 r#""fp32""#
2405 );
2406 assert_eq!(
2407 serde_json::to_string(&Quantization::Fp8).unwrap(),
2408 r#""fp8""#
2409 );
2410 assert_eq!(
2411 serde_json::to_string(&Quantization::Unknown).unwrap(),
2412 r#""unknown""#
2413 );
2414 }
2415
2416 #[test]
2417 fn test_provider_sort_strategy_serialization() {
2418 assert_eq!(
2419 serde_json::to_string(&ProviderSortStrategy::Price).unwrap(),
2420 r#""price""#
2421 );
2422 assert_eq!(
2423 serde_json::to_string(&ProviderSortStrategy::Throughput).unwrap(),
2424 r#""throughput""#
2425 );
2426 assert_eq!(
2427 serde_json::to_string(&ProviderSortStrategy::Latency).unwrap(),
2428 r#""latency""#
2429 );
2430 }
2431
2432 #[test]
2433 fn test_sort_partition_serialization() {
2434 assert_eq!(
2435 serde_json::to_string(&SortPartition::Model).unwrap(),
2436 r#""model""#
2437 );
2438 assert_eq!(
2439 serde_json::to_string(&SortPartition::None).unwrap(),
2440 r#""none""#
2441 );
2442 }
2443
2444 #[test]
2445 fn test_provider_sort_simple() {
2446 let sort = ProviderSort::Simple(ProviderSortStrategy::Latency);
2447 let json = serde_json::to_value(&sort).unwrap();
2448 assert_eq!(json, "latency");
2449 }
2450
2451 #[test]
2452 fn test_provider_sort_complex() {
2453 let sort = ProviderSort::Complex(
2454 ProviderSortConfig::new(ProviderSortStrategy::Price).partition(SortPartition::None),
2455 );
2456 let json = serde_json::to_value(&sort).unwrap();
2457 assert_eq!(json["by"], "price");
2458 assert_eq!(json["partition"], "none");
2459 }
2460
2461 #[test]
2462 fn test_provider_sort_complex_without_partition() {
2463 let sort = ProviderSort::Complex(ProviderSortConfig::new(ProviderSortStrategy::Throughput));
2464 let json = serde_json::to_value(&sort).unwrap();
2465 assert_eq!(json["by"], "throughput");
2466 assert!(json.get("partition").is_none());
2467 }
2468
2469 #[test]
2470 fn test_provider_sort_from_strategy() {
2471 let sort: ProviderSort = ProviderSortStrategy::Price.into();
2472 assert_eq!(sort, ProviderSort::Simple(ProviderSortStrategy::Price));
2473 }
2474
2475 #[test]
2476 fn test_provider_sort_from_config() {
2477 let config = ProviderSortConfig::new(ProviderSortStrategy::Latency);
2478 let sort: ProviderSort = config.into();
2479 match sort {
2480 ProviderSort::Complex(c) => assert_eq!(c.by, ProviderSortStrategy::Latency),
2481 _ => panic!("Expected Complex variant"),
2482 }
2483 }
2484
2485 #[test]
2486 fn test_percentile_thresholds_builder() {
2487 let thresholds = PercentileThresholds::new()
2488 .p50(10.0)
2489 .p75(25.0)
2490 .p90(50.0)
2491 .p99(100.0);
2492
2493 assert_eq!(thresholds.p50, Some(10.0));
2494 assert_eq!(thresholds.p75, Some(25.0));
2495 assert_eq!(thresholds.p90, Some(50.0));
2496 assert_eq!(thresholds.p99, Some(100.0));
2497 }
2498
2499 #[test]
2500 fn test_percentile_thresholds_default() {
2501 let thresholds = PercentileThresholds::default();
2502 assert_eq!(thresholds.p50, None);
2503 assert_eq!(thresholds.p75, None);
2504 assert_eq!(thresholds.p90, None);
2505 assert_eq!(thresholds.p99, None);
2506 }
2507
2508 #[test]
2509 fn test_throughput_threshold_simple() {
2510 let threshold = ThroughputThreshold::Simple(50.0);
2511 let json = serde_json::to_value(&threshold).unwrap();
2512 assert_eq!(json, 50.0);
2513 }
2514
2515 #[test]
2516 fn test_throughput_threshold_percentile() {
2517 let threshold = ThroughputThreshold::Percentile(PercentileThresholds::new().p90(50.0));
2518 let json = serde_json::to_value(&threshold).unwrap();
2519 assert_eq!(json["p90"], 50.0);
2520 }
2521
2522 #[test]
2523 fn test_latency_threshold_simple() {
2524 let threshold = LatencyThreshold::Simple(0.5);
2525 let json = serde_json::to_value(&threshold).unwrap();
2526 assert_eq!(json, 0.5);
2527 }
2528
2529 #[test]
2530 fn test_latency_threshold_percentile() {
2531 let threshold = LatencyThreshold::Percentile(PercentileThresholds::new().p50(0.1).p99(1.0));
2532 let json = serde_json::to_value(&threshold).unwrap();
2533 assert_eq!(json["p50"], 0.1);
2534 assert_eq!(json["p99"], 1.0);
2535 }
2536
2537 #[test]
2538 fn test_max_price_builder() {
2539 let price = MaxPrice::new().prompt(0.001).completion(0.002);
2540
2541 assert_eq!(price.prompt, Some(0.001));
2542 assert_eq!(price.completion, Some(0.002));
2543 assert_eq!(price.request, None);
2544 assert_eq!(price.image, None);
2545 }
2546
2547 #[test]
2548 fn test_max_price_all_fields() {
2549 let price = MaxPrice::new()
2550 .prompt(0.001)
2551 .completion(0.002)
2552 .request(0.01)
2553 .image(0.05);
2554
2555 let json = serde_json::to_value(&price).unwrap();
2556 assert_eq!(json["prompt"], 0.001);
2557 assert_eq!(json["completion"], 0.002);
2558 assert_eq!(json["request"], 0.01);
2559 assert_eq!(json["image"], 0.05);
2560 }
2561
2562 #[test]
2563 fn test_max_price_default() {
2564 let price = MaxPrice::default();
2565 assert_eq!(price.prompt, None);
2566 assert_eq!(price.completion, None);
2567 assert_eq!(price.request, None);
2568 assert_eq!(price.image, None);
2569 }
2570
2571 #[test]
2572 fn test_provider_preferences_default() {
2573 let prefs = ProviderPreferences::default();
2574 assert!(prefs.order.is_none());
2575 assert!(prefs.only.is_none());
2576 assert!(prefs.ignore.is_none());
2577 assert!(prefs.allow_fallbacks.is_none());
2578 assert!(prefs.require_parameters.is_none());
2579 assert!(prefs.data_collection.is_none());
2580 assert!(prefs.zdr.is_none());
2581 assert!(prefs.sort.is_none());
2582 assert!(prefs.preferred_min_throughput.is_none());
2583 assert!(prefs.preferred_max_latency.is_none());
2584 assert!(prefs.max_price.is_none());
2585 assert!(prefs.quantizations.is_none());
2586 }
2587
2588 #[test]
2589 fn test_provider_preferences_order_with_fallbacks() {
2590 let prefs = ProviderPreferences::new()
2591 .order(["anthropic", "openai"])
2592 .allow_fallbacks(true);
2593
2594 let json = prefs.to_json();
2595 let provider = &json["provider"];
2596
2597 assert_eq!(provider["order"], json!(["anthropic", "openai"]));
2598 assert_eq!(provider["allow_fallbacks"], true);
2599 }
2600
2601 #[test]
2602 fn test_provider_preferences_only_allowlist() {
2603 let prefs = ProviderPreferences::new()
2604 .only(["azure", "together"])
2605 .allow_fallbacks(false);
2606
2607 let json = prefs.to_json();
2608 let provider = &json["provider"];
2609
2610 assert_eq!(provider["only"], json!(["azure", "together"]));
2611 assert_eq!(provider["allow_fallbacks"], false);
2612 }
2613
2614 #[test]
2615 fn test_provider_preferences_ignore() {
2616 let prefs = ProviderPreferences::new().ignore(["deepinfra"]);
2617
2618 let json = prefs.to_json();
2619 let provider = &json["provider"];
2620
2621 assert_eq!(provider["ignore"], json!(["deepinfra"]));
2622 }
2623
2624 #[test]
2625 fn test_provider_preferences_sort_latency() {
2626 let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Latency);
2627
2628 let json = prefs.to_json();
2629 let provider = &json["provider"];
2630
2631 assert_eq!(provider["sort"], "latency");
2632 }
2633
2634 #[test]
2635 fn test_provider_preferences_price_with_throughput() {
2636 let prefs = ProviderPreferences::new()
2637 .sort(ProviderSortStrategy::Price)
2638 .preferred_min_throughput(ThroughputThreshold::Percentile(
2639 PercentileThresholds::new().p90(50.0),
2640 ));
2641
2642 let json = prefs.to_json();
2643 let provider = &json["provider"];
2644
2645 assert_eq!(provider["sort"], "price");
2646 assert_eq!(provider["preferred_min_throughput"]["p90"], 50.0);
2647 }
2648
2649 #[test]
2650 fn test_provider_preferences_require_parameters() {
2651 let prefs = ProviderPreferences::new().require_parameters(true);
2652
2653 let json = prefs.to_json();
2654 let provider = &json["provider"];
2655
2656 assert_eq!(provider["require_parameters"], true);
2657 }
2658
2659 #[test]
2660 fn test_provider_preferences_data_policy_and_zdr() {
2661 let prefs = ProviderPreferences::new()
2662 .data_collection(DataCollection::Deny)
2663 .zdr(true);
2664
2665 let json = prefs.to_json();
2666 let provider = &json["provider"];
2667
2668 assert_eq!(provider["data_collection"], "deny");
2669 assert_eq!(provider["zdr"], true);
2670 }
2671
2672 #[test]
2673 fn test_provider_preferences_quantizations() {
2674 let prefs =
2675 ProviderPreferences::new().quantizations([Quantization::Int8, Quantization::Fp16]);
2676
2677 let json = prefs.to_json();
2678 let provider = &json["provider"];
2679
2680 assert_eq!(provider["quantizations"], json!(["int8", "fp16"]));
2681 }
2682
2683 #[test]
2684 fn test_provider_preferences_convenience_methods() {
2685 let prefs = ProviderPreferences::new().zero_data_retention().fastest();
2686
2687 assert_eq!(prefs.zdr, Some(true));
2688 assert_eq!(
2689 prefs.sort,
2690 Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
2691 );
2692
2693 let prefs2 = ProviderPreferences::new().cheapest();
2694 assert_eq!(
2695 prefs2.sort,
2696 Some(ProviderSort::Simple(ProviderSortStrategy::Price))
2697 );
2698
2699 let prefs3 = ProviderPreferences::new().lowest_latency();
2700 assert_eq!(
2701 prefs3.sort,
2702 Some(ProviderSort::Simple(ProviderSortStrategy::Latency))
2703 );
2704 }
2705
2706 #[test]
2707 fn test_provider_preferences_serialization_skips_none() {
2708 let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Price);
2709
2710 let json = serde_json::to_value(&prefs).unwrap();
2711
2712 assert_eq!(json["sort"], "price");
2713 assert!(json.get("order").is_none());
2714 assert!(json.get("only").is_none());
2715 assert!(json.get("ignore").is_none());
2716 assert!(json.get("zdr").is_none());
2717 }
2718
2719 #[test]
2720 fn test_provider_preferences_deserialization() {
2721 let json = json!({
2722 "order": ["anthropic", "openai"],
2723 "sort": "throughput",
2724 "data_collection": "deny",
2725 "zdr": true,
2726 "quantizations": ["int8", "fp16"]
2727 });
2728
2729 let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
2730
2731 assert_eq!(
2732 prefs.order,
2733 Some(vec!["anthropic".to_string(), "openai".to_string()])
2734 );
2735 assert_eq!(
2736 prefs.sort,
2737 Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
2738 );
2739 assert_eq!(prefs.data_collection, Some(DataCollection::Deny));
2740 assert_eq!(prefs.zdr, Some(true));
2741 assert_eq!(
2742 prefs.quantizations,
2743 Some(vec![Quantization::Int8, Quantization::Fp16])
2744 );
2745 }
2746
2747 #[test]
2748 fn test_provider_preferences_deserialization_complex_sort() {
2749 let json = json!({
2750 "sort": {
2751 "by": "latency",
2752 "partition": "model"
2753 }
2754 });
2755
2756 let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
2757
2758 match prefs.sort {
2759 Some(ProviderSort::Complex(config)) => {
2760 assert_eq!(config.by, ProviderSortStrategy::Latency);
2761 assert_eq!(config.partition, Some(SortPartition::Model));
2762 }
2763 _ => panic!("Expected Complex sort variant"),
2764 }
2765 }
2766
2767 #[test]
2768 fn test_provider_preferences_full_integration() {
2769 let prefs = ProviderPreferences::new()
2770 .order(["anthropic", "openai"])
2771 .only(["anthropic", "openai", "google"])
2772 .sort(ProviderSortStrategy::Throughput)
2773 .data_collection(DataCollection::Deny)
2774 .zdr(true)
2775 .quantizations([Quantization::Int8])
2776 .allow_fallbacks(false);
2777
2778 let json = prefs.to_json();
2779
2780 assert!(json.get("provider").is_some());
2781 let provider = &json["provider"];
2782 assert_eq!(provider["order"], json!(["anthropic", "openai"]));
2783 assert_eq!(provider["only"], json!(["anthropic", "openai", "google"]));
2784 assert_eq!(provider["sort"], "throughput");
2785 assert_eq!(provider["data_collection"], "deny");
2786 assert_eq!(provider["zdr"], true);
2787 assert_eq!(provider["quantizations"], json!(["int8"]));
2788 assert_eq!(provider["allow_fallbacks"], false);
2789 }
2790
2791 #[test]
2792 fn test_provider_preferences_max_price() {
2793 let prefs =
2794 ProviderPreferences::new().max_price(MaxPrice::new().prompt(0.001).completion(0.002));
2795
2796 let json = prefs.to_json();
2797 let provider = &json["provider"];
2798
2799 assert_eq!(provider["max_price"]["prompt"], 0.001);
2800 assert_eq!(provider["max_price"]["completion"], 0.002);
2801 }
2802
2803 #[test]
2804 fn test_provider_preferences_preferred_max_latency() {
2805 let prefs = ProviderPreferences::new().preferred_max_latency(LatencyThreshold::Simple(0.5));
2806
2807 let json = prefs.to_json();
2808 let provider = &json["provider"];
2809
2810 assert_eq!(provider["preferred_max_latency"], 0.5);
2811 }
2812
2813 #[test]
2814 fn test_provider_preferences_empty_arrays() {
2815 let prefs = ProviderPreferences::new()
2816 .order(Vec::<String>::new())
2817 .quantizations(Vec::<Quantization>::new());
2818
2819 let json = prefs.to_json();
2820 let provider = &json["provider"];
2821
2822 assert_eq!(provider["order"], json!([]));
2823 assert_eq!(provider["quantizations"], json!([]));
2824 }
2825
2826 #[test]
2831 fn test_user_content_text_serialization() {
2832 let content = UserContent::text("Hello, world!");
2833 let json = serde_json::to_value(&content).unwrap();
2834
2835 assert_eq!(json["type"], "text");
2836 assert_eq!(json["text"], "Hello, world!");
2837 }
2838
2839 #[test]
2840 fn test_user_content_image_url_serialization() {
2841 let content = UserContent::image_url("https://example.com/image.png");
2842 let json = serde_json::to_value(&content).unwrap();
2843
2844 assert_eq!(json["type"], "image_url");
2845 assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
2846 assert!(json["image_url"].get("detail").is_none());
2847 }
2848
2849 #[test]
2850 fn test_user_content_image_url_with_detail_serialization() {
2851 let content =
2852 UserContent::image_url_with_detail("https://example.com/image.png", ImageDetail::High);
2853 let json = serde_json::to_value(&content).unwrap();
2854
2855 assert_eq!(json["type"], "image_url");
2856 assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
2857 assert_eq!(json["image_url"]["detail"], "high");
2858 }
2859
2860 #[test]
2861 fn test_user_content_image_base64_serialization() {
2862 let content = UserContent::image_base64("SGVsbG8=", "image/png", Some(ImageDetail::Low));
2863 let json = serde_json::to_value(&content).unwrap();
2864
2865 assert_eq!(json["type"], "image_url");
2866 assert_eq!(json["image_url"]["url"], "data:image/png;base64,SGVsbG8=");
2867 assert_eq!(json["image_url"]["detail"], "low");
2868 }
2869
2870 #[test]
2871 fn test_user_content_file_url_serialization() {
2872 let content = UserContent::file_url(
2873 "https://example.com/doc.pdf",
2874 Some("document.pdf".to_string()),
2875 );
2876 let json = serde_json::to_value(&content).unwrap();
2877
2878 assert_eq!(json["type"], "file");
2879 assert_eq!(json["file"]["file_data"], "https://example.com/doc.pdf");
2880 assert_eq!(json["file"]["filename"], "document.pdf");
2881 }
2882
2883 #[test]
2884 fn test_user_content_file_base64_serialization() {
2885 let content = UserContent::file_base64(
2886 "JVBERi0xLjQ=",
2887 "application/pdf",
2888 Some("report.pdf".to_string()),
2889 );
2890 let json = serde_json::to_value(&content).unwrap();
2891
2892 assert_eq!(json["type"], "file");
2893 assert_eq!(
2894 json["file"]["file_data"],
2895 "data:application/pdf;base64,JVBERi0xLjQ="
2896 );
2897 assert_eq!(json["file"]["filename"], "report.pdf");
2898 }
2899
2900 #[test]
2901 fn test_user_content_text_deserialization() {
2902 let json = json!({
2903 "type": "text",
2904 "text": "Hello!"
2905 });
2906
2907 let content: UserContent = serde_json::from_value(json).unwrap();
2908 assert_eq!(
2909 content,
2910 UserContent::Text {
2911 text: "Hello!".to_string()
2912 }
2913 );
2914 }
2915
2916 #[test]
2917 fn test_user_content_image_url_deserialization() {
2918 let json = json!({
2919 "type": "image_url",
2920 "image_url": {
2921 "url": "https://example.com/img.jpg",
2922 "detail": "high"
2923 }
2924 });
2925
2926 let content: UserContent = serde_json::from_value(json).unwrap();
2927 match content {
2928 UserContent::ImageUrl { image_url } => {
2929 assert_eq!(image_url.url, "https://example.com/img.jpg");
2930 assert_eq!(image_url.detail, Some(ImageDetail::High));
2931 }
2932 _ => panic!("Expected ImageUrl variant"),
2933 }
2934 }
2935
2936 #[test]
2937 fn test_user_content_file_deserialization() {
2938 let json = json!({
2939 "type": "file",
2940 "file": {
2941 "filename": "doc.pdf",
2942 "file_data": "https://example.com/doc.pdf"
2943 }
2944 });
2945
2946 let content: UserContent = serde_json::from_value(json).unwrap();
2947 match content {
2948 UserContent::File { file } => {
2949 assert_eq!(file.filename, Some("doc.pdf".to_string()));
2950 assert_eq!(
2951 file.file_data,
2952 Some("https://example.com/doc.pdf".to_string())
2953 );
2954 }
2955 _ => panic!("Expected File variant"),
2956 }
2957 }
2958
2959 #[test]
2960 fn test_message_user_with_text_serialization() {
2961 let message = Message::User {
2962 content: OneOrMany::one(UserContent::text("Hello")),
2963 name: None,
2964 };
2965 let json = serde_json::to_value(&message).unwrap();
2966
2967 assert_eq!(json["role"], "user");
2969 assert_eq!(json["content"], "Hello");
2970 }
2971
2972 #[test]
2973 fn test_message_user_with_mixed_content_serialization() {
2974 let message = Message::User {
2975 content: OneOrMany::many(vec![
2976 UserContent::text("Check this image:"),
2977 UserContent::image_url("https://example.com/img.png"),
2978 ])
2979 .unwrap(),
2980 name: None,
2981 };
2982 let json = serde_json::to_value(&message).unwrap();
2983
2984 assert_eq!(json["role"], "user");
2985 let content = json["content"].as_array().unwrap();
2986 assert_eq!(content.len(), 2);
2987 assert_eq!(content[0]["type"], "text");
2988 assert_eq!(content[1]["type"], "image_url");
2989 }
2990
2991 #[test]
2992 fn test_message_user_with_file_serialization() {
2993 let message = Message::User {
2994 content: OneOrMany::many(vec![
2995 UserContent::text("Analyze this PDF:"),
2996 UserContent::file_url(
2997 "https://example.com/doc.pdf",
2998 Some("document.pdf".to_string()),
2999 ),
3000 ])
3001 .unwrap(),
3002 name: None,
3003 };
3004 let json = serde_json::to_value(&message).unwrap();
3005
3006 assert_eq!(json["role"], "user");
3007 let content = json["content"].as_array().unwrap();
3008 assert_eq!(content.len(), 2);
3009 assert_eq!(content[0]["type"], "text");
3010 assert_eq!(content[1]["type"], "file");
3011 assert_eq!(
3012 content[1]["file"]["file_data"],
3013 "https://example.com/doc.pdf"
3014 );
3015 }
3016
3017 #[test]
3018 fn test_user_content_from_rig_text() {
3019 let rig_content = message::UserContent::Text(message::Text::new("Hello".to_string()));
3020 let openrouter_content: UserContent = rig_content.try_into().unwrap();
3021
3022 assert_eq!(
3023 openrouter_content,
3024 UserContent::Text {
3025 text: "Hello".to_string()
3026 }
3027 );
3028 }
3029
3030 #[test]
3031 fn test_user_content_from_rig_image_url() {
3032 let rig_content = message::UserContent::Image(message::Image {
3033 data: DocumentSourceKind::Url("https://example.com/img.png".to_string()),
3034 media_type: Some(message::ImageMediaType::PNG),
3035 detail: Some(ImageDetail::High),
3036 additional_params: None,
3037 });
3038 let openrouter_content: UserContent = rig_content.try_into().unwrap();
3039
3040 match openrouter_content {
3041 UserContent::ImageUrl { image_url } => {
3042 assert_eq!(image_url.url, "https://example.com/img.png");
3043 assert_eq!(image_url.detail, Some(ImageDetail::High));
3044 }
3045 _ => panic!("Expected ImageUrl variant"),
3046 }
3047 }
3048
3049 #[test]
3050 fn test_user_content_from_rig_image_base64() {
3051 let rig_content = message::UserContent::Image(message::Image {
3052 data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3053 media_type: Some(message::ImageMediaType::JPEG),
3054 detail: Some(ImageDetail::Low),
3055 additional_params: None,
3056 });
3057 let openrouter_content: UserContent = rig_content.try_into().unwrap();
3058
3059 match openrouter_content {
3060 UserContent::ImageUrl { image_url } => {
3061 assert_eq!(image_url.url, "data:image/jpeg;base64,SGVsbG8=");
3062 assert_eq!(image_url.detail, Some(ImageDetail::Low));
3063 }
3064 _ => panic!("Expected ImageUrl variant"),
3065 }
3066 }
3067
3068 #[test]
3069 fn test_user_content_from_rig_document_url() {
3070 let rig_content = message::UserContent::Document(message::Document {
3071 data: DocumentSourceKind::Url("https://example.com/doc.pdf".to_string()),
3072 media_type: Some(DocumentMediaType::PDF),
3073 additional_params: None,
3074 });
3075 let openrouter_content: UserContent = rig_content.try_into().unwrap();
3076
3077 match openrouter_content {
3078 UserContent::File { file } => {
3079 assert_eq!(
3080 file.file_data,
3081 Some("https://example.com/doc.pdf".to_string())
3082 );
3083 assert_eq!(file.filename, Some("document.pdf".to_string()));
3084 }
3085 _ => panic!("Expected File variant"),
3086 }
3087 }
3088
3089 #[test]
3090 fn test_user_content_from_rig_document_base64() {
3091 let rig_content = message::UserContent::Document(message::Document {
3092 data: DocumentSourceKind::Base64("JVBERi0xLjQ=".to_string()),
3093 media_type: Some(DocumentMediaType::PDF),
3094 additional_params: None,
3095 });
3096 let openrouter_content: UserContent = rig_content.try_into().unwrap();
3097
3098 match openrouter_content {
3099 UserContent::File { file } => {
3100 assert_eq!(
3101 file.file_data,
3102 Some("data:application/pdf;base64,JVBERi0xLjQ=".to_string())
3103 );
3104 assert_eq!(file.filename, Some("document.pdf".to_string()));
3105 }
3106 _ => panic!("Expected File variant"),
3107 }
3108 }
3109
3110 #[test]
3111 fn test_user_content_from_rig_document_file_id() {
3112 let rig_content = message::UserContent::Document(message::Document {
3113 data: DocumentSourceKind::FileId("file_abc".to_string()),
3114 media_type: None,
3115 additional_params: None,
3116 });
3117
3118 let result: Result<UserContent, _> = rig_content.try_into();
3119 assert!(matches!(
3120 result,
3121 Err(message::MessageError::ConversionError(message))
3122 if message.contains("Provider file IDs are not supported")
3123 ));
3124 }
3125
3126 #[test]
3127 fn test_openai_file_id_content_round_trips_through_rig_to_openrouter_error() {
3128 let openai_content = openai::UserContent::File {
3129 file: openai::FileData {
3130 file_data: None,
3131 file_id: Some("file_abc".to_string()),
3132 filename: None,
3133 },
3134 };
3135 let rig_content: message::UserContent = openai_content.into();
3136
3137 let result: Result<UserContent, _> = rig_content.try_into();
3138 assert!(matches!(
3139 result,
3140 Err(message::MessageError::ConversionError(message))
3141 if message.contains("Provider file IDs are not supported")
3142 ));
3143 }
3144
3145 #[test]
3146 fn test_user_content_from_rig_document_string_becomes_text() {
3147 let rig_content = message::UserContent::Document(message::Document {
3148 data: DocumentSourceKind::String("Plain text document content".to_string()),
3149 media_type: Some(DocumentMediaType::TXT),
3150 additional_params: None,
3151 });
3152 let openrouter_content: UserContent = rig_content.try_into().unwrap();
3153
3154 assert_eq!(
3155 openrouter_content,
3156 UserContent::Text {
3157 text: "Plain text document content".to_string()
3158 }
3159 );
3160 }
3161
3162 #[test]
3163 fn test_completion_response_with_reasoning_details_maps_to_typed_reasoning() {
3164 let json = json!({
3165 "id": "resp_123",
3166 "object": "chat.completion",
3167 "created": 1,
3168 "model": "openrouter/test-model",
3169 "choices": [{
3170 "index": 0,
3171 "finish_reason": "stop",
3172 "message": {
3173 "role": "assistant",
3174 "content": "hello",
3175 "reasoning": null,
3176 "reasoning_details": [
3177 {"type":"reasoning.summary","id":"rs_1","summary":"s1"},
3178 {"type":"reasoning.text","id":"rs_1","text":"t1","signature":"sig_1"},
3179 {"type":"reasoning.encrypted","id":"rs_1","data":"enc_1"}
3180 ]
3181 }
3182 }]
3183 });
3184
3185 let response: CompletionResponse = serde_json::from_value(json).unwrap();
3186 let converted: completion::CompletionResponse<CompletionResponse> =
3187 response.try_into().unwrap();
3188 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3189
3190 assert!(items.iter().any(|item| matches!(
3191 item,
3192 completion::AssistantContent::Reasoning(message::Reasoning { id: Some(id), content })
3193 if id == "rs_1" && content.len() == 3
3194 )));
3195 }
3196
3197 #[test]
3198 fn test_assistant_reasoning_emits_openrouter_reasoning_details() {
3199 let reasoning = message::Reasoning {
3200 id: Some("rs_2".to_string()),
3201 content: vec![
3202 message::ReasoningContent::Text {
3203 text: "step".to_string(),
3204 signature: Some("sig_step".to_string()),
3205 },
3206 message::ReasoningContent::Summary("summary".to_string()),
3207 message::ReasoningContent::Encrypted("enc_blob".to_string()),
3208 ],
3209 };
3210
3211 let messages = Vec::<Message>::try_from(OneOrMany::one(
3212 message::AssistantContent::Reasoning(reasoning),
3213 ))
3214 .unwrap();
3215 let Message::Assistant {
3216 reasoning,
3217 reasoning_details,
3218 ..
3219 } = messages.first().expect("assistant message")
3220 else {
3221 panic!("Expected assistant message");
3222 };
3223
3224 assert!(reasoning.is_none());
3225 assert_eq!(reasoning_details.len(), 3);
3226 assert!(matches!(
3227 reasoning_details.first(),
3228 Some(ReasoningDetails::Text {
3229 id: Some(id),
3230 text: Some(text),
3231 signature: Some(signature),
3232 ..
3233 }) if id == "rs_2" && text == "step" && signature == "sig_step"
3234 ));
3235 }
3236
3237 #[test]
3238 fn test_assistant_redacted_reasoning_emits_encrypted_detail_not_text() {
3239 let reasoning = message::Reasoning {
3240 id: Some("rs_redacted".to_string()),
3241 content: vec![message::ReasoningContent::Redacted {
3242 data: "opaque-redacted-data".to_string(),
3243 }],
3244 };
3245
3246 let messages = Vec::<Message>::try_from(OneOrMany::one(
3247 message::AssistantContent::Reasoning(reasoning),
3248 ))
3249 .unwrap();
3250
3251 let Message::Assistant {
3252 reasoning_details,
3253 reasoning,
3254 ..
3255 } = messages.first().expect("assistant message")
3256 else {
3257 panic!("Expected assistant message");
3258 };
3259
3260 assert!(reasoning.is_none());
3261 assert_eq!(reasoning_details.len(), 1);
3262 assert!(matches!(
3263 reasoning_details.first(),
3264 Some(ReasoningDetails::Encrypted {
3265 id: Some(id),
3266 data,
3267 ..
3268 }) if id == "rs_redacted" && data == "opaque-redacted-data"
3269 ));
3270 }
3271
3272 #[test]
3273 fn test_completion_response_reasoning_details_respects_index_ordering() {
3274 let json = json!({
3275 "id": "resp_ordering",
3276 "object": "chat.completion",
3277 "created": 1,
3278 "model": "openrouter/test-model",
3279 "choices": [{
3280 "index": 0,
3281 "finish_reason": "stop",
3282 "message": {
3283 "role": "assistant",
3284 "content": "hello",
3285 "reasoning": null,
3286 "reasoning_details": [
3287 {"type":"reasoning.summary","id":"rs_order","index":1,"summary":"second"},
3288 {"type":"reasoning.summary","id":"rs_order","index":0,"summary":"first"}
3289 ]
3290 }
3291 }]
3292 });
3293
3294 let response: CompletionResponse = serde_json::from_value(json).unwrap();
3295 let converted: completion::CompletionResponse<CompletionResponse> =
3296 response.try_into().unwrap();
3297 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3298 let reasoning_blocks: Vec<_> = items
3299 .into_iter()
3300 .filter_map(|item| match item {
3301 completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
3302 _ => None,
3303 })
3304 .collect();
3305
3306 assert_eq!(reasoning_blocks.len(), 1);
3307 assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_order"));
3308 assert_eq!(
3309 reasoning_blocks[0].content,
3310 vec![
3311 message::ReasoningContent::Summary("first".to_string()),
3312 message::ReasoningContent::Summary("second".to_string()),
3313 ]
3314 );
3315 }
3316
3317 #[test]
3318 fn test_user_content_from_rig_image_missing_media_type_error() {
3319 let rig_content = message::UserContent::Image(message::Image {
3320 data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3321 media_type: None, detail: None,
3323 additional_params: None,
3324 });
3325 let result: Result<UserContent, _> = rig_content.try_into();
3326
3327 assert!(result.is_err());
3328 let err = result.unwrap_err();
3329 assert!(err.to_string().contains("media type required"));
3330 }
3331
3332 #[test]
3333 fn test_user_content_from_rig_image_raw_bytes_error() {
3334 let rig_content = message::UserContent::Image(message::Image {
3335 data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3336 media_type: Some(message::ImageMediaType::PNG),
3337 detail: None,
3338 additional_params: None,
3339 });
3340 let result: Result<UserContent, _> = rig_content.try_into();
3341
3342 assert!(result.is_err());
3343 let err = result.unwrap_err();
3344 assert!(err.to_string().contains("base64"));
3345 }
3346
3347 #[test]
3348 fn test_user_content_from_rig_video_url() {
3349 let rig_content = message::UserContent::Video(message::Video {
3350 data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
3351 media_type: Some(message::VideoMediaType::MP4),
3352 additional_params: None,
3353 });
3354 let openrouter_content: UserContent = rig_content.try_into().unwrap();
3355
3356 match openrouter_content {
3357 UserContent::VideoUrl { video_url } => {
3358 assert_eq!(video_url.url, "https://example.com/video.mp4");
3359 }
3360 _ => panic!("Expected VideoUrl variant"),
3361 }
3362 }
3363
3364 #[test]
3365 fn test_user_content_from_rig_video_base64() {
3366 let rig_content = message::UserContent::Video(message::Video {
3367 data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3368 media_type: Some(message::VideoMediaType::MP4),
3369 additional_params: None,
3370 });
3371 let openrouter_content: UserContent = rig_content.try_into().unwrap();
3372
3373 match openrouter_content {
3374 UserContent::VideoUrl { video_url } => {
3375 assert_eq!(video_url.url, "data:video/mp4;base64,SGVsbG8=");
3376 }
3377 _ => panic!("Expected VideoUrl variant"),
3378 }
3379 }
3380
3381 #[test]
3382 fn test_user_content_from_rig_video_base64_missing_media_type_error() {
3383 let rig_content = message::UserContent::Video(message::Video {
3384 data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3385 media_type: None,
3386 additional_params: None,
3387 });
3388 let result: Result<UserContent, _> = rig_content.try_into();
3389
3390 assert!(result.is_err());
3391 let err = result.unwrap_err();
3392 assert!(err.to_string().contains("media type"));
3393 }
3394
3395 #[test]
3396 fn test_user_content_from_rig_video_raw_bytes_error() {
3397 let rig_content = message::UserContent::Video(message::Video {
3398 data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3399 media_type: Some(message::VideoMediaType::MP4),
3400 additional_params: None,
3401 });
3402 let result: Result<UserContent, _> = rig_content.try_into();
3403
3404 assert!(result.is_err());
3405 let err = result.unwrap_err();
3406 assert!(err.to_string().contains("base64"));
3407 }
3408
3409 #[test]
3410 fn test_user_content_from_rig_audio_base64() {
3411 let rig_content = message::UserContent::Audio(message::Audio {
3412 data: DocumentSourceKind::Base64("audiodata".to_string()),
3413 media_type: Some(message::AudioMediaType::MP3),
3414 additional_params: None,
3415 });
3416 let openrouter_content: UserContent = rig_content.try_into().unwrap();
3417
3418 match openrouter_content {
3419 UserContent::InputAudio { input_audio } => {
3420 assert_eq!(input_audio.data, "audiodata");
3421 assert_eq!(input_audio.format, message::AudioMediaType::MP3);
3422 }
3423 _ => panic!("Expected InputAudio variant"),
3424 }
3425 }
3426
3427 #[test]
3428 fn test_user_content_from_rig_audio_missing_media_type_error() {
3429 let rig_content = message::UserContent::Audio(message::Audio {
3430 data: DocumentSourceKind::Base64("audiodata".to_string()),
3431 media_type: None, additional_params: None,
3433 });
3434 let result: Result<UserContent, _> = rig_content.try_into();
3435
3436 assert!(result.is_err());
3437 let err = result.unwrap_err();
3438 assert!(err.to_string().contains("media type required"));
3439 }
3440
3441 #[test]
3442 fn test_user_content_from_rig_audio_url_error() {
3443 let rig_content = message::UserContent::Audio(message::Audio {
3444 data: DocumentSourceKind::Url("https://example.com/audio.wav".to_string()),
3445 media_type: Some(message::AudioMediaType::WAV),
3446 additional_params: None,
3447 });
3448 let result: Result<UserContent, _> = rig_content.try_into();
3449
3450 assert!(result.is_err());
3451 let err = result.unwrap_err();
3452 assert!(err.to_string().contains("base64"));
3453 }
3454
3455 #[test]
3456 fn test_user_content_from_rig_audio_raw_bytes_error() {
3457 let rig_content = message::UserContent::Audio(message::Audio {
3458 data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3459 media_type: Some(message::AudioMediaType::WAV),
3460 additional_params: None,
3461 });
3462 let result: Result<UserContent, _> = rig_content.try_into();
3463
3464 assert!(result.is_err());
3465 let err = result.unwrap_err();
3466 assert!(err.to_string().contains("base64"));
3467 }
3468
3469 #[test]
3470 fn test_message_conversion_with_pdf() {
3471 let rig_message = message::Message::User {
3472 content: OneOrMany::many(vec![
3473 message::UserContent::Text(message::Text::new(
3474 "Summarize this document".to_string(),
3475 )),
3476 message::UserContent::Document(message::Document {
3477 data: DocumentSourceKind::Url("https://example.com/paper.pdf".to_string()),
3478 media_type: Some(DocumentMediaType::PDF),
3479 additional_params: None,
3480 }),
3481 ])
3482 .unwrap(),
3483 };
3484
3485 let openrouter_messages: Vec<Message> = rig_message.try_into().unwrap();
3486 assert_eq!(openrouter_messages.len(), 1);
3487
3488 match &openrouter_messages[0] {
3489 Message::User { content, .. } => {
3490 assert_eq!(content.len(), 2);
3491
3492 match content.first_ref() {
3494 UserContent::Text { text, .. } => assert_eq!(text, "Summarize this document"),
3495 _ => panic!("Expected Text"),
3496 }
3497 }
3498 _ => panic!("Expected User message"),
3499 }
3500 }
3501
3502 #[test]
3503 fn test_user_content_from_string() {
3504 let content: UserContent = "Hello".into();
3505 assert_eq!(
3506 content,
3507 UserContent::Text {
3508 text: "Hello".to_string()
3509 }
3510 );
3511
3512 let content: UserContent = String::from("World").into();
3513 assert_eq!(
3514 content,
3515 UserContent::Text {
3516 text: "World".to_string()
3517 }
3518 );
3519 }
3520
3521 #[test]
3522 fn test_openai_user_content_conversion() {
3523 let openai_text = openai::UserContent::Text {
3525 text: "Hello".to_string(),
3526 };
3527 let converted: UserContent = openai_text.try_into().unwrap();
3528 assert_eq!(
3529 converted,
3530 UserContent::Text {
3531 text: "Hello".to_string()
3532 }
3533 );
3534
3535 let openai_image = openai::UserContent::Image {
3536 image_url: openai::ImageUrl {
3537 url: "https://example.com/img.png".to_string(),
3538 detail: ImageDetail::Auto,
3539 },
3540 };
3541 let converted: UserContent = openai_image.try_into().unwrap();
3542 match converted {
3543 UserContent::ImageUrl { image_url } => {
3544 assert_eq!(image_url.url, "https://example.com/img.png");
3545 assert_eq!(image_url.detail, Some(ImageDetail::Auto));
3546 }
3547 _ => panic!("Expected ImageUrl"),
3548 }
3549
3550 let openai_audio = openai::UserContent::Audio {
3551 input_audio: openai::InputAudio {
3552 data: "audiodata".to_string(),
3553 format: AudioMediaType::FLAC,
3554 },
3555 };
3556 let converted: UserContent = openai_audio.try_into().unwrap();
3557 match converted {
3558 UserContent::InputAudio { input_audio } => {
3559 assert_eq!(input_audio.data, "audiodata");
3560 assert_eq!(input_audio.format, AudioMediaType::FLAC);
3561 }
3562 _ => panic!("Expected InputAudio"),
3563 }
3564
3565 let openai_file = openai::UserContent::File {
3566 file: openai::FileData {
3567 file_data: Some("data:application/pdf;base64,AAAA".to_string()),
3568 file_id: None,
3569 filename: Some("uploaded.pdf".to_string()),
3570 },
3571 };
3572 let converted: UserContent = openai_file.try_into().unwrap();
3573 match converted {
3574 UserContent::File { file } => {
3575 assert_eq!(file.filename, Some("uploaded.pdf".to_string()));
3576 assert_eq!(
3577 file.file_data,
3578 Some("data:application/pdf;base64,AAAA".to_string())
3579 );
3580 }
3581 _ => panic!("Expected File"),
3582 }
3583
3584 let openai_file_id = openai::UserContent::File {
3585 file: openai::FileData {
3586 file_data: None,
3587 file_id: Some("file_abc".to_string()),
3588 filename: Some("uploaded.pdf".to_string()),
3589 },
3590 };
3591 let result: Result<UserContent, _> = openai_file_id.try_into();
3592 assert!(matches!(
3593 result,
3594 Err(message::MessageError::ConversionError(message))
3595 if message.contains("provider file IDs are not supported")
3596 ));
3597 }
3598
3599 #[test]
3600 fn test_completion_response_reasoning_details_with_multiple_ids_stay_separate() {
3601 let json = json!({
3602 "id": "resp_multi_id",
3603 "object": "chat.completion",
3604 "created": 1,
3605 "model": "openrouter/test-model",
3606 "choices": [{
3607 "index": 0,
3608 "finish_reason": "stop",
3609 "message": {
3610 "role": "assistant",
3611 "content": "hello",
3612 "reasoning": null,
3613 "reasoning_details": [
3614 {"type":"reasoning.summary","id":"rs_a","summary":"a1"},
3615 {"type":"reasoning.summary","id":"rs_b","summary":"b1"},
3616 {"type":"reasoning.summary","id":"rs_a","summary":"a2"}
3617 ]
3618 }
3619 }]
3620 });
3621
3622 let response: CompletionResponse = serde_json::from_value(json).unwrap();
3623 let converted: completion::CompletionResponse<CompletionResponse> =
3624 response.try_into().unwrap();
3625 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3626 let reasoning_blocks: Vec<_> = items
3627 .into_iter()
3628 .filter_map(|item| match item {
3629 completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
3630 _ => None,
3631 })
3632 .collect();
3633
3634 assert_eq!(reasoning_blocks.len(), 2);
3635 assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_a"));
3636 assert_eq!(
3637 reasoning_blocks[0].content,
3638 vec![
3639 message::ReasoningContent::Summary("a1".to_string()),
3640 message::ReasoningContent::Summary("a2".to_string()),
3641 ]
3642 );
3643 assert_eq!(reasoning_blocks[1].id.as_deref(), Some("rs_b"));
3644 assert_eq!(
3645 reasoning_blocks[1].content,
3646 vec![message::ReasoningContent::Summary("b1".to_string())]
3647 );
3648 }
3649
3650 #[test]
3651 fn test_user_content_audio_serialization() {
3652 let content = UserContent::audio_base64("SGVsbG8=", AudioMediaType::WAV);
3653 let json = serde_json::to_value(&content).unwrap();
3654
3655 assert_eq!(json["type"], "input_audio");
3656 assert_eq!(json["input_audio"]["data"], "SGVsbG8=");
3657 assert_eq!(json["input_audio"]["format"], "wav");
3658 }
3659
3660 #[test]
3661 fn test_user_content_audio_deserialization() {
3662 let json = json!({
3663 "type": "input_audio",
3664 "input_audio": {
3665 "data": "SGVsbG8=",
3666 "format": "wav"
3667 }
3668 });
3669
3670 let content: UserContent = serde_json::from_value(json).unwrap();
3671 match content {
3672 UserContent::InputAudio { input_audio } => {
3673 assert_eq!(input_audio.data, "SGVsbG8=");
3674 assert_eq!(input_audio.format, AudioMediaType::WAV);
3675 }
3676 _ => panic!("Expected InputAudio variant"),
3677 }
3678 }
3679
3680 #[test]
3681 fn test_message_user_with_audio_serialization() {
3682 let msg = Message::User {
3683 content: OneOrMany::many(vec![
3684 UserContent::text("Transcribe this audio:"),
3685 UserContent::audio_base64("SGVsbG8=", AudioMediaType::MP3),
3686 ])
3687 .unwrap(),
3688 name: None,
3689 };
3690 let json = serde_json::to_value(&msg).unwrap();
3691
3692 assert_eq!(json["role"], "user");
3693 let content = json["content"].as_array().unwrap();
3694 assert_eq!(content.len(), 2);
3695 assert_eq!(content[0]["type"], "text");
3696 assert_eq!(content[1]["type"], "input_audio");
3697 assert_eq!(content[1]["input_audio"]["data"], "SGVsbG8=");
3698 assert_eq!(content[1]["input_audio"]["format"], "mp3");
3699 }
3700
3701 #[test]
3702 fn test_user_content_video_url_serialization() {
3703 let content = UserContent::video_url("https://example.com/video.mp4");
3704 let json = serde_json::to_value(&content).unwrap();
3705
3706 assert_eq!(json["type"], "video_url");
3707 assert_eq!(json["video_url"]["url"], "https://example.com/video.mp4");
3708 }
3709
3710 #[test]
3711 fn test_user_content_video_base64_serialization() {
3712 let content = UserContent::video_base64("SGVsbG8=", VideoMediaType::MP4);
3713 let json = serde_json::to_value(&content).unwrap();
3714
3715 assert_eq!(json["type"], "video_url");
3716 assert_eq!(json["video_url"]["url"], "data:video/mp4;base64,SGVsbG8=");
3717 }
3718
3719 #[test]
3720 fn test_user_content_video_url_deserialization() {
3721 let json = json!({
3722 "type": "video_url",
3723 "video_url": {
3724 "url": "https://example.com/video.mp4"
3725 }
3726 });
3727
3728 let content: UserContent = serde_json::from_value(json).unwrap();
3729 match content {
3730 UserContent::VideoUrl { video_url } => {
3731 assert_eq!(video_url.url, "https://example.com/video.mp4");
3732 }
3733 _ => panic!("Expected VideoUrl variant"),
3734 }
3735 }
3736
3737 #[test]
3738 fn test_message_user_with_video_serialization() {
3739 let msg = Message::User {
3740 content: OneOrMany::many(vec![
3741 UserContent::text("Describe this video:"),
3742 UserContent::video_url("https://example.com/video.mp4"),
3743 ])
3744 .unwrap(),
3745 name: None,
3746 };
3747 let json = serde_json::to_value(&msg).unwrap();
3748
3749 assert_eq!(json["role"], "user");
3750 let content = json["content"].as_array().unwrap();
3751 assert_eq!(content.len(), 2);
3752 assert_eq!(content[0]["type"], "text");
3753 assert_eq!(content[1]["type"], "video_url");
3754 assert_eq!(
3755 content[1]["video_url"]["url"],
3756 "https://example.com/video.mp4"
3757 );
3758 }
3759
3760 #[test]
3761 fn test_user_content_video_url_no_media_type_needed() {
3762 let rig_content = message::UserContent::Video(message::Video {
3763 data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
3764 media_type: None,
3765 additional_params: None,
3766 });
3767 let openrouter_content: UserContent = rig_content.try_into().unwrap();
3768
3769 match openrouter_content {
3770 UserContent::VideoUrl { video_url } => {
3771 assert_eq!(video_url.url, "https://example.com/video.mp4");
3772 }
3773 _ => panic!("Expected VideoUrl variant"),
3774 }
3775 }
3776
3777 fn prompt_caching_completion_request() -> CompletionRequest {
3778 CompletionRequest {
3779 model: None,
3780 preamble: Some("You are a helpful assistant.".to_string()),
3781 chat_history: crate::OneOrMany::one(crate::message::Message::user("Hello")),
3782 documents: vec![],
3783 tools: vec![],
3784 temperature: None,
3785 max_tokens: None,
3786 tool_choice: None,
3787 additional_params: None,
3788 output_schema: None,
3789 }
3790 }
3791
3792 #[test]
3793 fn test_final_request_body_applies_prompt_caching_to_converted_completion_request() {
3794 let request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
3795 model: "anthropic/claude-3.5-sonnet",
3796 request: prompt_caching_completion_request(),
3797 strict_tools: false,
3798 })
3799 .expect("request conversion should succeed");
3800
3801 let body = final_request_body(&request, true).expect("request body should serialize");
3802 let system_block = &body["messages"][0]["content"][0];
3803
3804 assert_eq!(system_block["type"], "text");
3805 assert_eq!(system_block["text"], "You are a helpful assistant.");
3806 assert_eq!(system_block["cache_control"]["type"], "ephemeral");
3807
3808 let body = final_request_body(&request, false).expect("request body should serialize");
3809 assert!(
3810 body["messages"][0]["content"][0]
3811 .get("cache_control")
3812 .is_none(),
3813 "prompt caching should be opt-in"
3814 );
3815 }
3816
3817 #[test]
3818 fn test_final_request_body_preserves_stream_flag_when_prompt_caching_enabled() {
3819 let mut request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
3820 model: "anthropic/claude-3.5-sonnet",
3821 request: prompt_caching_completion_request(),
3822 strict_tools: false,
3823 })
3824 .expect("request conversion should succeed");
3825 request.additional_params = Some(json!({ "stream": true }));
3826
3827 let body = final_request_body(&request, true).expect("request body should serialize");
3828
3829 assert_eq!(body["stream"], true);
3830 assert_eq!(
3831 body["messages"][0]["content"][0]["cache_control"]["type"],
3832 "ephemeral"
3833 );
3834 }
3835
3836 #[test]
3837 fn test_apply_prompt_caching_string_system_message() {
3838 let mut body = json!({
3839 "model": "anthropic/claude-3.5-sonnet",
3840 "messages": [
3841 {"role": "system", "content": "You are a helpful assistant."},
3842 {"role": "user", "content": "Hello"}
3843 ]
3844 });
3845
3846 apply_prompt_caching(&mut body);
3847
3848 let system_content = &body["messages"][0]["content"];
3849 assert!(
3850 system_content.is_array(),
3851 "system content should be an array after caching"
3852 );
3853 let block = &system_content[0];
3854 assert_eq!(block["type"], "text");
3855 assert_eq!(block["text"], "You are a helpful assistant.");
3856 assert_eq!(block["cache_control"]["type"], "ephemeral");
3857
3858 assert_eq!(body["messages"][1]["content"], "Hello");
3860 }
3861
3862 #[test]
3863 fn test_apply_prompt_caching_array_system_message_marks_last_block() {
3864 let mut body = json!({
3865 "model": "anthropic/claude-3.5-sonnet",
3866 "messages": [
3867 {
3868 "role": "system",
3869 "content": [
3870 {"type": "text", "text": "Part 1. "},
3871 {"type": "text", "text": "Part 2."}
3872 ]
3873 }
3874 ]
3875 });
3876
3877 apply_prompt_caching(&mut body);
3878
3879 let system_content = &body["messages"][0]["content"];
3880 assert!(system_content.is_array());
3881 assert_eq!(system_content.as_array().unwrap().len(), 2);
3883 assert_eq!(system_content[0]["text"], "Part 1. ");
3884 assert!(system_content[0].get("cache_control").is_none());
3885 assert_eq!(system_content[1]["text"], "Part 2.");
3886 assert_eq!(system_content[1]["cache_control"]["type"], "ephemeral");
3887 }
3888
3889 #[test]
3890 fn test_apply_prompt_caching_preserves_non_text_blocks() {
3891 let mut body = json!({
3892 "model": "anthropic/claude-3.5-sonnet",
3893 "messages": [
3894 {
3895 "role": "system",
3896 "content": [
3897 {"type": "image", "source": {"type": "url", "url": "https://example.com/img.png"}},
3898 {"type": "text", "text": "Describe the image."}
3899 ]
3900 }
3901 ]
3902 });
3903
3904 apply_prompt_caching(&mut body);
3905
3906 let system_content = &body["messages"][0]["content"];
3907 assert_eq!(system_content.as_array().unwrap().len(), 2);
3908 assert_eq!(system_content[0]["type"], "image");
3910 assert!(system_content[0].get("cache_control").is_none());
3911 assert_eq!(system_content[1]["type"], "text");
3913 assert_eq!(system_content[1]["cache_control"]["type"], "ephemeral");
3914 }
3915
3916 #[test]
3917 fn test_apply_prompt_caching_no_system_message_is_noop() {
3918 let mut body = json!({
3919 "model": "openai/gpt-4o",
3920 "messages": [
3921 {"role": "user", "content": "Hello"}
3922 ]
3923 });
3924
3925 let body_before = body.clone();
3926 apply_prompt_caching(&mut body);
3927 assert_eq!(
3928 body, body_before,
3929 "body should be unchanged when no system message exists"
3930 );
3931 }
3932
3933 #[test]
3934 fn test_completion_response_extracts_generated_images() {
3935 let json = json!({
3936 "id": "resp_img",
3937 "object": "chat.completion",
3938 "created": 1,
3939 "model": "google/gemini-flash-image-preview",
3940 "choices": [{
3941 "index": 0,
3942 "finish_reason": "stop",
3943 "message": {
3944 "role": "assistant",
3945 "content": "Here is your image.",
3946 "images": [
3947 {"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}
3948 ]
3949 }
3950 }]
3951 });
3952
3953 let response: CompletionResponse = serde_json::from_value(json).unwrap();
3954 let converted: completion::CompletionResponse<CompletionResponse> =
3955 response.try_into().unwrap();
3956 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3957 assert_eq!(items.len(), 2);
3958
3959 assert!(items.iter().any(|item| matches!(
3960 item,
3961 completion::AssistantContent::Text(t) if t.text == "Here is your image."
3962 )));
3963 assert!(items.iter().any(|item| matches!(
3964 item,
3965 completion::AssistantContent::Image(message::Image {
3966 data: message::DocumentSourceKind::Base64(b64),
3967 media_type: Some(message::ImageMediaType::PNG),
3968 additional_params: Some(_),
3969 ..
3970 }) if b64 == "iVBORw0KGgo="
3971 )));
3972 assert!(
3973 items.iter().any(|item| matches!(
3974 item,
3975 completion::AssistantContent::Image(image)
3976 if is_openrouter_response_image(image)
3977 )),
3978 "generated images should be marked as OpenRouter response-only artifacts"
3979 );
3980 }
3981
3982 #[test]
3983 fn test_completion_response_extracts_generated_images_url() {
3984 let json = json!({
3985 "id": "resp_img_url",
3986 "object": "chat.completion",
3987 "created": 1,
3988 "model": "google/gemini-flash-image-preview",
3989 "choices": [{
3990 "index": 0,
3991 "finish_reason": "stop",
3992 "message": {
3993 "role": "assistant",
3994 "content": "Here is your image.",
3995 "images": [
3996 {"type":"image_url","image_url":{"url":"https://example.com/generated.png"}}
3997 ]
3998 }
3999 }]
4000 });
4001
4002 let response: CompletionResponse = serde_json::from_value(json).unwrap();
4003 let converted: completion::CompletionResponse<CompletionResponse> =
4004 response.try_into().unwrap();
4005 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
4006 assert_eq!(items.len(), 2);
4007
4008 assert!(items.iter().any(|item| matches!(
4009 item,
4010 completion::AssistantContent::Image(message::Image {
4011 data: message::DocumentSourceKind::Url(url),
4012 media_type: None,
4013 additional_params: Some(_),
4014 ..
4015 }) if url == "https://example.com/generated.png"
4016 )));
4017 assert!(
4018 items.iter().any(|item| matches!(
4019 item,
4020 completion::AssistantContent::Image(image)
4021 if is_openrouter_response_image(image)
4022 )),
4023 "generated URL images should be marked as OpenRouter response-only artifacts"
4024 );
4025 }
4026
4027 #[test]
4028 fn test_generated_images_do_not_break_assistant_history_conversion() {
4029 let generated_image = response_image_to_assistant_content(&ResponseImage {
4030 image_url: ImageUrl {
4031 url: "data:image/png;base64,abc".to_string(),
4032 detail: None,
4033 },
4034 });
4035
4036 let content = OneOrMany::many(vec![
4037 completion::AssistantContent::text("Here is your image."),
4038 generated_image,
4039 ])
4040 .unwrap();
4041 let messages = Vec::<Message>::try_from(content).unwrap();
4042
4043 assert_eq!(messages.len(), 1);
4044 assert!(matches!(
4045 &messages[0],
4046 Message::Assistant { content, .. }
4047 if content == &vec![openai::AssistantContent::Text {
4048 text: "Here is your image.".to_string()
4049 }]
4050 ));
4051 }
4052
4053 #[test]
4054 fn test_image_only_assistant_history_is_omitted_for_openrouter() {
4055 let generated_image = response_image_to_assistant_content(&ResponseImage {
4056 image_url: ImageUrl {
4057 url: "data:image/png;base64,abc".to_string(),
4058 detail: None,
4059 },
4060 });
4061
4062 let messages = Vec::<Message>::try_from(OneOrMany::one(generated_image)).unwrap();
4063
4064 assert!(
4065 messages.is_empty(),
4066 "response-only generated image turns should not be replayed as assistant content"
4067 );
4068 }
4069
4070 #[test]
4071 fn test_unmarked_assistant_image_history_errors_for_openrouter() {
4072 let image = completion::AssistantContent::image_base64(
4073 "abc",
4074 Some(message::ImageMediaType::PNG),
4075 None,
4076 );
4077
4078 let err = Vec::<Message>::try_from(OneOrMany::one(image)).unwrap_err();
4079
4080 match err {
4081 message::MessageError::ConversionError(message) => assert!(
4082 message.contains("OpenRouter does not support assistant image content"),
4083 "unexpected error: {message}"
4084 ),
4085 }
4086 }
4087
4088 #[test]
4089 fn test_mixed_text_and_generated_image_replays_text_only_for_openrouter() {
4090 let generated_image = response_image_to_assistant_content(&ResponseImage {
4091 image_url: ImageUrl {
4092 url: "https://example.com/generated.png".to_string(),
4093 detail: None,
4094 },
4095 });
4096
4097 let messages = Vec::<Message>::try_from(
4098 OneOrMany::many(vec![
4099 completion::AssistantContent::text("Keep this text."),
4100 generated_image,
4101 ])
4102 .unwrap(),
4103 )
4104 .unwrap();
4105
4106 let serialized = serde_json::to_value(&messages).unwrap();
4107 assert_eq!(
4108 serialized,
4109 json!([{
4110 "role": "assistant",
4111 "content": [{"type": "text", "text": "Keep this text."}]
4112 }])
4113 );
4114 }
4115
4116 #[test]
4117 fn test_assistant_images_not_serialized_in_request() {
4118 let msg = Message::Assistant {
4119 content: vec!["Hello".to_string().into()],
4120 refusal: None,
4121 audio: None,
4122 name: None,
4123 tool_calls: vec![],
4124 reasoning: None,
4125 reasoning_details: vec![],
4126 images: vec![ResponseImage {
4127 image_url: ImageUrl {
4128 url: "data:image/png;base64,abc".to_string(),
4129 detail: None,
4130 },
4131 }],
4132 };
4133 let serialized = serde_json::to_value(&msg).unwrap();
4134 assert!(
4135 serialized.get("images").is_none(),
4136 "images field must not appear in serialized assistant message"
4137 );
4138 }
4139}