Skip to main content

rig_core/providers/openrouter/
completion.rs

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