Skip to main content

rig_core/providers/openrouter/
completion.rs

1use super::{
2    client::{ApiErrorResponse, ApiResponse, Client, Usage},
3    streaming::StreamingCompletionResponse,
4};
5use crate::message::{
6    self, AudioMediaType, DocumentMediaType, DocumentSourceKind, ImageDetail, MimeType,
7    VideoMediaType,
8};
9use crate::telemetry::SpanCombinator;
10use crate::{
11    OneOrMany,
12    completion::{self, CompletionError, CompletionRequest},
13    http_client::HttpClientExt,
14    json_utils,
15    one_or_many::string_or_one_or_many,
16    providers::openai,
17};
18use bytes::Bytes;
19use serde::{Deserialize, Serialize, Serializer};
20use std::collections::HashMap;
21use tracing::{Instrument, Level, enabled, info_span};
22
23// ================================================================
24// OpenRouter Completion API
25// ================================================================
26
27/// The `qwen/qwq-32b` model. Find more models at <https://openrouter.ai/models>.
28pub const QWEN_QWQ_32B: &str = "qwen/qwq-32b";
29/// The `anthropic/claude-3.7-sonnet` model. Find more models at <https://openrouter.ai/models>.
30pub const CLAUDE_3_7_SONNET: &str = "anthropic/claude-3.7-sonnet";
31/// The `perplexity/sonar-pro` model. Find more models at <https://openrouter.ai/models>.
32pub const PERPLEXITY_SONAR_PRO: &str = "perplexity/sonar-pro";
33/// The `google/gemini-2.0-flash-001` model. Find more models at <https://openrouter.ai/models>.
34pub const GEMINI_FLASH_2_0: &str = "google/gemini-2.0-flash-001";
35
36// ================================================================
37// Provider Selection and Prioritization
38// ================================================================
39// See: https://openrouter.ai/docs/guides/routing/provider-selection
40
41/// Data collection policy for providers.
42///
43/// Controls whether providers are allowed to collect and store request data.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
45#[serde(rename_all = "lowercase")]
46pub enum DataCollection {
47    /// Allow providers that may collect data (default)
48    #[default]
49    Allow,
50    /// Restrict routing to providers that do not store user data non-transiently
51    Deny,
52}
53
54/// Model quantization levels supported by OpenRouter.
55///
56/// Restrict routing to providers serving a specific quantization level.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum Quantization {
60    /// 4-bit integer quantization
61    #[serde(rename = "int4")]
62    Int4,
63    /// 8-bit integer quantization
64    #[serde(rename = "int8")]
65    Int8,
66    /// 16-bit floating point
67    #[serde(rename = "fp16")]
68    Fp16,
69    /// Brain floating point 16-bit
70    #[serde(rename = "bf16")]
71    Bf16,
72    /// 32-bit floating point (full precision)
73    #[serde(rename = "fp32")]
74    Fp32,
75    /// 8-bit floating point
76    #[serde(rename = "fp8")]
77    Fp8,
78    /// Unknown or custom quantization level
79    #[serde(rename = "unknown")]
80    Unknown,
81}
82
83/// Simple sorting strategy for providers.
84///
85/// Determines how providers should be prioritized when multiple are available.
86/// If you set `sort`, default load balancing is disabled and providers are tried
87/// deterministically in the resulting order.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum ProviderSortStrategy {
91    /// Sort by price (cheapest first)
92    Price,
93    /// Sort by throughput (higher tokens/sec first)
94    Throughput,
95    /// Sort by latency (lower latency first)
96    Latency,
97}
98
99/// Partition strategy for multi-model requests.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "lowercase")]
102pub enum SortPartition {
103    /// Sort providers within each model group (default)
104    Model,
105    /// Sort providers globally across all models
106    None,
107}
108
109/// Complex sorting configuration with partition support.
110///
111/// For multi-model requests, allows control over how providers are sorted.
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct ProviderSortConfig {
114    /// Sorting strategy
115    pub by: ProviderSortStrategy,
116
117    /// Partition strategy (optional)
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub partition: Option<SortPartition>,
120}
121
122impl ProviderSortConfig {
123    /// Create a new sort config with the given strategy
124    pub fn new(by: ProviderSortStrategy) -> Self {
125        Self {
126            by,
127            partition: None,
128        }
129    }
130
131    /// Set partition strategy for multi-model requests
132    pub fn partition(mut self, partition: SortPartition) -> Self {
133        self.partition = Some(partition);
134        self
135    }
136}
137
138/// Sort configuration - can be a simple string or a complex object.
139///
140/// Use `ProviderSort::Simple` for basic sorting, or `ProviderSort::Complex`
141/// for multi-model requests with partition control.
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143#[serde(untagged)]
144pub enum ProviderSort {
145    /// Simple sorting by a single strategy
146    Simple(ProviderSortStrategy),
147    /// Complex sorting with partition support
148    Complex(ProviderSortConfig),
149}
150
151impl From<ProviderSortStrategy> for ProviderSort {
152    fn from(strategy: ProviderSortStrategy) -> Self {
153        ProviderSort::Simple(strategy)
154    }
155}
156
157impl From<ProviderSortConfig> for ProviderSort {
158    fn from(config: ProviderSortConfig) -> Self {
159        ProviderSort::Complex(config)
160    }
161}
162
163/// Throughput threshold configuration with percentile support.
164///
165/// Endpoints not meeting the threshold are deprioritized (moved later), not excluded.
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167#[serde(untagged)]
168pub enum ThroughputThreshold {
169    /// Simple threshold in tokens/sec
170    Simple(f64),
171    /// Percentile-based thresholds
172    Percentile(PercentileThresholds),
173}
174
175/// Latency threshold configuration with percentile support.
176///
177/// Endpoints not meeting the threshold are deprioritized, not excluded.
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179#[serde(untagged)]
180pub enum LatencyThreshold {
181    /// Simple threshold in seconds
182    Simple(f64),
183    /// Percentile-based thresholds
184    Percentile(PercentileThresholds),
185}
186
187/// Percentile-based thresholds for throughput or latency.
188#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
189pub struct PercentileThresholds {
190    /// 50th percentile threshold
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub p50: Option<f64>,
193    /// 75th percentile threshold
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub p75: Option<f64>,
196    /// 90th percentile threshold
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub p90: Option<f64>,
199    /// 99th percentile threshold
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub p99: Option<f64>,
202}
203
204impl PercentileThresholds {
205    /// Create new empty percentile thresholds
206    pub fn new() -> Self {
207        Self::default()
208    }
209
210    /// Set p50 threshold
211    pub fn p50(mut self, value: f64) -> Self {
212        self.p50 = Some(value);
213        self
214    }
215
216    /// Set p75 threshold
217    pub fn p75(mut self, value: f64) -> Self {
218        self.p75 = Some(value);
219        self
220    }
221
222    /// Set p90 threshold
223    pub fn p90(mut self, value: f64) -> Self {
224        self.p90 = Some(value);
225        self
226    }
227
228    /// Set p99 threshold
229    pub fn p99(mut self, value: f64) -> Self {
230        self.p99 = Some(value);
231        self
232    }
233}
234
235/// Maximum price configuration for hard ceiling on costs.
236///
237/// If no eligible provider is at or under the ceiling, the request fails.
238/// Units are OpenRouter pricing units (e.g., dollars per million tokens).
239#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
240pub struct MaxPrice {
241    /// Maximum price per prompt token
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub prompt: Option<f64>,
244    /// Maximum price per completion token
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub completion: Option<f64>,
247    /// Maximum price per request
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub request: Option<f64>,
250    /// Maximum price per image
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub image: Option<f64>,
253}
254
255impl MaxPrice {
256    /// Create new empty max price config
257    pub fn new() -> Self {
258        Self::default()
259    }
260
261    /// Set maximum price per prompt token
262    pub fn prompt(mut self, price: f64) -> Self {
263        self.prompt = Some(price);
264        self
265    }
266
267    /// Set maximum price per completion token
268    pub fn completion(mut self, price: f64) -> Self {
269        self.completion = Some(price);
270        self
271    }
272
273    /// Set maximum price per request
274    pub fn request(mut self, price: f64) -> Self {
275        self.request = Some(price);
276        self
277    }
278
279    /// Set maximum price per image
280    pub fn image(mut self, price: f64) -> Self {
281        self.image = Some(price);
282        self
283    }
284}
285
286/// Provider preferences for OpenRouter routing.
287///
288/// This struct allows you to control which providers are used and how they are prioritized
289/// when making requests through OpenRouter.
290///
291/// See: <https://openrouter.ai/docs/guides/routing/provider-selection>
292///
293/// # Example
294///
295/// ```rust
296/// use rig_core::providers::openrouter::{ProviderPreferences, ProviderSortStrategy, Quantization};
297///
298/// // Create preferences for zero data retention providers, sorted by throughput
299/// let prefs = ProviderPreferences::new()
300///     .sort(ProviderSortStrategy::Throughput)
301///     .zdr(true)
302///     .quantizations([Quantization::Int8])
303///     .only(["anthropic", "openai"]);
304/// ```
305#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
306pub struct ProviderPreferences {
307    // === Provider Selection Controls ===
308    /// Try these provider slugs in the given order first.
309    /// If `allow_fallbacks: true`, OpenRouter may try other providers after this list is exhausted.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub order: Option<Vec<String>>,
312
313    /// Hard allowlist. Only these provider slugs are eligible.
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub only: Option<Vec<String>>,
316
317    /// Blocklist. These provider slugs are never used.
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub ignore: Option<Vec<String>>,
320
321    /// If `false`, the router will not use any providers outside what your constraints permit.
322    /// Default is `true`.
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub allow_fallbacks: Option<bool>,
325
326    // === Compatibility and Policy Filters ===
327    /// If `true`, only route to providers that support all parameters in your request.
328    ///
329    /// This is recommended for structured outputs so OpenRouter only selects
330    /// providers that support the generated `response_format` parameter.
331    /// Default is `false`.
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub require_parameters: Option<bool>,
334
335    /// Data collection policy. If [`DataCollection::Deny`], restrict routing to providers
336    /// that do not store user data non-transiently. Default is [`DataCollection::Allow`].
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub data_collection: Option<DataCollection>,
339
340    /// If `true`, restrict routing to Zero Data Retention endpoints only.
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub zdr: Option<bool>,
343
344    // === Performance and Cost Preferences ===
345    /// Sorting strategy. Affects ordering, not strict exclusion.
346    /// If set, default load balancing is disabled.
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub sort: Option<ProviderSort>,
349
350    /// Throughput threshold. Endpoints not meeting the threshold are deprioritized.
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub preferred_min_throughput: Option<ThroughputThreshold>,
353
354    /// Latency threshold. Endpoints not meeting the threshold are deprioritized.
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub preferred_max_latency: Option<LatencyThreshold>,
357
358    /// Hard price ceiling. If no provider is at or under, the request fails.
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub max_price: Option<MaxPrice>,
361
362    // === Quantization Filter ===
363    /// Restrict routing to providers serving specific quantization levels.
364    #[serde(skip_serializing_if = "Option::is_none")]
365    pub quantizations: Option<Vec<Quantization>>,
366}
367
368impl ProviderPreferences {
369    /// Create a new empty provider preferences struct
370    pub fn new() -> Self {
371        Self::default()
372    }
373
374    // === Provider Selection Controls ===
375
376    /// Try these provider slugs in the given order first.
377    ///
378    /// If `allow_fallbacks` is true (default), OpenRouter may try other providers
379    /// after this list is exhausted.
380    ///
381    /// # Example
382    ///
383    /// ```rust
384    /// use rig_core::providers::openrouter::ProviderPreferences;
385    ///
386    /// let prefs = ProviderPreferences::new()
387    ///     .order(["anthropic", "openai"]);
388    /// ```
389    pub fn order(mut self, providers: impl IntoIterator<Item = impl Into<String>>) -> Self {
390        self.order = Some(providers.into_iter().map(|p| p.into()).collect());
391        self
392    }
393
394    /// Hard allowlist. Only these provider slugs are eligible.
395    ///
396    /// # Example
397    ///
398    /// ```rust
399    /// use rig_core::providers::openrouter::ProviderPreferences;
400    ///
401    /// let prefs = ProviderPreferences::new()
402    ///     .only(["azure", "together"])
403    ///     .allow_fallbacks(false);
404    /// ```
405    pub fn only(mut self, providers: impl IntoIterator<Item = impl Into<String>>) -> Self {
406        self.only = Some(providers.into_iter().map(|p| p.into()).collect());
407        self
408    }
409
410    /// Blocklist. These provider slugs are never used.
411    ///
412    /// # Example
413    ///
414    /// ```rust
415    /// use rig_core::providers::openrouter::ProviderPreferences;
416    ///
417    /// let prefs = ProviderPreferences::new()
418    ///     .ignore(["deepinfra"]);
419    /// ```
420    pub fn ignore(mut self, providers: impl IntoIterator<Item = impl Into<String>>) -> Self {
421        self.ignore = Some(providers.into_iter().map(|p| p.into()).collect());
422        self
423    }
424
425    /// Control whether fallbacks are allowed.
426    ///
427    /// If `false`, the router will not use any providers outside what your constraints permit.
428    /// Default is `true`.
429    pub fn allow_fallbacks(mut self, allow: bool) -> Self {
430        self.allow_fallbacks = Some(allow);
431        self
432    }
433
434    // === Compatibility and Policy Filters ===
435
436    /// If `true`, only route to providers that support all parameters in your request.
437    ///
438    /// Default is `false`, meaning providers may ignore unsupported parameters.
439    pub fn require_parameters(mut self, require: bool) -> Self {
440        self.require_parameters = Some(require);
441        self
442    }
443
444    /// Set data collection policy.
445    ///
446    /// If `Deny`, restrict routing to providers that do not store user data non-transiently.
447    pub fn data_collection(mut self, policy: DataCollection) -> Self {
448        self.data_collection = Some(policy);
449        self
450    }
451
452    /// If `true`, restrict routing to Zero Data Retention endpoints only.
453    ///
454    /// # Example
455    ///
456    /// ```rust
457    /// use rig_core::providers::openrouter::ProviderPreferences;
458    ///
459    /// let prefs = ProviderPreferences::new()
460    ///     .zdr(true);
461    /// ```
462    pub fn zdr(mut self, enable: bool) -> Self {
463        self.zdr = Some(enable);
464        self
465    }
466
467    // === Performance and Cost Preferences ===
468
469    /// Set the sorting strategy for providers.
470    ///
471    /// If set, default load balancing is disabled and providers are tried
472    /// deterministically in the resulting order.
473    ///
474    /// # Example
475    ///
476    /// ```rust
477    /// use rig_core::providers::openrouter::{ProviderPreferences, ProviderSortStrategy};
478    ///
479    /// let prefs = ProviderPreferences::new()
480    ///     .sort(ProviderSortStrategy::Latency);
481    /// ```
482    pub fn sort(mut self, sort: impl Into<ProviderSort>) -> Self {
483        self.sort = Some(sort.into());
484        self
485    }
486
487    /// Set preferred minimum throughput threshold.
488    ///
489    /// Endpoints not meeting the threshold are deprioritized (moved later), not excluded.
490    ///
491    /// # Example
492    ///
493    /// ```rust
494    /// use rig_core::providers::openrouter::{ProviderPreferences, ThroughputThreshold, PercentileThresholds};
495    ///
496    /// // Simple threshold
497    /// let prefs = ProviderPreferences::new()
498    ///     .preferred_min_throughput(ThroughputThreshold::Simple(50.0));
499    ///
500    /// // Percentile threshold
501    /// let prefs = ProviderPreferences::new()
502    ///     .preferred_min_throughput(ThroughputThreshold::Percentile(
503    ///         PercentileThresholds::new().p90(50.0)
504    ///     ));
505    /// ```
506    pub fn preferred_min_throughput(mut self, threshold: ThroughputThreshold) -> Self {
507        self.preferred_min_throughput = Some(threshold);
508        self
509    }
510
511    /// Set preferred maximum latency threshold.
512    ///
513    /// Endpoints not meeting the threshold are deprioritized, not excluded.
514    pub fn preferred_max_latency(mut self, threshold: LatencyThreshold) -> Self {
515        self.preferred_max_latency = Some(threshold);
516        self
517    }
518
519    /// Set maximum price ceiling.
520    ///
521    /// If no eligible provider is at or under the ceiling, the request fails.
522    pub fn max_price(mut self, price: MaxPrice) -> Self {
523        self.max_price = Some(price);
524        self
525    }
526
527    // === Quantization Filter ===
528
529    /// Restrict routing to providers serving specific quantization levels.
530    ///
531    /// # Example
532    ///
533    /// ```rust
534    /// use rig_core::providers::openrouter::{ProviderPreferences, Quantization};
535    ///
536    /// let prefs = ProviderPreferences::new()
537    ///     .quantizations([Quantization::Int8, Quantization::Fp16]);
538    /// ```
539    pub fn quantizations(mut self, quantizations: impl IntoIterator<Item = Quantization>) -> Self {
540        self.quantizations = Some(quantizations.into_iter().collect());
541        self
542    }
543
544    // === Convenience Methods ===
545
546    /// Convenience: Enable Zero Data Retention
547    pub fn zero_data_retention(self) -> Self {
548        self.zdr(true)
549    }
550
551    /// Convenience: Sort by throughput (higher tokens/sec first)
552    pub fn fastest(self) -> Self {
553        self.sort(ProviderSortStrategy::Throughput)
554    }
555
556    /// Convenience: Sort by price (cheapest first)
557    pub fn cheapest(self) -> Self {
558        self.sort(ProviderSortStrategy::Price)
559    }
560
561    /// Convenience: Sort by latency (lower latency first)
562    pub fn lowest_latency(self) -> Self {
563        self.sort(ProviderSortStrategy::Latency)
564    }
565
566    /// Convert to JSON value for use in additional_params
567    pub fn to_json(&self) -> serde_json::Value {
568        serde_json::json!({
569            "provider": self
570        })
571    }
572}
573
574/// A openrouter completion object.
575///
576/// For more information, see this link: <https://docs.openrouter.xyz/reference/create_chat_completion_v1_chat_completions_post>
577#[derive(Debug, Serialize, Deserialize)]
578pub struct CompletionResponse {
579    pub id: String,
580    pub object: String,
581    pub created: u64,
582    pub model: String,
583    pub choices: Vec<Choice>,
584    pub system_fingerprint: Option<String>,
585    pub usage: Option<Usage>,
586}
587
588impl From<ApiErrorResponse> for CompletionError {
589    fn from(err: ApiErrorResponse) -> Self {
590        CompletionError::ProviderError(err.message)
591    }
592}
593
594impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionResponse> {
595    type Error = CompletionError;
596
597    fn try_from(response: CompletionResponse) -> Result<Self, Self::Error> {
598        let choice = response.choices.first().ok_or_else(|| {
599            CompletionError::ResponseError("Response contained no choices".to_owned())
600        })?;
601
602        let content = match &choice.message {
603            Message::Assistant {
604                content,
605                tool_calls,
606                reasoning,
607                reasoning_details,
608                ..
609            } => {
610                let mut content = content
611                    .iter()
612                    .map(|c| match c {
613                        openai::AssistantContent::Text { text } => {
614                            completion::AssistantContent::text(text)
615                        }
616                        openai::AssistantContent::Refusal { refusal } => {
617                            completion::AssistantContent::text(refusal)
618                        }
619                    })
620                    .collect::<Vec<_>>();
621
622                content.extend(tool_calls.iter().map(|call| {
623                    completion::AssistantContent::tool_call(
624                        &call.id,
625                        &call.function.name,
626                        call.function.arguments.clone(),
627                    )
628                }));
629
630                let mut grouped_reasoning: HashMap<
631                    Option<String>,
632                    Vec<(usize, usize, message::ReasoningContent)>,
633                > = HashMap::new();
634                let mut reasoning_order: Vec<Option<String>> = Vec::new();
635                for (position, detail) in reasoning_details.iter().enumerate() {
636                    let (reasoning_id, sort_index, parsed_content) = match detail {
637                        ReasoningDetails::Summary {
638                            id, index, summary, ..
639                        } => (
640                            id.clone(),
641                            *index,
642                            Some(message::ReasoningContent::Summary(summary.clone())),
643                        ),
644                        ReasoningDetails::Encrypted {
645                            id, index, data, ..
646                        } => (
647                            id.clone(),
648                            *index,
649                            Some(message::ReasoningContent::Encrypted(data.clone())),
650                        ),
651                        ReasoningDetails::Text {
652                            id,
653                            index,
654                            text,
655                            signature,
656                            ..
657                        } => (
658                            id.clone(),
659                            *index,
660                            text.as_ref().map(|text| message::ReasoningContent::Text {
661                                text: text.clone(),
662                                signature: signature.clone(),
663                            }),
664                        ),
665                    };
666
667                    let Some(parsed_content) = parsed_content else {
668                        continue;
669                    };
670                    let sort_index = sort_index.unwrap_or(position);
671
672                    let entry = grouped_reasoning.entry(reasoning_id.clone());
673                    if matches!(entry, std::collections::hash_map::Entry::Vacant(_)) {
674                        reasoning_order.push(reasoning_id);
675                    }
676                    entry
677                        .or_default()
678                        .push((sort_index, position, parsed_content));
679                }
680
681                if grouped_reasoning.is_empty() {
682                    if let Some(reasoning) = reasoning {
683                        content.push(completion::AssistantContent::reasoning(reasoning));
684                    }
685                } else {
686                    for reasoning_id in reasoning_order {
687                        let Some(mut blocks) = grouped_reasoning.remove(&reasoning_id) else {
688                            continue;
689                        };
690                        blocks.sort_by_key(|(index, position, _)| (*index, *position));
691                        content.push(completion::AssistantContent::Reasoning(
692                            message::Reasoning {
693                                id: reasoning_id,
694                                content: blocks
695                                    .into_iter()
696                                    .map(|(_, _, content)| content)
697                                    .collect::<Vec<_>>(),
698                            },
699                        ));
700                    }
701                }
702
703                Ok(content)
704            }
705            _ => Err(CompletionError::ResponseError(
706                "Response did not contain a valid message or tool call".into(),
707            )),
708        }?;
709
710        let choice = OneOrMany::many(content).map_err(|_| {
711            CompletionError::ResponseError(
712                "Response contained no message or tool call (empty)".to_owned(),
713            )
714        })?;
715
716        let usage = response
717            .usage
718            .as_ref()
719            .map(|usage| completion::Usage {
720                input_tokens: usage.prompt_tokens as u64,
721                output_tokens: (usage.total_tokens - usage.prompt_tokens) as u64,
722                total_tokens: usage.total_tokens as u64,
723                cached_input_tokens: 0,
724                cache_creation_input_tokens: 0,
725                reasoning_tokens: 0,
726            })
727            .unwrap_or_default();
728
729        Ok(completion::CompletionResponse {
730            choice,
731            usage,
732            raw_response: response,
733            message_id: None,
734        })
735    }
736}
737
738/// User content types supported by OpenRouter.
739///
740/// OpenRouter uses different content type structures than OpenAI's Chat Completions API,
741/// particularly for file/document, audio, and video content. This enum matches OpenRouter's
742/// API specification.
743///
744/// # Supported Content Types
745///
746/// - **Text**: Plain text content
747/// - **ImageUrl**: Images via URL or base64 data URI
748/// - **File**: PDF documents and other files via URL or base64 data URI
749/// - **InputAudio**: Base64-encoded audio files (supported formats vary by model)
750/// - **VideoUrl**: Videos via URL or base64 data URI
751///
752/// # Example
753///
754/// ```rust
755/// use rig_core::providers::openrouter::UserContent;
756///
757/// // Text content
758/// let text = UserContent::text("Hello, world!");
759///
760/// // Image from URL
761/// let image = UserContent::image_url("https://example.com/image.png");
762///
763/// // PDF from URL
764/// let pdf = UserContent::file_url("https://example.com/document.pdf", Some("document.pdf".to_string()));
765///
766/// // Audio from base64
767/// use rig_core::completion::message::AudioMediaType;
768/// let audio = UserContent::audio_base64("base64data", AudioMediaType::WAV);
769///
770/// // Video from URL
771/// let video = UserContent::video_url("https://example.com/video.mp4");
772///
773/// // Video from base64
774/// use rig_core::completion::message::VideoMediaType;
775/// let video = UserContent::video_base64("base64data", VideoMediaType::MP4);
776/// ```
777#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
778#[serde(tag = "type", rename_all = "snake_case")]
779pub enum UserContent {
780    /// Plain text content
781    Text { text: String },
782
783    /// Image content (URL or base64 data URI)
784    ///
785    /// Supports: image/png, image/jpeg, image/webp, image/gif
786    #[serde(rename = "image_url")]
787    ImageUrl { image_url: ImageUrl },
788
789    /// File content (for PDFs and other documents)
790    ///
791    /// Uses `file_data` field which accepts either a publicly accessible URL
792    /// or base64-encoded content as a data URI.
793    File { file: FileContent },
794
795    /// Audio content (base64-encoded only; URLs are not supported for audio)
796    ///
797    /// Supported formats vary by model.
798    InputAudio { input_audio: openai::InputAudio },
799
800    /// Video content (URL or base64 data URI)
801    ///
802    /// Supports: video/mp4, video/mpeg, video/mov, video/webm.
803    /// URL support varies by provider.
804    #[serde(rename = "video_url")]
805    VideoUrl { video_url: VideoUrlContent },
806}
807
808impl UserContent {
809    /// Create text content
810    pub fn text(text: impl Into<String>) -> Self {
811        UserContent::Text { text: text.into() }
812    }
813
814    /// Create image content from URL
815    pub fn image_url(url: impl Into<String>) -> Self {
816        UserContent::ImageUrl {
817            image_url: ImageUrl {
818                url: url.into(),
819                detail: None,
820            },
821        }
822    }
823
824    /// Create image content from URL with detail level
825    pub fn image_url_with_detail(url: impl Into<String>, detail: ImageDetail) -> Self {
826        UserContent::ImageUrl {
827            image_url: ImageUrl {
828                url: url.into(),
829                detail: Some(detail),
830            },
831        }
832    }
833
834    /// Create image content from base64 data
835    ///
836    /// # Arguments
837    /// * `data` - Base64-encoded image data
838    /// * `mime_type` - MIME type (e.g., "image/png", "image/jpeg")
839    /// * `detail` - Optional detail level for image processing
840    pub fn image_base64(
841        data: impl Into<String>,
842        mime_type: &str,
843        detail: Option<ImageDetail>,
844    ) -> Self {
845        let data_uri = format!("data:{};base64,{}", mime_type, data.into());
846        UserContent::ImageUrl {
847            image_url: ImageUrl {
848                url: data_uri,
849                detail,
850            },
851        }
852    }
853
854    /// Create file content from URL
855    ///
856    /// # Arguments
857    /// * `url` - URL to the file (must be publicly accessible)
858    /// * `filename` - Optional filename for the document
859    pub fn file_url(url: impl Into<String>, filename: Option<String>) -> Self {
860        UserContent::File {
861            file: FileContent {
862                filename,
863                file_data: Some(url.into()),
864            },
865        }
866    }
867
868    /// Create file content from base64 data
869    ///
870    /// # Arguments
871    /// * `data` - Base64-encoded file data
872    /// * `mime_type` - MIME type (e.g., "application/pdf")
873    /// * `filename` - Optional filename for the document
874    pub fn file_base64(data: impl Into<String>, mime_type: &str, filename: Option<String>) -> Self {
875        let data_uri = format!("data:{};base64,{}", mime_type, data.into());
876        UserContent::File {
877            file: FileContent {
878                filename,
879                file_data: Some(data_uri),
880            },
881        }
882    }
883
884    /// Create audio content from base64-encoded data
885    ///
886    /// OpenRouter only supports base64-encoded audio; direct URLs are not supported.
887    ///
888    /// # Arguments
889    /// * `data` - Base64-encoded audio data
890    /// * `format` - Audio format (e.g., `AudioMediaType::WAV`, `AudioMediaType::MP3`)
891    pub fn audio_base64(data: impl Into<String>, format: AudioMediaType) -> Self {
892        UserContent::InputAudio {
893            input_audio: openai::InputAudio {
894                data: data.into(),
895                format,
896            },
897        }
898    }
899
900    /// Create video content from a URL
901    ///
902    /// URL support varies by provider.
903    ///
904    /// # Arguments
905    /// * `url` - URL to the video (must be publicly accessible)
906    pub fn video_url(url: impl Into<String>) -> Self {
907        UserContent::VideoUrl {
908            video_url: VideoUrlContent { url: url.into() },
909        }
910    }
911
912    /// Create video content from base64-encoded data
913    ///
914    /// # Arguments
915    /// * `data` - Base64-encoded video data
916    /// * `media_type` - Video media type (e.g., `VideoMediaType::MP4`)
917    pub fn video_base64(data: impl Into<String>, media_type: VideoMediaType) -> Self {
918        let mime = media_type.to_mime_type();
919        let data_uri = format!("data:{mime};base64,{}", data.into());
920        UserContent::VideoUrl {
921            video_url: VideoUrlContent { url: data_uri },
922        }
923    }
924}
925
926impl From<String> for UserContent {
927    fn from(text: String) -> Self {
928        UserContent::Text { text }
929    }
930}
931
932impl From<&str> for UserContent {
933    fn from(text: &str) -> Self {
934        UserContent::Text {
935            text: text.to_string(),
936        }
937    }
938}
939
940impl std::str::FromStr for UserContent {
941    type Err = std::convert::Infallible;
942
943    fn from_str(s: &str) -> Result<Self, Self::Err> {
944        Ok(UserContent::Text {
945            text: s.to_string(),
946        })
947    }
948}
949
950/// Image URL structure for OpenRouter
951#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
952pub struct ImageUrl {
953    /// URL or data URI (data:image/png;base64,...)
954    pub url: String,
955    /// Image detail level (optional)
956    #[serde(skip_serializing_if = "Option::is_none")]
957    pub detail: Option<ImageDetail>,
958}
959
960/// Video URL content structure for OpenRouter video support
961///
962/// OpenRouter supports both direct URLs and base64-encoded data URIs for video:
963/// - A publicly accessible URL
964/// - A base64-encoded data URI (e.g., `data:video/mp4;base64,...`)
965#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
966pub struct VideoUrlContent {
967    /// URL or data URI (data:video/mp4;base64,...)
968    pub url: String,
969}
970
971/// File content structure for OpenRouter PDF/document support
972///
973/// OpenRouter supports sending files (particularly PDFs) to models via the `file_data` field,
974/// which accepts either:
975/// - A publicly accessible URL to the file
976/// - A base64-encoded data URI (e.g., `data:application/pdf;base64,...`)
977#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
978pub struct FileContent {
979    /// Filename (e.g., "document.pdf")
980    #[serde(skip_serializing_if = "Option::is_none")]
981    pub filename: Option<String>,
982    /// File data source - URL or base64-encoded data URI
983    #[serde(skip_serializing_if = "Option::is_none")]
984    pub file_data: Option<String>,
985}
986
987/// Serializes user content as a plain string when there's a single text item,
988/// otherwise as an array of content parts.
989fn serialize_user_content<S>(
990    content: &OneOrMany<UserContent>,
991    serializer: S,
992) -> Result<S::Ok, S::Error>
993where
994    S: Serializer,
995{
996    if content.len() == 1
997        && let UserContent::Text { text } = content.first_ref()
998    {
999        return serializer.serialize_str(text);
1000    }
1001    content.serialize(serializer)
1002}
1003
1004impl TryFrom<message::UserContent> for UserContent {
1005    type Error = message::MessageError;
1006
1007    fn try_from(value: message::UserContent) -> Result<Self, Self::Error> {
1008        match value {
1009            message::UserContent::Text(message::Text { text }) => Ok(UserContent::Text { text }),
1010
1011            message::UserContent::Image(message::Image {
1012                data,
1013                detail,
1014                media_type,
1015                ..
1016            }) => {
1017                let url = match data {
1018                    DocumentSourceKind::Url(url) => url,
1019                    DocumentSourceKind::Base64(data) => {
1020                        let mime = media_type
1021                            .ok_or_else(|| {
1022                                message::MessageError::ConversionError(
1023                                    "Image media type required for base64 encoding".into(),
1024                                )
1025                            })?
1026                            .to_mime_type();
1027                        format!("data:{mime};base64,{data}")
1028                    }
1029                    DocumentSourceKind::Raw(_) => {
1030                        return Err(message::MessageError::ConversionError(
1031                            "Raw bytes not supported, encode as base64 first".into(),
1032                        ));
1033                    }
1034                    DocumentSourceKind::FileId(_) => {
1035                        return Err(message::MessageError::ConversionError(
1036                            "File IDs are not supported for images".into(),
1037                        ));
1038                    }
1039                    DocumentSourceKind::String(_) => {
1040                        return Err(message::MessageError::ConversionError(
1041                            "String source not supported for images".into(),
1042                        ));
1043                    }
1044                    DocumentSourceKind::Unknown => {
1045                        return Err(message::MessageError::ConversionError(
1046                            "Image has no data".into(),
1047                        ));
1048                    }
1049                };
1050                Ok(UserContent::ImageUrl {
1051                    image_url: ImageUrl { url, detail },
1052                })
1053            }
1054
1055            message::UserContent::Document(message::Document {
1056                data, media_type, ..
1057            }) => match data {
1058                DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
1059                    "Provider file IDs are not supported for OpenRouter document inputs".into(),
1060                )),
1061                DocumentSourceKind::Url(url) => {
1062                    let filename = media_type.as_ref().map(|mt| match mt {
1063                        DocumentMediaType::PDF => "document.pdf",
1064                        DocumentMediaType::TXT => "document.txt",
1065                        DocumentMediaType::HTML => "document.html",
1066                        DocumentMediaType::MARKDOWN => "document.md",
1067                        DocumentMediaType::CSV => "document.csv",
1068                        DocumentMediaType::XML => "document.xml",
1069                        _ => "document",
1070                    });
1071                    Ok(UserContent::File {
1072                        file: FileContent {
1073                            filename: filename.map(String::from),
1074                            file_data: Some(url),
1075                        },
1076                    })
1077                }
1078                DocumentSourceKind::Base64(data) => {
1079                    let mime = media_type
1080                        .as_ref()
1081                        .map(|m| m.to_mime_type())
1082                        .unwrap_or("application/pdf");
1083                    let data_uri = format!("data:{mime};base64,{data}");
1084
1085                    let filename = media_type.as_ref().map(|mt| match mt {
1086                        DocumentMediaType::PDF => "document.pdf",
1087                        DocumentMediaType::TXT => "document.txt",
1088                        DocumentMediaType::HTML => "document.html",
1089                        DocumentMediaType::MARKDOWN => "document.md",
1090                        DocumentMediaType::CSV => "document.csv",
1091                        DocumentMediaType::XML => "document.xml",
1092                        _ => "document",
1093                    });
1094
1095                    Ok(UserContent::File {
1096                        file: FileContent {
1097                            filename: filename.map(String::from),
1098                            file_data: Some(data_uri),
1099                        },
1100                    })
1101                }
1102                DocumentSourceKind::String(text) => Ok(UserContent::Text { text }),
1103                DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
1104                    "Raw bytes not supported for documents, encode as base64 first".into(),
1105                )),
1106                DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
1107                    "Document has no data".into(),
1108                )),
1109            },
1110
1111            message::UserContent::Audio(message::Audio {
1112                data, media_type, ..
1113            }) => match data {
1114                DocumentSourceKind::Base64(data) => {
1115                    let format = media_type.ok_or_else(|| {
1116                        message::MessageError::ConversionError(
1117                            "Audio media type required for base64 encoding".into(),
1118                        )
1119                    })?;
1120                    Ok(UserContent::InputAudio {
1121                        input_audio: openai::InputAudio { data, format },
1122                    })
1123                }
1124                DocumentSourceKind::Url(_) => Err(message::MessageError::ConversionError(
1125                    "OpenRouter does not support audio URLs, encode as base64 first".into(),
1126                )),
1127                DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
1128                    "Raw bytes not supported for audio, encode as base64 first".into(),
1129                )),
1130                DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
1131                    "File IDs are not supported for audio".into(),
1132                )),
1133                DocumentSourceKind::String(_) => Err(message::MessageError::ConversionError(
1134                    "String source not supported for audio".into(),
1135                )),
1136                DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
1137                    "Audio has no data".into(),
1138                )),
1139            },
1140
1141            message::UserContent::Video(message::Video {
1142                data, media_type, ..
1143            }) => {
1144                let url = match data {
1145                    DocumentSourceKind::Url(url) => url,
1146                    DocumentSourceKind::Base64(data) => {
1147                        let mime = media_type
1148                            .ok_or_else(|| {
1149                                message::MessageError::ConversionError(
1150                                    "Video media type required for base64 encoding".into(),
1151                                )
1152                            })?
1153                            .to_mime_type();
1154                        format!("data:{mime};base64,{data}")
1155                    }
1156                    DocumentSourceKind::Raw(_) => {
1157                        return Err(message::MessageError::ConversionError(
1158                            "Raw bytes not supported for video, encode as base64 first".into(),
1159                        ));
1160                    }
1161                    DocumentSourceKind::FileId(_) => {
1162                        return Err(message::MessageError::ConversionError(
1163                            "File IDs are not supported for video".into(),
1164                        ));
1165                    }
1166                    DocumentSourceKind::String(_) => {
1167                        return Err(message::MessageError::ConversionError(
1168                            "String source not supported for video".into(),
1169                        ));
1170                    }
1171                    DocumentSourceKind::Unknown => {
1172                        return Err(message::MessageError::ConversionError(
1173                            "Video has no data".into(),
1174                        ));
1175                    }
1176                };
1177                Ok(UserContent::VideoUrl {
1178                    video_url: VideoUrlContent { url },
1179                })
1180            }
1181
1182            message::UserContent::ToolResult(_) => Err(message::MessageError::ConversionError(
1183                "Tool results should be handled as separate messages".into(),
1184            )),
1185        }
1186    }
1187}
1188
1189impl TryFrom<OneOrMany<message::UserContent>> for Vec<Message> {
1190    type Error = message::MessageError;
1191
1192    fn try_from(value: OneOrMany<message::UserContent>) -> Result<Self, Self::Error> {
1193        let (tool_results, other_content): (Vec<_>, Vec<_>) = value
1194            .into_iter()
1195            .partition(|content| matches!(content, message::UserContent::ToolResult(_)));
1196
1197        // If there are messages with both tool results and user content, we handle
1198        // tool results first. It's unlikely that there will be both.
1199        if !tool_results.is_empty() {
1200            tool_results
1201                .into_iter()
1202                .map(|content| match content {
1203                    message::UserContent::ToolResult(tool_result) => Ok(Message::ToolResult {
1204                        tool_call_id: tool_result.id,
1205                        content: tool_result
1206                            .content
1207                            .into_iter()
1208                            .map(|c| match c {
1209                                message::ToolResultContent::Text(message::Text { text }) => text,
1210                                message::ToolResultContent::Image(_) => {
1211                                    "[Image content not supported in tool results]".to_string()
1212                                }
1213                            })
1214                            .collect::<Vec<_>>()
1215                            .join("\n"),
1216                    }),
1217                    _ => Err(message::MessageError::ConversionError(
1218                        "expected tool result content while converting OpenRouter input".into(),
1219                    )),
1220                })
1221                .collect::<Result<Vec<_>, _>>()
1222        } else {
1223            let user_content: Vec<UserContent> = other_content
1224                .into_iter()
1225                .map(|content| content.try_into())
1226                .collect::<Result<Vec<_>, _>>()?;
1227
1228            let content = OneOrMany::many(user_content).map_err(|_| {
1229                message::MessageError::ConversionError(
1230                    "OpenRouter user message did not contain any non-tool content".into(),
1231                )
1232            })?;
1233
1234            Ok(vec![Message::User {
1235                content,
1236                name: None,
1237            }])
1238        }
1239    }
1240}
1241
1242// ================================================================
1243// Response Types
1244// ================================================================
1245
1246#[derive(Debug, Deserialize, Serialize)]
1247pub struct Choice {
1248    pub index: usize,
1249    pub native_finish_reason: Option<String>,
1250    pub message: Message,
1251    pub finish_reason: Option<String>,
1252}
1253
1254/// OpenRouter message.
1255///
1256/// Almost identical to OpenAI's Message, but supports more parameters
1257/// for some providers like `reasoning`, and uses OpenRouter-specific
1258/// content types that support images, PDFs, and other file types.
1259#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1260#[serde(tag = "role", rename_all = "lowercase")]
1261pub enum Message {
1262    #[serde(alias = "developer")]
1263    System {
1264        #[serde(deserialize_with = "string_or_one_or_many")]
1265        content: OneOrMany<openai::SystemContent>,
1266        #[serde(skip_serializing_if = "Option::is_none")]
1267        name: Option<String>,
1268    },
1269    User {
1270        #[serde(
1271            deserialize_with = "string_or_one_or_many",
1272            serialize_with = "serialize_user_content"
1273        )]
1274        content: OneOrMany<UserContent>,
1275        #[serde(skip_serializing_if = "Option::is_none")]
1276        name: Option<String>,
1277    },
1278    Assistant {
1279        #[serde(
1280            default,
1281            deserialize_with = "json_utils::string_or_vec",
1282            skip_serializing_if = "Vec::is_empty"
1283        )]
1284        content: Vec<openai::AssistantContent>,
1285        #[serde(skip_serializing_if = "Option::is_none")]
1286        refusal: Option<String>,
1287        #[serde(skip_serializing_if = "Option::is_none")]
1288        audio: Option<openai::AudioAssistant>,
1289        #[serde(skip_serializing_if = "Option::is_none")]
1290        name: Option<String>,
1291        #[serde(
1292            default,
1293            deserialize_with = "json_utils::null_or_vec",
1294            skip_serializing_if = "Vec::is_empty"
1295        )]
1296        tool_calls: Vec<openai::ToolCall>,
1297        #[serde(skip_serializing_if = "Option::is_none")]
1298        reasoning: Option<String>,
1299        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1300        reasoning_details: Vec<ReasoningDetails>,
1301    },
1302    #[serde(rename = "tool")]
1303    ToolResult {
1304        tool_call_id: String,
1305        content: String,
1306    },
1307}
1308
1309impl Message {
1310    pub fn system(content: &str) -> Self {
1311        Message::System {
1312            content: OneOrMany::one(content.to_owned().into()),
1313            name: None,
1314        }
1315    }
1316}
1317
1318#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1319#[serde(tag = "type", rename_all = "snake_case")]
1320pub enum ReasoningDetails {
1321    #[serde(rename = "reasoning.summary")]
1322    Summary {
1323        id: Option<String>,
1324        format: Option<String>,
1325        index: Option<usize>,
1326        summary: String,
1327    },
1328    #[serde(rename = "reasoning.encrypted")]
1329    Encrypted {
1330        id: Option<String>,
1331        format: Option<String>,
1332        index: Option<usize>,
1333        data: String,
1334    },
1335    #[serde(rename = "reasoning.text")]
1336    Text {
1337        id: Option<String>,
1338        format: Option<String>,
1339        index: Option<usize>,
1340        text: Option<String>,
1341        signature: Option<String>,
1342    },
1343}
1344
1345#[derive(Debug, Deserialize, PartialEq, Clone)]
1346#[serde(untagged)]
1347enum ToolCallAdditionalParams {
1348    ReasoningDetails(ReasoningDetails),
1349    Minimal {
1350        id: Option<String>,
1351        format: Option<String>,
1352    },
1353}
1354
1355/// Convert OpenAI's user content to OpenRouter's user content.
1356impl TryFrom<openai::UserContent> for UserContent {
1357    type Error = message::MessageError;
1358
1359    fn try_from(value: openai::UserContent) -> Result<Self, Self::Error> {
1360        Ok(match value {
1361            openai::UserContent::Text { text } => UserContent::Text { text },
1362            openai::UserContent::Image { image_url } => UserContent::ImageUrl {
1363                image_url: ImageUrl {
1364                    url: image_url.url,
1365                    detail: Some(image_url.detail),
1366                },
1367            },
1368            openai::UserContent::Audio { input_audio } => UserContent::InputAudio { input_audio },
1369            openai::UserContent::File { file } => match file.file_data {
1370                Some(file_data) => UserContent::File {
1371                    file: FileContent {
1372                        filename: file.filename,
1373                        file_data: Some(file_data),
1374                    },
1375                },
1376                None => {
1377                    return Err(message::MessageError::ConversionError(
1378                        "OpenRouter file inputs require URL or base64 file_data; provider file IDs are not supported".into(),
1379                    ));
1380                }
1381            },
1382        })
1383    }
1384}
1385
1386impl TryFrom<openai::Message> for Message {
1387    type Error = message::MessageError;
1388
1389    fn try_from(value: openai::Message) -> Result<Self, Self::Error> {
1390        Ok(match value {
1391            openai::Message::System { content, name } => Self::System { content, name },
1392            openai::Message::User { content, name } => {
1393                let converted_content = content.try_map(UserContent::try_from)?;
1394                Self::User {
1395                    content: converted_content,
1396                    name,
1397                }
1398            }
1399            openai::Message::Assistant {
1400                content,
1401                reasoning,
1402                refusal,
1403                audio,
1404                name,
1405                tool_calls,
1406            } => Self::Assistant {
1407                content,
1408                refusal,
1409                audio,
1410                name,
1411                tool_calls,
1412                reasoning,
1413                reasoning_details: Vec::new(),
1414            },
1415            openai::Message::ToolResult {
1416                tool_call_id,
1417                content,
1418            } => Self::ToolResult {
1419                tool_call_id,
1420                content: content.as_text(),
1421            },
1422        })
1423    }
1424}
1425
1426impl TryFrom<OneOrMany<message::AssistantContent>> for Vec<Message> {
1427    type Error = message::MessageError;
1428
1429    fn try_from(value: OneOrMany<message::AssistantContent>) -> Result<Self, Self::Error> {
1430        let mut text_content = Vec::new();
1431        let mut tool_calls = Vec::new();
1432        let mut reasoning = None;
1433        let mut reasoning_details = Vec::new();
1434
1435        for content in value.into_iter() {
1436            match content {
1437                message::AssistantContent::Text(text) => text_content.push(text),
1438                message::AssistantContent::ToolCall(tool_call) => {
1439                    // We usually want to provide back the reasoning to OpenRouter since some
1440                    // providers require it.
1441                    // 1. Full reasoning details passed back the user
1442                    // 2. The signature, an id and a format if present
1443                    // 3. The signature and the call_id if present
1444                    if let Some(additional_params) = &tool_call.additional_params
1445                        && let Ok(additional_params) =
1446                            serde_json::from_value::<ToolCallAdditionalParams>(
1447                                additional_params.clone(),
1448                            )
1449                    {
1450                        match additional_params {
1451                            ToolCallAdditionalParams::ReasoningDetails(full) => {
1452                                reasoning_details.push(full);
1453                            }
1454                            ToolCallAdditionalParams::Minimal { id, format } => {
1455                                let id = id.or_else(|| tool_call.call_id.clone());
1456                                if let Some(signature) = &tool_call.signature
1457                                    && let Some(id) = id
1458                                {
1459                                    reasoning_details.push(ReasoningDetails::Encrypted {
1460                                        id: Some(id),
1461                                        format,
1462                                        index: None,
1463                                        data: signature.clone(),
1464                                    })
1465                                }
1466                            }
1467                        }
1468                    } else if let Some(signature) = &tool_call.signature {
1469                        reasoning_details.push(ReasoningDetails::Encrypted {
1470                            id: tool_call.call_id.clone(),
1471                            format: None,
1472                            index: None,
1473                            data: signature.clone(),
1474                        });
1475                    }
1476                    tool_calls.push(tool_call.into())
1477                }
1478                message::AssistantContent::Reasoning(r) => {
1479                    if r.content.is_empty() {
1480                        let display = r.display_text();
1481                        if !display.is_empty() {
1482                            reasoning = Some(display);
1483                        }
1484                    } else {
1485                        for reasoning_block in &r.content {
1486                            let index = Some(reasoning_details.len());
1487                            match reasoning_block {
1488                                message::ReasoningContent::Text { text, signature } => {
1489                                    reasoning_details.push(ReasoningDetails::Text {
1490                                        id: r.id.clone(),
1491                                        format: None,
1492                                        index,
1493                                        text: Some(text.clone()),
1494                                        signature: signature.clone(),
1495                                    });
1496                                }
1497                                message::ReasoningContent::Summary(summary) => {
1498                                    reasoning_details.push(ReasoningDetails::Summary {
1499                                        id: r.id.clone(),
1500                                        format: None,
1501                                        index,
1502                                        summary: summary.clone(),
1503                                    });
1504                                }
1505                                message::ReasoningContent::Encrypted(data)
1506                                | message::ReasoningContent::Redacted { data } => {
1507                                    reasoning_details.push(ReasoningDetails::Encrypted {
1508                                        id: r.id.clone(),
1509                                        format: None,
1510                                        index,
1511                                        data: data.clone(),
1512                                    });
1513                                }
1514                            }
1515                        }
1516                    }
1517                }
1518                message::AssistantContent::Image(_) => {
1519                    return Err(Self::Error::ConversionError(
1520                        "OpenRouter currently doesn't support images.".into(),
1521                    ));
1522                }
1523            }
1524        }
1525
1526        // `OneOrMany` ensures at least one `AssistantContent::Text` or `ToolCall` exists,
1527        //  so either `content` or `tool_calls` will have some content.
1528        Ok(vec![Message::Assistant {
1529            content: text_content
1530                .into_iter()
1531                .map(|content| content.text.into())
1532                .collect::<Vec<_>>(),
1533            refusal: None,
1534            audio: None,
1535            name: None,
1536            tool_calls,
1537            reasoning,
1538            reasoning_details,
1539        }])
1540    }
1541}
1542
1543// OpenRouter uses its own content types for User messages to support
1544// images and PDFs. Assistant messages still use OpenAI-compatible types.
1545impl TryFrom<message::Message> for Vec<Message> {
1546    type Error = message::MessageError;
1547
1548    fn try_from(message: message::Message) -> Result<Self, Self::Error> {
1549        match message {
1550            message::Message::System { content } => Ok(vec![Message::System {
1551                content: OneOrMany::one(content.into()),
1552                name: None,
1553            }]),
1554            message::Message::User { content } => {
1555                // Use OpenRouter's own conversion for User content
1556                // This supports images and PDF files via the file content type
1557                content.try_into()
1558            }
1559            message::Message::Assistant { content, .. } => content.try_into(),
1560        }
1561    }
1562}
1563
1564#[derive(Debug, Serialize, Deserialize)]
1565#[serde(untagged, rename_all = "snake_case")]
1566pub enum ToolChoice {
1567    None,
1568    Auto,
1569    Required,
1570    Function(Vec<ToolChoiceFunctionKind>),
1571}
1572
1573impl TryFrom<crate::message::ToolChoice> for ToolChoice {
1574    type Error = CompletionError;
1575
1576    fn try_from(value: crate::message::ToolChoice) -> Result<Self, Self::Error> {
1577        let res = match value {
1578            crate::message::ToolChoice::None => Self::None,
1579            crate::message::ToolChoice::Auto => Self::Auto,
1580            crate::message::ToolChoice::Required => Self::Required,
1581            crate::message::ToolChoice::Specific { function_names } => {
1582                let vec: Vec<ToolChoiceFunctionKind> = function_names
1583                    .into_iter()
1584                    .map(|name| ToolChoiceFunctionKind::Function { name })
1585                    .collect();
1586
1587                Self::Function(vec)
1588            }
1589        };
1590
1591        Ok(res)
1592    }
1593}
1594
1595#[derive(Debug, Serialize, Deserialize)]
1596#[serde(tag = "type", content = "function")]
1597pub enum ToolChoiceFunctionKind {
1598    Function { name: String },
1599}
1600
1601#[derive(Debug, Serialize, Deserialize)]
1602pub(super) struct OpenrouterCompletionRequest {
1603    model: String,
1604    pub messages: Vec<Message>,
1605    #[serde(skip_serializing_if = "Option::is_none")]
1606    temperature: Option<f64>,
1607    #[serde(skip_serializing_if = "Vec::is_empty")]
1608    tools: Vec<crate::providers::openai::completion::ToolDefinition>,
1609    #[serde(skip_serializing_if = "Option::is_none")]
1610    tool_choice: Option<crate::providers::openai::completion::ToolChoice>,
1611    #[serde(flatten, skip_serializing_if = "Option::is_none")]
1612    pub additional_params: Option<serde_json::Value>,
1613}
1614
1615/// Parameters for building an OpenRouter CompletionRequest
1616pub struct OpenRouterRequestParams<'a> {
1617    pub model: &'a str,
1618    pub request: CompletionRequest,
1619    pub strict_tools: bool,
1620}
1621
1622impl TryFrom<OpenRouterRequestParams<'_>> for OpenrouterCompletionRequest {
1623    type Error = CompletionError;
1624
1625    fn try_from(params: OpenRouterRequestParams) -> Result<Self, Self::Error> {
1626        let OpenRouterRequestParams {
1627            model,
1628            request: req,
1629            strict_tools,
1630        } = params;
1631        let model = req.model.clone().unwrap_or_else(|| model.to_string());
1632
1633        let mut full_history: Vec<Message> = match &req.preamble {
1634            Some(preamble) => vec![Message::system(preamble)],
1635            None => vec![],
1636        };
1637        if let Some(docs) = req.normalized_documents() {
1638            let docs: Vec<Message> = docs.try_into()?;
1639            full_history.extend(docs);
1640        }
1641
1642        let chat_history: Vec<Message> = req
1643            .chat_history
1644            .clone()
1645            .into_iter()
1646            .map(|message| message.try_into())
1647            .collect::<Result<Vec<Vec<Message>>, _>>()?
1648            .into_iter()
1649            .flatten()
1650            .collect();
1651
1652        full_history.extend(chat_history);
1653
1654        let tool_choice = req
1655            .tool_choice
1656            .clone()
1657            .map(crate::providers::openai::completion::ToolChoice::try_from)
1658            .transpose()?;
1659
1660        let tools: Vec<crate::providers::openai::completion::ToolDefinition> = req
1661            .tools
1662            .clone()
1663            .into_iter()
1664            .map(|tool| {
1665                let def = crate::providers::openai::completion::ToolDefinition::from(tool);
1666                if strict_tools { def.with_strict() } else { def }
1667            })
1668            .collect();
1669
1670        let additional_params = if let Some(schema) = req.output_schema {
1671            let name = schema
1672                .as_object()
1673                .and_then(|o| o.get("title"))
1674                .and_then(|v| v.as_str())
1675                .unwrap_or("response_schema")
1676                .to_string();
1677            let mut schema_value = schema.to_value();
1678            openai::sanitize_schema(&mut schema_value);
1679            let response_format = serde_json::json!({
1680                "response_format": {
1681                    "type": "json_schema",
1682                    "json_schema": {
1683                        "name": name,
1684                        "strict": true,
1685                        "schema": schema_value
1686                    }
1687                }
1688            });
1689            Some(match req.additional_params {
1690                Some(existing) => json_utils::merge(existing, response_format),
1691                None => response_format,
1692            })
1693        } else {
1694            req.additional_params
1695        };
1696
1697        Ok(Self {
1698            model,
1699            messages: full_history,
1700            temperature: req.temperature,
1701            tools,
1702            tool_choice,
1703            additional_params,
1704        })
1705    }
1706}
1707
1708impl TryFrom<(&str, CompletionRequest)> for OpenrouterCompletionRequest {
1709    type Error = CompletionError;
1710
1711    fn try_from((model, req): (&str, CompletionRequest)) -> Result<Self, Self::Error> {
1712        let model = req.model.clone().unwrap_or_else(|| model.to_string());
1713        OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1714            model: &model,
1715            request: req,
1716            strict_tools: false,
1717        })
1718    }
1719}
1720
1721#[derive(Clone)]
1722pub struct CompletionModel<T = reqwest::Client> {
1723    pub(crate) client: Client<T>,
1724    pub model: String,
1725    /// Enable strict mode for tool schemas.
1726    /// When enabled, tool schemas are sanitized to meet OpenAI's strict mode requirements.
1727    pub strict_tools: bool,
1728}
1729
1730impl<T> CompletionModel<T> {
1731    pub fn new(client: Client<T>, model: impl Into<String>) -> Self {
1732        Self {
1733            client,
1734            model: model.into(),
1735            strict_tools: false,
1736        }
1737    }
1738
1739    /// Enable strict mode for tool schemas.
1740    ///
1741    /// When enabled, tool schemas are automatically sanitized to meet OpenAI's strict mode requirements:
1742    /// - `additionalProperties: false` is added to all objects
1743    /// - All properties are marked as required
1744    /// - `strict: true` is set on each function definition
1745    ///
1746    /// Note: Not all models on OpenRouter support strict mode. This works best with OpenAI models.
1747    pub fn with_strict_tools(mut self) -> Self {
1748        self.strict_tools = true;
1749        self
1750    }
1751}
1752
1753impl<T> completion::CompletionModel for CompletionModel<T>
1754where
1755    T: HttpClientExt + Clone + std::fmt::Debug + Default + 'static,
1756{
1757    type Response = CompletionResponse;
1758    type StreamingResponse = StreamingCompletionResponse;
1759
1760    type Client = Client<T>;
1761
1762    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
1763        Self::new(client.clone(), model)
1764    }
1765
1766    async fn completion(
1767        &self,
1768        completion_request: CompletionRequest,
1769    ) -> Result<completion::CompletionResponse<CompletionResponse>, CompletionError> {
1770        let request_model = completion_request
1771            .model
1772            .clone()
1773            .unwrap_or_else(|| self.model.clone());
1774        let preamble = completion_request.preamble.clone();
1775        let request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1776            model: request_model.as_ref(),
1777            request: completion_request,
1778            strict_tools: self.strict_tools,
1779        })?;
1780
1781        if enabled!(Level::TRACE) {
1782            tracing::trace!(
1783                target: "rig::completions",
1784                "OpenRouter completion request: {}",
1785                serde_json::to_string_pretty(&request)?
1786            );
1787        }
1788
1789        let span = if tracing::Span::current().is_disabled() {
1790            info_span!(
1791                target: "rig::completions",
1792                "chat",
1793                gen_ai.operation.name = "chat",
1794                gen_ai.provider.name = "openrouter",
1795                gen_ai.request.model = &request_model,
1796                gen_ai.system_instructions = preamble,
1797                gen_ai.response.id = tracing::field::Empty,
1798                gen_ai.response.model = tracing::field::Empty,
1799                gen_ai.usage.output_tokens = tracing::field::Empty,
1800                gen_ai.usage.input_tokens = tracing::field::Empty,
1801                gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
1802            )
1803        } else {
1804            tracing::Span::current()
1805        };
1806
1807        let body = serde_json::to_vec(&request)?;
1808
1809        let req = self
1810            .client
1811            .post("/chat/completions")?
1812            .body(body)
1813            .map_err(|x| CompletionError::HttpError(x.into()))?;
1814
1815        async move {
1816            let response = self.client.send::<_, Bytes>(req).await?;
1817            let status = response.status();
1818            let response_body = response.into_body().into_future().await?.to_vec();
1819
1820            if status.is_success() {
1821                let parsed: ApiResponse<CompletionResponse> =
1822                    serde_json::from_slice(&response_body).map_err(|e| {
1823                        CompletionError::ResponseError(format!(
1824                            "Failed to parse OpenRouter completion response: {}, response body: {}",
1825                            e,
1826                            String::from_utf8_lossy(&response_body)
1827                        ))
1828                    })?;
1829                match parsed {
1830                    ApiResponse::Ok(response) => {
1831                        let span = tracing::Span::current();
1832                        span.record_token_usage(&response.usage);
1833                        span.record("gen_ai.response.id", &response.id);
1834                        span.record("gen_ai.response.model", &response.model);
1835
1836                        tracing::debug!(target: "rig::completions",
1837                            "OpenRouter response: {response:?}");
1838                        response.try_into()
1839                    }
1840                    ApiResponse::Err(err) => Err(CompletionError::ProviderError(err.message)),
1841                }
1842            } else {
1843                Err(CompletionError::ProviderError(
1844                    String::from_utf8_lossy(&response_body).to_string(),
1845                ))
1846            }
1847        }
1848        .instrument(span)
1849        .await
1850    }
1851
1852    async fn stream(
1853        &self,
1854        completion_request: CompletionRequest,
1855    ) -> Result<
1856        crate::streaming::StreamingCompletionResponse<Self::StreamingResponse>,
1857        CompletionError,
1858    > {
1859        CompletionModel::stream(self, completion_request).await
1860    }
1861}
1862
1863#[cfg(test)]
1864mod tests {
1865    use super::*;
1866    use serde_json::json;
1867
1868    #[test]
1869    fn test_openrouter_request_uses_request_model_override() {
1870        let request = CompletionRequest {
1871            model: Some("google/gemini-2.5-flash".to_string()),
1872            preamble: None,
1873            chat_history: crate::OneOrMany::one("Hello".into()),
1874            documents: vec![],
1875            tools: vec![],
1876            temperature: None,
1877            max_tokens: None,
1878            tool_choice: None,
1879            additional_params: None,
1880            output_schema: None,
1881        };
1882
1883        let openrouter_request =
1884            OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1885                .expect("request conversion should succeed");
1886        let serialized =
1887            serde_json::to_value(openrouter_request).expect("serialization should succeed");
1888
1889        assert_eq!(serialized["model"], "google/gemini-2.5-flash");
1890    }
1891
1892    #[test]
1893    fn test_openrouter_request_uses_default_model_when_override_unset() {
1894        let request = CompletionRequest {
1895            model: None,
1896            preamble: None,
1897            chat_history: crate::OneOrMany::one("Hello".into()),
1898            documents: vec![],
1899            tools: vec![],
1900            temperature: None,
1901            max_tokens: None,
1902            tool_choice: None,
1903            additional_params: None,
1904            output_schema: None,
1905        };
1906
1907        let openrouter_request =
1908            OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1909                .expect("request conversion should succeed");
1910        let serialized =
1911            serde_json::to_value(openrouter_request).expect("serialization should succeed");
1912
1913        assert_eq!(serialized["model"], "openai/gpt-4o-mini");
1914    }
1915
1916    #[test]
1917    fn test_openrouter_request_maps_output_schema_to_response_format() {
1918        let schema: schemars::Schema = serde_json::from_value(json!({
1919            "title": "WeatherResponse",
1920            "type": "object",
1921            "properties": {
1922                "city": { "type": "string" },
1923                "weather": { "type": "string" }
1924            }
1925        }))
1926        .expect("schema should deserialize");
1927
1928        let request = CompletionRequest {
1929            model: None,
1930            preamble: None,
1931            chat_history: crate::OneOrMany::one("Hello".into()),
1932            documents: vec![],
1933            tools: vec![],
1934            temperature: None,
1935            max_tokens: None,
1936            tool_choice: None,
1937            additional_params: None,
1938            output_schema: Some(schema),
1939        };
1940
1941        let openrouter_request =
1942            OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1943                .expect("request conversion should succeed");
1944        let serialized =
1945            serde_json::to_value(openrouter_request).expect("serialization should succeed");
1946
1947        assert_eq!(
1948            serialized["response_format"],
1949            json!({
1950                "type": "json_schema",
1951                "json_schema": {
1952                    "name": "WeatherResponse",
1953                    "strict": true,
1954                    "schema": {
1955                        "title": "WeatherResponse",
1956                        "type": "object",
1957                        "properties": {
1958                            "city": { "type": "string" },
1959                            "weather": { "type": "string" }
1960                        },
1961                        "additionalProperties": false,
1962                        "required": ["city", "weather"]
1963                    }
1964                }
1965            })
1966        );
1967    }
1968
1969    #[test]
1970    fn test_openrouter_request_merges_output_schema_with_provider_preferences() {
1971        let schema: schemars::Schema = serde_json::from_value(json!({
1972            "type": "object",
1973            "properties": {
1974                "answer": { "type": "string" }
1975            }
1976        }))
1977        .expect("schema should deserialize");
1978
1979        let request = CompletionRequest {
1980            model: None,
1981            preamble: None,
1982            chat_history: crate::OneOrMany::one("Hello".into()),
1983            documents: vec![],
1984            tools: vec![],
1985            temperature: None,
1986            max_tokens: None,
1987            tool_choice: None,
1988            additional_params: Some(
1989                ProviderPreferences::new()
1990                    .require_parameters(true)
1991                    .to_json(),
1992            ),
1993            output_schema: Some(schema),
1994        };
1995
1996        let openrouter_request =
1997            OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1998                .expect("request conversion should succeed");
1999        let serialized =
2000            serde_json::to_value(openrouter_request).expect("serialization should succeed");
2001
2002        assert_eq!(serialized["provider"]["require_parameters"], true);
2003        assert_eq!(serialized["response_format"]["type"], "json_schema");
2004        assert_eq!(
2005            serialized["response_format"]["json_schema"]["name"],
2006            "response_schema"
2007        );
2008        assert_eq!(
2009            serialized["response_format"]["json_schema"]["schema"]["additionalProperties"],
2010            false
2011        );
2012    }
2013
2014    #[test]
2015    fn test_completion_response_deserialization_gemini_flash() {
2016        // Real response from OpenRouter with google/gemini-2.5-flash
2017        let json = json!({
2018            "id": "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA",
2019            "provider": "Google",
2020            "model": "google/gemini-2.5-flash",
2021            "object": "chat.completion",
2022            "created": 1765971703u64,
2023            "choices": [{
2024                "logprobs": null,
2025                "finish_reason": "stop",
2026                "native_finish_reason": "STOP",
2027                "index": 0,
2028                "message": {
2029                    "role": "assistant",
2030                    "content": "CONTENT",
2031                    "refusal": null,
2032                    "reasoning": null
2033                }
2034            }],
2035            "usage": {
2036                "prompt_tokens": 669,
2037                "completion_tokens": 5,
2038                "total_tokens": 674
2039            }
2040        });
2041
2042        let response: CompletionResponse = serde_json::from_value(json).unwrap();
2043        assert_eq!(response.id, "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA");
2044        assert_eq!(response.model, "google/gemini-2.5-flash");
2045        assert_eq!(response.choices.len(), 1);
2046        assert_eq!(response.choices[0].finish_reason, Some("stop".to_string()));
2047    }
2048
2049    #[test]
2050    fn test_message_assistant_without_reasoning_details() {
2051        // Verify that missing reasoning_details field doesn't cause deserialization failure
2052        let json = json!({
2053            "role": "assistant",
2054            "content": "Hello world",
2055            "refusal": null,
2056            "reasoning": null
2057        });
2058
2059        let message: Message = serde_json::from_value(json).unwrap();
2060        match message {
2061            Message::Assistant {
2062                content,
2063                reasoning_details,
2064                ..
2065            } => {
2066                assert_eq!(content.len(), 1);
2067                assert!(reasoning_details.is_empty());
2068            }
2069            _ => panic!("Expected Assistant message"),
2070        }
2071    }
2072
2073    #[test]
2074    fn test_data_collection_serialization() {
2075        assert_eq!(
2076            serde_json::to_string(&DataCollection::Allow).unwrap(),
2077            r#""allow""#
2078        );
2079        assert_eq!(
2080            serde_json::to_string(&DataCollection::Deny).unwrap(),
2081            r#""deny""#
2082        );
2083    }
2084
2085    #[test]
2086    fn test_data_collection_default() {
2087        assert_eq!(DataCollection::default(), DataCollection::Allow);
2088    }
2089
2090    #[test]
2091    fn test_quantization_serialization() {
2092        assert_eq!(
2093            serde_json::to_string(&Quantization::Int4).unwrap(),
2094            r#""int4""#
2095        );
2096        assert_eq!(
2097            serde_json::to_string(&Quantization::Int8).unwrap(),
2098            r#""int8""#
2099        );
2100        assert_eq!(
2101            serde_json::to_string(&Quantization::Fp16).unwrap(),
2102            r#""fp16""#
2103        );
2104        assert_eq!(
2105            serde_json::to_string(&Quantization::Bf16).unwrap(),
2106            r#""bf16""#
2107        );
2108        assert_eq!(
2109            serde_json::to_string(&Quantization::Fp32).unwrap(),
2110            r#""fp32""#
2111        );
2112        assert_eq!(
2113            serde_json::to_string(&Quantization::Fp8).unwrap(),
2114            r#""fp8""#
2115        );
2116        assert_eq!(
2117            serde_json::to_string(&Quantization::Unknown).unwrap(),
2118            r#""unknown""#
2119        );
2120    }
2121
2122    #[test]
2123    fn test_provider_sort_strategy_serialization() {
2124        assert_eq!(
2125            serde_json::to_string(&ProviderSortStrategy::Price).unwrap(),
2126            r#""price""#
2127        );
2128        assert_eq!(
2129            serde_json::to_string(&ProviderSortStrategy::Throughput).unwrap(),
2130            r#""throughput""#
2131        );
2132        assert_eq!(
2133            serde_json::to_string(&ProviderSortStrategy::Latency).unwrap(),
2134            r#""latency""#
2135        );
2136    }
2137
2138    #[test]
2139    fn test_sort_partition_serialization() {
2140        assert_eq!(
2141            serde_json::to_string(&SortPartition::Model).unwrap(),
2142            r#""model""#
2143        );
2144        assert_eq!(
2145            serde_json::to_string(&SortPartition::None).unwrap(),
2146            r#""none""#
2147        );
2148    }
2149
2150    #[test]
2151    fn test_provider_sort_simple() {
2152        let sort = ProviderSort::Simple(ProviderSortStrategy::Latency);
2153        let json = serde_json::to_value(&sort).unwrap();
2154        assert_eq!(json, "latency");
2155    }
2156
2157    #[test]
2158    fn test_provider_sort_complex() {
2159        let sort = ProviderSort::Complex(
2160            ProviderSortConfig::new(ProviderSortStrategy::Price).partition(SortPartition::None),
2161        );
2162        let json = serde_json::to_value(&sort).unwrap();
2163        assert_eq!(json["by"], "price");
2164        assert_eq!(json["partition"], "none");
2165    }
2166
2167    #[test]
2168    fn test_provider_sort_complex_without_partition() {
2169        let sort = ProviderSort::Complex(ProviderSortConfig::new(ProviderSortStrategy::Throughput));
2170        let json = serde_json::to_value(&sort).unwrap();
2171        assert_eq!(json["by"], "throughput");
2172        assert!(json.get("partition").is_none());
2173    }
2174
2175    #[test]
2176    fn test_provider_sort_from_strategy() {
2177        let sort: ProviderSort = ProviderSortStrategy::Price.into();
2178        assert_eq!(sort, ProviderSort::Simple(ProviderSortStrategy::Price));
2179    }
2180
2181    #[test]
2182    fn test_provider_sort_from_config() {
2183        let config = ProviderSortConfig::new(ProviderSortStrategy::Latency);
2184        let sort: ProviderSort = config.into();
2185        match sort {
2186            ProviderSort::Complex(c) => assert_eq!(c.by, ProviderSortStrategy::Latency),
2187            _ => panic!("Expected Complex variant"),
2188        }
2189    }
2190
2191    #[test]
2192    fn test_percentile_thresholds_builder() {
2193        let thresholds = PercentileThresholds::new()
2194            .p50(10.0)
2195            .p75(25.0)
2196            .p90(50.0)
2197            .p99(100.0);
2198
2199        assert_eq!(thresholds.p50, Some(10.0));
2200        assert_eq!(thresholds.p75, Some(25.0));
2201        assert_eq!(thresholds.p90, Some(50.0));
2202        assert_eq!(thresholds.p99, Some(100.0));
2203    }
2204
2205    #[test]
2206    fn test_percentile_thresholds_default() {
2207        let thresholds = PercentileThresholds::default();
2208        assert_eq!(thresholds.p50, None);
2209        assert_eq!(thresholds.p75, None);
2210        assert_eq!(thresholds.p90, None);
2211        assert_eq!(thresholds.p99, None);
2212    }
2213
2214    #[test]
2215    fn test_throughput_threshold_simple() {
2216        let threshold = ThroughputThreshold::Simple(50.0);
2217        let json = serde_json::to_value(&threshold).unwrap();
2218        assert_eq!(json, 50.0);
2219    }
2220
2221    #[test]
2222    fn test_throughput_threshold_percentile() {
2223        let threshold = ThroughputThreshold::Percentile(PercentileThresholds::new().p90(50.0));
2224        let json = serde_json::to_value(&threshold).unwrap();
2225        assert_eq!(json["p90"], 50.0);
2226    }
2227
2228    #[test]
2229    fn test_latency_threshold_simple() {
2230        let threshold = LatencyThreshold::Simple(0.5);
2231        let json = serde_json::to_value(&threshold).unwrap();
2232        assert_eq!(json, 0.5);
2233    }
2234
2235    #[test]
2236    fn test_latency_threshold_percentile() {
2237        let threshold = LatencyThreshold::Percentile(PercentileThresholds::new().p50(0.1).p99(1.0));
2238        let json = serde_json::to_value(&threshold).unwrap();
2239        assert_eq!(json["p50"], 0.1);
2240        assert_eq!(json["p99"], 1.0);
2241    }
2242
2243    #[test]
2244    fn test_max_price_builder() {
2245        let price = MaxPrice::new().prompt(0.001).completion(0.002);
2246
2247        assert_eq!(price.prompt, Some(0.001));
2248        assert_eq!(price.completion, Some(0.002));
2249        assert_eq!(price.request, None);
2250        assert_eq!(price.image, None);
2251    }
2252
2253    #[test]
2254    fn test_max_price_all_fields() {
2255        let price = MaxPrice::new()
2256            .prompt(0.001)
2257            .completion(0.002)
2258            .request(0.01)
2259            .image(0.05);
2260
2261        let json = serde_json::to_value(&price).unwrap();
2262        assert_eq!(json["prompt"], 0.001);
2263        assert_eq!(json["completion"], 0.002);
2264        assert_eq!(json["request"], 0.01);
2265        assert_eq!(json["image"], 0.05);
2266    }
2267
2268    #[test]
2269    fn test_max_price_default() {
2270        let price = MaxPrice::default();
2271        assert_eq!(price.prompt, None);
2272        assert_eq!(price.completion, None);
2273        assert_eq!(price.request, None);
2274        assert_eq!(price.image, None);
2275    }
2276
2277    #[test]
2278    fn test_provider_preferences_default() {
2279        let prefs = ProviderPreferences::default();
2280        assert!(prefs.order.is_none());
2281        assert!(prefs.only.is_none());
2282        assert!(prefs.ignore.is_none());
2283        assert!(prefs.allow_fallbacks.is_none());
2284        assert!(prefs.require_parameters.is_none());
2285        assert!(prefs.data_collection.is_none());
2286        assert!(prefs.zdr.is_none());
2287        assert!(prefs.sort.is_none());
2288        assert!(prefs.preferred_min_throughput.is_none());
2289        assert!(prefs.preferred_max_latency.is_none());
2290        assert!(prefs.max_price.is_none());
2291        assert!(prefs.quantizations.is_none());
2292    }
2293
2294    #[test]
2295    fn test_provider_preferences_order_with_fallbacks() {
2296        let prefs = ProviderPreferences::new()
2297            .order(["anthropic", "openai"])
2298            .allow_fallbacks(true);
2299
2300        let json = prefs.to_json();
2301        let provider = &json["provider"];
2302
2303        assert_eq!(provider["order"], json!(["anthropic", "openai"]));
2304        assert_eq!(provider["allow_fallbacks"], true);
2305    }
2306
2307    #[test]
2308    fn test_provider_preferences_only_allowlist() {
2309        let prefs = ProviderPreferences::new()
2310            .only(["azure", "together"])
2311            .allow_fallbacks(false);
2312
2313        let json = prefs.to_json();
2314        let provider = &json["provider"];
2315
2316        assert_eq!(provider["only"], json!(["azure", "together"]));
2317        assert_eq!(provider["allow_fallbacks"], false);
2318    }
2319
2320    #[test]
2321    fn test_provider_preferences_ignore() {
2322        let prefs = ProviderPreferences::new().ignore(["deepinfra"]);
2323
2324        let json = prefs.to_json();
2325        let provider = &json["provider"];
2326
2327        assert_eq!(provider["ignore"], json!(["deepinfra"]));
2328    }
2329
2330    #[test]
2331    fn test_provider_preferences_sort_latency() {
2332        let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Latency);
2333
2334        let json = prefs.to_json();
2335        let provider = &json["provider"];
2336
2337        assert_eq!(provider["sort"], "latency");
2338    }
2339
2340    #[test]
2341    fn test_provider_preferences_price_with_throughput() {
2342        let prefs = ProviderPreferences::new()
2343            .sort(ProviderSortStrategy::Price)
2344            .preferred_min_throughput(ThroughputThreshold::Percentile(
2345                PercentileThresholds::new().p90(50.0),
2346            ));
2347
2348        let json = prefs.to_json();
2349        let provider = &json["provider"];
2350
2351        assert_eq!(provider["sort"], "price");
2352        assert_eq!(provider["preferred_min_throughput"]["p90"], 50.0);
2353    }
2354
2355    #[test]
2356    fn test_provider_preferences_require_parameters() {
2357        let prefs = ProviderPreferences::new().require_parameters(true);
2358
2359        let json = prefs.to_json();
2360        let provider = &json["provider"];
2361
2362        assert_eq!(provider["require_parameters"], true);
2363    }
2364
2365    #[test]
2366    fn test_provider_preferences_data_policy_and_zdr() {
2367        let prefs = ProviderPreferences::new()
2368            .data_collection(DataCollection::Deny)
2369            .zdr(true);
2370
2371        let json = prefs.to_json();
2372        let provider = &json["provider"];
2373
2374        assert_eq!(provider["data_collection"], "deny");
2375        assert_eq!(provider["zdr"], true);
2376    }
2377
2378    #[test]
2379    fn test_provider_preferences_quantizations() {
2380        let prefs =
2381            ProviderPreferences::new().quantizations([Quantization::Int8, Quantization::Fp16]);
2382
2383        let json = prefs.to_json();
2384        let provider = &json["provider"];
2385
2386        assert_eq!(provider["quantizations"], json!(["int8", "fp16"]));
2387    }
2388
2389    #[test]
2390    fn test_provider_preferences_convenience_methods() {
2391        let prefs = ProviderPreferences::new().zero_data_retention().fastest();
2392
2393        assert_eq!(prefs.zdr, Some(true));
2394        assert_eq!(
2395            prefs.sort,
2396            Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
2397        );
2398
2399        let prefs2 = ProviderPreferences::new().cheapest();
2400        assert_eq!(
2401            prefs2.sort,
2402            Some(ProviderSort::Simple(ProviderSortStrategy::Price))
2403        );
2404
2405        let prefs3 = ProviderPreferences::new().lowest_latency();
2406        assert_eq!(
2407            prefs3.sort,
2408            Some(ProviderSort::Simple(ProviderSortStrategy::Latency))
2409        );
2410    }
2411
2412    #[test]
2413    fn test_provider_preferences_serialization_skips_none() {
2414        let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Price);
2415
2416        let json = serde_json::to_value(&prefs).unwrap();
2417
2418        assert_eq!(json["sort"], "price");
2419        assert!(json.get("order").is_none());
2420        assert!(json.get("only").is_none());
2421        assert!(json.get("ignore").is_none());
2422        assert!(json.get("zdr").is_none());
2423    }
2424
2425    #[test]
2426    fn test_provider_preferences_deserialization() {
2427        let json = json!({
2428            "order": ["anthropic", "openai"],
2429            "sort": "throughput",
2430            "data_collection": "deny",
2431            "zdr": true,
2432            "quantizations": ["int8", "fp16"]
2433        });
2434
2435        let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
2436
2437        assert_eq!(
2438            prefs.order,
2439            Some(vec!["anthropic".to_string(), "openai".to_string()])
2440        );
2441        assert_eq!(
2442            prefs.sort,
2443            Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
2444        );
2445        assert_eq!(prefs.data_collection, Some(DataCollection::Deny));
2446        assert_eq!(prefs.zdr, Some(true));
2447        assert_eq!(
2448            prefs.quantizations,
2449            Some(vec![Quantization::Int8, Quantization::Fp16])
2450        );
2451    }
2452
2453    #[test]
2454    fn test_provider_preferences_deserialization_complex_sort() {
2455        let json = json!({
2456            "sort": {
2457                "by": "latency",
2458                "partition": "model"
2459            }
2460        });
2461
2462        let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
2463
2464        match prefs.sort {
2465            Some(ProviderSort::Complex(config)) => {
2466                assert_eq!(config.by, ProviderSortStrategy::Latency);
2467                assert_eq!(config.partition, Some(SortPartition::Model));
2468            }
2469            _ => panic!("Expected Complex sort variant"),
2470        }
2471    }
2472
2473    #[test]
2474    fn test_provider_preferences_full_integration() {
2475        let prefs = ProviderPreferences::new()
2476            .order(["anthropic", "openai"])
2477            .only(["anthropic", "openai", "google"])
2478            .sort(ProviderSortStrategy::Throughput)
2479            .data_collection(DataCollection::Deny)
2480            .zdr(true)
2481            .quantizations([Quantization::Int8])
2482            .allow_fallbacks(false);
2483
2484        let json = prefs.to_json();
2485
2486        assert!(json.get("provider").is_some());
2487        let provider = &json["provider"];
2488        assert_eq!(provider["order"], json!(["anthropic", "openai"]));
2489        assert_eq!(provider["only"], json!(["anthropic", "openai", "google"]));
2490        assert_eq!(provider["sort"], "throughput");
2491        assert_eq!(provider["data_collection"], "deny");
2492        assert_eq!(provider["zdr"], true);
2493        assert_eq!(provider["quantizations"], json!(["int8"]));
2494        assert_eq!(provider["allow_fallbacks"], false);
2495    }
2496
2497    #[test]
2498    fn test_provider_preferences_max_price() {
2499        let prefs =
2500            ProviderPreferences::new().max_price(MaxPrice::new().prompt(0.001).completion(0.002));
2501
2502        let json = prefs.to_json();
2503        let provider = &json["provider"];
2504
2505        assert_eq!(provider["max_price"]["prompt"], 0.001);
2506        assert_eq!(provider["max_price"]["completion"], 0.002);
2507    }
2508
2509    #[test]
2510    fn test_provider_preferences_preferred_max_latency() {
2511        let prefs = ProviderPreferences::new().preferred_max_latency(LatencyThreshold::Simple(0.5));
2512
2513        let json = prefs.to_json();
2514        let provider = &json["provider"];
2515
2516        assert_eq!(provider["preferred_max_latency"], 0.5);
2517    }
2518
2519    #[test]
2520    fn test_provider_preferences_empty_arrays() {
2521        let prefs = ProviderPreferences::new()
2522            .order(Vec::<String>::new())
2523            .quantizations(Vec::<Quantization>::new());
2524
2525        let json = prefs.to_json();
2526        let provider = &json["provider"];
2527
2528        assert_eq!(provider["order"], json!([]));
2529        assert_eq!(provider["quantizations"], json!([]));
2530    }
2531
2532    // ================================================================
2533    // File Support Tests
2534    // ================================================================
2535
2536    #[test]
2537    fn test_user_content_text_serialization() {
2538        let content = UserContent::text("Hello, world!");
2539        let json = serde_json::to_value(&content).unwrap();
2540
2541        assert_eq!(json["type"], "text");
2542        assert_eq!(json["text"], "Hello, world!");
2543    }
2544
2545    #[test]
2546    fn test_user_content_image_url_serialization() {
2547        let content = UserContent::image_url("https://example.com/image.png");
2548        let json = serde_json::to_value(&content).unwrap();
2549
2550        assert_eq!(json["type"], "image_url");
2551        assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
2552        assert!(json["image_url"].get("detail").is_none());
2553    }
2554
2555    #[test]
2556    fn test_user_content_image_url_with_detail_serialization() {
2557        let content =
2558            UserContent::image_url_with_detail("https://example.com/image.png", ImageDetail::High);
2559        let json = serde_json::to_value(&content).unwrap();
2560
2561        assert_eq!(json["type"], "image_url");
2562        assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
2563        assert_eq!(json["image_url"]["detail"], "high");
2564    }
2565
2566    #[test]
2567    fn test_user_content_image_base64_serialization() {
2568        let content = UserContent::image_base64("SGVsbG8=", "image/png", Some(ImageDetail::Low));
2569        let json = serde_json::to_value(&content).unwrap();
2570
2571        assert_eq!(json["type"], "image_url");
2572        assert_eq!(json["image_url"]["url"], "data:image/png;base64,SGVsbG8=");
2573        assert_eq!(json["image_url"]["detail"], "low");
2574    }
2575
2576    #[test]
2577    fn test_user_content_file_url_serialization() {
2578        let content = UserContent::file_url(
2579            "https://example.com/doc.pdf",
2580            Some("document.pdf".to_string()),
2581        );
2582        let json = serde_json::to_value(&content).unwrap();
2583
2584        assert_eq!(json["type"], "file");
2585        assert_eq!(json["file"]["file_data"], "https://example.com/doc.pdf");
2586        assert_eq!(json["file"]["filename"], "document.pdf");
2587    }
2588
2589    #[test]
2590    fn test_user_content_file_base64_serialization() {
2591        let content = UserContent::file_base64(
2592            "JVBERi0xLjQ=",
2593            "application/pdf",
2594            Some("report.pdf".to_string()),
2595        );
2596        let json = serde_json::to_value(&content).unwrap();
2597
2598        assert_eq!(json["type"], "file");
2599        assert_eq!(
2600            json["file"]["file_data"],
2601            "data:application/pdf;base64,JVBERi0xLjQ="
2602        );
2603        assert_eq!(json["file"]["filename"], "report.pdf");
2604    }
2605
2606    #[test]
2607    fn test_user_content_text_deserialization() {
2608        let json = json!({
2609            "type": "text",
2610            "text": "Hello!"
2611        });
2612
2613        let content: UserContent = serde_json::from_value(json).unwrap();
2614        assert_eq!(
2615            content,
2616            UserContent::Text {
2617                text: "Hello!".to_string()
2618            }
2619        );
2620    }
2621
2622    #[test]
2623    fn test_user_content_image_url_deserialization() {
2624        let json = json!({
2625            "type": "image_url",
2626            "image_url": {
2627                "url": "https://example.com/img.jpg",
2628                "detail": "high"
2629            }
2630        });
2631
2632        let content: UserContent = serde_json::from_value(json).unwrap();
2633        match content {
2634            UserContent::ImageUrl { image_url } => {
2635                assert_eq!(image_url.url, "https://example.com/img.jpg");
2636                assert_eq!(image_url.detail, Some(ImageDetail::High));
2637            }
2638            _ => panic!("Expected ImageUrl variant"),
2639        }
2640    }
2641
2642    #[test]
2643    fn test_user_content_file_deserialization() {
2644        let json = json!({
2645            "type": "file",
2646            "file": {
2647                "filename": "doc.pdf",
2648                "file_data": "https://example.com/doc.pdf"
2649            }
2650        });
2651
2652        let content: UserContent = serde_json::from_value(json).unwrap();
2653        match content {
2654            UserContent::File { file } => {
2655                assert_eq!(file.filename, Some("doc.pdf".to_string()));
2656                assert_eq!(
2657                    file.file_data,
2658                    Some("https://example.com/doc.pdf".to_string())
2659                );
2660            }
2661            _ => panic!("Expected File variant"),
2662        }
2663    }
2664
2665    #[test]
2666    fn test_message_user_with_text_serialization() {
2667        let message = Message::User {
2668            content: OneOrMany::one(UserContent::text("Hello")),
2669            name: None,
2670        };
2671        let json = serde_json::to_value(&message).unwrap();
2672
2673        // Single text content should be serialized as a plain string
2674        assert_eq!(json["role"], "user");
2675        assert_eq!(json["content"], "Hello");
2676    }
2677
2678    #[test]
2679    fn test_message_user_with_mixed_content_serialization() {
2680        let message = Message::User {
2681            content: OneOrMany::many(vec![
2682                UserContent::text("Check this image:"),
2683                UserContent::image_url("https://example.com/img.png"),
2684            ])
2685            .unwrap(),
2686            name: None,
2687        };
2688        let json = serde_json::to_value(&message).unwrap();
2689
2690        assert_eq!(json["role"], "user");
2691        let content = json["content"].as_array().unwrap();
2692        assert_eq!(content.len(), 2);
2693        assert_eq!(content[0]["type"], "text");
2694        assert_eq!(content[1]["type"], "image_url");
2695    }
2696
2697    #[test]
2698    fn test_message_user_with_file_serialization() {
2699        let message = Message::User {
2700            content: OneOrMany::many(vec![
2701                UserContent::text("Analyze this PDF:"),
2702                UserContent::file_url(
2703                    "https://example.com/doc.pdf",
2704                    Some("document.pdf".to_string()),
2705                ),
2706            ])
2707            .unwrap(),
2708            name: None,
2709        };
2710        let json = serde_json::to_value(&message).unwrap();
2711
2712        assert_eq!(json["role"], "user");
2713        let content = json["content"].as_array().unwrap();
2714        assert_eq!(content.len(), 2);
2715        assert_eq!(content[0]["type"], "text");
2716        assert_eq!(content[1]["type"], "file");
2717        assert_eq!(
2718            content[1]["file"]["file_data"],
2719            "https://example.com/doc.pdf"
2720        );
2721    }
2722
2723    #[test]
2724    fn test_user_content_from_rig_text() {
2725        let rig_content = message::UserContent::Text(message::Text {
2726            text: "Hello".to_string(),
2727        });
2728        let openrouter_content: UserContent = rig_content.try_into().unwrap();
2729
2730        assert_eq!(
2731            openrouter_content,
2732            UserContent::Text {
2733                text: "Hello".to_string()
2734            }
2735        );
2736    }
2737
2738    #[test]
2739    fn test_user_content_from_rig_image_url() {
2740        let rig_content = message::UserContent::Image(message::Image {
2741            data: DocumentSourceKind::Url("https://example.com/img.png".to_string()),
2742            media_type: Some(message::ImageMediaType::PNG),
2743            detail: Some(ImageDetail::High),
2744            additional_params: None,
2745        });
2746        let openrouter_content: UserContent = rig_content.try_into().unwrap();
2747
2748        match openrouter_content {
2749            UserContent::ImageUrl { image_url } => {
2750                assert_eq!(image_url.url, "https://example.com/img.png");
2751                assert_eq!(image_url.detail, Some(ImageDetail::High));
2752            }
2753            _ => panic!("Expected ImageUrl variant"),
2754        }
2755    }
2756
2757    #[test]
2758    fn test_user_content_from_rig_image_base64() {
2759        let rig_content = message::UserContent::Image(message::Image {
2760            data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
2761            media_type: Some(message::ImageMediaType::JPEG),
2762            detail: Some(ImageDetail::Low),
2763            additional_params: None,
2764        });
2765        let openrouter_content: UserContent = rig_content.try_into().unwrap();
2766
2767        match openrouter_content {
2768            UserContent::ImageUrl { image_url } => {
2769                assert_eq!(image_url.url, "data:image/jpeg;base64,SGVsbG8=");
2770                assert_eq!(image_url.detail, Some(ImageDetail::Low));
2771            }
2772            _ => panic!("Expected ImageUrl variant"),
2773        }
2774    }
2775
2776    #[test]
2777    fn test_user_content_from_rig_document_url() {
2778        let rig_content = message::UserContent::Document(message::Document {
2779            data: DocumentSourceKind::Url("https://example.com/doc.pdf".to_string()),
2780            media_type: Some(DocumentMediaType::PDF),
2781            additional_params: None,
2782        });
2783        let openrouter_content: UserContent = rig_content.try_into().unwrap();
2784
2785        match openrouter_content {
2786            UserContent::File { file } => {
2787                assert_eq!(
2788                    file.file_data,
2789                    Some("https://example.com/doc.pdf".to_string())
2790                );
2791                assert_eq!(file.filename, Some("document.pdf".to_string()));
2792            }
2793            _ => panic!("Expected File variant"),
2794        }
2795    }
2796
2797    #[test]
2798    fn test_user_content_from_rig_document_base64() {
2799        let rig_content = message::UserContent::Document(message::Document {
2800            data: DocumentSourceKind::Base64("JVBERi0xLjQ=".to_string()),
2801            media_type: Some(DocumentMediaType::PDF),
2802            additional_params: None,
2803        });
2804        let openrouter_content: UserContent = rig_content.try_into().unwrap();
2805
2806        match openrouter_content {
2807            UserContent::File { file } => {
2808                assert_eq!(
2809                    file.file_data,
2810                    Some("data:application/pdf;base64,JVBERi0xLjQ=".to_string())
2811                );
2812                assert_eq!(file.filename, Some("document.pdf".to_string()));
2813            }
2814            _ => panic!("Expected File variant"),
2815        }
2816    }
2817
2818    #[test]
2819    fn test_user_content_from_rig_document_file_id() {
2820        let rig_content = message::UserContent::Document(message::Document {
2821            data: DocumentSourceKind::FileId("file_abc".to_string()),
2822            media_type: None,
2823            additional_params: None,
2824        });
2825
2826        let result: Result<UserContent, _> = rig_content.try_into();
2827        assert!(matches!(
2828            result,
2829            Err(message::MessageError::ConversionError(message))
2830                if message.contains("Provider file IDs are not supported")
2831        ));
2832    }
2833
2834    #[test]
2835    fn test_openai_file_id_content_round_trips_through_rig_to_openrouter_error() {
2836        let openai_content = openai::UserContent::File {
2837            file: openai::FileData {
2838                file_data: None,
2839                file_id: Some("file_abc".to_string()),
2840                filename: None,
2841            },
2842        };
2843        let rig_content: message::UserContent = openai_content.into();
2844
2845        let result: Result<UserContent, _> = rig_content.try_into();
2846        assert!(matches!(
2847            result,
2848            Err(message::MessageError::ConversionError(message))
2849                if message.contains("Provider file IDs are not supported")
2850        ));
2851    }
2852
2853    #[test]
2854    fn test_user_content_from_rig_document_string_becomes_text() {
2855        let rig_content = message::UserContent::Document(message::Document {
2856            data: DocumentSourceKind::String("Plain text document content".to_string()),
2857            media_type: Some(DocumentMediaType::TXT),
2858            additional_params: None,
2859        });
2860        let openrouter_content: UserContent = rig_content.try_into().unwrap();
2861
2862        assert_eq!(
2863            openrouter_content,
2864            UserContent::Text {
2865                text: "Plain text document content".to_string()
2866            }
2867        );
2868    }
2869
2870    #[test]
2871    fn test_completion_response_with_reasoning_details_maps_to_typed_reasoning() {
2872        let json = json!({
2873            "id": "resp_123",
2874            "object": "chat.completion",
2875            "created": 1,
2876            "model": "openrouter/test-model",
2877            "choices": [{
2878                "index": 0,
2879                "finish_reason": "stop",
2880                "message": {
2881                    "role": "assistant",
2882                    "content": "hello",
2883                    "reasoning": null,
2884                    "reasoning_details": [
2885                        {"type":"reasoning.summary","id":"rs_1","summary":"s1"},
2886                        {"type":"reasoning.text","id":"rs_1","text":"t1","signature":"sig_1"},
2887                        {"type":"reasoning.encrypted","id":"rs_1","data":"enc_1"}
2888                    ]
2889                }
2890            }]
2891        });
2892
2893        let response: CompletionResponse = serde_json::from_value(json).unwrap();
2894        let converted: completion::CompletionResponse<CompletionResponse> =
2895            response.try_into().unwrap();
2896        let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
2897
2898        assert!(items.iter().any(|item| matches!(
2899            item,
2900            completion::AssistantContent::Reasoning(message::Reasoning { id: Some(id), content })
2901                if id == "rs_1" && content.len() == 3
2902        )));
2903    }
2904
2905    #[test]
2906    fn test_assistant_reasoning_emits_openrouter_reasoning_details() {
2907        let reasoning = message::Reasoning {
2908            id: Some("rs_2".to_string()),
2909            content: vec![
2910                message::ReasoningContent::Text {
2911                    text: "step".to_string(),
2912                    signature: Some("sig_step".to_string()),
2913                },
2914                message::ReasoningContent::Summary("summary".to_string()),
2915                message::ReasoningContent::Encrypted("enc_blob".to_string()),
2916            ],
2917        };
2918
2919        let messages = Vec::<Message>::try_from(OneOrMany::one(
2920            message::AssistantContent::Reasoning(reasoning),
2921        ))
2922        .unwrap();
2923        let Message::Assistant {
2924            reasoning,
2925            reasoning_details,
2926            ..
2927        } = messages.first().expect("assistant message")
2928        else {
2929            panic!("Expected assistant message");
2930        };
2931
2932        assert!(reasoning.is_none());
2933        assert_eq!(reasoning_details.len(), 3);
2934        assert!(matches!(
2935            reasoning_details.first(),
2936            Some(ReasoningDetails::Text {
2937                id: Some(id),
2938                text: Some(text),
2939                signature: Some(signature),
2940                ..
2941            }) if id == "rs_2" && text == "step" && signature == "sig_step"
2942        ));
2943    }
2944
2945    #[test]
2946    fn test_assistant_redacted_reasoning_emits_encrypted_detail_not_text() {
2947        let reasoning = message::Reasoning {
2948            id: Some("rs_redacted".to_string()),
2949            content: vec![message::ReasoningContent::Redacted {
2950                data: "opaque-redacted-data".to_string(),
2951            }],
2952        };
2953
2954        let messages = Vec::<Message>::try_from(OneOrMany::one(
2955            message::AssistantContent::Reasoning(reasoning),
2956        ))
2957        .unwrap();
2958
2959        let Message::Assistant {
2960            reasoning_details,
2961            reasoning,
2962            ..
2963        } = messages.first().expect("assistant message")
2964        else {
2965            panic!("Expected assistant message");
2966        };
2967
2968        assert!(reasoning.is_none());
2969        assert_eq!(reasoning_details.len(), 1);
2970        assert!(matches!(
2971            reasoning_details.first(),
2972            Some(ReasoningDetails::Encrypted {
2973                id: Some(id),
2974                data,
2975                ..
2976            }) if id == "rs_redacted" && data == "opaque-redacted-data"
2977        ));
2978    }
2979
2980    #[test]
2981    fn test_completion_response_reasoning_details_respects_index_ordering() {
2982        let json = json!({
2983            "id": "resp_ordering",
2984            "object": "chat.completion",
2985            "created": 1,
2986            "model": "openrouter/test-model",
2987            "choices": [{
2988                "index": 0,
2989                "finish_reason": "stop",
2990                "message": {
2991                    "role": "assistant",
2992                    "content": "hello",
2993                    "reasoning": null,
2994                    "reasoning_details": [
2995                        {"type":"reasoning.summary","id":"rs_order","index":1,"summary":"second"},
2996                        {"type":"reasoning.summary","id":"rs_order","index":0,"summary":"first"}
2997                    ]
2998                }
2999            }]
3000        });
3001
3002        let response: CompletionResponse = serde_json::from_value(json).unwrap();
3003        let converted: completion::CompletionResponse<CompletionResponse> =
3004            response.try_into().unwrap();
3005        let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3006        let reasoning_blocks: Vec<_> = items
3007            .into_iter()
3008            .filter_map(|item| match item {
3009                completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
3010                _ => None,
3011            })
3012            .collect();
3013
3014        assert_eq!(reasoning_blocks.len(), 1);
3015        assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_order"));
3016        assert_eq!(
3017            reasoning_blocks[0].content,
3018            vec![
3019                message::ReasoningContent::Summary("first".to_string()),
3020                message::ReasoningContent::Summary("second".to_string()),
3021            ]
3022        );
3023    }
3024
3025    #[test]
3026    fn test_user_content_from_rig_image_missing_media_type_error() {
3027        let rig_content = message::UserContent::Image(message::Image {
3028            data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3029            media_type: None, // Missing media type
3030            detail: None,
3031            additional_params: None,
3032        });
3033        let result: Result<UserContent, _> = rig_content.try_into();
3034
3035        assert!(result.is_err());
3036        let err = result.unwrap_err();
3037        assert!(err.to_string().contains("media type required"));
3038    }
3039
3040    #[test]
3041    fn test_user_content_from_rig_image_raw_bytes_error() {
3042        let rig_content = message::UserContent::Image(message::Image {
3043            data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3044            media_type: Some(message::ImageMediaType::PNG),
3045            detail: None,
3046            additional_params: None,
3047        });
3048        let result: Result<UserContent, _> = rig_content.try_into();
3049
3050        assert!(result.is_err());
3051        let err = result.unwrap_err();
3052        assert!(err.to_string().contains("base64"));
3053    }
3054
3055    #[test]
3056    fn test_user_content_from_rig_video_url() {
3057        let rig_content = message::UserContent::Video(message::Video {
3058            data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
3059            media_type: Some(message::VideoMediaType::MP4),
3060            additional_params: None,
3061        });
3062        let openrouter_content: UserContent = rig_content.try_into().unwrap();
3063
3064        match openrouter_content {
3065            UserContent::VideoUrl { video_url } => {
3066                assert_eq!(video_url.url, "https://example.com/video.mp4");
3067            }
3068            _ => panic!("Expected VideoUrl variant"),
3069        }
3070    }
3071
3072    #[test]
3073    fn test_user_content_from_rig_video_base64() {
3074        let rig_content = message::UserContent::Video(message::Video {
3075            data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3076            media_type: Some(message::VideoMediaType::MP4),
3077            additional_params: None,
3078        });
3079        let openrouter_content: UserContent = rig_content.try_into().unwrap();
3080
3081        match openrouter_content {
3082            UserContent::VideoUrl { video_url } => {
3083                assert_eq!(video_url.url, "data:video/mp4;base64,SGVsbG8=");
3084            }
3085            _ => panic!("Expected VideoUrl variant"),
3086        }
3087    }
3088
3089    #[test]
3090    fn test_user_content_from_rig_video_base64_missing_media_type_error() {
3091        let rig_content = message::UserContent::Video(message::Video {
3092            data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3093            media_type: None,
3094            additional_params: None,
3095        });
3096        let result: Result<UserContent, _> = rig_content.try_into();
3097
3098        assert!(result.is_err());
3099        let err = result.unwrap_err();
3100        assert!(err.to_string().contains("media type"));
3101    }
3102
3103    #[test]
3104    fn test_user_content_from_rig_video_raw_bytes_error() {
3105        let rig_content = message::UserContent::Video(message::Video {
3106            data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3107            media_type: Some(message::VideoMediaType::MP4),
3108            additional_params: None,
3109        });
3110        let result: Result<UserContent, _> = rig_content.try_into();
3111
3112        assert!(result.is_err());
3113        let err = result.unwrap_err();
3114        assert!(err.to_string().contains("base64"));
3115    }
3116
3117    #[test]
3118    fn test_user_content_from_rig_audio_base64() {
3119        let rig_content = message::UserContent::Audio(message::Audio {
3120            data: DocumentSourceKind::Base64("audiodata".to_string()),
3121            media_type: Some(message::AudioMediaType::MP3),
3122            additional_params: None,
3123        });
3124        let openrouter_content: UserContent = rig_content.try_into().unwrap();
3125
3126        match openrouter_content {
3127            UserContent::InputAudio { input_audio } => {
3128                assert_eq!(input_audio.data, "audiodata");
3129                assert_eq!(input_audio.format, message::AudioMediaType::MP3);
3130            }
3131            _ => panic!("Expected InputAudio variant"),
3132        }
3133    }
3134
3135    #[test]
3136    fn test_user_content_from_rig_audio_missing_media_type_error() {
3137        let rig_content = message::UserContent::Audio(message::Audio {
3138            data: DocumentSourceKind::Base64("audiodata".to_string()),
3139            media_type: None, // missing media type
3140            additional_params: None,
3141        });
3142        let result: Result<UserContent, _> = rig_content.try_into();
3143
3144        assert!(result.is_err());
3145        let err = result.unwrap_err();
3146        assert!(err.to_string().contains("media type required"));
3147    }
3148
3149    #[test]
3150    fn test_user_content_from_rig_audio_url_error() {
3151        let rig_content = message::UserContent::Audio(message::Audio {
3152            data: DocumentSourceKind::Url("https://example.com/audio.wav".to_string()),
3153            media_type: Some(message::AudioMediaType::WAV),
3154            additional_params: None,
3155        });
3156        let result: Result<UserContent, _> = rig_content.try_into();
3157
3158        assert!(result.is_err());
3159        let err = result.unwrap_err();
3160        assert!(err.to_string().contains("base64"));
3161    }
3162
3163    #[test]
3164    fn test_user_content_from_rig_audio_raw_bytes_error() {
3165        let rig_content = message::UserContent::Audio(message::Audio {
3166            data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3167            media_type: Some(message::AudioMediaType::WAV),
3168            additional_params: None,
3169        });
3170        let result: Result<UserContent, _> = rig_content.try_into();
3171
3172        assert!(result.is_err());
3173        let err = result.unwrap_err();
3174        assert!(err.to_string().contains("base64"));
3175    }
3176
3177    #[test]
3178    fn test_message_conversion_with_pdf() {
3179        let rig_message = message::Message::User {
3180            content: OneOrMany::many(vec![
3181                message::UserContent::Text(message::Text {
3182                    text: "Summarize this document".to_string(),
3183                }),
3184                message::UserContent::Document(message::Document {
3185                    data: DocumentSourceKind::Url("https://example.com/paper.pdf".to_string()),
3186                    media_type: Some(DocumentMediaType::PDF),
3187                    additional_params: None,
3188                }),
3189            ])
3190            .unwrap(),
3191        };
3192
3193        let openrouter_messages: Vec<Message> = rig_message.try_into().unwrap();
3194        assert_eq!(openrouter_messages.len(), 1);
3195
3196        match &openrouter_messages[0] {
3197            Message::User { content, .. } => {
3198                assert_eq!(content.len(), 2);
3199
3200                // First should be text
3201                match content.first_ref() {
3202                    UserContent::Text { text } => assert_eq!(text, "Summarize this document"),
3203                    _ => panic!("Expected Text"),
3204                }
3205            }
3206            _ => panic!("Expected User message"),
3207        }
3208    }
3209
3210    #[test]
3211    fn test_user_content_from_string() {
3212        let content: UserContent = "Hello".into();
3213        assert_eq!(
3214            content,
3215            UserContent::Text {
3216                text: "Hello".to_string()
3217            }
3218        );
3219
3220        let content: UserContent = String::from("World").into();
3221        assert_eq!(
3222            content,
3223            UserContent::Text {
3224                text: "World".to_string()
3225            }
3226        );
3227    }
3228
3229    #[test]
3230    fn test_openai_user_content_conversion() {
3231        // Test that OpenAI UserContent can be converted to OpenRouter UserContent
3232        let openai_text = openai::UserContent::Text {
3233            text: "Hello".to_string(),
3234        };
3235        let converted: UserContent = openai_text.try_into().unwrap();
3236        assert_eq!(
3237            converted,
3238            UserContent::Text {
3239                text: "Hello".to_string()
3240            }
3241        );
3242
3243        let openai_image = openai::UserContent::Image {
3244            image_url: openai::ImageUrl {
3245                url: "https://example.com/img.png".to_string(),
3246                detail: ImageDetail::Auto,
3247            },
3248        };
3249        let converted: UserContent = openai_image.try_into().unwrap();
3250        match converted {
3251            UserContent::ImageUrl { image_url } => {
3252                assert_eq!(image_url.url, "https://example.com/img.png");
3253                assert_eq!(image_url.detail, Some(ImageDetail::Auto));
3254            }
3255            _ => panic!("Expected ImageUrl"),
3256        }
3257
3258        let openai_audio = openai::UserContent::Audio {
3259            input_audio: openai::InputAudio {
3260                data: "audiodata".to_string(),
3261                format: AudioMediaType::FLAC,
3262            },
3263        };
3264        let converted: UserContent = openai_audio.try_into().unwrap();
3265        match converted {
3266            UserContent::InputAudio { input_audio } => {
3267                assert_eq!(input_audio.data, "audiodata");
3268                assert_eq!(input_audio.format, AudioMediaType::FLAC);
3269            }
3270            _ => panic!("Expected InputAudio"),
3271        }
3272
3273        let openai_file = openai::UserContent::File {
3274            file: openai::FileData {
3275                file_data: Some("data:application/pdf;base64,AAAA".to_string()),
3276                file_id: None,
3277                filename: Some("uploaded.pdf".to_string()),
3278            },
3279        };
3280        let converted: UserContent = openai_file.try_into().unwrap();
3281        match converted {
3282            UserContent::File { file } => {
3283                assert_eq!(file.filename, Some("uploaded.pdf".to_string()));
3284                assert_eq!(
3285                    file.file_data,
3286                    Some("data:application/pdf;base64,AAAA".to_string())
3287                );
3288            }
3289            _ => panic!("Expected File"),
3290        }
3291
3292        let openai_file_id = openai::UserContent::File {
3293            file: openai::FileData {
3294                file_data: None,
3295                file_id: Some("file_abc".to_string()),
3296                filename: Some("uploaded.pdf".to_string()),
3297            },
3298        };
3299        let result: Result<UserContent, _> = openai_file_id.try_into();
3300        assert!(matches!(
3301            result,
3302            Err(message::MessageError::ConversionError(message))
3303                if message.contains("provider file IDs are not supported")
3304        ));
3305    }
3306
3307    #[test]
3308    fn test_completion_response_reasoning_details_with_multiple_ids_stay_separate() {
3309        let json = json!({
3310            "id": "resp_multi_id",
3311            "object": "chat.completion",
3312            "created": 1,
3313            "model": "openrouter/test-model",
3314            "choices": [{
3315                "index": 0,
3316                "finish_reason": "stop",
3317                "message": {
3318                    "role": "assistant",
3319                    "content": "hello",
3320                    "reasoning": null,
3321                    "reasoning_details": [
3322                        {"type":"reasoning.summary","id":"rs_a","summary":"a1"},
3323                        {"type":"reasoning.summary","id":"rs_b","summary":"b1"},
3324                        {"type":"reasoning.summary","id":"rs_a","summary":"a2"}
3325                    ]
3326                }
3327            }]
3328        });
3329
3330        let response: CompletionResponse = serde_json::from_value(json).unwrap();
3331        let converted: completion::CompletionResponse<CompletionResponse> =
3332            response.try_into().unwrap();
3333        let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3334        let reasoning_blocks: Vec<_> = items
3335            .into_iter()
3336            .filter_map(|item| match item {
3337                completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
3338                _ => None,
3339            })
3340            .collect();
3341
3342        assert_eq!(reasoning_blocks.len(), 2);
3343        assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_a"));
3344        assert_eq!(
3345            reasoning_blocks[0].content,
3346            vec![
3347                message::ReasoningContent::Summary("a1".to_string()),
3348                message::ReasoningContent::Summary("a2".to_string()),
3349            ]
3350        );
3351        assert_eq!(reasoning_blocks[1].id.as_deref(), Some("rs_b"));
3352        assert_eq!(
3353            reasoning_blocks[1].content,
3354            vec![message::ReasoningContent::Summary("b1".to_string())]
3355        );
3356    }
3357
3358    #[test]
3359    fn test_user_content_audio_serialization() {
3360        let content = UserContent::audio_base64("SGVsbG8=", AudioMediaType::WAV);
3361        let json = serde_json::to_value(&content).unwrap();
3362
3363        assert_eq!(json["type"], "input_audio");
3364        assert_eq!(json["input_audio"]["data"], "SGVsbG8=");
3365        assert_eq!(json["input_audio"]["format"], "wav");
3366    }
3367
3368    #[test]
3369    fn test_user_content_audio_deserialization() {
3370        let json = json!({
3371            "type": "input_audio",
3372            "input_audio": {
3373                "data": "SGVsbG8=",
3374                "format": "wav"
3375            }
3376        });
3377
3378        let content: UserContent = serde_json::from_value(json).unwrap();
3379        match content {
3380            UserContent::InputAudio { input_audio } => {
3381                assert_eq!(input_audio.data, "SGVsbG8=");
3382                assert_eq!(input_audio.format, AudioMediaType::WAV);
3383            }
3384            _ => panic!("Expected InputAudio variant"),
3385        }
3386    }
3387
3388    #[test]
3389    fn test_message_user_with_audio_serialization() {
3390        let msg = Message::User {
3391            content: OneOrMany::many(vec![
3392                UserContent::text("Transcribe this audio:"),
3393                UserContent::audio_base64("SGVsbG8=", AudioMediaType::MP3),
3394            ])
3395            .unwrap(),
3396            name: None,
3397        };
3398        let json = serde_json::to_value(&msg).unwrap();
3399
3400        assert_eq!(json["role"], "user");
3401        let content = json["content"].as_array().unwrap();
3402        assert_eq!(content.len(), 2);
3403        assert_eq!(content[0]["type"], "text");
3404        assert_eq!(content[1]["type"], "input_audio");
3405        assert_eq!(content[1]["input_audio"]["data"], "SGVsbG8=");
3406        assert_eq!(content[1]["input_audio"]["format"], "mp3");
3407    }
3408
3409    #[test]
3410    fn test_user_content_video_url_serialization() {
3411        let content = UserContent::video_url("https://example.com/video.mp4");
3412        let json = serde_json::to_value(&content).unwrap();
3413
3414        assert_eq!(json["type"], "video_url");
3415        assert_eq!(json["video_url"]["url"], "https://example.com/video.mp4");
3416    }
3417
3418    #[test]
3419    fn test_user_content_video_base64_serialization() {
3420        let content = UserContent::video_base64("SGVsbG8=", VideoMediaType::MP4);
3421        let json = serde_json::to_value(&content).unwrap();
3422
3423        assert_eq!(json["type"], "video_url");
3424        assert_eq!(json["video_url"]["url"], "data:video/mp4;base64,SGVsbG8=");
3425    }
3426
3427    #[test]
3428    fn test_user_content_video_url_deserialization() {
3429        let json = json!({
3430            "type": "video_url",
3431            "video_url": {
3432                "url": "https://example.com/video.mp4"
3433            }
3434        });
3435
3436        let content: UserContent = serde_json::from_value(json).unwrap();
3437        match content {
3438            UserContent::VideoUrl { video_url } => {
3439                assert_eq!(video_url.url, "https://example.com/video.mp4");
3440            }
3441            _ => panic!("Expected VideoUrl variant"),
3442        }
3443    }
3444
3445    #[test]
3446    fn test_message_user_with_video_serialization() {
3447        let msg = Message::User {
3448            content: OneOrMany::many(vec![
3449                UserContent::text("Describe this video:"),
3450                UserContent::video_url("https://example.com/video.mp4"),
3451            ])
3452            .unwrap(),
3453            name: None,
3454        };
3455        let json = serde_json::to_value(&msg).unwrap();
3456
3457        assert_eq!(json["role"], "user");
3458        let content = json["content"].as_array().unwrap();
3459        assert_eq!(content.len(), 2);
3460        assert_eq!(content[0]["type"], "text");
3461        assert_eq!(content[1]["type"], "video_url");
3462        assert_eq!(
3463            content[1]["video_url"]["url"],
3464            "https://example.com/video.mp4"
3465        );
3466    }
3467
3468    #[test]
3469    fn test_user_content_video_url_no_media_type_needed() {
3470        let rig_content = message::UserContent::Video(message::Video {
3471            data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
3472            media_type: None,
3473            additional_params: None,
3474        });
3475        let openrouter_content: UserContent = rig_content.try_into().unwrap();
3476
3477        match openrouter_content {
3478            UserContent::VideoUrl { video_url } => {
3479                assert_eq!(video_url.url, "https://example.com/video.mp4");
3480            }
3481            _ => panic!("Expected VideoUrl variant"),
3482        }
3483    }
3484}