1use super::client::{OpenRouterExt, Usage};
2use crate::message::{self, DocumentMediaType, DocumentSourceKind, MimeType};
3use crate::telemetry::ProviderResponseExt;
4use crate::{
5 completion::{self, CompletionError, CompletionRequest},
6 json_utils,
7 providers::internal::openai_chat_completions_compatible::map_openai_finish_reason,
8 providers::openai,
9};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13pub const QWEN_QWQ_32B: &str = "qwen/qwq-32b";
19pub const CLAUDE_3_7_SONNET: &str = "anthropic/claude-3.7-sonnet";
21pub const PERPLEXITY_SONAR_PRO: &str = "perplexity/sonar-pro";
23pub const GEMINI_FLASH_2_0: &str = "google/gemini-2.0-flash-001";
25
26pub(crate) const PROVIDER_NAME: &str = "openrouter";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
39#[serde(rename_all = "lowercase")]
40pub enum DataCollection {
41 #[default]
43 Allow,
44 Deny,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum Quantization {
54 #[serde(rename = "int4")]
56 Int4,
57 #[serde(rename = "int8")]
59 Int8,
60 #[serde(rename = "fp16")]
62 Fp16,
63 #[serde(rename = "bf16")]
65 Bf16,
66 #[serde(rename = "fp32")]
68 Fp32,
69 #[serde(rename = "fp8")]
71 Fp8,
72 #[serde(rename = "unknown")]
74 Unknown,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "lowercase")]
84pub enum ProviderSortStrategy {
85 Price,
87 Throughput,
89 Latency,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "lowercase")]
96pub enum SortPartition {
97 Model,
99 None,
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct ProviderSortConfig {
108 pub by: ProviderSortStrategy,
110
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub partition: Option<SortPartition>,
114}
115
116impl ProviderSortConfig {
117 pub fn new(by: ProviderSortStrategy) -> Self {
119 Self {
120 by,
121 partition: None,
122 }
123 }
124
125 pub fn partition(mut self, partition: SortPartition) -> Self {
127 self.partition = Some(partition);
128 self
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137#[serde(untagged)]
138pub enum ProviderSort {
139 Simple(ProviderSortStrategy),
141 Complex(ProviderSortConfig),
143}
144
145impl From<ProviderSortStrategy> for ProviderSort {
146 fn from(strategy: ProviderSortStrategy) -> Self {
147 ProviderSort::Simple(strategy)
148 }
149}
150
151impl From<ProviderSortConfig> for ProviderSort {
152 fn from(config: ProviderSortConfig) -> Self {
153 ProviderSort::Complex(config)
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161#[serde(untagged)]
162pub enum ThroughputThreshold {
163 Simple(f64),
165 Percentile(PercentileThresholds),
167}
168
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173#[serde(untagged)]
174pub enum LatencyThreshold {
175 Simple(f64),
177 Percentile(PercentileThresholds),
179}
180
181#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
183pub struct PercentileThresholds {
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub p50: Option<f64>,
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub p75: Option<f64>,
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub p90: Option<f64>,
193 #[serde(skip_serializing_if = "Option::is_none")]
195 pub p99: Option<f64>,
196}
197
198impl PercentileThresholds {
199 pub fn new() -> Self {
201 Self::default()
202 }
203
204 pub fn p50(mut self, value: f64) -> Self {
206 self.p50 = Some(value);
207 self
208 }
209
210 pub fn p75(mut self, value: f64) -> Self {
212 self.p75 = Some(value);
213 self
214 }
215
216 pub fn p90(mut self, value: f64) -> Self {
218 self.p90 = Some(value);
219 self
220 }
221
222 pub fn p99(mut self, value: f64) -> Self {
224 self.p99 = Some(value);
225 self
226 }
227}
228
229#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
234pub struct MaxPrice {
235 #[serde(skip_serializing_if = "Option::is_none")]
237 pub prompt: Option<f64>,
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub completion: Option<f64>,
241 #[serde(skip_serializing_if = "Option::is_none")]
243 pub request: Option<f64>,
244 #[serde(skip_serializing_if = "Option::is_none")]
246 pub image: Option<f64>,
247}
248
249impl MaxPrice {
250 pub fn new() -> Self {
252 Self::default()
253 }
254
255 pub fn prompt(mut self, price: f64) -> Self {
257 self.prompt = Some(price);
258 self
259 }
260
261 pub fn completion(mut self, price: f64) -> Self {
263 self.completion = Some(price);
264 self
265 }
266
267 pub fn request(mut self, price: f64) -> Self {
269 self.request = Some(price);
270 self
271 }
272
273 pub fn image(mut self, price: f64) -> Self {
275 self.image = Some(price);
276 self
277 }
278}
279
280#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
300pub struct ProviderPreferences {
301 #[serde(skip_serializing_if = "Option::is_none")]
305 pub order: Option<Vec<String>>,
306
307 #[serde(skip_serializing_if = "Option::is_none")]
309 pub only: Option<Vec<String>>,
310
311 #[serde(skip_serializing_if = "Option::is_none")]
313 pub ignore: Option<Vec<String>>,
314
315 #[serde(skip_serializing_if = "Option::is_none")]
318 pub allow_fallbacks: Option<bool>,
319
320 #[serde(skip_serializing_if = "Option::is_none")]
327 pub require_parameters: Option<bool>,
328
329 #[serde(skip_serializing_if = "Option::is_none")]
332 pub data_collection: Option<DataCollection>,
333
334 #[serde(skip_serializing_if = "Option::is_none")]
336 pub zdr: Option<bool>,
337
338 #[serde(skip_serializing_if = "Option::is_none")]
342 pub sort: Option<ProviderSort>,
343
344 #[serde(skip_serializing_if = "Option::is_none")]
346 pub preferred_min_throughput: Option<ThroughputThreshold>,
347
348 #[serde(skip_serializing_if = "Option::is_none")]
350 pub preferred_max_latency: Option<LatencyThreshold>,
351
352 #[serde(skip_serializing_if = "Option::is_none")]
354 pub max_price: Option<MaxPrice>,
355
356 #[serde(skip_serializing_if = "Option::is_none")]
359 pub quantizations: Option<Vec<Quantization>>,
360}
361
362impl ProviderPreferences {
363 pub fn new() -> Self {
365 Self::default()
366 }
367
368 pub fn order(mut self, providers: impl IntoIterator<Item = impl Into<String>>) -> Self {
384 self.order = Some(providers.into_iter().map(|p| p.into()).collect());
385 self
386 }
387
388 pub fn only(mut self, providers: impl IntoIterator<Item = impl Into<String>>) -> Self {
400 self.only = Some(providers.into_iter().map(|p| p.into()).collect());
401 self
402 }
403
404 pub fn ignore(mut self, providers: impl IntoIterator<Item = impl Into<String>>) -> Self {
415 self.ignore = Some(providers.into_iter().map(|p| p.into()).collect());
416 self
417 }
418
419 pub fn allow_fallbacks(mut self, allow: bool) -> Self {
424 self.allow_fallbacks = Some(allow);
425 self
426 }
427
428 pub fn require_parameters(mut self, require: bool) -> Self {
434 self.require_parameters = Some(require);
435 self
436 }
437
438 pub fn data_collection(mut self, policy: DataCollection) -> Self {
442 self.data_collection = Some(policy);
443 self
444 }
445
446 pub fn zdr(mut self, enable: bool) -> Self {
457 self.zdr = Some(enable);
458 self
459 }
460
461 pub fn sort(mut self, sort: impl Into<ProviderSort>) -> Self {
477 self.sort = Some(sort.into());
478 self
479 }
480
481 pub fn preferred_min_throughput(mut self, threshold: ThroughputThreshold) -> Self {
501 self.preferred_min_throughput = Some(threshold);
502 self
503 }
504
505 pub fn preferred_max_latency(mut self, threshold: LatencyThreshold) -> Self {
509 self.preferred_max_latency = Some(threshold);
510 self
511 }
512
513 pub fn max_price(mut self, price: MaxPrice) -> Self {
517 self.max_price = Some(price);
518 self
519 }
520
521 pub fn quantizations(mut self, quantizations: impl IntoIterator<Item = Quantization>) -> Self {
534 self.quantizations = Some(quantizations.into_iter().collect());
535 self
536 }
537
538 pub fn zero_data_retention(self) -> Self {
542 self.zdr(true)
543 }
544
545 pub fn fastest(self) -> Self {
547 self.sort(ProviderSortStrategy::Throughput)
548 }
549
550 pub fn cheapest(self) -> Self {
552 self.sort(ProviderSortStrategy::Price)
553 }
554
555 pub fn lowest_latency(self) -> Self {
557 self.sort(ProviderSortStrategy::Latency)
558 }
559
560 pub fn to_json(&self) -> serde_json::Value {
562 serde_json::json!({
563 "provider": self
564 })
565 }
566}
567
568fn deserialize_openrouter_choices_dropping_incomplete_tool_calls<'de, D>(
569 deserializer: D,
570) -> Result<Vec<Choice>, D::Error>
571where
572 D: serde::Deserializer<'de>,
573{
574 crate::providers::internal::openai_chat_completions_compatible::deserialize_choices_dropping_incomplete_tool_calls_when(
575 deserializer,
576 |choice| {
577 let normalized = choice
578 .get("finish_reason")
579 .and_then(serde_json::Value::as_str)
580 .filter(|reason| !reason.is_empty());
581 if let Some(reason) = normalized {
582 return matches!(map_openai_finish_reason(reason), completion::FinishReason::Length);
583 }
584
585 choice
586 .get("native_finish_reason")
587 .and_then(serde_json::Value::as_str)
588 .filter(|reason| !reason.is_empty())
589 .is_some_and(|reason| {
590 matches!(map_native_finish_reason(reason), completion::FinishReason::Length)
591 })
592 },
593 )
594}
595
596#[derive(Clone, Debug, Serialize, Deserialize)]
601pub struct CompletionResponse {
602 pub id: String,
603 pub object: String,
604 pub created: u64,
605 pub model: String,
606 #[serde(deserialize_with = "deserialize_openrouter_choices_dropping_incomplete_tool_calls")]
607 pub choices: Vec<Choice>,
608 pub system_fingerprint: Option<String>,
609 #[serde(default, skip_serializing_if = "Option::is_none")]
611 pub provider: Option<String>,
612 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub service_tier: Option<String>,
615 pub usage: Option<Usage>,
616}
617
618pub(crate) fn map_finish_reason(choice: &Choice) -> Option<completion::FinishReason> {
631 if let Some(reason) = choice
632 .finish_reason
633 .as_deref()
634 .filter(|reason| !reason.is_empty())
635 {
636 return Some(map_openai_finish_reason(reason));
637 }
638
639 choice
640 .native_finish_reason
641 .as_deref()
642 .filter(|reason| !reason.is_empty())
643 .map(map_native_finish_reason)
644}
645
646pub(crate) fn map_native_finish_reason(reason: &str) -> completion::FinishReason {
653 match reason.to_ascii_lowercase().as_str() {
654 "stop" | "end_turn" | "stop_sequence" | "complete" | "completed" => {
657 completion::FinishReason::Stop
658 }
659 "length" | "max_tokens" | "max_output_tokens" | "model_length" => {
660 completion::FinishReason::Length
661 }
662 "tool_calls" | "function_call" | "tool_use" => completion::FinishReason::ToolCalls,
663 "content_filter" | "safety" | "blocklist" | "prohibited_content" | "spii" => {
664 completion::FinishReason::ContentFilter
665 }
666 _ => completion::FinishReason::Other(reason.to_owned()),
667 }
668}
669
670impl crate::completion::NormalizeCompletionResponse for CompletionResponse {
677 fn normalize(self, provider: &str) -> Result<completion::CompletionResponse, CompletionError> {
678 let response = self;
679 let choice = response.choices.first().ok_or_else(|| {
680 CompletionError::ResponseError("Response contained no choices".to_owned())
681 })?;
682 let finish_reason = map_finish_reason(choice);
683
684 let content = match &choice.message {
685 Message::Assistant {
686 content: message_content,
687 tool_calls,
688 reasoning,
689 reasoning_details,
690 images,
691 refusal,
692 ..
693 } => {
694 let refusal_fallback = openai::completion::assistant_refusal_fallback(
704 message_content,
705 refusal.as_deref(),
706 );
707
708 let mut normalized_content = Vec::new();
714 let mut grouped_reasoning: HashMap<
715 Option<String>,
716 Vec<(usize, usize, message::ReasoningContent)>,
717 > = HashMap::new();
718 let mut reasoning_order: Vec<Option<String>> = Vec::new();
719 for (position, detail) in reasoning_details.iter().enumerate() {
720 let (reasoning_id, sort_index, parsed_content) = match detail {
721 ReasoningDetails::Summary {
722 id, index, summary, ..
723 } => (
724 id.clone(),
725 *index,
726 Some(message::ReasoningContent::Summary(summary.clone())),
727 ),
728 ReasoningDetails::Encrypted {
729 id, index, data, ..
730 } => (
731 id.clone(),
732 *index,
733 Some(message::ReasoningContent::Encrypted(data.clone())),
734 ),
735 ReasoningDetails::Text {
736 id,
737 index,
738 text,
739 signature,
740 ..
741 } => (
742 id.clone(),
743 *index,
744 text.as_ref().map(|text| message::ReasoningContent::Text {
745 text: text.clone(),
746 signature: signature.clone(),
747 }),
748 ),
749 };
750
751 let Some(parsed_content) = parsed_content else {
752 continue;
753 };
754 let sort_index = sort_index.unwrap_or(position);
755
756 let entry = grouped_reasoning.entry(reasoning_id.clone());
757 if matches!(entry, std::collections::hash_map::Entry::Vacant(_)) {
758 reasoning_order.push(reasoning_id);
759 }
760 entry
761 .or_default()
762 .push((sort_index, position, parsed_content));
763 }
764
765 if grouped_reasoning.is_empty() {
766 if let Some(reasoning) = reasoning {
767 normalized_content.push(completion::AssistantContent::reasoning(reasoning));
768 }
769 } else {
770 for reasoning_id in reasoning_order {
771 let Some(mut blocks) = grouped_reasoning.remove(&reasoning_id) else {
772 continue;
773 };
774 blocks.sort_by_key(|(index, position, _)| (*index, *position));
775 normalized_content.push(completion::AssistantContent::Reasoning(
776 message::Reasoning {
777 id: reasoning_id,
778 content: blocks
779 .into_iter()
780 .map(|(_, _, content)| content)
781 .collect::<Vec<_>>(),
782 },
783 ));
784 }
785 }
786
787 normalized_content.extend(message_content.iter().map(|part| match part {
788 openai::AssistantContent::Text { text, .. } => {
789 completion::AssistantContent::text(text)
790 }
791 openai::AssistantContent::Refusal { refusal } => {
792 completion::AssistantContent::text(refusal)
793 }
794 }));
795
796 if let Some(refusal) = refusal_fallback {
797 normalized_content.push(completion::AssistantContent::text(refusal));
798 }
799
800 normalized_content.extend(tool_calls.iter().map(|call| {
801 completion::AssistantContent::tool_call(
802 &call.id,
803 &call.function.name,
804 call.function.arguments.clone(),
805 )
806 }));
807
808 normalized_content.extend(images.iter().map(response_image_to_assistant_content));
809
810 Ok(normalized_content)
811 }
812 _ => Err(CompletionError::ResponseError(
813 "Response did not contain a valid message or tool call".into(),
814 )),
815 }?;
816
817 let choice = match &finish_reason {
823 Some(reason) if reason.truncated_output() => content,
824 _ => crate::message::require_non_empty_response(content)?,
825 };
826
827 let usage = response
828 .usage
829 .as_ref()
830 .map(completion::Usage::from)
831 .unwrap_or_default();
832
833 Ok(
834 completion::CompletionResponse::new(choice, usage, provider)
838 .with_response_id(response.id)
839 .with_model(response.model)
840 .with_optional_finish_reason(finish_reason),
841 )
842 }
843}
844
845impl ProviderResponseExt for CompletionResponse {
846 type Usage = Usage;
847
848 fn get_response_id(&self) -> Option<String> {
849 Some(self.id.clone())
850 }
851
852 fn get_response_model_name(&self) -> Option<String> {
853 Some(self.model.clone())
854 }
855
856 fn get_text_response(&self) -> Option<String> {
857 let response = self
858 .choices
859 .iter()
860 .filter_map(|choice| {
861 openai::completion::assistant_message_text_response(&choice.message)
862 })
863 .collect::<Vec<_>>()
864 .join("\n");
865
866 (!response.is_empty()).then_some(response)
867 }
868
869 fn get_usage(&self) -> Option<Self::Usage> {
870 self.usage.clone()
871 }
872}
873
874pub use crate::providers::openai::completion::{
879 FileData, ImageUrl, Message, ReasoningDetails, ResponseImage, UserContent, VideoUrl,
880};
881
882const OPENROUTER_RESPONSE_ONLY_KEY: &str = "response_only";
883const OPENROUTER_RESPONSE_IMAGE_SOURCE_KEY: &str = "source";
884const OPENROUTER_ASSISTANT_IMAGES_SOURCE: &str = "assistant.images";
885
886fn parse_data_uri(url: &str) -> Option<(&str, &str)> {
889 url.strip_prefix("data:")?.split_once(";base64,")
890}
891
892fn openrouter_response_image_params() -> Option<message::AdditionalParams> {
893 message::AdditionalParams::from_entries([(
894 "openrouter",
895 serde_json::json!({
896 OPENROUTER_RESPONSE_ONLY_KEY: true,
897 OPENROUTER_RESPONSE_IMAGE_SOURCE_KEY: OPENROUTER_ASSISTANT_IMAGES_SOURCE,
898 }),
899 )])
900}
901
902fn response_image_to_assistant_content(image: &ResponseImage) -> completion::AssistantContent {
903 let url = &image.image_url.url;
904 if let Some((mime, b64)) = parse_data_uri(url) {
905 completion::AssistantContent::Image(message::Image {
906 data: message::DocumentSourceKind::Base64(b64.to_string()),
907 media_type: message::ImageMediaType::from_mime_type(mime),
908 detail: None,
909 additional_params: openrouter_response_image_params(),
910 })
911 } else {
912 completion::AssistantContent::Image(message::Image {
913 data: message::DocumentSourceKind::Url(url.clone()),
914 media_type: None,
915 detail: None,
916 additional_params: openrouter_response_image_params(),
917 })
918 }
919}
920
921fn is_openrouter_response_image(image: &message::Image) -> bool {
922 image
923 .additional_params
924 .as_ref()
925 .and_then(|params| params.wire_extras("openrouter"))
926 .is_some_and(|params| {
927 params
928 .get(OPENROUTER_RESPONSE_ONLY_KEY)
929 .and_then(|value| value.as_bool())
930 .unwrap_or(false)
931 && params
932 .get(OPENROUTER_RESPONSE_IMAGE_SOURCE_KEY)
933 .and_then(|value| value.as_str())
934 == Some(OPENROUTER_ASSISTANT_IMAGES_SOURCE)
935 })
936}
937
938fn user_content_to_openai(
950 value: message::UserContent,
951) -> Result<UserContent, message::MessageError> {
952 match value {
953 message::UserContent::Image(message::Image {
954 data,
955 detail,
956 media_type,
957 ..
958 }) => {
959 let url = match data {
960 DocumentSourceKind::Url(url) => url,
961 DocumentSourceKind::Base64(data) => {
962 let mime = media_type
963 .ok_or_else(|| {
964 message::MessageError::ConversionError(
965 "Image media type required for base64 encoding".into(),
966 )
967 })?
968 .to_mime_type();
969 format!("data:{mime};base64,{data}")
970 }
971 DocumentSourceKind::Raw(_) => {
972 return Err(message::MessageError::ConversionError(
973 "Raw bytes not supported, encode as base64 first".into(),
974 ));
975 }
976 DocumentSourceKind::FileId(_) => {
977 return Err(message::MessageError::ConversionError(
978 "File IDs are not supported for images".into(),
979 ));
980 }
981 DocumentSourceKind::String(_) => {
982 return Err(message::MessageError::ConversionError(
983 "String source not supported for images".into(),
984 ));
985 }
986 DocumentSourceKind::Unknown => {
987 return Err(message::MessageError::ConversionError(
988 "Image has no data".into(),
989 ));
990 }
991 };
992 Ok(UserContent::Image {
993 image_url: ImageUrl { url, detail },
994 })
995 }
996
997 message::UserContent::Document(message::Document {
998 data, media_type, ..
999 }) => match data {
1000 DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
1001 "Provider file IDs are not supported for OpenRouter document inputs".into(),
1002 )),
1003 DocumentSourceKind::Url(url) => Ok(UserContent::File {
1004 file: FileData {
1005 file_data: Some(url),
1006 file_id: None,
1007 filename: document_filename(media_type.as_ref()),
1008 },
1009 }),
1010 DocumentSourceKind::Base64(data) => {
1011 let mime = media_type
1012 .as_ref()
1013 .map(|m| m.to_mime_type())
1014 .unwrap_or("application/pdf");
1015 let data_uri = format!("data:{mime};base64,{data}");
1016
1017 Ok(UserContent::File {
1018 file: FileData {
1019 file_data: Some(data_uri),
1020 file_id: None,
1021 filename: document_filename(media_type.as_ref()),
1022 },
1023 })
1024 }
1025 DocumentSourceKind::String(text) => Ok(UserContent::Text { text }),
1026 DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
1027 "Raw bytes not supported for documents, encode as base64 first".into(),
1028 )),
1029 DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
1030 "Document has no data".into(),
1031 )),
1032 },
1033
1034 message::UserContent::Audio(message::Audio {
1035 data, media_type, ..
1036 }) => match data {
1037 DocumentSourceKind::Base64(data) => {
1038 let format = media_type.ok_or_else(|| {
1039 message::MessageError::ConversionError(
1040 "Audio media type required for base64 encoding".into(),
1041 )
1042 })?;
1043 Ok(UserContent::Audio {
1044 input_audio: openai::InputAudio { data, format },
1045 })
1046 }
1047 DocumentSourceKind::Url(_) => Err(message::MessageError::ConversionError(
1048 "OpenRouter does not support audio URLs, encode as base64 first".into(),
1049 )),
1050 DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
1051 "Raw bytes not supported for audio, encode as base64 first".into(),
1052 )),
1053 DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
1054 "File IDs are not supported for audio".into(),
1055 )),
1056 DocumentSourceKind::String(_) => Err(message::MessageError::ConversionError(
1057 "String source not supported for audio".into(),
1058 )),
1059 DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
1060 "Audio has no data".into(),
1061 )),
1062 },
1063
1064 message::UserContent::ToolResult(_) => Err(message::MessageError::ConversionError(
1065 "Tool results should be handled as separate messages".into(),
1066 )),
1067
1068 value => UserContent::try_from(value),
1070 }
1071}
1072
1073fn document_filename(media_type: Option<&DocumentMediaType>) -> Option<String> {
1074 media_type.map(|mt| {
1075 match mt {
1076 DocumentMediaType::PDF => "document.pdf",
1077 DocumentMediaType::TXT => "document.txt",
1078 DocumentMediaType::HTML => "document.html",
1079 DocumentMediaType::MARKDOWN => "document.md",
1080 DocumentMediaType::CSV => "document.csv",
1081 DocumentMediaType::XML => "document.xml",
1082 _ => "document",
1083 }
1084 .to_string()
1085 })
1086}
1087
1088fn user_contents_to_messages(
1089 value: Vec<message::UserContent>,
1090) -> Result<Vec<Message>, message::MessageError> {
1091 fn flush_user_content(messages: &mut Vec<Message>, pending: &mut Vec<UserContent>) {
1092 if pending.is_empty() {
1097 return;
1098 }
1099
1100 messages.push(Message::User {
1101 content: std::mem::take(pending),
1102 name: None,
1103 });
1104 }
1105
1106 let mut messages = Vec::new();
1107 let mut pending = Vec::new();
1108
1109 for content in value {
1110 match content {
1111 message::UserContent::ToolResult(tool_result) => {
1112 flush_user_content(&mut messages, &mut pending);
1113 let tool_call_id = tool_result.wire_call_id().to_owned();
1118 let content = tool_result
1119 .content
1120 .into_iter()
1121 .map(|content| match content {
1122 message::ToolResultContent::Text(message::Text { text, .. }) => Ok(text),
1123 message::ToolResultContent::Json { value } => Ok(value.to_string()),
1124 message::ToolResultContent::Image(_) => {
1125 Err(message::MessageError::ConversionError(
1126 "OpenRouter does not support images in tool results".into(),
1127 ))
1128 }
1129 })
1130 .collect::<Result<Vec<_>, _>>()?
1131 .join("\n");
1132 messages.push(Message::ToolResult {
1133 tool_call_id,
1134 content: openai::completion::ToolResultContentValue::String(content),
1135 });
1136 }
1137 content => pending.push(user_content_to_openai(content)?),
1138 }
1139 }
1140
1141 flush_user_content(&mut messages, &mut pending);
1142 Ok(messages)
1143}
1144
1145#[derive(Clone, Debug, Deserialize, Serialize)]
1150pub struct Choice {
1151 pub index: usize,
1152 pub native_finish_reason: Option<String>,
1153 pub message: Message,
1154 pub finish_reason: Option<String>,
1155 #[serde(default, skip_serializing_if = "Option::is_none")]
1160 pub logprobs: Option<serde_json::Value>,
1161}
1162
1163#[derive(Debug, Deserialize, PartialEq, Clone)]
1164#[serde(untagged)]
1165enum ToolCallAdditionalParams {
1166 ReasoningDetails(ReasoningDetails),
1167 Minimal {
1168 id: Option<String>,
1169 format: Option<String>,
1170 },
1171}
1172
1173fn assistant_contents_to_messages(
1181 value: Vec<message::AssistantContent>,
1182) -> Result<Vec<Message>, message::MessageError> {
1183 let mut text_content = Vec::new();
1184 let mut tool_calls = Vec::new();
1185 let mut reasoning = None;
1186 let mut reasoning_details = Vec::new();
1187
1188 for content in value.into_iter() {
1189 match content {
1190 message::AssistantContent::Text(text) => text_content.push(text),
1191 message::AssistantContent::ToolCall(tool_call) => {
1192 if let Some(additional_params) = &tool_call.additional_params
1198 && let Ok(additional_params) = serde_json::from_value::<ToolCallAdditionalParams>(
1199 additional_params.clone(),
1200 )
1201 {
1202 match additional_params {
1203 ToolCallAdditionalParams::ReasoningDetails(full) => {
1204 reasoning_details.push(full);
1205 }
1206 ToolCallAdditionalParams::Minimal { id, format } => {
1207 let id = id
1211 .or_else(|| {
1212 tool_call
1213 .provider
1214 .as_ref()
1215 .map(|provider| provider.call_id.clone())
1216 })
1217 .unwrap_or_else(|| tool_call.id.as_str().to_owned());
1218 if let Some(signature) = &tool_call.signature {
1219 reasoning_details.push(ReasoningDetails::Encrypted {
1220 id: Some(id),
1221 format,
1222 index: None,
1223 data: signature.clone(),
1224 })
1225 }
1226 }
1227 }
1228 } else if let Some(signature) = &tool_call.signature {
1229 reasoning_details.push(ReasoningDetails::Encrypted {
1230 id: Some(
1231 tool_call
1232 .provider
1233 .as_ref()
1234 .map(|provider| provider.call_id.clone())
1235 .unwrap_or_else(|| tool_call.id.as_str().to_owned()),
1236 ),
1237 format: None,
1238 index: None,
1239 data: signature.clone(),
1240 });
1241 }
1242 tool_calls.push(tool_call.into())
1243 }
1244 message::AssistantContent::Reasoning(r) => {
1245 if r.content.is_empty() {
1246 let display = r.display_text();
1247 if !display.is_empty() {
1248 reasoning = Some(display);
1249 }
1250 } else {
1251 let reasoning_id = r.id.clone().filter(|id| !id.is_empty());
1255 for reasoning_block in &r.content {
1256 let index = Some(reasoning_details.len());
1257 match reasoning_block {
1258 message::ReasoningContent::Text { text, signature } => {
1259 reasoning_details.push(ReasoningDetails::Text {
1260 id: reasoning_id.clone(),
1261 format: None,
1262 index,
1263 text: Some(text.clone()),
1264 signature: signature.clone(),
1265 });
1266 }
1267 message::ReasoningContent::Summary(summary) => {
1268 reasoning_details.push(ReasoningDetails::Summary {
1269 id: reasoning_id.clone(),
1270 format: None,
1271 index,
1272 summary: summary.clone(),
1273 });
1274 }
1275 message::ReasoningContent::Encrypted(data)
1276 | message::ReasoningContent::Redacted { data } => {
1277 reasoning_details.push(ReasoningDetails::Encrypted {
1278 id: reasoning_id.clone(),
1279 format: None,
1280 index,
1281 data: data.clone(),
1282 });
1283 }
1284 }
1285 }
1286 }
1287 }
1288 message::AssistantContent::Image(image) if is_openrouter_response_image(&image) => {
1289 }
1293 message::AssistantContent::Image(_) => {
1294 return Err(message::MessageError::ConversionError(
1295 "OpenRouter does not support assistant image content in request history; pass images as user image inputs instead".into(),
1296 ));
1297 }
1298 }
1299 }
1300
1301 if text_content.is_empty()
1302 && tool_calls.is_empty()
1303 && reasoning.is_none()
1304 && reasoning_details.is_empty()
1305 {
1306 return Ok(vec![]);
1307 }
1308
1309 Ok(vec![Message::Assistant {
1310 content: text_content
1311 .into_iter()
1312 .map(|content| content.text.into())
1313 .collect::<Vec<_>>(),
1314 refusal: None,
1315 audio: None,
1316 name: None,
1317 tool_calls,
1318 reasoning,
1319 reasoning_details,
1320 images: Vec::new(),
1321 }])
1322}
1323
1324pub fn messages_from_rig_message(
1331 message: message::Message,
1332) -> Result<Vec<Message>, message::MessageError> {
1333 match message {
1334 message::Message::System { content } => Ok(vec![Message::system(&content)]),
1335 message::Message::User { content } => user_contents_to_messages(content),
1336 message::Message::Assistant { content, .. } => assistant_contents_to_messages(content),
1337 }
1338}
1339
1340pub(super) fn apply_prompt_caching(body: &mut serde_json::Value) {
1352 let Some(obj) = body.as_object_mut() else {
1353 return;
1354 };
1355 let Some(messages) = obj.get_mut("messages").and_then(|v| v.as_array_mut()) else {
1356 return;
1357 };
1358
1359 let Some(system_msg) = messages
1360 .iter_mut()
1361 .find(|m| m.get("role").and_then(|v| v.as_str()) == Some("system"))
1362 else {
1363 return;
1364 };
1365
1366 match system_msg.get("content").cloned() {
1367 Some(serde_json::Value::String(s)) => {
1368 if let Some(obj) = system_msg.as_object_mut() {
1369 obj.insert(
1370 "content".to_string(),
1371 serde_json::json!([{
1372 "type": "text",
1373 "text": s,
1374 "cache_control": { "type": "ephemeral" }
1375 }]),
1376 );
1377 }
1378 }
1379 Some(serde_json::Value::Array(mut arr)) => {
1380 if let Some(last) = arr.last_mut()
1383 && let Some(obj) = last.as_object_mut()
1384 {
1385 obj.insert(
1386 "cache_control".to_string(),
1387 serde_json::json!({ "type": "ephemeral" }),
1388 );
1389 }
1390 if let Some(obj) = system_msg.as_object_mut() {
1391 obj.insert("content".to_string(), serde_json::Value::Array(arr));
1392 }
1393 }
1394 _ => {}
1395 }
1396}
1397
1398pub(super) fn finalize_openrouter_request_body(body: &mut serde_json::Value, prompt_caching: bool) {
1399 if prompt_caching {
1400 apply_prompt_caching(body);
1401 }
1402
1403 if let Some(messages) = body
1407 .get_mut("messages")
1408 .and_then(serde_json::Value::as_array_mut)
1409 {
1410 for message in messages {
1411 if let Some(message) = message.as_object_mut()
1412 && message.get("role").and_then(serde_json::Value::as_str) == Some("assistant")
1413 && let Some(reasoning) = message.remove("reasoning_content")
1414 {
1415 message.insert("reasoning".to_string(), reasoning);
1416 }
1417 }
1418 }
1419}
1420
1421#[cfg(test)]
1422pub(super) fn final_request_body(
1423 request: &OpenrouterCompletionRequest,
1424 prompt_caching: bool,
1425) -> Result<serde_json::Value, CompletionError> {
1426 let mut body = serde_json::to_value(request)?;
1427 finalize_openrouter_request_body(&mut body, prompt_caching);
1428 Ok(body)
1429}
1430
1431pub(super) type OpenrouterCompletionRequest = openai::completion::CompletionRequest;
1432
1433pub struct OpenRouterRequestParams<'a> {
1435 pub model: &'a str,
1436 pub request: CompletionRequest,
1437 pub strict_tools: bool,
1438}
1439
1440impl TryFrom<OpenRouterRequestParams<'_>> for OpenrouterCompletionRequest {
1441 type Error = CompletionError;
1442
1443 fn try_from(params: OpenRouterRequestParams) -> Result<Self, Self::Error> {
1444 let OpenRouterRequestParams {
1445 model,
1446 request: req,
1447 strict_tools,
1448 } = params;
1449 let chat_history = req.chat_history_with_documents();
1450 let model = req.model.clone().unwrap_or_else(|| model.to_string());
1451
1452 let mut full_history: Vec<Message> = match &req.preamble {
1453 Some(preamble) => vec![Message::system(preamble)],
1454 None => vec![],
1455 };
1456
1457 let chat_history: Vec<Message> = chat_history
1458 .into_iter()
1459 .map(messages_from_rig_message)
1460 .collect::<Result<Vec<Vec<Message>>, _>>()?
1461 .into_iter()
1462 .flatten()
1463 .collect();
1464
1465 full_history.extend(chat_history);
1466
1467 let tool_choice = req
1468 .tool_choice
1469 .clone()
1470 .map(crate::providers::openai::completion::ToolChoice::try_from)
1471 .transpose()?;
1472
1473 let tools: Vec<crate::providers::openai::completion::ToolDefinition> = req
1474 .tools
1475 .clone()
1476 .into_iter()
1477 .map(|tool| {
1478 let def = crate::providers::openai::completion::ToolDefinition::from(tool);
1479 if strict_tools { def.with_strict() } else { def }
1480 })
1481 .collect();
1482
1483 let additional_params = if let Some(schema) = req.output_schema {
1484 let name = schema
1485 .as_object()
1486 .and_then(|o| o.get("title"))
1487 .and_then(|v| v.as_str())
1488 .unwrap_or("response_schema")
1489 .to_string();
1490 let mut schema_value = schema.to_value();
1491 openai::sanitize_schema(&mut schema_value);
1492 let response_format = serde_json::json!({
1493 "response_format": {
1494 "type": "json_schema",
1495 "json_schema": {
1496 "name": name,
1497 "strict": true,
1498 "schema": schema_value
1499 }
1500 }
1501 });
1502 Some(match req.additional_params {
1503 Some(existing) => json_utils::merge(existing, response_format),
1504 None => response_format,
1505 })
1506 } else {
1507 req.additional_params
1508 };
1509
1510 Ok(Self {
1511 model,
1512 messages: full_history,
1513 temperature: req.temperature,
1514 max_tokens: req.max_tokens,
1515 tools,
1516 tool_choice,
1517 additional_params,
1518 })
1519 }
1520}
1521
1522impl TryFrom<(&str, CompletionRequest)> for OpenrouterCompletionRequest {
1523 type Error = CompletionError;
1524
1525 fn try_from((model, req): (&str, CompletionRequest)) -> Result<Self, Self::Error> {
1526 let model = req.model.clone().unwrap_or_else(|| model.to_string());
1527 OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1528 model: &model,
1529 request: req,
1530 strict_tools: false,
1531 })
1532 }
1533}
1534
1535impl openai::completion::OpenAICompatibleProvider for OpenRouterExt {
1536 const PROVIDER_NAME: &'static str = self::PROVIDER_NAME;
1537
1538 type StreamingUsage = Usage;
1539 type Response = CompletionResponse;
1540
1541 const STREAM_INCLUDE_USAGE: bool = false;
1542
1543 fn map_streaming_finish_reason(
1544 &self,
1545 finish_reason: Option<&str>,
1546 native_finish_reason: Option<&str>,
1547 ) -> Option<crate::completion::FinishReason> {
1548 if let Some(reason) = finish_reason.filter(|reason| !reason.is_empty()) {
1549 return Some(map_openai_finish_reason(reason));
1550 }
1551
1552 native_finish_reason
1553 .filter(|reason| !reason.is_empty())
1554 .map(map_native_finish_reason)
1555 }
1556
1557 fn build_completion_request(
1558 &self,
1559 model: String,
1560 request: CompletionRequest,
1561 options: openai::completion::CompletionModelOptions,
1562 ) -> Result<openai::completion::CompletionRequest, CompletionError> {
1563 OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1564 model: &model,
1565 request,
1566 strict_tools: options.strict_tools,
1567 })
1568 }
1569
1570 fn finalize_request_body_with_options(
1571 &self,
1572 body: &mut serde_json::Value,
1573 options: openai::completion::CompletionModelOptions,
1574 ) -> Result<(), CompletionError> {
1575 finalize_openrouter_request_body(body, options.prompt_caching);
1576 Ok(())
1577 }
1578
1579 fn streaming_detail_reasoning(
1587 &self,
1588 detail: &serde_json::Value,
1589 ) -> Option<(
1590 crate::streaming::StreamPartId,
1591 Option<crate::streaming::WireId>,
1592 message::ReasoningContent,
1593 )> {
1594 let Ok(ReasoningDetails::Encrypted { id, data, .. }) =
1595 serde_json::from_value::<ReasoningDetails>(detail.clone())
1596 else {
1597 return None;
1598 };
1599
1600 let provider_id = id.and_then(crate::streaming::WireId::new);
1610 let key = provider_id
1611 .as_ref()
1612 .map(|id| crate::streaming::StreamPartId::wire(id.as_str()))
1613 .unwrap_or(crate::streaming::StreamPartId::minted(
1614 crate::streaming::MintKind::EncryptedReasoning,
1615 0,
1616 ));
1617 Some((key, provider_id, message::ReasoningContent::Encrypted(data)))
1618 }
1619
1620 fn streaming_reasoning_signature(&self, detail: &serde_json::Value) -> Option<String> {
1626 let Ok(ReasoningDetails::Text {
1627 signature: Some(signature),
1628 ..
1629 }) = serde_json::from_value::<ReasoningDetails>(detail.clone())
1630 else {
1631 return None;
1632 };
1633 (!signature.is_empty()).then_some(signature)
1634 }
1635}
1636
1637pub type CompletionModel<H = reqwest::Client> =
1646 openai::completion::GenericCompletionModel<OpenRouterExt, H>;
1647
1648pub type StreamingCompletionResponse =
1650 openai::completion::streaming::StreamingCompletionResponse<Usage>;
1651
1652impl<H> openai::completion::GenericCompletionModel<OpenRouterExt, H> {
1653 pub fn with_prompt_caching(mut self) -> Self {
1660 self.prompt_caching = true;
1661 self
1662 }
1663}
1664
1665#[cfg(test)]
1666mod tests {
1667 use super::*;
1668 use crate::completion::NormalizeCompletionResponse;
1669 use crate::message::{AudioMediaType, ImageDetail, VideoMediaType};
1670 use serde_json::json;
1671
1672 #[test]
1673 fn openrouter_client_constructs_a_completion_model() {
1674 use crate::client::CompletionClient;
1678
1679 let client =
1680 crate::providers::openrouter::Client::new("dummy-key").expect("Client::new() failed");
1681 let model = client.completion_model(GEMINI_FLASH_2_0);
1682
1683 assert_eq!(model.model, GEMINI_FLASH_2_0);
1684 }
1685
1686 #[test]
1687 fn mixed_user_content_preserves_order_around_tool_results() {
1688 let content = vec![
1689 message::UserContent::text("before"),
1690 message::UserContent::tool_result_with_call_id(
1691 "result-id",
1692 "call-id".to_string(),
1693 "tool",
1694 vec![message::ToolResultContent::text("tool output")],
1695 ),
1696 message::UserContent::text("after"),
1697 ];
1698
1699 let messages = user_contents_to_messages(content).expect("message conversion");
1700
1701 assert!(matches!(
1702 messages.as_slice(),
1703 [
1704 Message::User { content: before, .. },
1705 Message::ToolResult { tool_call_id, .. },
1706 Message::User { content: after, .. },
1707 ] if matches!(before.first(), Some(UserContent::Text { text }) if text == "before")
1708 && tool_call_id == "call-id"
1709 && matches!(after.first(), Some(UserContent::Text { text }) if text == "after")
1710 ));
1711 }
1712
1713 #[test]
1714 fn test_openrouter_request_uses_request_model_override() {
1715 let request = CompletionRequest {
1716 model: Some("google/gemini-2.5-flash".to_string()),
1717 preamble: None,
1718 chat_history: vec!["Hello".into()],
1719 documents: vec![],
1720 tools: vec![],
1721 temperature: None,
1722 max_tokens: None,
1723 tool_choice: None,
1724 additional_params: None,
1725 output_schema: None,
1726 record_telemetry_content: false,
1727 };
1728
1729 let openrouter_request =
1730 OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1731 .expect("request conversion should succeed");
1732 let serialized =
1733 serde_json::to_value(openrouter_request).expect("serialization should succeed");
1734
1735 assert_eq!(serialized["model"], "google/gemini-2.5-flash");
1736 }
1737
1738 #[test]
1742 fn openrouter_request_carries_caller_max_tokens() {
1743 let request = CompletionRequest {
1744 model: None,
1745 preamble: None,
1746 chat_history: vec!["Hello".into()],
1747 documents: vec![],
1748 tools: vec![],
1749 temperature: None,
1750 max_tokens: Some(512),
1751 tool_choice: None,
1752 additional_params: None,
1753 output_schema: None,
1754 record_telemetry_content: false,
1755 };
1756
1757 let openrouter_request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1758 model: "openai/gpt-4o-mini",
1759 request,
1760 strict_tools: false,
1761 })
1762 .expect("request conversion should succeed");
1763 let serialized =
1764 serde_json::to_value(openrouter_request).expect("serialization should succeed");
1765
1766 assert_eq!(serialized["max_tokens"], 512);
1767 }
1768
1769 #[test]
1770 fn openrouter_params_include_direct_request_documents() {
1771 let request = CompletionRequest {
1772 model: None,
1773 preamble: None,
1774 chat_history: vec![crate::message::Message::user("What is glarb-glarb?")],
1775 documents: vec![crate::completion::request::Document {
1776 id: "doc_1".to_string(),
1777 text: "Definition of glarb-glarb: an ancient tool.".to_string(),
1778 additional_props: Default::default(),
1779 }],
1780 tools: vec![],
1781 temperature: None,
1782 max_tokens: None,
1783 tool_choice: None,
1784 additional_params: None,
1785 output_schema: None,
1786 record_telemetry_content: false,
1787 };
1788
1789 let request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1790 model: "openai/gpt-4o-mini",
1791 request,
1792 strict_tools: false,
1793 })
1794 .expect("request conversion should succeed");
1795 let serialized = serde_json::to_value(request).expect("serialization should succeed");
1796
1797 assert!(
1798 serialized["messages"].to_string().contains("glarb-glarb"),
1799 "direct request documents should be normalized through public params"
1800 );
1801 }
1802
1803 #[test]
1804 fn test_openrouter_request_uses_default_model_when_override_unset() {
1805 let request = CompletionRequest {
1806 model: None,
1807 preamble: None,
1808 chat_history: vec!["Hello".into()],
1809 documents: vec![],
1810 tools: vec![],
1811 temperature: None,
1812 max_tokens: None,
1813 tool_choice: None,
1814 additional_params: None,
1815 output_schema: None,
1816 record_telemetry_content: false,
1817 };
1818
1819 let openrouter_request =
1820 OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1821 .expect("request conversion should succeed");
1822 let serialized =
1823 serde_json::to_value(openrouter_request).expect("serialization should succeed");
1824
1825 assert_eq!(serialized["model"], "openai/gpt-4o-mini");
1826 }
1827
1828 #[test]
1829 fn final_request_body_serializes_assistant_reasoning_under_openrouter_key() {
1830 let request = OpenrouterCompletionRequest {
1834 model: "openai/gpt-4o".to_string(),
1835 messages: vec![Message::Assistant {
1836 content: vec![],
1837 reasoning: Some("thinking it through".to_string()),
1838 refusal: None,
1839 audio: None,
1840 name: None,
1841 tool_calls: vec![],
1842 reasoning_details: vec![],
1843 images: vec![],
1844 }],
1845 temperature: None,
1846 max_tokens: None,
1847 tools: vec![],
1848 tool_choice: None,
1849 additional_params: None,
1850 };
1851
1852 let body = final_request_body(&request, false).expect("body should serialize");
1853
1854 assert_eq!(
1855 body["messages"][0]["reasoning"],
1856 serde_json::json!("thinking it through")
1857 );
1858 assert!(
1859 body["messages"][0].get("reasoning_content").is_none(),
1860 "OpenRouter's assistant reasoning key is `reasoning`, not `reasoning_content`"
1861 );
1862 }
1863
1864 #[test]
1865 fn test_openrouter_request_maps_output_schema_to_response_format() {
1866 let schema: schemars::Schema = serde_json::from_value(json!({
1867 "title": "WeatherResponse",
1868 "type": "object",
1869 "properties": {
1870 "city": { "type": "string" },
1871 "weather": { "type": "string" }
1872 }
1873 }))
1874 .expect("schema should deserialize");
1875
1876 let request = CompletionRequest {
1877 model: None,
1878 preamble: None,
1879 chat_history: vec!["Hello".into()],
1880 documents: vec![],
1881 tools: vec![],
1882 temperature: None,
1883 max_tokens: None,
1884 tool_choice: None,
1885 additional_params: None,
1886 output_schema: Some(schema),
1887 record_telemetry_content: false,
1888 };
1889
1890 let openrouter_request =
1891 OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1892 .expect("request conversion should succeed");
1893 let serialized =
1894 serde_json::to_value(openrouter_request).expect("serialization should succeed");
1895
1896 assert_eq!(
1897 serialized["response_format"],
1898 json!({
1899 "type": "json_schema",
1900 "json_schema": {
1901 "name": "WeatherResponse",
1902 "strict": true,
1903 "schema": {
1904 "title": "WeatherResponse",
1905 "type": "object",
1906 "properties": {
1907 "city": { "type": "string" },
1908 "weather": { "type": "string" }
1909 },
1910 "additionalProperties": false,
1911 "required": ["city", "weather"]
1912 }
1913 }
1914 })
1915 );
1916 }
1917
1918 #[test]
1919 fn test_openrouter_request_merges_output_schema_with_provider_preferences() {
1920 let schema: schemars::Schema = serde_json::from_value(json!({
1921 "type": "object",
1922 "properties": {
1923 "answer": { "type": "string" }
1924 }
1925 }))
1926 .expect("schema should deserialize");
1927
1928 let request = CompletionRequest {
1929 model: None,
1930 preamble: None,
1931 chat_history: vec!["Hello".into()],
1932 documents: vec![],
1933 tools: vec![],
1934 temperature: None,
1935 max_tokens: None,
1936 tool_choice: None,
1937 additional_params: Some(
1938 ProviderPreferences::new()
1939 .require_parameters(true)
1940 .to_json(),
1941 ),
1942 output_schema: Some(schema),
1943 record_telemetry_content: false,
1944 };
1945
1946 let openrouter_request =
1947 OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1948 .expect("request conversion should succeed");
1949 let serialized =
1950 serde_json::to_value(openrouter_request).expect("serialization should succeed");
1951
1952 assert_eq!(serialized["provider"]["require_parameters"], true);
1953 assert_eq!(serialized["response_format"]["type"], "json_schema");
1954 assert_eq!(
1955 serialized["response_format"]["json_schema"]["name"],
1956 "response_schema"
1957 );
1958 assert_eq!(
1959 serialized["response_format"]["json_schema"]["schema"]["additionalProperties"],
1960 false
1961 );
1962 }
1963
1964 #[test]
1965 fn test_completion_response_deserialization_gemini_flash() {
1966 let json = json!({
1968 "id": "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA",
1969 "provider": "Google",
1970 "model": "google/gemini-2.5-flash",
1971 "object": "chat.completion",
1972 "created": 1765971703u64,
1973 "choices": [{
1974 "logprobs": null,
1975 "finish_reason": "stop",
1976 "native_finish_reason": "STOP",
1977 "index": 0,
1978 "message": {
1979 "role": "assistant",
1980 "content": "CONTENT",
1981 "refusal": null,
1982 "reasoning": null
1983 }
1984 }],
1985 "usage": {
1986 "prompt_tokens": 669,
1987 "completion_tokens": 5,
1988 "total_tokens": 674
1989 }
1990 });
1991
1992 let response: CompletionResponse = serde_json::from_value(json).unwrap();
1993 assert_eq!(response.id, "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA");
1994 assert_eq!(response.model, "google/gemini-2.5-flash");
1995 assert_eq!(response.choices.len(), 1);
1996 assert_eq!(response.choices[0].finish_reason, Some("stop".to_string()));
1997 assert_eq!(response.choices[0].logprobs, None);
1998 let serialized = serde_json::to_value(&response).unwrap();
1999 assert!(
2000 serialized["choices"][0].get("logprobs").is_none(),
2001 "an absent optional native field stays absent when serialized"
2002 );
2003 }
2004
2005 #[test]
2006 fn raw_completion_choice_retains_logprobs() {
2007 let logprobs = json!({
2008 "content": [{
2009 "token": "cobalt",
2010 "logprob": -0.01,
2011 "bytes": [99],
2012 "top_logprobs": []
2013 }],
2014 "refusal": null
2015 });
2016 let response: CompletionResponse = serde_json::from_value(json!({
2017 "id": "gen-logprobs",
2018 "object": "chat.completion",
2019 "created": 1,
2020 "model": "openai/gpt-4o-mini",
2021 "system_fingerprint": null,
2022 "choices": [{
2023 "index": 0,
2024 "native_finish_reason": "stop",
2025 "finish_reason": "stop",
2026 "message": {"role": "assistant", "content": "cobalt"},
2027 "logprobs": logprobs
2028 }],
2029 "usage": null
2030 }))
2031 .expect("OpenRouter's documented probability object should decode");
2032
2033 assert_eq!(response.choices[0].logprobs, Some(logprobs));
2034 }
2035
2036 #[test]
2037 fn test_completion_response_usage_prefers_reported_completion_tokens() {
2038 let json = json!({
2039 "id": "gen-usage-divergent",
2040 "object": "chat.completion",
2041 "created": 1,
2042 "model": "anthropic/claude-3.5-sonnet",
2043 "choices": [{
2044 "index": 0,
2045 "message": {"role": "assistant", "content": "ok"},
2046 "finish_reason": "stop"
2047 }],
2048 "usage": {"prompt_tokens": 500, "completion_tokens": 10, "total_tokens": 505}
2050 });
2051
2052 let response: CompletionResponse = serde_json::from_value(json).unwrap();
2053 let converted = response.normalize(PROVIDER_NAME).unwrap();
2054 assert_eq!(converted.usage.output_tokens, 10);
2055 }
2056
2057 #[test]
2058 fn test_completion_response_usage_falls_back_when_completion_tokens_missing() {
2059 let json = json!({
2060 "id": "gen-usage-omitted",
2061 "object": "chat.completion",
2062 "created": 1,
2063 "model": "some/gateway-model",
2064 "choices": [{
2065 "index": 0,
2066 "message": {"role": "assistant", "content": "ok"},
2067 "finish_reason": "stop"
2068 }],
2069 "usage": {"prompt_tokens": 100, "total_tokens": 110}
2070 });
2071
2072 let response: CompletionResponse = serde_json::from_value(json).unwrap();
2073 let converted = response.normalize(PROVIDER_NAME).unwrap();
2074 assert_eq!(converted.usage.output_tokens, 10);
2075 }
2076
2077 #[test]
2078 fn test_completion_response_maps_cache_token_accounting() {
2079 let json = json!({
2080 "id": "gen-cache-test",
2081 "object": "chat.completion",
2082 "created": 1,
2083 "model": "anthropic/claude-3.5-sonnet",
2084 "choices": [{
2085 "index": 0,
2086 "finish_reason": "stop",
2087 "message": {
2088 "role": "assistant",
2089 "content": "Hi"
2090 }
2091 }],
2092 "usage": {
2093 "prompt_tokens": 500,
2094 "completion_tokens": 10,
2095 "total_tokens": 510,
2096 "prompt_tokens_details": {
2097 "cached_tokens": 400,
2098 "cache_write_tokens": 50
2099 }
2100 }
2101 });
2102
2103 let response: CompletionResponse = serde_json::from_value(json).unwrap();
2104 let converted = response.normalize(PROVIDER_NAME).unwrap();
2105
2106 assert_eq!(converted.usage.input_tokens, 500);
2107 assert_eq!(converted.usage.output_tokens, 10);
2108 assert_eq!(converted.usage.cached_input_tokens, 400);
2109 assert_eq!(converted.usage.cache_creation_input_tokens, 50);
2110 }
2111
2112 #[test]
2113 fn test_completion_response_cache_tokens_absent_defaults_to_zero() {
2114 let json = json!({
2115 "id": "gen-no-cache",
2116 "object": "chat.completion",
2117 "created": 1,
2118 "model": "openai/gpt-4o",
2119 "choices": [{
2120 "index": 0,
2121 "finish_reason": "stop",
2122 "message": {
2123 "role": "assistant",
2124 "content": "Hi"
2125 }
2126 }],
2127 "usage": {
2128 "prompt_tokens": 100,
2129 "completion_tokens": 10,
2130 "total_tokens": 110
2131 }
2132 });
2133
2134 let response: CompletionResponse = serde_json::from_value(json).unwrap();
2135 let converted = response.normalize(PROVIDER_NAME).unwrap();
2136
2137 assert_eq!(converted.usage.cached_input_tokens, 0);
2138 assert_eq!(converted.usage.cache_creation_input_tokens, 0);
2139 }
2140
2141 #[test]
2142 fn test_completion_response_deserialization_gemini_model_role() {
2143 let json = json!({
2144 "id": "gen-BBBBBBBBBB-BBBBBBBBBBBBBBBBBBBB",
2145 "provider": "Google",
2146 "model": "google/gemini-2.5-pro-exp-03-25:free",
2147 "object": "chat.completion",
2148 "created": 1743780565u64,
2149 "choices": [{
2150 "logprobs": null,
2151 "finish_reason": "stop",
2152 "native_finish_reason": "STOP",
2153 "index": 0,
2154 "message": {
2155 "role": "model",
2156 "content": "CONTENT",
2157 "refusal": null,
2158 "reasoning": null
2159 }
2160 }],
2161 "usage": {
2162 "prompt_tokens": 669,
2163 "completion_tokens": 5,
2164 "total_tokens": 674
2165 }
2166 });
2167
2168 let response: CompletionResponse = serde_json::from_value(json).unwrap();
2169 let converted = response.normalize(PROVIDER_NAME).unwrap();
2170
2171 assert_eq!(
2174 converted.model.as_deref(),
2175 Some("google/gemini-2.5-pro-exp-03-25:free")
2176 );
2177 assert_eq!(converted.provider, "openrouter");
2178 assert!(matches!(
2179 converted.choice.first(),
2180 Some(completion::AssistantContent::Text(text)) if text.text == "CONTENT"
2181 ));
2182 }
2183
2184 #[test]
2185 fn openrouter_finish_reasons_map_and_preserve_unknown_values() {
2186 use crate::completion::FinishReason;
2187
2188 let choice = |finish_reason: Option<&str>, native: Option<&str>| Choice {
2189 index: 0,
2190 native_finish_reason: native.map(str::to_string),
2191 message: Message::Assistant {
2192 content: vec![],
2193 reasoning: None,
2194 refusal: None,
2195 audio: None,
2196 name: None,
2197 tool_calls: vec![],
2198 reasoning_details: vec![],
2199 images: vec![],
2200 },
2201 finish_reason: finish_reason.map(str::to_string),
2202 logprobs: None,
2203 };
2204
2205 assert_eq!(
2206 map_finish_reason(&choice(Some("stop"), Some("STOP"))),
2207 Some(FinishReason::Stop)
2208 );
2209 assert_eq!(
2210 map_finish_reason(&choice(Some("length"), None)),
2211 Some(FinishReason::Length)
2212 );
2213 assert_eq!(
2214 map_finish_reason(&choice(Some("tool_calls"), None)),
2215 Some(FinishReason::ToolCalls)
2216 );
2217 assert_eq!(
2218 map_finish_reason(&choice(Some("content_filter"), None)),
2219 Some(FinishReason::ContentFilter)
2220 );
2221 assert_eq!(
2222 map_finish_reason(&choice(None, Some("completed"))),
2223 Some(FinishReason::Stop)
2224 );
2225 assert_eq!(
2226 map_finish_reason(&choice(None, Some("max_output_tokens"))),
2227 Some(FinishReason::Length)
2228 );
2229 assert_eq!(
2232 map_finish_reason(&choice(Some("error"), None)),
2233 Some(FinishReason::Other("error".to_string()))
2234 );
2235 assert_eq!(
2238 map_finish_reason(&choice(None, Some("MALFORMED_FUNCTION_CALL"))),
2239 Some(FinishReason::Other("MALFORMED_FUNCTION_CALL".to_string()))
2240 );
2241 assert_eq!(map_finish_reason(&choice(None, None)), None);
2242 }
2243
2244 #[test]
2245 fn openrouter_stop_with_tool_call_reports_tool_calls() {
2246 let json = json!({
2249 "id": "gen-tool",
2250 "object": "chat.completion",
2251 "created": 1,
2252 "model": "anthropic/claude-3.5-sonnet",
2253 "choices": [{
2254 "index": 0,
2255 "finish_reason": "stop",
2256 "message": {
2257 "role": "assistant",
2258 "content": "",
2259 "tool_calls": [{
2260 "id": "call_1",
2261 "type": "function",
2262 "function": {"name": "lookup", "arguments": "{}"}
2263 }]
2264 }
2265 }]
2266 });
2267
2268 let response: CompletionResponse = serde_json::from_value(json).unwrap();
2269 let converted = response.normalize(PROVIDER_NAME).unwrap();
2270
2271 assert_eq!(
2272 converted.finish_reason(),
2273 Some(crate::completion::FinishReason::ToolCalls)
2274 );
2275 }
2276
2277 #[test]
2284 fn openrouter_truncated_tool_arguments_do_not_destroy_the_response() {
2285 let json = json!({
2286 "id": "gen-truncated",
2287 "object": "chat.completion",
2288 "created": 1,
2289 "model": "deepseek/deepseek-chat",
2290 "choices": [{
2291 "index": 0,
2292 "finish_reason": "length",
2293 "message": {
2294 "role": "assistant",
2295 "content": "Acknowledged.",
2296 "tool_calls": [
2297 {
2298 "id": "call_1",
2299 "type": "function",
2300 "function": {"name": "page", "arguments": "{\"team\":\"platform\"}"}
2301 },
2302 {
2303 "id": "call_2",
2304 "type": "function",
2305 "function": {"name": "file_report", "arguments": "{\"summary\": "}
2306 }
2307 ]
2308 }
2309 }],
2310 "usage": {"prompt_tokens": 10, "completion_tokens": 24, "total_tokens": 34}
2311 });
2312
2313 let response: CompletionResponse = serde_json::from_value(json).unwrap();
2314 let converted = response.normalize(PROVIDER_NAME).unwrap();
2315
2316 assert_eq!(
2317 converted.finish_reason(),
2318 Some(crate::completion::FinishReason::Length)
2319 );
2320 let names = converted
2321 .choice
2322 .iter()
2323 .filter_map(|content| match content {
2324 completion::AssistantContent::ToolCall(call) => Some(call.function.name.as_str()),
2325 _ => None,
2326 })
2327 .collect::<Vec<_>>();
2328 assert_eq!(names, vec!["page"], "only the truncated call is dropped");
2329 assert!(
2330 converted.choice.iter().any(|content| matches!(
2331 content,
2332 completion::AssistantContent::Text(text) if text.text == "Acknowledged."
2333 )),
2334 "the turn's text survives: {:?}",
2335 converted.choice
2336 );
2337 assert_eq!(converted.usage.total_tokens, 34);
2338 }
2339
2340 #[test]
2341 fn openrouter_native_length_fallback_tolerates_truncated_tool_arguments() {
2342 let json = json!({
2343 "id": "gen-native-truncated",
2344 "object": "chat.completion",
2345 "created": 1,
2346 "model": "anthropic/claude-haiku-4.5",
2347 "choices": [{
2348 "index": 0,
2349 "finish_reason": null,
2350 "native_finish_reason": "max_output_tokens",
2351 "message": {
2352 "role": "assistant",
2353 "content": "still useful",
2354 "tool_calls": [{
2355 "id": "call_1",
2356 "type": "function",
2357 "function": {"name": "lookup", "arguments": "{\"q\":"}
2358 }]
2359 }
2360 }]
2361 });
2362
2363 let response: CompletionResponse = serde_json::from_value(json)
2364 .expect("the native terminal reason should authorize narrow truncation tolerance");
2365 let converted = response.normalize(PROVIDER_NAME).unwrap();
2366
2367 assert_eq!(
2368 converted.finish_reason(),
2369 Some(crate::completion::FinishReason::Length)
2370 );
2371 assert!(
2372 converted
2373 .choice
2374 .iter()
2375 .all(|content| !matches!(content, completion::AssistantContent::ToolCall(_)))
2376 );
2377 assert!(matches!(
2378 converted.choice.first(),
2379 Some(completion::AssistantContent::Text(text)) if text.text == "still useful"
2380 ));
2381 }
2382
2383 #[test]
2384 fn openrouter_length_preserves_an_empty_turn_after_dropping_its_only_call() {
2385 for (finish_reason, native_finish_reason) in
2386 [(Some("length"), None), (None, Some("max_output_tokens"))]
2387 {
2388 let response: CompletionResponse = serde_json::from_value(json!({
2389 "id": "gen-empty-truncated",
2390 "object": "chat.completion",
2391 "created": 1,
2392 "model": "openai/gpt-4.1-mini",
2393 "choices": [{
2394 "index": 0,
2395 "finish_reason": finish_reason,
2396 "native_finish_reason": native_finish_reason,
2397 "message": {
2398 "role": "assistant",
2399 "content": null,
2400 "tool_calls": [{
2401 "id": "call_1",
2402 "type": "function",
2403 "function": {"name": "lookup", "arguments": ""}
2404 }]
2405 }
2406 }],
2407 "usage": {"prompt_tokens": 10, "completion_tokens": 1, "total_tokens": 11}
2408 }))
2409 .expect("outer length should permit dropping the incomplete call");
2410 let converted = response
2411 .normalize(PROVIDER_NAME)
2412 .expect("an empty truncated turn still carries its diagnostic");
2413
2414 assert!(converted.choice.is_empty());
2415 assert_eq!(
2416 converted.finish_reason(),
2417 Some(crate::completion::FinishReason::Length)
2418 );
2419 assert_eq!(converted.usage.total_tokens, 11);
2420 assert_eq!(
2421 converted.response_id.as_deref(),
2422 Some("gen-empty-truncated")
2423 );
2424 }
2425 }
2426
2427 #[test]
2428 fn openrouter_content_filter_preserves_an_empty_turn() {
2429 let response: CompletionResponse = serde_json::from_value(json!({
2430 "id": "gen-filtered",
2431 "object": "chat.completion",
2432 "created": 1,
2433 "model": "openai/gpt-4.1-mini",
2434 "choices": [{
2435 "index": 0,
2436 "finish_reason": "content_filter",
2437 "message": {"role": "assistant", "content": null}
2438 }]
2439 }))
2440 .unwrap();
2441 let converted = response.normalize(PROVIDER_NAME).unwrap();
2442
2443 assert!(converted.choice.is_empty());
2444 assert_eq!(
2445 converted.finish_reason(),
2446 Some(crate::completion::FinishReason::ContentFilter)
2447 );
2448 }
2449
2450 #[test]
2451 fn openrouter_malformed_completed_tool_arguments_remain_loud() {
2452 let json = json!({
2453 "id": "gen-malformed",
2454 "object": "chat.completion",
2455 "created": 1,
2456 "model": "deepseek/deepseek-chat",
2457 "choices": [{
2458 "index": 0,
2459 "finish_reason": "tool_calls",
2460 "native_finish_reason": "max_output_tokens",
2461 "message": {
2462 "role": "assistant",
2463 "content": "",
2464 "tool_calls": [{
2465 "id": "call_1",
2466 "type": "function",
2467 "function": {"name": "lookup", "arguments": "{\"q\":"}
2468 }]
2469 }
2470 }]
2471 });
2472
2473 assert!(
2474 serde_json::from_value::<CompletionResponse>(json).is_err(),
2475 "only an outer output-length reason authorizes truncation tolerance"
2476 );
2477 }
2478
2479 #[tokio::test]
2480 async fn streaming_native_length_fallback_drops_partial_tool_call() {
2481 use crate::client::CompletionClient;
2482 use crate::completion::CompletionModel as _;
2483 use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_data_lines;
2484 use crate::streaming::StreamedAssistantContent;
2485 use crate::test_utils::MockStreamingClient;
2486 use futures::StreamExt;
2487
2488 let http_client = MockStreamingClient {
2489 sse_bytes: sse_bytes_from_data_lines([
2490 r#"{"id":"gen-native-truncated","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"still useful","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":"}}]},"finish_reason":null,"native_finish_reason":null}]}"#,
2491 r#"{"id":"gen-native-truncated","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{},"finish_reason":null,"native_finish_reason":"max_output_tokens"}]}"#,
2492 "[DONE]",
2493 ]),
2494 };
2495 let client = crate::providers::openrouter::Client::builder()
2496 .api_key("dummy-key")
2497 .http_client(http_client)
2498 .build()
2499 .expect("client should build");
2500 let model = client.completion_model("anthropic/claude-haiku-4.5");
2501 let request = model.completion_request("lookup").build();
2502 let mut stream = model.stream(request).await.expect("stream should start");
2503
2504 let mut terminal = None;
2505 let mut saw_tool_call = false;
2506 while let Some(item) = stream.next().await {
2507 match item.expect("native max_tokens truncation is tolerated") {
2508 StreamedAssistantContent::ToolCall { .. } => saw_tool_call = true,
2509 StreamedAssistantContent::Final(final_record) => terminal = Some(final_record),
2510 _ => {}
2511 }
2512 }
2513
2514 assert!(
2515 !saw_tool_call,
2516 "the partial call must not become executable"
2517 );
2518 assert_eq!(
2519 terminal.and_then(|record| record.finish_reason),
2520 Some(crate::completion::FinishReason::Length)
2521 );
2522 }
2523
2524 #[tokio::test]
2525 async fn streaming_normalized_reason_wins_over_native_length() {
2526 use crate::client::CompletionClient;
2527 use crate::completion::CompletionModel as _;
2528 use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_data_lines;
2529 use crate::streaming::StreamedAssistantContent;
2530 use crate::test_utils::MockStreamingClient;
2531 use futures::StreamExt;
2532
2533 let http_client = MockStreamingClient {
2534 sse_bytes: sse_bytes_from_data_lines([
2535 r#"{"id":"gen-normalized-wins","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":"}}]},"finish_reason":null,"native_finish_reason":null}]}"#,
2536 r#"{"id":"gen-normalized-wins","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls","native_finish_reason":"max_output_tokens"}]}"#,
2537 "[DONE]",
2538 ]),
2539 };
2540 let client = crate::providers::openrouter::Client::builder()
2541 .api_key("dummy-key")
2542 .http_client(http_client)
2543 .build()
2544 .expect("client should build");
2545 let model = client.completion_model("anthropic/claude-haiku-4.5");
2546 let request = model.completion_request("lookup").build();
2547 let mut stream = model.stream(request).await.expect("stream should start");
2548
2549 let mut terminal = None;
2550 let mut errors = Vec::new();
2551 while let Some(item) = stream.next().await {
2552 match item {
2553 Ok(StreamedAssistantContent::Final(final_record)) => terminal = Some(final_record),
2554 Ok(_) => {}
2555 Err(error) => errors.push(error.to_string()),
2556 }
2557 }
2558
2559 assert_eq!(errors.len(), 1, "the completed malformed call stays loud");
2560 assert!(errors[0].contains("malformed JSON input"), "{}", errors[0]);
2561 assert_eq!(
2562 terminal.and_then(|record| record.finish_reason),
2563 Some(crate::completion::FinishReason::ToolCalls)
2564 );
2565 }
2566
2567 #[test]
2568 fn test_message_assistant_without_reasoning_details() {
2569 let json = json!({
2571 "role": "assistant",
2572 "content": "Hello world",
2573 "refusal": null,
2574 "reasoning": null
2575 });
2576
2577 let message: Message = serde_json::from_value(json).unwrap();
2578 match message {
2579 Message::Assistant {
2580 content,
2581 reasoning_details,
2582 ..
2583 } => {
2584 assert_eq!(content.len(), 1);
2585 assert!(reasoning_details.is_empty());
2586 }
2587 _ => panic!("Expected Assistant message"),
2588 }
2589 }
2590
2591 #[test]
2592 fn test_data_collection_serialization() {
2593 assert_eq!(
2594 serde_json::to_string(&DataCollection::Allow).unwrap(),
2595 r#""allow""#
2596 );
2597 assert_eq!(
2598 serde_json::to_string(&DataCollection::Deny).unwrap(),
2599 r#""deny""#
2600 );
2601 }
2602
2603 #[test]
2604 fn test_data_collection_default() {
2605 assert_eq!(DataCollection::default(), DataCollection::Allow);
2606 }
2607
2608 #[test]
2609 fn test_quantization_serialization() {
2610 assert_eq!(
2611 serde_json::to_string(&Quantization::Int4).unwrap(),
2612 r#""int4""#
2613 );
2614 assert_eq!(
2615 serde_json::to_string(&Quantization::Int8).unwrap(),
2616 r#""int8""#
2617 );
2618 assert_eq!(
2619 serde_json::to_string(&Quantization::Fp16).unwrap(),
2620 r#""fp16""#
2621 );
2622 assert_eq!(
2623 serde_json::to_string(&Quantization::Bf16).unwrap(),
2624 r#""bf16""#
2625 );
2626 assert_eq!(
2627 serde_json::to_string(&Quantization::Fp32).unwrap(),
2628 r#""fp32""#
2629 );
2630 assert_eq!(
2631 serde_json::to_string(&Quantization::Fp8).unwrap(),
2632 r#""fp8""#
2633 );
2634 assert_eq!(
2635 serde_json::to_string(&Quantization::Unknown).unwrap(),
2636 r#""unknown""#
2637 );
2638 }
2639
2640 #[test]
2641 fn test_provider_sort_strategy_serialization() {
2642 assert_eq!(
2643 serde_json::to_string(&ProviderSortStrategy::Price).unwrap(),
2644 r#""price""#
2645 );
2646 assert_eq!(
2647 serde_json::to_string(&ProviderSortStrategy::Throughput).unwrap(),
2648 r#""throughput""#
2649 );
2650 assert_eq!(
2651 serde_json::to_string(&ProviderSortStrategy::Latency).unwrap(),
2652 r#""latency""#
2653 );
2654 }
2655
2656 #[test]
2657 fn test_sort_partition_serialization() {
2658 assert_eq!(
2659 serde_json::to_string(&SortPartition::Model).unwrap(),
2660 r#""model""#
2661 );
2662 assert_eq!(
2663 serde_json::to_string(&SortPartition::None).unwrap(),
2664 r#""none""#
2665 );
2666 }
2667
2668 #[test]
2669 fn test_provider_sort_simple() {
2670 let sort = ProviderSort::Simple(ProviderSortStrategy::Latency);
2671 let json = serde_json::to_value(&sort).unwrap();
2672 assert_eq!(json, "latency");
2673 }
2674
2675 #[test]
2676 fn test_provider_sort_complex() {
2677 let sort = ProviderSort::Complex(
2678 ProviderSortConfig::new(ProviderSortStrategy::Price).partition(SortPartition::None),
2679 );
2680 let json = serde_json::to_value(&sort).unwrap();
2681 assert_eq!(json["by"], "price");
2682 assert_eq!(json["partition"], "none");
2683 }
2684
2685 #[test]
2686 fn test_provider_sort_complex_without_partition() {
2687 let sort = ProviderSort::Complex(ProviderSortConfig::new(ProviderSortStrategy::Throughput));
2688 let json = serde_json::to_value(&sort).unwrap();
2689 assert_eq!(json["by"], "throughput");
2690 assert!(json.get("partition").is_none());
2691 }
2692
2693 #[test]
2694 fn test_provider_sort_from_strategy() {
2695 let sort: ProviderSort = ProviderSortStrategy::Price.into();
2696 assert_eq!(sort, ProviderSort::Simple(ProviderSortStrategy::Price));
2697 }
2698
2699 #[test]
2700 fn test_provider_sort_from_config() {
2701 let config = ProviderSortConfig::new(ProviderSortStrategy::Latency);
2702 let sort: ProviderSort = config.into();
2703 match sort {
2704 ProviderSort::Complex(c) => assert_eq!(c.by, ProviderSortStrategy::Latency),
2705 _ => panic!("Expected Complex variant"),
2706 }
2707 }
2708
2709 #[test]
2710 fn test_percentile_thresholds_builder() {
2711 let thresholds = PercentileThresholds::new()
2712 .p50(10.0)
2713 .p75(25.0)
2714 .p90(50.0)
2715 .p99(100.0);
2716
2717 assert_eq!(thresholds.p50, Some(10.0));
2718 assert_eq!(thresholds.p75, Some(25.0));
2719 assert_eq!(thresholds.p90, Some(50.0));
2720 assert_eq!(thresholds.p99, Some(100.0));
2721 }
2722
2723 #[test]
2724 fn test_percentile_thresholds_default() {
2725 let thresholds = PercentileThresholds::default();
2726 assert_eq!(thresholds.p50, None);
2727 assert_eq!(thresholds.p75, None);
2728 assert_eq!(thresholds.p90, None);
2729 assert_eq!(thresholds.p99, None);
2730 }
2731
2732 #[test]
2733 fn test_throughput_threshold_simple() {
2734 let threshold = ThroughputThreshold::Simple(50.0);
2735 let json = serde_json::to_value(&threshold).unwrap();
2736 assert_eq!(json, 50.0);
2737 }
2738
2739 #[test]
2740 fn test_throughput_threshold_percentile() {
2741 let threshold = ThroughputThreshold::Percentile(PercentileThresholds::new().p90(50.0));
2742 let json = serde_json::to_value(&threshold).unwrap();
2743 assert_eq!(json["p90"], 50.0);
2744 }
2745
2746 #[test]
2747 fn test_latency_threshold_simple() {
2748 let threshold = LatencyThreshold::Simple(0.5);
2749 let json = serde_json::to_value(&threshold).unwrap();
2750 assert_eq!(json, 0.5);
2751 }
2752
2753 #[test]
2754 fn test_latency_threshold_percentile() {
2755 let threshold = LatencyThreshold::Percentile(PercentileThresholds::new().p50(0.1).p99(1.0));
2756 let json = serde_json::to_value(&threshold).unwrap();
2757 assert_eq!(json["p50"], 0.1);
2758 assert_eq!(json["p99"], 1.0);
2759 }
2760
2761 #[test]
2762 fn test_max_price_builder() {
2763 let price = MaxPrice::new().prompt(0.001).completion(0.002);
2764
2765 assert_eq!(price.prompt, Some(0.001));
2766 assert_eq!(price.completion, Some(0.002));
2767 assert_eq!(price.request, None);
2768 assert_eq!(price.image, None);
2769 }
2770
2771 #[test]
2772 fn test_max_price_all_fields() {
2773 let price = MaxPrice::new()
2774 .prompt(0.001)
2775 .completion(0.002)
2776 .request(0.01)
2777 .image(0.05);
2778
2779 let json = serde_json::to_value(&price).unwrap();
2780 assert_eq!(json["prompt"], 0.001);
2781 assert_eq!(json["completion"], 0.002);
2782 assert_eq!(json["request"], 0.01);
2783 assert_eq!(json["image"], 0.05);
2784 }
2785
2786 #[test]
2787 fn test_max_price_default() {
2788 let price = MaxPrice::default();
2789 assert_eq!(price.prompt, None);
2790 assert_eq!(price.completion, None);
2791 assert_eq!(price.request, None);
2792 assert_eq!(price.image, None);
2793 }
2794
2795 #[test]
2796 fn test_provider_preferences_default() {
2797 let prefs = ProviderPreferences::default();
2798 assert!(prefs.order.is_none());
2799 assert!(prefs.only.is_none());
2800 assert!(prefs.ignore.is_none());
2801 assert!(prefs.allow_fallbacks.is_none());
2802 assert!(prefs.require_parameters.is_none());
2803 assert!(prefs.data_collection.is_none());
2804 assert!(prefs.zdr.is_none());
2805 assert!(prefs.sort.is_none());
2806 assert!(prefs.preferred_min_throughput.is_none());
2807 assert!(prefs.preferred_max_latency.is_none());
2808 assert!(prefs.max_price.is_none());
2809 assert!(prefs.quantizations.is_none());
2810 }
2811
2812 #[test]
2813 fn test_provider_preferences_order_with_fallbacks() {
2814 let prefs = ProviderPreferences::new()
2815 .order(["anthropic", "openai"])
2816 .allow_fallbacks(true);
2817
2818 let json = prefs.to_json();
2819 let provider = &json["provider"];
2820
2821 assert_eq!(provider["order"], json!(["anthropic", "openai"]));
2822 assert_eq!(provider["allow_fallbacks"], true);
2823 }
2824
2825 #[test]
2826 fn test_provider_preferences_only_allowlist() {
2827 let prefs = ProviderPreferences::new()
2828 .only(["azure", "together"])
2829 .allow_fallbacks(false);
2830
2831 let json = prefs.to_json();
2832 let provider = &json["provider"];
2833
2834 assert_eq!(provider["only"], json!(["azure", "together"]));
2835 assert_eq!(provider["allow_fallbacks"], false);
2836 }
2837
2838 #[test]
2839 fn test_provider_preferences_ignore() {
2840 let prefs = ProviderPreferences::new().ignore(["deepinfra"]);
2841
2842 let json = prefs.to_json();
2843 let provider = &json["provider"];
2844
2845 assert_eq!(provider["ignore"], json!(["deepinfra"]));
2846 }
2847
2848 #[test]
2849 fn test_provider_preferences_sort_latency() {
2850 let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Latency);
2851
2852 let json = prefs.to_json();
2853 let provider = &json["provider"];
2854
2855 assert_eq!(provider["sort"], "latency");
2856 }
2857
2858 #[test]
2859 fn test_provider_preferences_price_with_throughput() {
2860 let prefs = ProviderPreferences::new()
2861 .sort(ProviderSortStrategy::Price)
2862 .preferred_min_throughput(ThroughputThreshold::Percentile(
2863 PercentileThresholds::new().p90(50.0),
2864 ));
2865
2866 let json = prefs.to_json();
2867 let provider = &json["provider"];
2868
2869 assert_eq!(provider["sort"], "price");
2870 assert_eq!(provider["preferred_min_throughput"]["p90"], 50.0);
2871 }
2872
2873 #[test]
2874 fn test_provider_preferences_require_parameters() {
2875 let prefs = ProviderPreferences::new().require_parameters(true);
2876
2877 let json = prefs.to_json();
2878 let provider = &json["provider"];
2879
2880 assert_eq!(provider["require_parameters"], true);
2881 }
2882
2883 #[test]
2884 fn test_provider_preferences_data_policy_and_zdr() {
2885 let prefs = ProviderPreferences::new()
2886 .data_collection(DataCollection::Deny)
2887 .zdr(true);
2888
2889 let json = prefs.to_json();
2890 let provider = &json["provider"];
2891
2892 assert_eq!(provider["data_collection"], "deny");
2893 assert_eq!(provider["zdr"], true);
2894 }
2895
2896 #[test]
2897 fn test_provider_preferences_quantizations() {
2898 let prefs =
2899 ProviderPreferences::new().quantizations([Quantization::Int8, Quantization::Fp16]);
2900
2901 let json = prefs.to_json();
2902 let provider = &json["provider"];
2903
2904 assert_eq!(provider["quantizations"], json!(["int8", "fp16"]));
2905 }
2906
2907 #[test]
2908 fn test_provider_preferences_convenience_methods() {
2909 let prefs = ProviderPreferences::new().zero_data_retention().fastest();
2910
2911 assert_eq!(prefs.zdr, Some(true));
2912 assert_eq!(
2913 prefs.sort,
2914 Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
2915 );
2916
2917 let prefs2 = ProviderPreferences::new().cheapest();
2918 assert_eq!(
2919 prefs2.sort,
2920 Some(ProviderSort::Simple(ProviderSortStrategy::Price))
2921 );
2922
2923 let prefs3 = ProviderPreferences::new().lowest_latency();
2924 assert_eq!(
2925 prefs3.sort,
2926 Some(ProviderSort::Simple(ProviderSortStrategy::Latency))
2927 );
2928 }
2929
2930 #[test]
2931 fn test_provider_preferences_serialization_skips_none() {
2932 let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Price);
2933
2934 let json = serde_json::to_value(&prefs).unwrap();
2935
2936 assert_eq!(json["sort"], "price");
2937 assert!(json.get("order").is_none());
2938 assert!(json.get("only").is_none());
2939 assert!(json.get("ignore").is_none());
2940 assert!(json.get("zdr").is_none());
2941 }
2942
2943 #[test]
2944 fn test_provider_preferences_deserialization() {
2945 let json = json!({
2946 "order": ["anthropic", "openai"],
2947 "sort": "throughput",
2948 "data_collection": "deny",
2949 "zdr": true,
2950 "quantizations": ["int8", "fp16"]
2951 });
2952
2953 let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
2954
2955 assert_eq!(
2956 prefs.order,
2957 Some(vec!["anthropic".to_string(), "openai".to_string()])
2958 );
2959 assert_eq!(
2960 prefs.sort,
2961 Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
2962 );
2963 assert_eq!(prefs.data_collection, Some(DataCollection::Deny));
2964 assert_eq!(prefs.zdr, Some(true));
2965 assert_eq!(
2966 prefs.quantizations,
2967 Some(vec![Quantization::Int8, Quantization::Fp16])
2968 );
2969 }
2970
2971 #[test]
2972 fn test_provider_preferences_deserialization_complex_sort() {
2973 let json = json!({
2974 "sort": {
2975 "by": "latency",
2976 "partition": "model"
2977 }
2978 });
2979
2980 let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
2981
2982 match prefs.sort {
2983 Some(ProviderSort::Complex(config)) => {
2984 assert_eq!(config.by, ProviderSortStrategy::Latency);
2985 assert_eq!(config.partition, Some(SortPartition::Model));
2986 }
2987 _ => panic!("Expected Complex sort variant"),
2988 }
2989 }
2990
2991 #[test]
2992 fn test_provider_preferences_full_integration() {
2993 let prefs = ProviderPreferences::new()
2994 .order(["anthropic", "openai"])
2995 .only(["anthropic", "openai", "google"])
2996 .sort(ProviderSortStrategy::Throughput)
2997 .data_collection(DataCollection::Deny)
2998 .zdr(true)
2999 .quantizations([Quantization::Int8])
3000 .allow_fallbacks(false);
3001
3002 let json = prefs.to_json();
3003
3004 assert!(json.get("provider").is_some());
3005 let provider = &json["provider"];
3006 assert_eq!(provider["order"], json!(["anthropic", "openai"]));
3007 assert_eq!(provider["only"], json!(["anthropic", "openai", "google"]));
3008 assert_eq!(provider["sort"], "throughput");
3009 assert_eq!(provider["data_collection"], "deny");
3010 assert_eq!(provider["zdr"], true);
3011 assert_eq!(provider["quantizations"], json!(["int8"]));
3012 assert_eq!(provider["allow_fallbacks"], false);
3013 }
3014
3015 #[test]
3016 fn test_provider_preferences_max_price() {
3017 let prefs =
3018 ProviderPreferences::new().max_price(MaxPrice::new().prompt(0.001).completion(0.002));
3019
3020 let json = prefs.to_json();
3021 let provider = &json["provider"];
3022
3023 assert_eq!(provider["max_price"]["prompt"], 0.001);
3024 assert_eq!(provider["max_price"]["completion"], 0.002);
3025 }
3026
3027 #[test]
3028 fn test_provider_preferences_preferred_max_latency() {
3029 let prefs = ProviderPreferences::new().preferred_max_latency(LatencyThreshold::Simple(0.5));
3030
3031 let json = prefs.to_json();
3032 let provider = &json["provider"];
3033
3034 assert_eq!(provider["preferred_max_latency"], 0.5);
3035 }
3036
3037 #[test]
3038 fn test_provider_preferences_empty_arrays() {
3039 let prefs = ProviderPreferences::new()
3040 .order(Vec::<String>::new())
3041 .quantizations(Vec::<Quantization>::new());
3042
3043 let json = prefs.to_json();
3044 let provider = &json["provider"];
3045
3046 assert_eq!(provider["order"], json!([]));
3047 assert_eq!(provider["quantizations"], json!([]));
3048 }
3049
3050 #[test]
3055 fn test_user_content_text_serialization() {
3056 let content = UserContent::Text {
3057 text: "Hello, world!".to_string(),
3058 };
3059 let json = serde_json::to_value(&content).unwrap();
3060
3061 assert_eq!(json["type"], "text");
3062 assert_eq!(json["text"], "Hello, world!");
3063 }
3064
3065 #[test]
3066 fn test_user_content_image_url_serialization() {
3067 let content = UserContent::Image {
3068 image_url: ImageUrl {
3069 url: "https://example.com/image.png".to_string(),
3070 detail: None,
3071 },
3072 };
3073 let json = serde_json::to_value(&content).unwrap();
3074
3075 assert_eq!(json["type"], "image_url");
3076 assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
3077 assert!(json["image_url"].get("detail").is_none());
3078 }
3079
3080 #[test]
3081 fn test_user_content_image_url_with_detail_serialization() {
3082 let content = UserContent::Image {
3083 image_url: ImageUrl {
3084 url: "https://example.com/image.png".to_string(),
3085 detail: Some(ImageDetail::High),
3086 },
3087 };
3088 let json = serde_json::to_value(&content).unwrap();
3089
3090 assert_eq!(json["type"], "image_url");
3091 assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
3092 assert_eq!(json["image_url"]["detail"], "high");
3093 }
3094
3095 #[test]
3096 fn test_user_content_image_base64_serialization() {
3097 let content = UserContent::Image {
3098 image_url: ImageUrl {
3099 url: "data:image/png;base64,SGVsbG8=".to_string(),
3100 detail: Some(ImageDetail::Low),
3101 },
3102 };
3103 let json = serde_json::to_value(&content).unwrap();
3104
3105 assert_eq!(json["type"], "image_url");
3106 assert_eq!(json["image_url"]["url"], "data:image/png;base64,SGVsbG8=");
3107 assert_eq!(json["image_url"]["detail"], "low");
3108 }
3109
3110 #[test]
3111 fn test_user_content_file_url_serialization() {
3112 let content = UserContent::File {
3113 file: FileData {
3114 file_data: Some("https://example.com/doc.pdf".to_string()),
3115 file_id: None,
3116 filename: Some("document.pdf".to_string()),
3117 },
3118 };
3119 let json = serde_json::to_value(&content).unwrap();
3120
3121 assert_eq!(json["type"], "file");
3122 assert_eq!(json["file"]["file_data"], "https://example.com/doc.pdf");
3123 assert_eq!(json["file"]["filename"], "document.pdf");
3124 }
3125
3126 #[test]
3127 fn test_user_content_file_base64_serialization() {
3128 let content = UserContent::File {
3129 file: FileData {
3130 file_data: Some("data:application/pdf;base64,JVBERi0xLjQ=".to_string()),
3131 file_id: None,
3132 filename: Some("report.pdf".to_string()),
3133 },
3134 };
3135 let json = serde_json::to_value(&content).unwrap();
3136
3137 assert_eq!(json["type"], "file");
3138 assert_eq!(
3139 json["file"]["file_data"],
3140 "data:application/pdf;base64,JVBERi0xLjQ="
3141 );
3142 assert_eq!(json["file"]["filename"], "report.pdf");
3143 }
3144
3145 #[test]
3146 fn test_user_content_text_deserialization() {
3147 let json = json!({
3148 "type": "text",
3149 "text": "Hello!"
3150 });
3151
3152 let content: UserContent = serde_json::from_value(json).unwrap();
3153 assert_eq!(
3154 content,
3155 UserContent::Text {
3156 text: "Hello!".to_string()
3157 }
3158 );
3159 }
3160
3161 #[test]
3162 fn test_user_content_image_url_deserialization() {
3163 let json = json!({
3164 "type": "image_url",
3165 "image_url": {
3166 "url": "https://example.com/img.jpg",
3167 "detail": "high"
3168 }
3169 });
3170
3171 let content: UserContent = serde_json::from_value(json).unwrap();
3172 match content {
3173 UserContent::Image { image_url } => {
3174 assert_eq!(image_url.url, "https://example.com/img.jpg");
3175 assert_eq!(image_url.detail, Some(ImageDetail::High));
3176 }
3177 _ => panic!("Expected Image variant"),
3178 }
3179 }
3180
3181 #[test]
3182 fn test_user_content_file_deserialization() {
3183 let json = json!({
3184 "type": "file",
3185 "file": {
3186 "filename": "doc.pdf",
3187 "file_data": "https://example.com/doc.pdf"
3188 }
3189 });
3190
3191 let content: UserContent = serde_json::from_value(json).unwrap();
3192 match content {
3193 UserContent::File { file } => {
3194 assert_eq!(file.filename, Some("doc.pdf".to_string()));
3195 assert_eq!(
3196 file.file_data,
3197 Some("https://example.com/doc.pdf".to_string())
3198 );
3199 }
3200 _ => panic!("Expected File variant"),
3201 }
3202 }
3203
3204 #[test]
3205 fn test_message_user_with_text_serialization() {
3206 let message = Message::User {
3207 content: vec![UserContent::Text {
3208 text: "Hello".to_string(),
3209 }],
3210 name: None,
3211 };
3212 let json = serde_json::to_value(&message).unwrap();
3213
3214 assert_eq!(json["role"], "user");
3216 assert_eq!(json["content"], "Hello");
3217 }
3218
3219 #[test]
3220 fn test_message_user_with_mixed_content_serialization() {
3221 let message = Message::User {
3222 content: vec![
3223 UserContent::Text {
3224 text: "Check this image:".to_string(),
3225 },
3226 UserContent::Image {
3227 image_url: ImageUrl {
3228 url: "https://example.com/img.png".to_string(),
3229 detail: None,
3230 },
3231 },
3232 ],
3233 name: None,
3234 };
3235 let json = serde_json::to_value(&message).unwrap();
3236
3237 assert_eq!(json["role"], "user");
3238 let content = json["content"].as_array().unwrap();
3239 assert_eq!(content.len(), 2);
3240 assert_eq!(content[0]["type"], "text");
3241 assert_eq!(content[1]["type"], "image_url");
3242 }
3243
3244 #[test]
3245 fn test_message_user_with_file_serialization() {
3246 let message = Message::User {
3247 content: vec![
3248 UserContent::Text {
3249 text: "Analyze this PDF:".to_string(),
3250 },
3251 UserContent::File {
3252 file: FileData {
3253 file_data: Some("https://example.com/doc.pdf".to_string()),
3254 file_id: None,
3255 filename: Some("document.pdf".to_string()),
3256 },
3257 },
3258 ],
3259 name: None,
3260 };
3261 let json = serde_json::to_value(&message).unwrap();
3262
3263 assert_eq!(json["role"], "user");
3264 let content = json["content"].as_array().unwrap();
3265 assert_eq!(content.len(), 2);
3266 assert_eq!(content[0]["type"], "text");
3267 assert_eq!(content[1]["type"], "file");
3268 assert_eq!(
3269 content[1]["file"]["file_data"],
3270 "https://example.com/doc.pdf"
3271 );
3272 }
3273
3274 #[test]
3275 fn test_user_content_from_rig_text() {
3276 let rig_content = message::UserContent::Text(message::Text::new("Hello".to_string()));
3277 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3278
3279 assert_eq!(
3280 openrouter_content,
3281 UserContent::Text {
3282 text: "Hello".to_string()
3283 }
3284 );
3285 }
3286
3287 #[test]
3288 fn test_user_content_from_rig_image_url() {
3289 let rig_content = message::UserContent::Image(message::Image {
3290 data: DocumentSourceKind::Url("https://example.com/img.png".to_string()),
3291 media_type: Some(message::ImageMediaType::PNG),
3292 detail: Some(ImageDetail::High),
3293 additional_params: None,
3294 });
3295 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3296
3297 match openrouter_content {
3298 UserContent::Image { image_url } => {
3299 assert_eq!(image_url.url, "https://example.com/img.png");
3300 assert_eq!(image_url.detail, Some(ImageDetail::High));
3301 }
3302 _ => panic!("Expected Image variant"),
3303 }
3304 }
3305
3306 #[test]
3307 fn test_user_content_from_rig_image_base64() {
3308 let rig_content = message::UserContent::Image(message::Image {
3309 data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3310 media_type: Some(message::ImageMediaType::JPEG),
3311 detail: Some(ImageDetail::Low),
3312 additional_params: None,
3313 });
3314 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3315
3316 match openrouter_content {
3317 UserContent::Image { image_url } => {
3318 assert_eq!(image_url.url, "data:image/jpeg;base64,SGVsbG8=");
3319 assert_eq!(image_url.detail, Some(ImageDetail::Low));
3320 }
3321 _ => panic!("Expected Image variant"),
3322 }
3323 }
3324
3325 #[test]
3326 fn test_user_content_from_rig_document_url() {
3327 let rig_content = message::UserContent::Document(message::Document {
3328 data: DocumentSourceKind::Url("https://example.com/doc.pdf".to_string()),
3329 media_type: Some(DocumentMediaType::PDF),
3330 additional_params: None,
3331 });
3332 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3333
3334 match openrouter_content {
3335 UserContent::File { file } => {
3336 assert_eq!(
3337 file.file_data,
3338 Some("https://example.com/doc.pdf".to_string())
3339 );
3340 assert_eq!(file.filename, Some("document.pdf".to_string()));
3341 }
3342 _ => panic!("Expected File variant"),
3343 }
3344 }
3345
3346 #[test]
3347 fn test_user_content_from_rig_document_base64() {
3348 let rig_content = message::UserContent::Document(message::Document {
3349 data: DocumentSourceKind::Base64("JVBERi0xLjQ=".to_string()),
3350 media_type: Some(DocumentMediaType::PDF),
3351 additional_params: None,
3352 });
3353 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3354
3355 match openrouter_content {
3356 UserContent::File { file } => {
3357 assert_eq!(
3358 file.file_data,
3359 Some("data:application/pdf;base64,JVBERi0xLjQ=".to_string())
3360 );
3361 assert_eq!(file.filename, Some("document.pdf".to_string()));
3362 }
3363 _ => panic!("Expected File variant"),
3364 }
3365 }
3366
3367 #[test]
3368 fn test_user_content_from_rig_document_file_id() {
3369 let rig_content = message::UserContent::Document(message::Document {
3370 data: DocumentSourceKind::FileId("file_abc".to_string()),
3371 media_type: None,
3372 additional_params: None,
3373 });
3374
3375 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3376 assert!(matches!(
3377 result,
3378 Err(message::MessageError::ConversionError(message))
3379 if message.contains("Provider file IDs are not supported")
3380 ));
3381 }
3382
3383 #[test]
3384 fn test_openai_file_id_content_round_trips_through_rig_to_openrouter_error() {
3385 let openai_content = openai::UserContent::File {
3386 file: openai::FileData {
3387 file_data: None,
3388 file_id: Some("file_abc".to_string()),
3389 filename: None,
3390 },
3391 };
3392 let rig_content: message::UserContent = openai_content.into();
3393
3394 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3395 assert!(matches!(
3396 result,
3397 Err(message::MessageError::ConversionError(message))
3398 if message.contains("Provider file IDs are not supported")
3399 ));
3400 }
3401
3402 #[test]
3403 fn test_user_content_from_rig_document_string_becomes_text() {
3404 let rig_content = message::UserContent::Document(message::Document {
3405 data: DocumentSourceKind::String("Plain text document content".to_string()),
3406 media_type: Some(DocumentMediaType::TXT),
3407 additional_params: None,
3408 });
3409 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3410
3411 assert_eq!(
3412 openrouter_content,
3413 UserContent::Text {
3414 text: "Plain text document content".to_string()
3415 }
3416 );
3417 }
3418
3419 #[test]
3420 fn test_completion_response_with_reasoning_details_maps_to_typed_reasoning() {
3421 let json = json!({
3422 "id": "resp_123",
3423 "object": "chat.completion",
3424 "created": 1,
3425 "model": "openrouter/test-model",
3426 "choices": [{
3427 "index": 0,
3428 "finish_reason": "stop",
3429 "message": {
3430 "role": "assistant",
3431 "content": "hello",
3432 "reasoning": null,
3433 "reasoning_details": [
3434 {"type":"reasoning.summary","id":"rs_1","summary":"s1"},
3435 {"type":"reasoning.text","id":"rs_1","text":"t1","signature":"sig_1"},
3436 {"type":"reasoning.encrypted","id":"rs_1","data":"enc_1"}
3437 ],
3438 "tool_calls": [{
3439 "id": "call_1",
3440 "type": "function",
3441 "function": {"name": "lookup", "arguments": "{}"}
3442 }]
3443 }
3444 }]
3445 });
3446
3447 let response: CompletionResponse = serde_json::from_value(json).unwrap();
3448 let converted = response.normalize(PROVIDER_NAME).unwrap();
3449 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3450
3451 assert_eq!(items.len(), 3, "reasoning, text, then tool call");
3452 assert!(matches!(
3453 &items[0],
3454 completion::AssistantContent::Reasoning(message::Reasoning { id: Some(id), content })
3455 if id == "rs_1" && content.len() == 3
3456 ));
3457 assert!(matches!(
3458 &items[1],
3459 completion::AssistantContent::Text(text) if text.text == "hello"
3460 ));
3461 assert!(matches!(
3462 &items[2],
3463 completion::AssistantContent::ToolCall(call) if call.function.name == "lookup"
3464 ));
3465 }
3466
3467 #[tokio::test]
3478 async fn streaming_encrypted_reasoning_detail_reaches_the_choice_and_replays() {
3479 use crate::client::CompletionClient;
3480 use crate::completion::CompletionModel as _;
3481 use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_data_lines;
3482 use crate::streaming::StreamedAssistantContent;
3483 use crate::test_utils::MockStreamingClient;
3484 use futures::StreamExt;
3485
3486 let http_client = MockStreamingClient {
3487 sse_bytes: sse_bytes_from_data_lines([
3488 r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":null,"reasoning_details":[{"type":"reasoning.encrypted","id":"rs_1","format":"openai-responses-v1","index":0,"data":"enc_blob"}]},"finish_reason":null}]}"#,
3489 r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}"#,
3490 r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":\"Tokyo\"}"}}]},"finish_reason":null}]}"#,
3491 r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#,
3492 "[DONE]",
3493 ]),
3494 };
3495
3496 let client = crate::providers::openrouter::Client::builder()
3497 .api_key("dummy-key")
3498 .http_client(http_client)
3499 .build()
3500 .expect("client should build");
3501 let model = client.completion_model("openai/o4-mini");
3502 let request = model.completion_request("weather?").build();
3503 let mut stream = model.stream(request).await.expect("stream should start");
3504
3505 let mut events: Vec<&'static str> = Vec::new();
3506 let mut streamed_tool_calls = Vec::new();
3507 while let Some(chunk) = stream.next().await {
3508 match chunk.expect("stream item should be ok") {
3509 StreamedAssistantContent::Reasoning { reasoning, .. } => {
3510 assert_eq!(reasoning.id.as_deref(), Some("rs_1"));
3511 assert!(matches!(
3512 reasoning.content.first(),
3513 Some(message::ReasoningContent::Encrypted(data)) if data == "enc_blob"
3514 ));
3515 events.push("reasoning");
3516 }
3517 StreamedAssistantContent::ToolCall { tool_call, .. } => {
3518 streamed_tool_calls.push(tool_call);
3519 events.push("tool_call");
3520 }
3521 _ => {}
3522 }
3523 }
3524
3525 assert_eq!(events, vec!["reasoning", "tool_call"]);
3528
3529 let tool_call = streamed_tool_calls.first().expect("streamed tool call");
3532 assert_eq!(tool_call.id, "call_1");
3533 assert!(tool_call.signature.is_none());
3534 assert!(tool_call.additional_params.is_none());
3535
3536 let choice: Vec<message::AssistantContent> = stream.choice.clone().into_iter().collect();
3538 assert!(
3539 choice.iter().any(|content| matches!(
3540 content,
3541 message::AssistantContent::Reasoning(message::Reasoning { id: Some(id), content })
3542 if id == "rs_1"
3543 && matches!(
3544 content.first(),
3545 Some(message::ReasoningContent::Encrypted(data)) if data == "enc_blob"
3546 )
3547 )),
3548 "encrypted reasoning must reach the aggregated choice: {choice:#?}"
3549 );
3550
3551 let messages =
3553 assistant_contents_to_messages(stream.choice.clone()).expect("history conversion");
3554 let Message::Assistant {
3555 reasoning_details, ..
3556 } = messages.first().expect("assistant message")
3557 else {
3558 panic!("Expected assistant message");
3559 };
3560 assert!(
3561 reasoning_details.iter().any(|detail| matches!(
3562 detail,
3563 ReasoningDetails::Encrypted { id: Some(id), data, .. }
3564 if id == "rs_1" && data == "enc_blob"
3565 )),
3566 "encrypted reasoning must replay as a reasoning_details entry: {reasoning_details:#?}"
3567 );
3568 }
3569
3570 #[tokio::test]
3575 async fn streaming_anthropic_reasoning_signature_reaches_choice_and_replays() {
3576 use crate::client::CompletionClient;
3577 use crate::completion::CompletionModel as _;
3578 use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_data_lines;
3579 use crate::test_utils::MockStreamingClient;
3580 use futures::StreamExt;
3581
3582 let http_client = MockStreamingClient {
3583 sse_bytes: sse_bytes_from_data_lines([
3584 r#"{"id":"chatcmpl-1","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"think first","reasoning_details":[{"type":"reasoning.text","format":"anthropic-claude-v1","index":0,"text":"think first"}]},"finish_reason":null,"native_finish_reason":null}]}"#,
3585 r#"{"id":"chatcmpl-1","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_details":[{"type":"reasoning.text","format":"anthropic-claude-v1","index":0,"signature":"sig-live-shape"}]},"finish_reason":null,"native_finish_reason":null}]}"#,
3586 r#"{"id":"chatcmpl-1","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"toolu_1","type":"function","function":{"name":"lookup","arguments":"{}"}}]},"finish_reason":null,"native_finish_reason":null}]}"#,
3587 r#"{"id":"chatcmpl-1","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls","native_finish_reason":"tool_use"}]}"#,
3588 "[DONE]",
3589 ]),
3590 };
3591
3592 let client = crate::providers::openrouter::Client::builder()
3593 .api_key("dummy-key")
3594 .http_client(http_client)
3595 .build()
3596 .expect("client should build");
3597 let model = client.completion_model("anthropic/claude-haiku-4.5");
3598 let request = model.completion_request("lookup").build();
3599 let mut stream = model.stream(request).await.expect("stream should start");
3600 while let Some(item) = stream.next().await {
3601 item.expect("signed reasoning stream item");
3602 }
3603
3604 let choice = stream.choice.clone().into_iter().collect::<Vec<_>>();
3605 assert!(matches!(
3606 choice.first(),
3607 Some(message::AssistantContent::Reasoning(message::Reasoning { content, .. }))
3608 if matches!(
3609 content.first(),
3610 Some(message::ReasoningContent::Text { text, signature: Some(signature) })
3611 if text == "think first" && signature == "sig-live-shape"
3612 )
3613 ));
3614 assert!(matches!(
3615 choice.get(1),
3616 Some(message::AssistantContent::ToolCall(call)) if call.function.name == "lookup"
3617 ));
3618
3619 let messages = assistant_contents_to_messages(choice).expect("history conversion");
3620 let Message::Assistant {
3621 reasoning_details, ..
3622 } = messages.first().expect("assistant message")
3623 else {
3624 panic!("expected assistant history message");
3625 };
3626 assert!(matches!(
3627 reasoning_details.first(),
3628 Some(ReasoningDetails::Text {
3629 text: Some(text),
3630 signature: Some(signature),
3631 ..
3632 }) if text == "think first" && signature == "sig-live-shape"
3633 ));
3634 }
3635
3636 #[tokio::test]
3647 async fn id_less_encrypted_detail_does_not_replace_open_reasoning_text() {
3648 use crate::client::CompletionClient;
3649 use crate::completion::CompletionModel as _;
3650 use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_data_lines;
3651 use crate::test_utils::MockStreamingClient;
3652 use futures::StreamExt;
3653
3654 let http_client = MockStreamingClient {
3655 sse_bytes: sse_bytes_from_data_lines([
3656 r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"deep "},"finish_reason":null}]}"#,
3657 r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"reasoning":"thought"},"finish_reason":null}]}"#,
3658 r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"reasoning":null,"reasoning_details":[{"type":"reasoning.encrypted","id":null,"format":"openai-responses-v1","index":0,"data":"enc_blob"}]},"finish_reason":null}]}"#,
3659 r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#,
3660 "[DONE]",
3661 ]),
3662 };
3663
3664 let client = crate::providers::openrouter::Client::builder()
3665 .api_key("dummy-key")
3666 .http_client(http_client)
3667 .build()
3668 .expect("client should build");
3669 let model = client.completion_model("openai/o4-mini");
3670 let request = model.completion_request("weather?").build();
3671 let mut stream = model.stream(request).await.expect("stream should start");
3672 while stream.next().await.is_some() {}
3673
3674 let choice: Vec<message::AssistantContent> = stream.choice.clone().into_iter().collect();
3675 assert!(
3676 choice.iter().any(|content| matches!(
3677 content,
3678 message::AssistantContent::Reasoning(message::Reasoning { content, .. })
3679 if matches!(
3680 content.first(),
3681 Some(message::ReasoningContent::Text { text, .. }) if text == "deep thought"
3682 )
3683 )),
3684 "the accumulated reasoning text must survive the encrypted detail: {choice:#?}"
3685 );
3686 assert!(
3687 choice.iter().any(|content| matches!(
3688 content,
3689 message::AssistantContent::Reasoning(message::Reasoning { id: None, content })
3690 if matches!(
3691 content.first(),
3692 Some(message::ReasoningContent::Encrypted(data)) if data == "enc_blob"
3693 )
3694 )),
3695 "the encrypted blob must reach the choice as its own part: {choice:#?}"
3696 );
3697 }
3698
3699 #[test]
3703 fn id_less_encrypted_reasoning_replays_with_a_null_wire_id() {
3704 use crate::providers::openai::completion::OpenAICompatibleProvider as _;
3705
3706 let detail = json!({
3707 "type": "reasoning.encrypted",
3708 "id": null,
3709 "format": null,
3710 "index": 0,
3711 "data": "enc_blob",
3712 });
3713 let (id, provider_id, content) = OpenRouterExt
3714 .streaming_detail_reasoning(&detail)
3715 .expect("encrypted detail should map to reasoning");
3716 assert!(
3721 id.is_minted(),
3722 "id-less details key by a minted key: {id:?}"
3723 );
3724 assert!(
3725 provider_id.is_none(),
3726 "absence is None, never a fabricated id"
3727 );
3728 assert!(matches!(
3729 content,
3730 message::ReasoningContent::Encrypted(ref data) if data == "enc_blob"
3731 ));
3732
3733 let messages = assistant_contents_to_messages(vec![message::AssistantContent::Reasoning(
3734 message::Reasoning {
3735 id: provider_id.map(|id| id.into_string()),
3736 content: vec![content],
3737 },
3738 )])
3739 .unwrap();
3740 let Message::Assistant {
3741 reasoning_details, ..
3742 } = messages.first().expect("assistant message")
3743 else {
3744 panic!("Expected assistant message");
3745 };
3746 assert!(matches!(
3747 reasoning_details.first(),
3748 Some(ReasoningDetails::Encrypted { id: None, data, .. }) if data == "enc_blob"
3749 ));
3750 }
3751
3752 #[test]
3753 fn test_assistant_reasoning_emits_openrouter_reasoning_details() {
3754 let reasoning = message::Reasoning {
3755 id: Some("rs_2".to_string()),
3756 content: vec![
3757 message::ReasoningContent::Text {
3758 text: "step".to_string(),
3759 signature: Some("sig_step".to_string()),
3760 },
3761 message::ReasoningContent::Summary("summary".to_string()),
3762 message::ReasoningContent::Encrypted("enc_blob".to_string()),
3763 ],
3764 };
3765
3766 let messages =
3767 assistant_contents_to_messages(vec![message::AssistantContent::Reasoning(reasoning)])
3768 .unwrap();
3769 let Message::Assistant {
3770 reasoning,
3771 reasoning_details,
3772 ..
3773 } = messages.first().expect("assistant message")
3774 else {
3775 panic!("Expected assistant message");
3776 };
3777
3778 assert!(reasoning.is_none());
3779 assert_eq!(reasoning_details.len(), 3);
3780 assert!(matches!(
3781 reasoning_details.first(),
3782 Some(ReasoningDetails::Text {
3783 id: Some(id),
3784 text: Some(text),
3785 signature: Some(signature),
3786 ..
3787 }) if id == "rs_2" && text == "step" && signature == "sig_step"
3788 ));
3789 }
3790
3791 #[test]
3792 fn test_tool_call_signature_without_params_uses_wire_id_for_encrypted_detail() {
3793 let tool_call = message::ToolCall::from_wire(
3794 "call_wire",
3795 message::ToolFunction {
3796 name: "lookup".to_string(),
3797 arguments: json!({}),
3798 },
3799 )
3800 .with_signature(Some("sig-data".to_string()));
3801
3802 let messages =
3803 assistant_contents_to_messages(vec![message::AssistantContent::ToolCall(tool_call)])
3804 .unwrap();
3805
3806 let Message::Assistant {
3807 reasoning_details, ..
3808 } = messages.first().expect("assistant message")
3809 else {
3810 panic!("Expected assistant message");
3811 };
3812
3813 assert!(matches!(
3814 reasoning_details.first(),
3815 Some(ReasoningDetails::Encrypted {
3816 id: Some(id),
3817 data,
3818 ..
3819 }) if id == "call_wire" && data == "sig-data"
3820 ));
3821 }
3822
3823 #[test]
3824 fn test_tool_call_minimal_params_fall_back_to_wire_id() {
3825 let tool_call = message::ToolCall::from_wire(
3826 "call_wire",
3827 message::ToolFunction {
3828 name: "lookup".to_string(),
3829 arguments: json!({}),
3830 },
3831 )
3832 .with_signature(Some("sig-data".to_string()))
3833 .with_additional_params(Some(json!({"format": "anthropic"})));
3836
3837 let messages =
3838 assistant_contents_to_messages(vec![message::AssistantContent::ToolCall(tool_call)])
3839 .unwrap();
3840
3841 let Message::Assistant {
3842 reasoning_details, ..
3843 } = messages.first().expect("assistant message")
3844 else {
3845 panic!("Expected assistant message");
3846 };
3847
3848 assert!(matches!(
3849 reasoning_details.first(),
3850 Some(ReasoningDetails::Encrypted {
3851 id: Some(id),
3852 format,
3853 data,
3854 ..
3855 }) if id == "call_wire" && data == "sig-data" && format.as_deref() == Some("anthropic")
3856 ));
3857 }
3858
3859 #[test]
3860 fn test_assistant_redacted_reasoning_emits_encrypted_detail_not_text() {
3861 let reasoning = message::Reasoning {
3862 id: Some("rs_redacted".to_string()),
3863 content: vec![message::ReasoningContent::Redacted {
3864 data: "opaque-redacted-data".to_string(),
3865 }],
3866 };
3867
3868 let messages =
3869 assistant_contents_to_messages(vec![message::AssistantContent::Reasoning(reasoning)])
3870 .unwrap();
3871
3872 let Message::Assistant {
3873 reasoning_details,
3874 reasoning,
3875 ..
3876 } = messages.first().expect("assistant message")
3877 else {
3878 panic!("Expected assistant message");
3879 };
3880
3881 assert!(reasoning.is_none());
3882 assert_eq!(reasoning_details.len(), 1);
3883 assert!(matches!(
3884 reasoning_details.first(),
3885 Some(ReasoningDetails::Encrypted {
3886 id: Some(id),
3887 data,
3888 ..
3889 }) if id == "rs_redacted" && data == "opaque-redacted-data"
3890 ));
3891 }
3892
3893 #[test]
3894 fn test_completion_response_reasoning_details_respects_index_ordering() {
3895 let json = json!({
3896 "id": "resp_ordering",
3897 "object": "chat.completion",
3898 "created": 1,
3899 "model": "openrouter/test-model",
3900 "choices": [{
3901 "index": 0,
3902 "finish_reason": "stop",
3903 "message": {
3904 "role": "assistant",
3905 "content": "hello",
3906 "reasoning": null,
3907 "reasoning_details": [
3908 {"type":"reasoning.summary","id":"rs_order","index":1,"summary":"second"},
3909 {"type":"reasoning.summary","id":"rs_order","index":0,"summary":"first"}
3910 ]
3911 }
3912 }]
3913 });
3914
3915 let response: CompletionResponse = serde_json::from_value(json).unwrap();
3916 let converted = response.normalize(PROVIDER_NAME).unwrap();
3917 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3918 let reasoning_blocks: Vec<_> = items
3919 .into_iter()
3920 .filter_map(|item| match item {
3921 completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
3922 _ => None,
3923 })
3924 .collect();
3925
3926 assert_eq!(reasoning_blocks.len(), 1);
3927 assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_order"));
3928 assert_eq!(
3929 reasoning_blocks[0].content,
3930 vec![
3931 message::ReasoningContent::Summary("first".to_string()),
3932 message::ReasoningContent::Summary("second".to_string()),
3933 ]
3934 );
3935 }
3936
3937 #[test]
3938 fn test_user_content_from_rig_image_missing_media_type_error() {
3939 let rig_content = message::UserContent::Image(message::Image {
3940 data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3941 media_type: None, detail: None,
3943 additional_params: None,
3944 });
3945 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3946
3947 assert!(result.is_err());
3948 let err = result.unwrap_err();
3949 assert!(err.to_string().contains("media type required"));
3950 }
3951
3952 #[test]
3953 fn test_user_content_from_rig_image_raw_bytes_error() {
3954 let rig_content = message::UserContent::Image(message::Image {
3955 data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3956 media_type: Some(message::ImageMediaType::PNG),
3957 detail: None,
3958 additional_params: None,
3959 });
3960 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3961
3962 assert!(result.is_err());
3963 let err = result.unwrap_err();
3964 assert!(err.to_string().contains("base64"));
3965 }
3966
3967 #[test]
3968 fn test_user_content_from_rig_video_url() {
3969 let rig_content = message::UserContent::Video(message::Video {
3970 data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
3971 media_type: Some(message::VideoMediaType::MP4),
3972 additional_params: None,
3973 });
3974 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3975
3976 match openrouter_content {
3977 UserContent::Video { video_url } => {
3978 assert_eq!(video_url.url, "https://example.com/video.mp4");
3979 }
3980 _ => panic!("Expected Video variant"),
3981 }
3982 }
3983
3984 #[test]
3985 fn test_user_content_from_rig_video_base64() {
3986 let rig_content = message::UserContent::Video(message::Video {
3987 data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3988 media_type: Some(message::VideoMediaType::MP4),
3989 additional_params: None,
3990 });
3991 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3992
3993 match openrouter_content {
3994 UserContent::Video { video_url } => {
3995 assert_eq!(video_url.url, "data:video/mp4;base64,SGVsbG8=");
3996 }
3997 _ => panic!("Expected Video variant"),
3998 }
3999 }
4000
4001 #[test]
4002 fn test_user_content_from_rig_video_base64_missing_media_type_error() {
4003 let rig_content = message::UserContent::Video(message::Video {
4004 data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
4005 media_type: None,
4006 additional_params: None,
4007 });
4008 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
4009
4010 assert!(result.is_err());
4011 let err = result.unwrap_err();
4012 assert!(err.to_string().contains("media type"));
4013 }
4014
4015 #[test]
4016 fn test_user_content_from_rig_video_raw_bytes_error() {
4017 let rig_content = message::UserContent::Video(message::Video {
4018 data: DocumentSourceKind::Raw(vec![1, 2, 3]),
4019 media_type: Some(message::VideoMediaType::MP4),
4020 additional_params: None,
4021 });
4022 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
4023
4024 assert!(result.is_err());
4025 let err = result.unwrap_err();
4026 assert!(err.to_string().contains("base64"));
4027 }
4028
4029 #[test]
4030 fn test_user_content_from_rig_audio_base64() {
4031 let rig_content = message::UserContent::Audio(message::Audio {
4032 data: DocumentSourceKind::Base64("audiodata".to_string()),
4033 media_type: Some(message::AudioMediaType::MP3),
4034 additional_params: None,
4035 });
4036 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
4037
4038 match openrouter_content {
4039 UserContent::Audio { input_audio } => {
4040 assert_eq!(input_audio.data, "audiodata");
4041 assert_eq!(input_audio.format, message::AudioMediaType::MP3);
4042 }
4043 _ => panic!("Expected Audio variant"),
4044 }
4045 }
4046
4047 #[test]
4048 fn test_user_content_from_rig_audio_missing_media_type_error() {
4049 let rig_content = message::UserContent::Audio(message::Audio {
4050 data: DocumentSourceKind::Base64("audiodata".to_string()),
4051 media_type: None, additional_params: None,
4053 });
4054 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
4055
4056 assert!(result.is_err());
4057 let err = result.unwrap_err();
4058 assert!(err.to_string().contains("media type required"));
4059 }
4060
4061 #[test]
4062 fn test_user_content_from_rig_audio_url_error() {
4063 let rig_content = message::UserContent::Audio(message::Audio {
4064 data: DocumentSourceKind::Url("https://example.com/audio.wav".to_string()),
4065 media_type: Some(message::AudioMediaType::WAV),
4066 additional_params: None,
4067 });
4068 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
4069
4070 assert!(result.is_err());
4071 let err = result.unwrap_err();
4072 assert!(err.to_string().contains("base64"));
4073 }
4074
4075 #[test]
4076 fn test_user_content_from_rig_audio_raw_bytes_error() {
4077 let rig_content = message::UserContent::Audio(message::Audio {
4078 data: DocumentSourceKind::Raw(vec![1, 2, 3]),
4079 media_type: Some(message::AudioMediaType::WAV),
4080 additional_params: None,
4081 });
4082 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
4083
4084 assert!(result.is_err());
4085 let err = result.unwrap_err();
4086 assert!(err.to_string().contains("base64"));
4087 }
4088
4089 #[test]
4090 fn test_user_content_from_rig_video_file_id_error() {
4091 let rig_content = message::UserContent::Video(message::Video {
4092 data: DocumentSourceKind::FileId("file-123".to_string()),
4093 media_type: Some(message::VideoMediaType::MP4),
4094 additional_params: None,
4095 });
4096 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
4097
4098 assert!(result.is_err());
4099 let err = result.unwrap_err();
4100 assert!(
4101 err.to_string()
4102 .contains("File IDs are not supported for video")
4103 );
4104 }
4105
4106 #[test]
4107 fn test_user_content_from_rig_audio_file_id_error() {
4108 let rig_content = message::UserContent::Audio(message::Audio {
4109 data: DocumentSourceKind::FileId("file-123".to_string()),
4110 media_type: Some(message::AudioMediaType::MP3),
4111 additional_params: None,
4112 });
4113 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
4114
4115 assert!(result.is_err());
4116 let err = result.unwrap_err();
4117 assert!(
4118 err.to_string()
4119 .contains("File IDs are not supported for audio")
4120 );
4121 }
4122
4123 #[test]
4124 fn test_video_helper_converts_to_data_uri() {
4125 let rig_content =
4128 message::UserContent::video("SGVsbG8=", Some(message::VideoMediaType::MP4));
4129 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
4130
4131 match openrouter_content {
4132 UserContent::Video { video_url } => {
4133 assert_eq!(video_url.url, "data:video/mp4;base64,SGVsbG8=");
4134 }
4135 _ => panic!("Expected Video variant"),
4136 }
4137 }
4138
4139 #[test]
4140 fn test_video_url_helper_passes_url_through() {
4141 let rig_content = message::UserContent::video_url("https://example.com/video.mp4", None);
4144 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
4145
4146 match openrouter_content {
4147 UserContent::Video { video_url } => {
4148 assert_eq!(video_url.url, "https://example.com/video.mp4");
4149 }
4150 _ => panic!("Expected Video variant"),
4151 }
4152 }
4153
4154 #[test]
4155 fn test_video_raw_helper_errors() {
4156 let rig_content =
4159 message::UserContent::video_raw(vec![1, 2, 3], Some(message::VideoMediaType::MP4));
4160 let result: Result<UserContent, _> = user_content_to_openai(rig_content);
4161
4162 assert!(result.is_err());
4163 let err = result.unwrap_err();
4164 assert!(err.to_string().contains("base64"));
4165 }
4166
4167 #[test]
4168 fn test_message_conversion_with_pdf() {
4169 let rig_message = message::Message::User {
4170 content: vec![
4171 message::UserContent::Text(message::Text::new(
4172 "Summarize this document".to_string(),
4173 )),
4174 message::UserContent::Document(message::Document {
4175 data: DocumentSourceKind::Url("https://example.com/paper.pdf".to_string()),
4176 media_type: Some(DocumentMediaType::PDF),
4177 additional_params: None,
4178 }),
4179 ],
4180 };
4181
4182 let openrouter_messages: Vec<Message> = messages_from_rig_message(rig_message).unwrap();
4183 assert_eq!(openrouter_messages.len(), 1);
4184
4185 match &openrouter_messages[0] {
4186 Message::User { content, .. } => {
4187 assert_eq!(content.len(), 2);
4188
4189 match content.first() {
4191 Some(UserContent::Text { text, .. }) => {
4192 assert_eq!(text, "Summarize this document")
4193 }
4194 _ => panic!("Expected Text"),
4195 }
4196 }
4197 _ => panic!("Expected User message"),
4198 }
4199 }
4200
4201 #[test]
4202 fn test_user_content_from_string() {
4203 let content: UserContent = "Hello".into();
4204 assert_eq!(
4205 content,
4206 UserContent::Text {
4207 text: "Hello".to_string()
4208 }
4209 );
4210
4211 let content: UserContent = String::from("World").into();
4212 assert_eq!(
4213 content,
4214 UserContent::Text {
4215 text: "World".to_string()
4216 }
4217 );
4218 }
4219
4220 #[test]
4221 fn test_completion_response_reasoning_details_with_multiple_ids_stay_separate() {
4222 let json = json!({
4223 "id": "resp_multi_id",
4224 "object": "chat.completion",
4225 "created": 1,
4226 "model": "openrouter/test-model",
4227 "choices": [{
4228 "index": 0,
4229 "finish_reason": "stop",
4230 "message": {
4231 "role": "assistant",
4232 "content": "hello",
4233 "reasoning": null,
4234 "reasoning_details": [
4235 {"type":"reasoning.summary","id":"rs_a","summary":"a1"},
4236 {"type":"reasoning.summary","id":"rs_b","summary":"b1"},
4237 {"type":"reasoning.summary","id":"rs_a","summary":"a2"}
4238 ]
4239 }
4240 }]
4241 });
4242
4243 let response: CompletionResponse = serde_json::from_value(json).unwrap();
4244 let converted = response.normalize(PROVIDER_NAME).unwrap();
4245 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
4246 let reasoning_blocks: Vec<_> = items
4247 .into_iter()
4248 .filter_map(|item| match item {
4249 completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
4250 _ => None,
4251 })
4252 .collect();
4253
4254 assert_eq!(reasoning_blocks.len(), 2);
4255 assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_a"));
4256 assert_eq!(
4257 reasoning_blocks[0].content,
4258 vec![
4259 message::ReasoningContent::Summary("a1".to_string()),
4260 message::ReasoningContent::Summary("a2".to_string()),
4261 ]
4262 );
4263 assert_eq!(reasoning_blocks[1].id.as_deref(), Some("rs_b"));
4264 assert_eq!(
4265 reasoning_blocks[1].content,
4266 vec![message::ReasoningContent::Summary("b1".to_string())]
4267 );
4268 }
4269
4270 #[test]
4271 fn test_user_content_audio_serialization() {
4272 let content = UserContent::Audio {
4273 input_audio: openai::InputAudio {
4274 data: "SGVsbG8=".to_string(),
4275 format: AudioMediaType::WAV,
4276 },
4277 };
4278 let json = serde_json::to_value(&content).unwrap();
4279
4280 assert_eq!(json["type"], "input_audio");
4281 assert_eq!(json["input_audio"]["data"], "SGVsbG8=");
4282 assert_eq!(json["input_audio"]["format"], "wav");
4283 }
4284
4285 #[test]
4286 fn test_user_content_audio_deserialization() {
4287 let json = json!({
4288 "type": "input_audio",
4289 "input_audio": {
4290 "data": "SGVsbG8=",
4291 "format": "wav"
4292 }
4293 });
4294
4295 let content: UserContent = serde_json::from_value(json).unwrap();
4296 match content {
4297 UserContent::Audio { input_audio } => {
4298 assert_eq!(input_audio.data, "SGVsbG8=");
4299 assert_eq!(input_audio.format, AudioMediaType::WAV);
4300 }
4301 _ => panic!("Expected Audio variant"),
4302 }
4303 }
4304
4305 #[test]
4306 fn test_message_user_with_audio_serialization() {
4307 let msg = Message::User {
4308 content: vec![
4309 UserContent::Text {
4310 text: "Transcribe this audio:".to_string(),
4311 },
4312 UserContent::Audio {
4313 input_audio: openai::InputAudio {
4314 data: "SGVsbG8=".to_string(),
4315 format: AudioMediaType::MP3,
4316 },
4317 },
4318 ],
4319 name: None,
4320 };
4321 let json = serde_json::to_value(&msg).unwrap();
4322
4323 assert_eq!(json["role"], "user");
4324 let content = json["content"].as_array().unwrap();
4325 assert_eq!(content.len(), 2);
4326 assert_eq!(content[0]["type"], "text");
4327 assert_eq!(content[1]["type"], "input_audio");
4328 assert_eq!(content[1]["input_audio"]["data"], "SGVsbG8=");
4329 assert_eq!(content[1]["input_audio"]["format"], "mp3");
4330 }
4331
4332 #[test]
4333 fn test_user_content_video_url_serialization() {
4334 let content = UserContent::Video {
4335 video_url: VideoUrl {
4336 url: "https://example.com/video.mp4".to_string(),
4337 },
4338 };
4339 let json = serde_json::to_value(&content).unwrap();
4340
4341 assert_eq!(json["type"], "video_url");
4342 assert_eq!(json["video_url"]["url"], "https://example.com/video.mp4");
4343 }
4344
4345 #[test]
4346 fn test_user_content_video_base64_serialization() {
4347 let content = UserContent::Video {
4348 video_url: VideoUrl {
4349 url: format!(
4350 "data:{};base64,SGVsbG8=",
4351 VideoMediaType::MP4.to_mime_type()
4352 ),
4353 },
4354 };
4355 let json = serde_json::to_value(&content).unwrap();
4356
4357 assert_eq!(json["type"], "video_url");
4358 assert_eq!(json["video_url"]["url"], "data:video/mp4;base64,SGVsbG8=");
4359 }
4360
4361 #[test]
4362 fn test_user_content_video_url_deserialization() {
4363 let json = json!({
4364 "type": "video_url",
4365 "video_url": {
4366 "url": "https://example.com/video.mp4"
4367 }
4368 });
4369
4370 let content: UserContent = serde_json::from_value(json).unwrap();
4371 match content {
4372 UserContent::Video { video_url } => {
4373 assert_eq!(video_url.url, "https://example.com/video.mp4");
4374 }
4375 _ => panic!("Expected Video variant"),
4376 }
4377 }
4378
4379 #[test]
4380 fn test_message_user_with_video_serialization() {
4381 let msg = Message::User {
4382 content: vec![
4383 UserContent::Text {
4384 text: "Describe this video:".to_string(),
4385 },
4386 UserContent::Video {
4387 video_url: VideoUrl {
4388 url: "https://example.com/video.mp4".to_string(),
4389 },
4390 },
4391 ],
4392 name: None,
4393 };
4394 let json = serde_json::to_value(&msg).unwrap();
4395
4396 assert_eq!(json["role"], "user");
4397 let content = json["content"].as_array().unwrap();
4398 assert_eq!(content.len(), 2);
4399 assert_eq!(content[0]["type"], "text");
4400 assert_eq!(content[1]["type"], "video_url");
4401 assert_eq!(
4402 content[1]["video_url"]["url"],
4403 "https://example.com/video.mp4"
4404 );
4405 }
4406
4407 #[test]
4408 fn test_user_content_video_url_no_media_type_needed() {
4409 let rig_content = message::UserContent::Video(message::Video {
4410 data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
4411 media_type: None,
4412 additional_params: None,
4413 });
4414 let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
4415
4416 match openrouter_content {
4417 UserContent::Video { video_url } => {
4418 assert_eq!(video_url.url, "https://example.com/video.mp4");
4419 }
4420 _ => panic!("Expected Video variant"),
4421 }
4422 }
4423
4424 fn prompt_caching_completion_request() -> CompletionRequest {
4425 CompletionRequest {
4426 model: None,
4427 preamble: Some("You are a helpful assistant.".to_string()),
4428 chat_history: vec![crate::message::Message::user("Hello")],
4429 documents: vec![],
4430 tools: vec![],
4431 temperature: None,
4432 max_tokens: None,
4433 tool_choice: None,
4434 additional_params: None,
4435 output_schema: None,
4436 record_telemetry_content: false,
4437 }
4438 }
4439
4440 #[test]
4441 fn test_final_request_body_applies_prompt_caching_to_converted_completion_request() {
4442 let request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
4443 model: "anthropic/claude-3.5-sonnet",
4444 request: prompt_caching_completion_request(),
4445 strict_tools: false,
4446 })
4447 .expect("request conversion should succeed");
4448
4449 let body = final_request_body(&request, true).expect("request body should serialize");
4450 let system_block = &body["messages"][0]["content"][0];
4451
4452 assert_eq!(system_block["type"], "text");
4453 assert_eq!(system_block["text"], "You are a helpful assistant.");
4454 assert_eq!(system_block["cache_control"]["type"], "ephemeral");
4455
4456 let body = final_request_body(&request, false).expect("request body should serialize");
4457 assert!(
4458 body["messages"][0]["content"][0]
4459 .get("cache_control")
4460 .is_none(),
4461 "prompt caching should be opt-in"
4462 );
4463 }
4464
4465 #[test]
4466 fn test_final_request_body_preserves_stream_flag_when_prompt_caching_enabled() {
4467 let mut request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
4468 model: "anthropic/claude-3.5-sonnet",
4469 request: prompt_caching_completion_request(),
4470 strict_tools: false,
4471 })
4472 .expect("request conversion should succeed");
4473 request.additional_params = Some(json!({ "stream": true }));
4474
4475 let body = final_request_body(&request, true).expect("request body should serialize");
4476
4477 assert_eq!(body["stream"], true);
4478 assert_eq!(
4479 body["messages"][0]["content"][0]["cache_control"]["type"],
4480 "ephemeral"
4481 );
4482 }
4483
4484 #[test]
4485 fn test_apply_prompt_caching_string_system_message() {
4486 let mut body = json!({
4487 "model": "anthropic/claude-3.5-sonnet",
4488 "messages": [
4489 {"role": "system", "content": "You are a helpful assistant."},
4490 {"role": "user", "content": "Hello"}
4491 ]
4492 });
4493
4494 apply_prompt_caching(&mut body);
4495
4496 let system_content = &body["messages"][0]["content"];
4497 assert!(
4498 system_content.is_array(),
4499 "system content should be an array after caching"
4500 );
4501 let block = &system_content[0];
4502 assert_eq!(block["type"], "text");
4503 assert_eq!(block["text"], "You are a helpful assistant.");
4504 assert_eq!(block["cache_control"]["type"], "ephemeral");
4505
4506 assert_eq!(body["messages"][1]["content"], "Hello");
4508 }
4509
4510 #[test]
4511 fn test_apply_prompt_caching_array_system_message_marks_last_block() {
4512 let mut body = json!({
4513 "model": "anthropic/claude-3.5-sonnet",
4514 "messages": [
4515 {
4516 "role": "system",
4517 "content": [
4518 {"type": "text", "text": "Part 1. "},
4519 {"type": "text", "text": "Part 2."}
4520 ]
4521 }
4522 ]
4523 });
4524
4525 apply_prompt_caching(&mut body);
4526
4527 let system_content = &body["messages"][0]["content"];
4528 assert!(system_content.is_array());
4529 assert_eq!(system_content.as_array().unwrap().len(), 2);
4531 assert_eq!(system_content[0]["text"], "Part 1. ");
4532 assert!(system_content[0].get("cache_control").is_none());
4533 assert_eq!(system_content[1]["text"], "Part 2.");
4534 assert_eq!(system_content[1]["cache_control"]["type"], "ephemeral");
4535 }
4536
4537 #[test]
4538 fn test_apply_prompt_caching_preserves_non_text_blocks() {
4539 let mut body = json!({
4540 "model": "anthropic/claude-3.5-sonnet",
4541 "messages": [
4542 {
4543 "role": "system",
4544 "content": [
4545 {"type": "image", "source": {"type": "url", "url": "https://example.com/img.png"}},
4546 {"type": "text", "text": "Describe the image."}
4547 ]
4548 }
4549 ]
4550 });
4551
4552 apply_prompt_caching(&mut body);
4553
4554 let system_content = &body["messages"][0]["content"];
4555 assert_eq!(system_content.as_array().unwrap().len(), 2);
4556 assert_eq!(system_content[0]["type"], "image");
4558 assert!(system_content[0].get("cache_control").is_none());
4559 assert_eq!(system_content[1]["type"], "text");
4561 assert_eq!(system_content[1]["cache_control"]["type"], "ephemeral");
4562 }
4563
4564 #[test]
4565 fn test_apply_prompt_caching_no_system_message_is_noop() {
4566 let mut body = json!({
4567 "model": "openai/gpt-4o",
4568 "messages": [
4569 {"role": "user", "content": "Hello"}
4570 ]
4571 });
4572
4573 let body_before = body.clone();
4574 apply_prompt_caching(&mut body);
4575 assert_eq!(
4576 body, body_before,
4577 "body should be unchanged when no system message exists"
4578 );
4579 }
4580
4581 #[test]
4582 fn test_completion_response_extracts_generated_images() {
4583 let json = json!({
4584 "id": "resp_img",
4585 "object": "chat.completion",
4586 "created": 1,
4587 "model": "google/gemini-flash-image-preview",
4588 "choices": [{
4589 "index": 0,
4590 "finish_reason": "stop",
4591 "message": {
4592 "role": "assistant",
4593 "content": "Here is your image.",
4594 "images": [
4595 {"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}
4596 ]
4597 }
4598 }]
4599 });
4600
4601 let response: CompletionResponse = serde_json::from_value(json).unwrap();
4602 let converted = response.normalize(PROVIDER_NAME).unwrap();
4603 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
4604 assert_eq!(items.len(), 2);
4605
4606 assert!(items.iter().any(|item| matches!(
4607 item,
4608 completion::AssistantContent::Text(t) if t.text == "Here is your image."
4609 )));
4610 assert!(items.iter().any(|item| matches!(
4611 item,
4612 completion::AssistantContent::Image(message::Image {
4613 data: message::DocumentSourceKind::Base64(b64),
4614 media_type: Some(message::ImageMediaType::PNG),
4615 additional_params: Some(_),
4616 ..
4617 }) if b64 == "iVBORw0KGgo="
4618 )));
4619 assert!(
4620 items.iter().any(|item| matches!(
4621 item,
4622 completion::AssistantContent::Image(image)
4623 if is_openrouter_response_image(image)
4624 )),
4625 "generated images should be marked as OpenRouter response-only artifacts"
4626 );
4627 }
4628
4629 #[test]
4630 fn test_completion_response_extracts_generated_images_url() {
4631 let json = json!({
4632 "id": "resp_img_url",
4633 "object": "chat.completion",
4634 "created": 1,
4635 "model": "google/gemini-flash-image-preview",
4636 "choices": [{
4637 "index": 0,
4638 "finish_reason": "stop",
4639 "message": {
4640 "role": "assistant",
4641 "content": "Here is your image.",
4642 "images": [
4643 {"type":"image_url","image_url":{"url":"https://example.com/generated.png"}}
4644 ]
4645 }
4646 }]
4647 });
4648
4649 let response: CompletionResponse = serde_json::from_value(json).unwrap();
4650 let converted = response.normalize(PROVIDER_NAME).unwrap();
4651 let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
4652 assert_eq!(items.len(), 2);
4653
4654 assert!(items.iter().any(|item| matches!(
4655 item,
4656 completion::AssistantContent::Image(message::Image {
4657 data: message::DocumentSourceKind::Url(url),
4658 media_type: None,
4659 additional_params: Some(_),
4660 ..
4661 }) if url == "https://example.com/generated.png"
4662 )));
4663 assert!(
4664 items.iter().any(|item| matches!(
4665 item,
4666 completion::AssistantContent::Image(image)
4667 if is_openrouter_response_image(image)
4668 )),
4669 "generated URL images should be marked as OpenRouter response-only artifacts"
4670 );
4671 }
4672
4673 #[test]
4674 fn test_generated_images_do_not_break_assistant_history_conversion() {
4675 let generated_image = response_image_to_assistant_content(&ResponseImage {
4676 image_url: ImageUrl {
4677 url: "data:image/png;base64,abc".to_string(),
4678 detail: None,
4679 },
4680 });
4681
4682 let content = vec![
4683 completion::AssistantContent::text("Here is your image."),
4684 generated_image,
4685 ];
4686 let messages = assistant_contents_to_messages(content).unwrap();
4687
4688 assert_eq!(messages.len(), 1);
4689 assert!(matches!(
4690 &messages[0],
4691 Message::Assistant { content, .. }
4692 if content == &vec![openai::AssistantContent::Text {
4693 text: "Here is your image.".to_string()
4694 }]
4695 ));
4696 }
4697
4698 #[test]
4699 fn test_image_only_assistant_history_is_omitted_for_openrouter() {
4700 let generated_image = response_image_to_assistant_content(&ResponseImage {
4701 image_url: ImageUrl {
4702 url: "data:image/png;base64,abc".to_string(),
4703 detail: None,
4704 },
4705 });
4706
4707 let messages = assistant_contents_to_messages(vec![generated_image]).unwrap();
4708
4709 assert!(
4710 messages.is_empty(),
4711 "response-only generated image turns should not be replayed as assistant content"
4712 );
4713 }
4714
4715 #[test]
4716 fn test_unmarked_assistant_image_history_errors_for_openrouter() {
4717 let image = completion::AssistantContent::image_base64(
4718 "abc",
4719 Some(message::ImageMediaType::PNG),
4720 None,
4721 );
4722
4723 let err = assistant_contents_to_messages(vec![image]).unwrap_err();
4724
4725 match err {
4726 message::MessageError::ConversionError(message) => assert!(
4727 message.contains("OpenRouter does not support assistant image content"),
4728 "unexpected error: {message}"
4729 ),
4730 }
4731 }
4732
4733 #[test]
4734 fn test_mixed_text_and_generated_image_replays_text_only_for_openrouter() {
4735 let generated_image = response_image_to_assistant_content(&ResponseImage {
4736 image_url: ImageUrl {
4737 url: "https://example.com/generated.png".to_string(),
4738 detail: None,
4739 },
4740 });
4741
4742 let messages = assistant_contents_to_messages(vec![
4743 completion::AssistantContent::text("Keep this text."),
4744 generated_image,
4745 ])
4746 .unwrap();
4747
4748 let serialized = serde_json::to_value(&messages).unwrap();
4749 assert_eq!(
4750 serialized,
4751 json!([{
4752 "role": "assistant",
4753 "content": [{"type": "text", "text": "Keep this text."}]
4754 }])
4755 );
4756 }
4757
4758 #[test]
4759 fn test_assistant_images_not_serialized_in_request() {
4760 let msg = Message::Assistant {
4761 content: vec!["Hello".to_string().into()],
4762 refusal: None,
4763 audio: None,
4764 name: None,
4765 tool_calls: vec![],
4766 reasoning: None,
4767 reasoning_details: vec![],
4768 images: vec![ResponseImage {
4769 image_url: ImageUrl {
4770 url: "data:image/png;base64,abc".to_string(),
4771 detail: None,
4772 },
4773 }],
4774 };
4775 let serialized = serde_json::to_value(&msg).unwrap();
4776 assert!(
4777 serialized.get("images").is_none(),
4778 "images field must not appear in serialized assistant message"
4779 );
4780 }
4781
4782 fn refusal_response(message: serde_json::Value) -> CompletionResponse {
4789 serde_json::from_value(json!({
4790 "id": "gen-refusal",
4791 "object": "chat.completion",
4792 "created": 1,
4793 "model": "openai/gpt-4o",
4794 "choices": [{ "index": 0, "message": message, "finish_reason": "stop" }],
4795 }))
4796 .unwrap()
4797 }
4798
4799 #[test]
4800 fn raw_completion_response_retains_routing_metadata() {
4801 let response: CompletionResponse = serde_json::from_value(json!({
4802 "id": "gen-routing",
4803 "object": "chat.completion",
4804 "created": 1,
4805 "model": "openai/gpt-4o-mini",
4806 "provider": "OpenAI",
4807 "service_tier": "default",
4808 "choices": [{
4809 "index": 0,
4810 "message": {"role": "assistant", "content": "ok"},
4811 "finish_reason": "stop"
4812 }]
4813 }))
4814 .expect("live OpenRouter routing metadata should deserialize");
4815
4816 assert_eq!(response.provider.as_deref(), Some("OpenAI"));
4817 assert_eq!(response.service_tier.as_deref(), Some("default"));
4818 }
4819
4820 fn text_parts(response: &completion::CompletionResponse) -> Vec<String> {
4821 response
4822 .choice
4823 .iter()
4824 .filter_map(|part| match part {
4825 completion::AssistantContent::Text(text) => Some(text.text.clone()),
4826 _ => None,
4827 })
4828 .collect()
4829 }
4830
4831 #[test]
4834 fn refusal_fallback_surfaces_a_null_content_refusal() {
4835 let response = refusal_response(json!({
4836 "role": "assistant",
4837 "content": null,
4838 "refusal": "I'm very sorry, but I can't assist with that request.",
4839 }));
4840
4841 let converted = response.normalize(PROVIDER_NAME).unwrap();
4842 assert_eq!(
4843 text_parts(&converted),
4844 vec!["I'm very sorry, but I can't assist with that request."]
4845 );
4846 }
4847
4848 #[test]
4850 fn refusal_fallback_surfaces_a_missing_content_refusal() {
4851 let response = refusal_response(json!({
4852 "role": "assistant",
4853 "refusal": "No.",
4854 }));
4855
4856 assert_eq!(
4857 text_parts(&response.normalize(PROVIDER_NAME).unwrap()),
4858 vec!["No."]
4859 );
4860 }
4861
4862 #[test]
4871 fn refusal_fallback_surfaces_a_refusal_beside_empty_content() {
4872 let response = refusal_response(json!({
4873 "role": "assistant",
4874 "content": "",
4875 "refusal": "No.",
4876 }));
4877
4878 let converted = response.normalize(PROVIDER_NAME).unwrap();
4879 assert_eq!(
4880 text_parts(&converted),
4881 vec!["".to_owned(), "No.".to_owned()]
4882 );
4883 }
4884
4885 #[test]
4890 fn refusal_fallback_defers_to_non_empty_content() {
4891 let response = refusal_response(json!({
4892 "role": "assistant",
4893 "content": "Here is the answer.",
4894 "refusal": "I'm sorry.",
4895 }));
4896
4897 assert_eq!(
4898 text_parts(&response.normalize(PROVIDER_NAME).unwrap()),
4899 vec!["Here is the answer."]
4900 );
4901 }
4902
4903 #[test]
4905 fn refusal_fallback_ignores_an_empty_refusal() {
4906 let response = refusal_response(json!({
4907 "role": "assistant",
4908 "content": null,
4909 "refusal": "",
4910 "tool_calls": [{
4911 "id": "call_1",
4912 "type": "function",
4913 "function": { "name": "ping", "arguments": "{}" }
4914 }],
4915 }));
4916
4917 let converted = response.normalize(PROVIDER_NAME).unwrap();
4918 assert!(text_parts(&converted).is_empty(), "{:?}", converted.choice);
4919 assert_eq!(converted.choice.len(), 1);
4920 }
4921
4922 #[test]
4925 fn refusal_fallback_leaves_a_tool_call_turn_alone() {
4926 let response = refusal_response(json!({
4927 "role": "assistant",
4928 "content": null,
4929 "tool_calls": [{
4930 "id": "call_1",
4931 "type": "function",
4932 "function": { "name": "ping", "arguments": "{}" }
4933 }],
4934 }));
4935
4936 let converted = response.normalize(PROVIDER_NAME).unwrap();
4937 assert_eq!(converted.choice.len(), 1);
4938 assert!(matches!(
4939 converted.choice.first(),
4940 Some(completion::AssistantContent::ToolCall(_))
4941 ));
4942 }
4943
4944 #[test]
4947 fn refusal_fallback_coexists_with_tool_calls() {
4948 let response = refusal_response(json!({
4949 "role": "assistant",
4950 "content": null,
4951 "refusal": "I can't help with that.",
4952 "tool_calls": [{
4953 "id": "call_1",
4954 "type": "function",
4955 "function": { "name": "ping", "arguments": "{}" }
4956 }],
4957 }));
4958
4959 let converted = response.normalize(PROVIDER_NAME).unwrap();
4960 assert_eq!(text_parts(&converted), vec!["I can't help with that."]);
4961 assert!(
4962 converted
4963 .choice
4964 .iter()
4965 .any(|part| matches!(part, completion::AssistantContent::ToolCall(_)))
4966 );
4967 }
4968
4969 #[test]
4972 fn refusal_fallback_applies_beside_reasoning_details() {
4973 let response = refusal_response(json!({
4974 "role": "assistant",
4975 "content": null,
4976 "refusal": "I can't help with that.",
4977 "reasoning_details": [
4978 { "type": "reasoning.summary", "id": "rs_1", "format": "openai-responses-v1",
4979 "index": 0, "summary": "considered" }
4980 ],
4981 }));
4982
4983 let converted = response.normalize(PROVIDER_NAME).unwrap();
4984 assert_eq!(text_parts(&converted), vec!["I can't help with that."]);
4985 assert!(
4986 converted
4987 .choice
4988 .iter()
4989 .any(|part| matches!(part, completion::AssistantContent::Reasoning(_)))
4990 );
4991 }
4992
4993 #[test]
4997 fn refusal_fallback_does_not_double_up_with_a_refusal_content_part() {
4998 let response = refusal_response(json!({
4999 "role": "assistant",
5000 "content": [{ "type": "refusal", "refusal": "I can't help with that." }],
5001 "refusal": "I can't help with that.",
5002 }));
5003
5004 assert_eq!(
5005 text_parts(&response.normalize(PROVIDER_NAME).unwrap()),
5006 vec!["I can't help with that."]
5007 );
5008 }
5009
5010 #[test]
5014 fn refusal_fallback_keeps_raw_and_normalized_text_in_step() {
5015 let response = refusal_response(json!({
5016 "role": "assistant",
5017 "content": null,
5018 "refusal": "I'm sorry, but I can't help with that.",
5019 }));
5020
5021 let raw_text = response.get_text_response().unwrap();
5022 let normalized = response.normalize(PROVIDER_NAME).unwrap();
5023
5024 assert_eq!(text_parts(&normalized), vec![raw_text]);
5025 }
5026}