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