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