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    let (tool_results, other_content): (Vec<_>, Vec<_>) = value
1016        .into_iter()
1017        .partition(|content| matches!(content, message::UserContent::ToolResult(_)));
1018
1019    // If there are messages with both tool results and user content, we handle
1020    // tool results first. It's unlikely that there will be both.
1021    if !tool_results.is_empty() {
1022        tool_results
1023            .into_iter()
1024            .map(|content| match content {
1025                message::UserContent::ToolResult(tool_result) => Ok(Message::ToolResult {
1026                    // Prefer the provider-issued call id, matching the
1027                    // assistant echo (shared From<message::ToolCall>).
1028                    tool_call_id: tool_result.call_id.unwrap_or(tool_result.id),
1029                    content: openai::completion::ToolResultContentValue::String(
1030                        tool_result
1031                            .content
1032                            .into_iter()
1033                            .map(|c| match c {
1034                                message::ToolResultContent::Text(message::Text {
1035                                    text, ..
1036                                }) => text,
1037                                message::ToolResultContent::Image(_) => {
1038                                    "[Image content not supported in tool results]".to_string()
1039                                }
1040                            })
1041                            .collect::<Vec<_>>()
1042                            .join("\n"),
1043                    ),
1044                }),
1045                _ => Err(message::MessageError::ConversionError(
1046                    "expected tool result content while converting OpenRouter input".into(),
1047                )),
1048            })
1049            .collect::<Result<Vec<_>, _>>()
1050    } else {
1051        let user_content: Vec<UserContent> = other_content
1052            .into_iter()
1053            .map(user_content_to_openai)
1054            .collect::<Result<Vec<_>, _>>()?;
1055
1056        let content = OneOrMany::many(user_content).map_err(|_| {
1057            message::MessageError::ConversionError(
1058                "OpenRouter user message did not contain any non-tool content".into(),
1059            )
1060        })?;
1061
1062        Ok(vec![Message::User {
1063            content,
1064            name: None,
1065        }])
1066    }
1067}
1068
1069// ================================================================
1070// Response Types
1071// ================================================================
1072
1073#[derive(Clone, Debug, Deserialize, Serialize)]
1074pub struct Choice {
1075    pub index: usize,
1076    pub native_finish_reason: Option<String>,
1077    pub message: Message,
1078    pub finish_reason: Option<String>,
1079}
1080
1081#[derive(Debug, Deserialize, PartialEq, Clone)]
1082#[serde(untagged)]
1083enum ToolCallAdditionalParams {
1084    ReasoningDetails(ReasoningDetails),
1085    Minimal {
1086        id: Option<String>,
1087        format: Option<String>,
1088    },
1089}
1090
1091/// Replay assistant history — including structured reasoning — as OpenRouter
1092/// request messages.
1093///
1094/// Maps rig [`message::Reasoning`] blocks back onto the `reasoning_details`
1095/// field of the shared assistant message, and recovers reasoning metadata
1096/// stored on tool calls (signature / `additional_params`) so providers that
1097/// require reasoning to be echoed back on tool-call turns keep working.
1098fn assistant_contents_to_messages(
1099    value: OneOrMany<message::AssistantContent>,
1100) -> Result<Vec<Message>, message::MessageError> {
1101    let mut text_content = Vec::new();
1102    let mut tool_calls = Vec::new();
1103    let mut reasoning = None;
1104    let mut reasoning_details = Vec::new();
1105
1106    for content in value.into_iter() {
1107        match content {
1108            message::AssistantContent::Text(text) => text_content.push(text),
1109            message::AssistantContent::ToolCall(tool_call) => {
1110                // We usually want to provide back the reasoning to OpenRouter since some
1111                // providers require it.
1112                // 1. Full reasoning details passed back the user
1113                // 2. The signature, an id and a format if present
1114                // 3. The signature and the call_id if present
1115                if let Some(additional_params) = &tool_call.additional_params
1116                    && let Ok(additional_params) = serde_json::from_value::<ToolCallAdditionalParams>(
1117                        additional_params.clone(),
1118                    )
1119                {
1120                    match additional_params {
1121                        ToolCallAdditionalParams::ReasoningDetails(full) => {
1122                            reasoning_details.push(full);
1123                        }
1124                        ToolCallAdditionalParams::Minimal { id, format } => {
1125                            // Correlate with the id the wire tool call will
1126                            // carry (call_id when present, else id).
1127                            let id = id
1128                                .or_else(|| tool_call.call_id.clone())
1129                                .unwrap_or_else(|| tool_call.id.clone());
1130                            if let Some(signature) = &tool_call.signature {
1131                                reasoning_details.push(ReasoningDetails::Encrypted {
1132                                    id: Some(id),
1133                                    format,
1134                                    index: None,
1135                                    data: signature.clone(),
1136                                })
1137                            }
1138                        }
1139                    }
1140                } else if let Some(signature) = &tool_call.signature {
1141                    reasoning_details.push(ReasoningDetails::Encrypted {
1142                        id: Some(
1143                            tool_call
1144                                .call_id
1145                                .clone()
1146                                .unwrap_or_else(|| tool_call.id.clone()),
1147                        ),
1148                        format: None,
1149                        index: None,
1150                        data: signature.clone(),
1151                    });
1152                }
1153                tool_calls.push(tool_call.into())
1154            }
1155            message::AssistantContent::Reasoning(r) => {
1156                if r.content.is_empty() {
1157                    let display = r.display_text();
1158                    if !display.is_empty() {
1159                        reasoning = Some(display);
1160                    }
1161                } else {
1162                    for reasoning_block in &r.content {
1163                        let index = Some(reasoning_details.len());
1164                        match reasoning_block {
1165                            message::ReasoningContent::Text { text, signature } => {
1166                                reasoning_details.push(ReasoningDetails::Text {
1167                                    id: r.id.clone(),
1168                                    format: None,
1169                                    index,
1170                                    text: Some(text.clone()),
1171                                    signature: signature.clone(),
1172                                });
1173                            }
1174                            message::ReasoningContent::Summary(summary) => {
1175                                reasoning_details.push(ReasoningDetails::Summary {
1176                                    id: r.id.clone(),
1177                                    format: None,
1178                                    index,
1179                                    summary: summary.clone(),
1180                                });
1181                            }
1182                            message::ReasoningContent::Encrypted(data)
1183                            | message::ReasoningContent::Redacted { data } => {
1184                                reasoning_details.push(ReasoningDetails::Encrypted {
1185                                    id: r.id.clone(),
1186                                    format: None,
1187                                    index,
1188                                    data: data.clone(),
1189                                });
1190                            }
1191                        }
1192                    }
1193                }
1194            }
1195            message::AssistantContent::Image(image) if is_openrouter_response_image(&image) => {
1196                // OpenRouter generated images are response artifacts. They remain
1197                // visible in Rig history, but OpenRouter does not define them as
1198                // replayable assistant request content.
1199            }
1200            message::AssistantContent::Image(_) => {
1201                return Err(message::MessageError::ConversionError(
1202                        "OpenRouter does not support assistant image content in request history; pass images as user image inputs instead".into(),
1203                    ));
1204            }
1205        }
1206    }
1207
1208    if text_content.is_empty()
1209        && tool_calls.is_empty()
1210        && reasoning.is_none()
1211        && reasoning_details.is_empty()
1212    {
1213        return Ok(vec![]);
1214    }
1215
1216    Ok(vec![Message::Assistant {
1217        content: text_content
1218            .into_iter()
1219            .map(|content| content.text.into())
1220            .collect::<Vec<_>>(),
1221        refusal: None,
1222        audio: None,
1223        name: None,
1224        tool_calls,
1225        reasoning,
1226        reasoning_details,
1227        images: Vec::new(),
1228    }])
1229}
1230
1231/// Convert a rig message into OpenRouter request messages.
1232///
1233/// OpenRouter shares the OpenAI message model, but keeps its own conversion
1234/// rules for user content (see `user_content_to_openai`) and for replaying
1235/// assistant reasoning, so it does not use the shared
1236/// `TryFrom<message::Message> for Vec<openai::Message>` conversion.
1237pub fn messages_from_rig_message(
1238    message: message::Message,
1239) -> Result<Vec<Message>, message::MessageError> {
1240    match message {
1241        message::Message::System { content } => Ok(vec![Message::system(&content)]),
1242        message::Message::User { content } => user_contents_to_messages(content),
1243        message::Message::Assistant { content, .. } => assistant_contents_to_messages(content),
1244    }
1245}
1246
1247/// Apply explicit prompt-caching markers to an already-serialized OpenRouter
1248/// request body.
1249///
1250/// Finds the first system message in `messages` and converts its `content`
1251/// to a structured text block with `cache_control: {"type": "ephemeral"}`.
1252/// This tells OpenRouter providers that support explicit `cache_control`
1253/// breakpoints to cache the system prompt so subsequent turns that share the
1254/// same prefix can be billed at the cache-hit rate.
1255///
1256/// This is intended for models and providers that support explicit
1257/// `cache_control` breakpoints.
1258pub(super) fn apply_prompt_caching(body: &mut serde_json::Value) {
1259    let Some(obj) = body.as_object_mut() else {
1260        return;
1261    };
1262    let Some(messages) = obj.get_mut("messages").and_then(|v| v.as_array_mut()) else {
1263        return;
1264    };
1265
1266    let Some(system_msg) = messages
1267        .iter_mut()
1268        .find(|m| m.get("role").and_then(|v| v.as_str()) == Some("system"))
1269    else {
1270        return;
1271    };
1272
1273    match system_msg.get("content").cloned() {
1274        Some(serde_json::Value::String(s)) => {
1275            if let Some(obj) = system_msg.as_object_mut() {
1276                obj.insert(
1277                    "content".to_string(),
1278                    serde_json::json!([{
1279                        "type": "text",
1280                        "text": s,
1281                        "cache_control": { "type": "ephemeral" }
1282                    }]),
1283                );
1284            }
1285        }
1286        Some(serde_json::Value::Array(mut arr)) => {
1287            // Mark the last block as the cache boundary; all other blocks (including
1288            // non-text blocks such as images) are preserved unchanged.
1289            if let Some(last) = arr.last_mut()
1290                && let Some(obj) = last.as_object_mut()
1291            {
1292                obj.insert(
1293                    "cache_control".to_string(),
1294                    serde_json::json!({ "type": "ephemeral" }),
1295                );
1296            }
1297            if let Some(obj) = system_msg.as_object_mut() {
1298                obj.insert("content".to_string(), serde_json::Value::Array(arr));
1299            }
1300        }
1301        _ => {}
1302    }
1303}
1304
1305pub(super) fn finalize_openrouter_request_body(body: &mut serde_json::Value, prompt_caching: bool) {
1306    if prompt_caching {
1307        apply_prompt_caching(body);
1308    }
1309
1310    // The shared assistant message serializes hidden reasoning under the
1311    // llama.cpp/DeepSeek key `reasoning_content`; OpenRouter's documented
1312    // assistant field is `reasoning`.
1313    if let Some(messages) = body
1314        .get_mut("messages")
1315        .and_then(serde_json::Value::as_array_mut)
1316    {
1317        for message in messages {
1318            if let Some(message) = message.as_object_mut()
1319                && message.get("role").and_then(serde_json::Value::as_str) == Some("assistant")
1320                && let Some(reasoning) = message.remove("reasoning_content")
1321            {
1322                message.insert("reasoning".to_string(), reasoning);
1323            }
1324        }
1325    }
1326}
1327
1328#[cfg(test)]
1329pub(super) fn final_request_body(
1330    request: &OpenrouterCompletionRequest,
1331    prompt_caching: bool,
1332) -> Result<serde_json::Value, CompletionError> {
1333    let mut body = serde_json::to_value(request)?;
1334    finalize_openrouter_request_body(&mut body, prompt_caching);
1335    Ok(body)
1336}
1337
1338pub(super) type OpenrouterCompletionRequest = openai::completion::CompletionRequest;
1339
1340/// Parameters for building an OpenRouter CompletionRequest
1341pub struct OpenRouterRequestParams<'a> {
1342    pub model: &'a str,
1343    pub request: CompletionRequest,
1344    pub strict_tools: bool,
1345}
1346
1347impl TryFrom<OpenRouterRequestParams<'_>> for OpenrouterCompletionRequest {
1348    type Error = CompletionError;
1349
1350    fn try_from(params: OpenRouterRequestParams) -> Result<Self, Self::Error> {
1351        let OpenRouterRequestParams {
1352            model,
1353            request: req,
1354            strict_tools,
1355        } = params;
1356        let chat_history = req.chat_history_with_documents();
1357        let model = req.model.clone().unwrap_or_else(|| model.to_string());
1358
1359        let mut full_history: Vec<Message> = match &req.preamble {
1360            Some(preamble) => vec![Message::system(preamble)],
1361            None => vec![],
1362        };
1363
1364        let chat_history: Vec<Message> = chat_history
1365            .into_iter()
1366            .map(messages_from_rig_message)
1367            .collect::<Result<Vec<Vec<Message>>, _>>()?
1368            .into_iter()
1369            .flatten()
1370            .collect();
1371
1372        full_history.extend(chat_history);
1373
1374        let tool_choice = req
1375            .tool_choice
1376            .clone()
1377            .map(crate::providers::openai::completion::ToolChoice::try_from)
1378            .transpose()?;
1379
1380        let tools: Vec<crate::providers::openai::completion::ToolDefinition> = req
1381            .tools
1382            .clone()
1383            .into_iter()
1384            .map(|tool| {
1385                let def = crate::providers::openai::completion::ToolDefinition::from(tool);
1386                if strict_tools { def.with_strict() } else { def }
1387            })
1388            .collect();
1389
1390        let additional_params = if let Some(schema) = req.output_schema {
1391            let name = schema
1392                .as_object()
1393                .and_then(|o| o.get("title"))
1394                .and_then(|v| v.as_str())
1395                .unwrap_or("response_schema")
1396                .to_string();
1397            let mut schema_value = schema.to_value();
1398            openai::sanitize_schema(&mut schema_value);
1399            let response_format = serde_json::json!({
1400                "response_format": {
1401                    "type": "json_schema",
1402                    "json_schema": {
1403                        "name": name,
1404                        "strict": true,
1405                        "schema": schema_value
1406                    }
1407                }
1408            });
1409            Some(match req.additional_params {
1410                Some(existing) => json_utils::merge(existing, response_format),
1411                None => response_format,
1412            })
1413        } else {
1414            req.additional_params
1415        };
1416
1417        Ok(Self {
1418            model,
1419            messages: full_history,
1420            temperature: req.temperature,
1421            max_tokens: None,
1422            tools,
1423            tool_choice,
1424            additional_params,
1425        })
1426    }
1427}
1428
1429impl TryFrom<(&str, CompletionRequest)> for OpenrouterCompletionRequest {
1430    type Error = CompletionError;
1431
1432    fn try_from((model, req): (&str, CompletionRequest)) -> Result<Self, Self::Error> {
1433        let model = req.model.clone().unwrap_or_else(|| model.to_string());
1434        OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1435            model: &model,
1436            request: req,
1437            strict_tools: false,
1438        })
1439    }
1440}
1441
1442impl openai::completion::OpenAICompatibleProvider for OpenRouterExt {
1443    const PROVIDER_NAME: &'static str = "openrouter";
1444
1445    type StreamingUsage = Usage;
1446    type Response = CompletionResponse;
1447
1448    const STREAM_INCLUDE_USAGE: bool = false;
1449
1450    fn build_completion_request(
1451        &self,
1452        model: String,
1453        request: CompletionRequest,
1454        options: openai::completion::CompletionModelOptions,
1455    ) -> Result<openai::completion::CompletionRequest, CompletionError> {
1456        OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1457            model: &model,
1458            request,
1459            strict_tools: options.strict_tools,
1460        })
1461    }
1462
1463    fn finalize_request_body_with_options(
1464        &self,
1465        body: &mut serde_json::Value,
1466        options: openai::completion::CompletionModelOptions,
1467    ) -> Result<(), CompletionError> {
1468        finalize_openrouter_request_body(body, options.prompt_caching);
1469        Ok(())
1470    }
1471
1472    fn decorate_streaming_tool_call(
1473        &self,
1474        detail: &serde_json::Value,
1475        tool_calls: &mut std::collections::HashMap<usize, crate::streaming::RawStreamingToolCall>,
1476    ) {
1477        let Ok(ReasoningDetails::Encrypted { id, data, .. }) =
1478            serde_json::from_value::<ReasoningDetails>(detail.clone())
1479        else {
1480            return;
1481        };
1482        let Some(id) = id else {
1483            return;
1484        };
1485        let Some(tool_call) = tool_calls
1486            .values_mut()
1487            .find(|tool_call| tool_call.id.eq(&id))
1488        else {
1489            return;
1490        };
1491
1492        tool_call.signature = Some(data);
1493        tool_call.additional_params = Some(detail.clone());
1494    }
1495}
1496
1497/// OpenRouter completion model, driven by the shared OpenAI Chat Completions path.
1498pub type CompletionModel<H = reqwest::Client> =
1499    openai::completion::GenericCompletionModel<OpenRouterExt, H>;
1500
1501/// Final streaming response, shared with the OpenAI Chat Completions path.
1502pub type StreamingCompletionResponse =
1503    openai::completion::streaming::StreamingCompletionResponse<Usage>;
1504
1505impl<H> openai::completion::GenericCompletionModel<OpenRouterExt, H> {
1506    /// Enable explicit prompt caching for supported OpenRouter models.
1507    ///
1508    /// Adds `cache_control: {"type": "ephemeral"}` to the system-prompt
1509    /// block so subsequent turns that share the same system prefix can be
1510    /// billed at the cache-hit rate when the selected model/provider supports
1511    /// explicit cache breakpoints.
1512    pub fn with_prompt_caching(mut self) -> Self {
1513        self.prompt_caching = true;
1514        self
1515    }
1516}
1517
1518#[cfg(test)]
1519mod tests {
1520    use super::*;
1521    use crate::message::{AudioMediaType, ImageDetail, VideoMediaType};
1522    use serde_json::json;
1523
1524    #[test]
1525    fn test_openrouter_request_uses_request_model_override() {
1526        let request = CompletionRequest {
1527            model: Some("google/gemini-2.5-flash".to_string()),
1528            preamble: None,
1529            chat_history: crate::OneOrMany::one("Hello".into()),
1530            documents: vec![],
1531            tools: vec![],
1532            temperature: None,
1533            max_tokens: None,
1534            tool_choice: None,
1535            additional_params: None,
1536            output_schema: None,
1537        };
1538
1539        let openrouter_request =
1540            OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1541                .expect("request conversion should succeed");
1542        let serialized =
1543            serde_json::to_value(openrouter_request).expect("serialization should succeed");
1544
1545        assert_eq!(serialized["model"], "google/gemini-2.5-flash");
1546    }
1547
1548    #[test]
1549    fn openrouter_params_include_direct_request_documents() {
1550        let request = CompletionRequest {
1551            model: None,
1552            preamble: None,
1553            chat_history: crate::OneOrMany::one(crate::message::Message::user(
1554                "What is glarb-glarb?",
1555            )),
1556            documents: vec![crate::completion::request::Document {
1557                id: "doc_1".to_string(),
1558                text: "Definition of glarb-glarb: an ancient tool.".to_string(),
1559                additional_props: Default::default(),
1560            }],
1561            tools: vec![],
1562            temperature: None,
1563            max_tokens: None,
1564            tool_choice: None,
1565            additional_params: None,
1566            output_schema: None,
1567        };
1568
1569        let request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1570            model: "openai/gpt-4o-mini",
1571            request,
1572            strict_tools: false,
1573        })
1574        .expect("request conversion should succeed");
1575        let serialized = serde_json::to_value(request).expect("serialization should succeed");
1576
1577        assert!(
1578            serialized["messages"].to_string().contains("glarb-glarb"),
1579            "direct request documents should be normalized through public params"
1580        );
1581    }
1582
1583    #[test]
1584    fn test_openrouter_request_uses_default_model_when_override_unset() {
1585        let request = CompletionRequest {
1586            model: None,
1587            preamble: None,
1588            chat_history: crate::OneOrMany::one("Hello".into()),
1589            documents: vec![],
1590            tools: vec![],
1591            temperature: None,
1592            max_tokens: None,
1593            tool_choice: None,
1594            additional_params: None,
1595            output_schema: None,
1596        };
1597
1598        let openrouter_request =
1599            OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1600                .expect("request conversion should succeed");
1601        let serialized =
1602            serde_json::to_value(openrouter_request).expect("serialization should succeed");
1603
1604        assert_eq!(serialized["model"], "openai/gpt-4o-mini");
1605    }
1606
1607    #[test]
1608    fn final_request_body_serializes_assistant_reasoning_under_openrouter_key() {
1609        // Reasoning replay normally flows through `reasoning_details`; the
1610        // plain string field must nevertheless hit the wire under
1611        // OpenRouter's `reasoning` key, not the shared `reasoning_content`.
1612        let request = OpenrouterCompletionRequest {
1613            model: "openai/gpt-4o".to_string(),
1614            messages: vec![Message::Assistant {
1615                content: vec![],
1616                reasoning: Some("thinking it through".to_string()),
1617                refusal: None,
1618                audio: None,
1619                name: None,
1620                tool_calls: vec![],
1621                reasoning_details: vec![],
1622                images: vec![],
1623            }],
1624            temperature: None,
1625            max_tokens: None,
1626            tools: vec![],
1627            tool_choice: None,
1628            additional_params: None,
1629        };
1630
1631        let body = final_request_body(&request, false).expect("body should serialize");
1632
1633        assert_eq!(
1634            body["messages"][0]["reasoning"],
1635            serde_json::json!("thinking it through")
1636        );
1637        assert!(
1638            body["messages"][0].get("reasoning_content").is_none(),
1639            "OpenRouter's assistant reasoning key is `reasoning`, not `reasoning_content`"
1640        );
1641    }
1642
1643    #[test]
1644    fn test_openrouter_request_maps_output_schema_to_response_format() {
1645        let schema: schemars::Schema = serde_json::from_value(json!({
1646            "title": "WeatherResponse",
1647            "type": "object",
1648            "properties": {
1649                "city": { "type": "string" },
1650                "weather": { "type": "string" }
1651            }
1652        }))
1653        .expect("schema should deserialize");
1654
1655        let request = CompletionRequest {
1656            model: None,
1657            preamble: None,
1658            chat_history: crate::OneOrMany::one("Hello".into()),
1659            documents: vec![],
1660            tools: vec![],
1661            temperature: None,
1662            max_tokens: None,
1663            tool_choice: None,
1664            additional_params: None,
1665            output_schema: Some(schema),
1666        };
1667
1668        let openrouter_request =
1669            OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1670                .expect("request conversion should succeed");
1671        let serialized =
1672            serde_json::to_value(openrouter_request).expect("serialization should succeed");
1673
1674        assert_eq!(
1675            serialized["response_format"],
1676            json!({
1677                "type": "json_schema",
1678                "json_schema": {
1679                    "name": "WeatherResponse",
1680                    "strict": true,
1681                    "schema": {
1682                        "title": "WeatherResponse",
1683                        "type": "object",
1684                        "properties": {
1685                            "city": { "type": "string" },
1686                            "weather": { "type": "string" }
1687                        },
1688                        "additionalProperties": false,
1689                        "required": ["city", "weather"]
1690                    }
1691                }
1692            })
1693        );
1694    }
1695
1696    #[test]
1697    fn test_openrouter_request_merges_output_schema_with_provider_preferences() {
1698        let schema: schemars::Schema = serde_json::from_value(json!({
1699            "type": "object",
1700            "properties": {
1701                "answer": { "type": "string" }
1702            }
1703        }))
1704        .expect("schema should deserialize");
1705
1706        let request = CompletionRequest {
1707            model: None,
1708            preamble: None,
1709            chat_history: crate::OneOrMany::one("Hello".into()),
1710            documents: vec![],
1711            tools: vec![],
1712            temperature: None,
1713            max_tokens: None,
1714            tool_choice: None,
1715            additional_params: Some(
1716                ProviderPreferences::new()
1717                    .require_parameters(true)
1718                    .to_json(),
1719            ),
1720            output_schema: Some(schema),
1721        };
1722
1723        let openrouter_request =
1724            OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1725                .expect("request conversion should succeed");
1726        let serialized =
1727            serde_json::to_value(openrouter_request).expect("serialization should succeed");
1728
1729        assert_eq!(serialized["provider"]["require_parameters"], true);
1730        assert_eq!(serialized["response_format"]["type"], "json_schema");
1731        assert_eq!(
1732            serialized["response_format"]["json_schema"]["name"],
1733            "response_schema"
1734        );
1735        assert_eq!(
1736            serialized["response_format"]["json_schema"]["schema"]["additionalProperties"],
1737            false
1738        );
1739    }
1740
1741    #[test]
1742    fn test_completion_response_deserialization_gemini_flash() {
1743        // Real response from OpenRouter with google/gemini-2.5-flash
1744        let json = json!({
1745            "id": "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA",
1746            "provider": "Google",
1747            "model": "google/gemini-2.5-flash",
1748            "object": "chat.completion",
1749            "created": 1765971703u64,
1750            "choices": [{
1751                "logprobs": null,
1752                "finish_reason": "stop",
1753                "native_finish_reason": "STOP",
1754                "index": 0,
1755                "message": {
1756                    "role": "assistant",
1757                    "content": "CONTENT",
1758                    "refusal": null,
1759                    "reasoning": null
1760                }
1761            }],
1762            "usage": {
1763                "prompt_tokens": 669,
1764                "completion_tokens": 5,
1765                "total_tokens": 674
1766            }
1767        });
1768
1769        let response: CompletionResponse = serde_json::from_value(json).unwrap();
1770        assert_eq!(response.id, "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA");
1771        assert_eq!(response.model, "google/gemini-2.5-flash");
1772        assert_eq!(response.choices.len(), 1);
1773        assert_eq!(response.choices[0].finish_reason, Some("stop".to_string()));
1774    }
1775
1776    #[test]
1777    fn test_completion_response_usage_prefers_reported_completion_tokens() {
1778        let json = json!({
1779            "id": "gen-usage-divergent",
1780            "object": "chat.completion",
1781            "created": 1,
1782            "model": "anthropic/claude-3.5-sonnet",
1783            "choices": [{
1784                "index": 0,
1785                "message": {"role": "assistant", "content": "ok"},
1786                "finish_reason": "stop"
1787            }],
1788            // Divergent accounting: total != prompt + completion.
1789            "usage": {"prompt_tokens": 500, "completion_tokens": 10, "total_tokens": 505}
1790        });
1791
1792        let response: CompletionResponse = serde_json::from_value(json).unwrap();
1793        let converted = completion::CompletionResponse::try_from(response).unwrap();
1794        assert_eq!(converted.usage.output_tokens, 10);
1795    }
1796
1797    #[test]
1798    fn test_completion_response_usage_falls_back_when_completion_tokens_missing() {
1799        let json = json!({
1800            "id": "gen-usage-omitted",
1801            "object": "chat.completion",
1802            "created": 1,
1803            "model": "some/gateway-model",
1804            "choices": [{
1805                "index": 0,
1806                "message": {"role": "assistant", "content": "ok"},
1807                "finish_reason": "stop"
1808            }],
1809            "usage": {"prompt_tokens": 100, "total_tokens": 110}
1810        });
1811
1812        let response: CompletionResponse = serde_json::from_value(json).unwrap();
1813        let converted = completion::CompletionResponse::try_from(response).unwrap();
1814        assert_eq!(converted.usage.output_tokens, 10);
1815    }
1816
1817    #[test]
1818    fn test_completion_response_maps_cache_token_accounting() {
1819        let json = json!({
1820            "id": "gen-cache-test",
1821            "object": "chat.completion",
1822            "created": 1,
1823            "model": "anthropic/claude-3.5-sonnet",
1824            "choices": [{
1825                "index": 0,
1826                "finish_reason": "stop",
1827                "message": {
1828                    "role": "assistant",
1829                    "content": "Hi"
1830                }
1831            }],
1832            "usage": {
1833                "prompt_tokens": 500,
1834                "completion_tokens": 10,
1835                "total_tokens": 510,
1836                "prompt_tokens_details": {
1837                    "cached_tokens": 400,
1838                    "cache_write_tokens": 50
1839                }
1840            }
1841        });
1842
1843        let response: CompletionResponse = serde_json::from_value(json).unwrap();
1844        let converted: completion::CompletionResponse<CompletionResponse> =
1845            response.try_into().unwrap();
1846
1847        assert_eq!(converted.usage.input_tokens, 500);
1848        assert_eq!(converted.usage.output_tokens, 10);
1849        assert_eq!(converted.usage.cached_input_tokens, 400);
1850        assert_eq!(converted.usage.cache_creation_input_tokens, 50);
1851    }
1852
1853    #[test]
1854    fn test_completion_response_cache_tokens_absent_defaults_to_zero() {
1855        let json = json!({
1856            "id": "gen-no-cache",
1857            "object": "chat.completion",
1858            "created": 1,
1859            "model": "openai/gpt-4o",
1860            "choices": [{
1861                "index": 0,
1862                "finish_reason": "stop",
1863                "message": {
1864                    "role": "assistant",
1865                    "content": "Hi"
1866                }
1867            }],
1868            "usage": {
1869                "prompt_tokens": 100,
1870                "completion_tokens": 10,
1871                "total_tokens": 110
1872            }
1873        });
1874
1875        let response: CompletionResponse = serde_json::from_value(json).unwrap();
1876        let converted: completion::CompletionResponse<CompletionResponse> =
1877            response.try_into().unwrap();
1878
1879        assert_eq!(converted.usage.cached_input_tokens, 0);
1880        assert_eq!(converted.usage.cache_creation_input_tokens, 0);
1881    }
1882
1883    #[test]
1884    fn test_completion_response_deserialization_gemini_model_role() {
1885        let json = json!({
1886            "id": "gen-BBBBBBBBBB-BBBBBBBBBBBBBBBBBBBB",
1887            "provider": "Google",
1888            "model": "google/gemini-2.5-pro-exp-03-25:free",
1889            "object": "chat.completion",
1890            "created": 1743780565u64,
1891            "choices": [{
1892                "logprobs": null,
1893                "finish_reason": "stop",
1894                "native_finish_reason": "STOP",
1895                "index": 0,
1896                "message": {
1897                    "role": "model",
1898                    "content": "CONTENT",
1899                    "refusal": null,
1900                    "reasoning": null
1901                }
1902            }],
1903            "usage": {
1904                "prompt_tokens": 669,
1905                "completion_tokens": 5,
1906                "total_tokens": 674
1907            }
1908        });
1909
1910        let response: CompletionResponse = serde_json::from_value(json).unwrap();
1911        let converted: completion::CompletionResponse<CompletionResponse> =
1912            response.try_into().unwrap();
1913
1914        assert_eq!(
1915            converted.raw_response.model,
1916            "google/gemini-2.5-pro-exp-03-25:free"
1917        );
1918        assert!(matches!(
1919            converted.choice.first(),
1920            completion::AssistantContent::Text(text) if text.text == "CONTENT"
1921        ));
1922    }
1923
1924    #[test]
1925    fn test_message_assistant_without_reasoning_details() {
1926        // Verify that missing reasoning_details field doesn't cause deserialization failure
1927        let json = json!({
1928            "role": "assistant",
1929            "content": "Hello world",
1930            "refusal": null,
1931            "reasoning": null
1932        });
1933
1934        let message: Message = serde_json::from_value(json).unwrap();
1935        match message {
1936            Message::Assistant {
1937                content,
1938                reasoning_details,
1939                ..
1940            } => {
1941                assert_eq!(content.len(), 1);
1942                assert!(reasoning_details.is_empty());
1943            }
1944            _ => panic!("Expected Assistant message"),
1945        }
1946    }
1947
1948    #[test]
1949    fn test_data_collection_serialization() {
1950        assert_eq!(
1951            serde_json::to_string(&DataCollection::Allow).unwrap(),
1952            r#""allow""#
1953        );
1954        assert_eq!(
1955            serde_json::to_string(&DataCollection::Deny).unwrap(),
1956            r#""deny""#
1957        );
1958    }
1959
1960    #[test]
1961    fn test_data_collection_default() {
1962        assert_eq!(DataCollection::default(), DataCollection::Allow);
1963    }
1964
1965    #[test]
1966    fn test_quantization_serialization() {
1967        assert_eq!(
1968            serde_json::to_string(&Quantization::Int4).unwrap(),
1969            r#""int4""#
1970        );
1971        assert_eq!(
1972            serde_json::to_string(&Quantization::Int8).unwrap(),
1973            r#""int8""#
1974        );
1975        assert_eq!(
1976            serde_json::to_string(&Quantization::Fp16).unwrap(),
1977            r#""fp16""#
1978        );
1979        assert_eq!(
1980            serde_json::to_string(&Quantization::Bf16).unwrap(),
1981            r#""bf16""#
1982        );
1983        assert_eq!(
1984            serde_json::to_string(&Quantization::Fp32).unwrap(),
1985            r#""fp32""#
1986        );
1987        assert_eq!(
1988            serde_json::to_string(&Quantization::Fp8).unwrap(),
1989            r#""fp8""#
1990        );
1991        assert_eq!(
1992            serde_json::to_string(&Quantization::Unknown).unwrap(),
1993            r#""unknown""#
1994        );
1995    }
1996
1997    #[test]
1998    fn test_provider_sort_strategy_serialization() {
1999        assert_eq!(
2000            serde_json::to_string(&ProviderSortStrategy::Price).unwrap(),
2001            r#""price""#
2002        );
2003        assert_eq!(
2004            serde_json::to_string(&ProviderSortStrategy::Throughput).unwrap(),
2005            r#""throughput""#
2006        );
2007        assert_eq!(
2008            serde_json::to_string(&ProviderSortStrategy::Latency).unwrap(),
2009            r#""latency""#
2010        );
2011    }
2012
2013    #[test]
2014    fn test_sort_partition_serialization() {
2015        assert_eq!(
2016            serde_json::to_string(&SortPartition::Model).unwrap(),
2017            r#""model""#
2018        );
2019        assert_eq!(
2020            serde_json::to_string(&SortPartition::None).unwrap(),
2021            r#""none""#
2022        );
2023    }
2024
2025    #[test]
2026    fn test_provider_sort_simple() {
2027        let sort = ProviderSort::Simple(ProviderSortStrategy::Latency);
2028        let json = serde_json::to_value(&sort).unwrap();
2029        assert_eq!(json, "latency");
2030    }
2031
2032    #[test]
2033    fn test_provider_sort_complex() {
2034        let sort = ProviderSort::Complex(
2035            ProviderSortConfig::new(ProviderSortStrategy::Price).partition(SortPartition::None),
2036        );
2037        let json = serde_json::to_value(&sort).unwrap();
2038        assert_eq!(json["by"], "price");
2039        assert_eq!(json["partition"], "none");
2040    }
2041
2042    #[test]
2043    fn test_provider_sort_complex_without_partition() {
2044        let sort = ProviderSort::Complex(ProviderSortConfig::new(ProviderSortStrategy::Throughput));
2045        let json = serde_json::to_value(&sort).unwrap();
2046        assert_eq!(json["by"], "throughput");
2047        assert!(json.get("partition").is_none());
2048    }
2049
2050    #[test]
2051    fn test_provider_sort_from_strategy() {
2052        let sort: ProviderSort = ProviderSortStrategy::Price.into();
2053        assert_eq!(sort, ProviderSort::Simple(ProviderSortStrategy::Price));
2054    }
2055
2056    #[test]
2057    fn test_provider_sort_from_config() {
2058        let config = ProviderSortConfig::new(ProviderSortStrategy::Latency);
2059        let sort: ProviderSort = config.into();
2060        match sort {
2061            ProviderSort::Complex(c) => assert_eq!(c.by, ProviderSortStrategy::Latency),
2062            _ => panic!("Expected Complex variant"),
2063        }
2064    }
2065
2066    #[test]
2067    fn test_percentile_thresholds_builder() {
2068        let thresholds = PercentileThresholds::new()
2069            .p50(10.0)
2070            .p75(25.0)
2071            .p90(50.0)
2072            .p99(100.0);
2073
2074        assert_eq!(thresholds.p50, Some(10.0));
2075        assert_eq!(thresholds.p75, Some(25.0));
2076        assert_eq!(thresholds.p90, Some(50.0));
2077        assert_eq!(thresholds.p99, Some(100.0));
2078    }
2079
2080    #[test]
2081    fn test_percentile_thresholds_default() {
2082        let thresholds = PercentileThresholds::default();
2083        assert_eq!(thresholds.p50, None);
2084        assert_eq!(thresholds.p75, None);
2085        assert_eq!(thresholds.p90, None);
2086        assert_eq!(thresholds.p99, None);
2087    }
2088
2089    #[test]
2090    fn test_throughput_threshold_simple() {
2091        let threshold = ThroughputThreshold::Simple(50.0);
2092        let json = serde_json::to_value(&threshold).unwrap();
2093        assert_eq!(json, 50.0);
2094    }
2095
2096    #[test]
2097    fn test_throughput_threshold_percentile() {
2098        let threshold = ThroughputThreshold::Percentile(PercentileThresholds::new().p90(50.0));
2099        let json = serde_json::to_value(&threshold).unwrap();
2100        assert_eq!(json["p90"], 50.0);
2101    }
2102
2103    #[test]
2104    fn test_latency_threshold_simple() {
2105        let threshold = LatencyThreshold::Simple(0.5);
2106        let json = serde_json::to_value(&threshold).unwrap();
2107        assert_eq!(json, 0.5);
2108    }
2109
2110    #[test]
2111    fn test_latency_threshold_percentile() {
2112        let threshold = LatencyThreshold::Percentile(PercentileThresholds::new().p50(0.1).p99(1.0));
2113        let json = serde_json::to_value(&threshold).unwrap();
2114        assert_eq!(json["p50"], 0.1);
2115        assert_eq!(json["p99"], 1.0);
2116    }
2117
2118    #[test]
2119    fn test_max_price_builder() {
2120        let price = MaxPrice::new().prompt(0.001).completion(0.002);
2121
2122        assert_eq!(price.prompt, Some(0.001));
2123        assert_eq!(price.completion, Some(0.002));
2124        assert_eq!(price.request, None);
2125        assert_eq!(price.image, None);
2126    }
2127
2128    #[test]
2129    fn test_max_price_all_fields() {
2130        let price = MaxPrice::new()
2131            .prompt(0.001)
2132            .completion(0.002)
2133            .request(0.01)
2134            .image(0.05);
2135
2136        let json = serde_json::to_value(&price).unwrap();
2137        assert_eq!(json["prompt"], 0.001);
2138        assert_eq!(json["completion"], 0.002);
2139        assert_eq!(json["request"], 0.01);
2140        assert_eq!(json["image"], 0.05);
2141    }
2142
2143    #[test]
2144    fn test_max_price_default() {
2145        let price = MaxPrice::default();
2146        assert_eq!(price.prompt, None);
2147        assert_eq!(price.completion, None);
2148        assert_eq!(price.request, None);
2149        assert_eq!(price.image, None);
2150    }
2151
2152    #[test]
2153    fn test_provider_preferences_default() {
2154        let prefs = ProviderPreferences::default();
2155        assert!(prefs.order.is_none());
2156        assert!(prefs.only.is_none());
2157        assert!(prefs.ignore.is_none());
2158        assert!(prefs.allow_fallbacks.is_none());
2159        assert!(prefs.require_parameters.is_none());
2160        assert!(prefs.data_collection.is_none());
2161        assert!(prefs.zdr.is_none());
2162        assert!(prefs.sort.is_none());
2163        assert!(prefs.preferred_min_throughput.is_none());
2164        assert!(prefs.preferred_max_latency.is_none());
2165        assert!(prefs.max_price.is_none());
2166        assert!(prefs.quantizations.is_none());
2167    }
2168
2169    #[test]
2170    fn test_provider_preferences_order_with_fallbacks() {
2171        let prefs = ProviderPreferences::new()
2172            .order(["anthropic", "openai"])
2173            .allow_fallbacks(true);
2174
2175        let json = prefs.to_json();
2176        let provider = &json["provider"];
2177
2178        assert_eq!(provider["order"], json!(["anthropic", "openai"]));
2179        assert_eq!(provider["allow_fallbacks"], true);
2180    }
2181
2182    #[test]
2183    fn test_provider_preferences_only_allowlist() {
2184        let prefs = ProviderPreferences::new()
2185            .only(["azure", "together"])
2186            .allow_fallbacks(false);
2187
2188        let json = prefs.to_json();
2189        let provider = &json["provider"];
2190
2191        assert_eq!(provider["only"], json!(["azure", "together"]));
2192        assert_eq!(provider["allow_fallbacks"], false);
2193    }
2194
2195    #[test]
2196    fn test_provider_preferences_ignore() {
2197        let prefs = ProviderPreferences::new().ignore(["deepinfra"]);
2198
2199        let json = prefs.to_json();
2200        let provider = &json["provider"];
2201
2202        assert_eq!(provider["ignore"], json!(["deepinfra"]));
2203    }
2204
2205    #[test]
2206    fn test_provider_preferences_sort_latency() {
2207        let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Latency);
2208
2209        let json = prefs.to_json();
2210        let provider = &json["provider"];
2211
2212        assert_eq!(provider["sort"], "latency");
2213    }
2214
2215    #[test]
2216    fn test_provider_preferences_price_with_throughput() {
2217        let prefs = ProviderPreferences::new()
2218            .sort(ProviderSortStrategy::Price)
2219            .preferred_min_throughput(ThroughputThreshold::Percentile(
2220                PercentileThresholds::new().p90(50.0),
2221            ));
2222
2223        let json = prefs.to_json();
2224        let provider = &json["provider"];
2225
2226        assert_eq!(provider["sort"], "price");
2227        assert_eq!(provider["preferred_min_throughput"]["p90"], 50.0);
2228    }
2229
2230    #[test]
2231    fn test_provider_preferences_require_parameters() {
2232        let prefs = ProviderPreferences::new().require_parameters(true);
2233
2234        let json = prefs.to_json();
2235        let provider = &json["provider"];
2236
2237        assert_eq!(provider["require_parameters"], true);
2238    }
2239
2240    #[test]
2241    fn test_provider_preferences_data_policy_and_zdr() {
2242        let prefs = ProviderPreferences::new()
2243            .data_collection(DataCollection::Deny)
2244            .zdr(true);
2245
2246        let json = prefs.to_json();
2247        let provider = &json["provider"];
2248
2249        assert_eq!(provider["data_collection"], "deny");
2250        assert_eq!(provider["zdr"], true);
2251    }
2252
2253    #[test]
2254    fn test_provider_preferences_quantizations() {
2255        let prefs =
2256            ProviderPreferences::new().quantizations([Quantization::Int8, Quantization::Fp16]);
2257
2258        let json = prefs.to_json();
2259        let provider = &json["provider"];
2260
2261        assert_eq!(provider["quantizations"], json!(["int8", "fp16"]));
2262    }
2263
2264    #[test]
2265    fn test_provider_preferences_convenience_methods() {
2266        let prefs = ProviderPreferences::new().zero_data_retention().fastest();
2267
2268        assert_eq!(prefs.zdr, Some(true));
2269        assert_eq!(
2270            prefs.sort,
2271            Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
2272        );
2273
2274        let prefs2 = ProviderPreferences::new().cheapest();
2275        assert_eq!(
2276            prefs2.sort,
2277            Some(ProviderSort::Simple(ProviderSortStrategy::Price))
2278        );
2279
2280        let prefs3 = ProviderPreferences::new().lowest_latency();
2281        assert_eq!(
2282            prefs3.sort,
2283            Some(ProviderSort::Simple(ProviderSortStrategy::Latency))
2284        );
2285    }
2286
2287    #[test]
2288    fn test_provider_preferences_serialization_skips_none() {
2289        let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Price);
2290
2291        let json = serde_json::to_value(&prefs).unwrap();
2292
2293        assert_eq!(json["sort"], "price");
2294        assert!(json.get("order").is_none());
2295        assert!(json.get("only").is_none());
2296        assert!(json.get("ignore").is_none());
2297        assert!(json.get("zdr").is_none());
2298    }
2299
2300    #[test]
2301    fn test_provider_preferences_deserialization() {
2302        let json = json!({
2303            "order": ["anthropic", "openai"],
2304            "sort": "throughput",
2305            "data_collection": "deny",
2306            "zdr": true,
2307            "quantizations": ["int8", "fp16"]
2308        });
2309
2310        let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
2311
2312        assert_eq!(
2313            prefs.order,
2314            Some(vec!["anthropic".to_string(), "openai".to_string()])
2315        );
2316        assert_eq!(
2317            prefs.sort,
2318            Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
2319        );
2320        assert_eq!(prefs.data_collection, Some(DataCollection::Deny));
2321        assert_eq!(prefs.zdr, Some(true));
2322        assert_eq!(
2323            prefs.quantizations,
2324            Some(vec![Quantization::Int8, Quantization::Fp16])
2325        );
2326    }
2327
2328    #[test]
2329    fn test_provider_preferences_deserialization_complex_sort() {
2330        let json = json!({
2331            "sort": {
2332                "by": "latency",
2333                "partition": "model"
2334            }
2335        });
2336
2337        let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
2338
2339        match prefs.sort {
2340            Some(ProviderSort::Complex(config)) => {
2341                assert_eq!(config.by, ProviderSortStrategy::Latency);
2342                assert_eq!(config.partition, Some(SortPartition::Model));
2343            }
2344            _ => panic!("Expected Complex sort variant"),
2345        }
2346    }
2347
2348    #[test]
2349    fn test_provider_preferences_full_integration() {
2350        let prefs = ProviderPreferences::new()
2351            .order(["anthropic", "openai"])
2352            .only(["anthropic", "openai", "google"])
2353            .sort(ProviderSortStrategy::Throughput)
2354            .data_collection(DataCollection::Deny)
2355            .zdr(true)
2356            .quantizations([Quantization::Int8])
2357            .allow_fallbacks(false);
2358
2359        let json = prefs.to_json();
2360
2361        assert!(json.get("provider").is_some());
2362        let provider = &json["provider"];
2363        assert_eq!(provider["order"], json!(["anthropic", "openai"]));
2364        assert_eq!(provider["only"], json!(["anthropic", "openai", "google"]));
2365        assert_eq!(provider["sort"], "throughput");
2366        assert_eq!(provider["data_collection"], "deny");
2367        assert_eq!(provider["zdr"], true);
2368        assert_eq!(provider["quantizations"], json!(["int8"]));
2369        assert_eq!(provider["allow_fallbacks"], false);
2370    }
2371
2372    #[test]
2373    fn test_provider_preferences_max_price() {
2374        let prefs =
2375            ProviderPreferences::new().max_price(MaxPrice::new().prompt(0.001).completion(0.002));
2376
2377        let json = prefs.to_json();
2378        let provider = &json["provider"];
2379
2380        assert_eq!(provider["max_price"]["prompt"], 0.001);
2381        assert_eq!(provider["max_price"]["completion"], 0.002);
2382    }
2383
2384    #[test]
2385    fn test_provider_preferences_preferred_max_latency() {
2386        let prefs = ProviderPreferences::new().preferred_max_latency(LatencyThreshold::Simple(0.5));
2387
2388        let json = prefs.to_json();
2389        let provider = &json["provider"];
2390
2391        assert_eq!(provider["preferred_max_latency"], 0.5);
2392    }
2393
2394    #[test]
2395    fn test_provider_preferences_empty_arrays() {
2396        let prefs = ProviderPreferences::new()
2397            .order(Vec::<String>::new())
2398            .quantizations(Vec::<Quantization>::new());
2399
2400        let json = prefs.to_json();
2401        let provider = &json["provider"];
2402
2403        assert_eq!(provider["order"], json!([]));
2404        assert_eq!(provider["quantizations"], json!([]));
2405    }
2406
2407    // ================================================================
2408    // File Support Tests
2409    // ================================================================
2410
2411    #[test]
2412    fn test_user_content_text_serialization() {
2413        let content = UserContent::Text {
2414            text: "Hello, world!".to_string(),
2415        };
2416        let json = serde_json::to_value(&content).unwrap();
2417
2418        assert_eq!(json["type"], "text");
2419        assert_eq!(json["text"], "Hello, world!");
2420    }
2421
2422    #[test]
2423    fn test_user_content_image_url_serialization() {
2424        let content = UserContent::Image {
2425            image_url: ImageUrl {
2426                url: "https://example.com/image.png".to_string(),
2427                detail: None,
2428            },
2429        };
2430        let json = serde_json::to_value(&content).unwrap();
2431
2432        assert_eq!(json["type"], "image_url");
2433        assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
2434        assert!(json["image_url"].get("detail").is_none());
2435    }
2436
2437    #[test]
2438    fn test_user_content_image_url_with_detail_serialization() {
2439        let content = UserContent::Image {
2440            image_url: ImageUrl {
2441                url: "https://example.com/image.png".to_string(),
2442                detail: Some(ImageDetail::High),
2443            },
2444        };
2445        let json = serde_json::to_value(&content).unwrap();
2446
2447        assert_eq!(json["type"], "image_url");
2448        assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
2449        assert_eq!(json["image_url"]["detail"], "high");
2450    }
2451
2452    #[test]
2453    fn test_user_content_image_base64_serialization() {
2454        let content = UserContent::Image {
2455            image_url: ImageUrl {
2456                url: "data:image/png;base64,SGVsbG8=".to_string(),
2457                detail: Some(ImageDetail::Low),
2458            },
2459        };
2460        let json = serde_json::to_value(&content).unwrap();
2461
2462        assert_eq!(json["type"], "image_url");
2463        assert_eq!(json["image_url"]["url"], "data:image/png;base64,SGVsbG8=");
2464        assert_eq!(json["image_url"]["detail"], "low");
2465    }
2466
2467    #[test]
2468    fn test_user_content_file_url_serialization() {
2469        let content = UserContent::File {
2470            file: FileData {
2471                file_data: Some("https://example.com/doc.pdf".to_string()),
2472                file_id: None,
2473                filename: Some("document.pdf".to_string()),
2474            },
2475        };
2476        let json = serde_json::to_value(&content).unwrap();
2477
2478        assert_eq!(json["type"], "file");
2479        assert_eq!(json["file"]["file_data"], "https://example.com/doc.pdf");
2480        assert_eq!(json["file"]["filename"], "document.pdf");
2481    }
2482
2483    #[test]
2484    fn test_user_content_file_base64_serialization() {
2485        let content = UserContent::File {
2486            file: FileData {
2487                file_data: Some("data:application/pdf;base64,JVBERi0xLjQ=".to_string()),
2488                file_id: None,
2489                filename: Some("report.pdf".to_string()),
2490            },
2491        };
2492        let json = serde_json::to_value(&content).unwrap();
2493
2494        assert_eq!(json["type"], "file");
2495        assert_eq!(
2496            json["file"]["file_data"],
2497            "data:application/pdf;base64,JVBERi0xLjQ="
2498        );
2499        assert_eq!(json["file"]["filename"], "report.pdf");
2500    }
2501
2502    #[test]
2503    fn test_user_content_text_deserialization() {
2504        let json = json!({
2505            "type": "text",
2506            "text": "Hello!"
2507        });
2508
2509        let content: UserContent = serde_json::from_value(json).unwrap();
2510        assert_eq!(
2511            content,
2512            UserContent::Text {
2513                text: "Hello!".to_string()
2514            }
2515        );
2516    }
2517
2518    #[test]
2519    fn test_user_content_image_url_deserialization() {
2520        let json = json!({
2521            "type": "image_url",
2522            "image_url": {
2523                "url": "https://example.com/img.jpg",
2524                "detail": "high"
2525            }
2526        });
2527
2528        let content: UserContent = serde_json::from_value(json).unwrap();
2529        match content {
2530            UserContent::Image { image_url } => {
2531                assert_eq!(image_url.url, "https://example.com/img.jpg");
2532                assert_eq!(image_url.detail, Some(ImageDetail::High));
2533            }
2534            _ => panic!("Expected Image variant"),
2535        }
2536    }
2537
2538    #[test]
2539    fn test_user_content_file_deserialization() {
2540        let json = json!({
2541            "type": "file",
2542            "file": {
2543                "filename": "doc.pdf",
2544                "file_data": "https://example.com/doc.pdf"
2545            }
2546        });
2547
2548        let content: UserContent = serde_json::from_value(json).unwrap();
2549        match content {
2550            UserContent::File { file } => {
2551                assert_eq!(file.filename, Some("doc.pdf".to_string()));
2552                assert_eq!(
2553                    file.file_data,
2554                    Some("https://example.com/doc.pdf".to_string())
2555                );
2556            }
2557            _ => panic!("Expected File variant"),
2558        }
2559    }
2560
2561    #[test]
2562    fn test_message_user_with_text_serialization() {
2563        let message = Message::User {
2564            content: OneOrMany::one(UserContent::Text {
2565                text: "Hello".to_string(),
2566            }),
2567            name: None,
2568        };
2569        let json = serde_json::to_value(&message).unwrap();
2570
2571        // Single text content should be serialized as a plain string
2572        assert_eq!(json["role"], "user");
2573        assert_eq!(json["content"], "Hello");
2574    }
2575
2576    #[test]
2577    fn test_message_user_with_mixed_content_serialization() {
2578        let message = Message::User {
2579            content: OneOrMany::many(vec![
2580                UserContent::Text {
2581                    text: "Check this image:".to_string(),
2582                },
2583                UserContent::Image {
2584                    image_url: ImageUrl {
2585                        url: "https://example.com/img.png".to_string(),
2586                        detail: None,
2587                    },
2588                },
2589            ])
2590            .unwrap(),
2591            name: None,
2592        };
2593        let json = serde_json::to_value(&message).unwrap();
2594
2595        assert_eq!(json["role"], "user");
2596        let content = json["content"].as_array().unwrap();
2597        assert_eq!(content.len(), 2);
2598        assert_eq!(content[0]["type"], "text");
2599        assert_eq!(content[1]["type"], "image_url");
2600    }
2601
2602    #[test]
2603    fn test_message_user_with_file_serialization() {
2604        let message = Message::User {
2605            content: OneOrMany::many(vec![
2606                UserContent::Text {
2607                    text: "Analyze this PDF:".to_string(),
2608                },
2609                UserContent::File {
2610                    file: FileData {
2611                        file_data: Some("https://example.com/doc.pdf".to_string()),
2612                        file_id: None,
2613                        filename: Some("document.pdf".to_string()),
2614                    },
2615                },
2616            ])
2617            .unwrap(),
2618            name: None,
2619        };
2620        let json = serde_json::to_value(&message).unwrap();
2621
2622        assert_eq!(json["role"], "user");
2623        let content = json["content"].as_array().unwrap();
2624        assert_eq!(content.len(), 2);
2625        assert_eq!(content[0]["type"], "text");
2626        assert_eq!(content[1]["type"], "file");
2627        assert_eq!(
2628            content[1]["file"]["file_data"],
2629            "https://example.com/doc.pdf"
2630        );
2631    }
2632
2633    #[test]
2634    fn test_user_content_from_rig_text() {
2635        let rig_content = message::UserContent::Text(message::Text::new("Hello".to_string()));
2636        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2637
2638        assert_eq!(
2639            openrouter_content,
2640            UserContent::Text {
2641                text: "Hello".to_string()
2642            }
2643        );
2644    }
2645
2646    #[test]
2647    fn test_user_content_from_rig_image_url() {
2648        let rig_content = message::UserContent::Image(message::Image {
2649            data: DocumentSourceKind::Url("https://example.com/img.png".to_string()),
2650            media_type: Some(message::ImageMediaType::PNG),
2651            detail: Some(ImageDetail::High),
2652            additional_params: None,
2653        });
2654        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2655
2656        match openrouter_content {
2657            UserContent::Image { image_url } => {
2658                assert_eq!(image_url.url, "https://example.com/img.png");
2659                assert_eq!(image_url.detail, Some(ImageDetail::High));
2660            }
2661            _ => panic!("Expected Image variant"),
2662        }
2663    }
2664
2665    #[test]
2666    fn test_user_content_from_rig_image_base64() {
2667        let rig_content = message::UserContent::Image(message::Image {
2668            data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
2669            media_type: Some(message::ImageMediaType::JPEG),
2670            detail: Some(ImageDetail::Low),
2671            additional_params: None,
2672        });
2673        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2674
2675        match openrouter_content {
2676            UserContent::Image { image_url } => {
2677                assert_eq!(image_url.url, "data:image/jpeg;base64,SGVsbG8=");
2678                assert_eq!(image_url.detail, Some(ImageDetail::Low));
2679            }
2680            _ => panic!("Expected Image variant"),
2681        }
2682    }
2683
2684    #[test]
2685    fn test_user_content_from_rig_document_url() {
2686        let rig_content = message::UserContent::Document(message::Document {
2687            data: DocumentSourceKind::Url("https://example.com/doc.pdf".to_string()),
2688            media_type: Some(DocumentMediaType::PDF),
2689            additional_params: None,
2690        });
2691        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2692
2693        match openrouter_content {
2694            UserContent::File { file } => {
2695                assert_eq!(
2696                    file.file_data,
2697                    Some("https://example.com/doc.pdf".to_string())
2698                );
2699                assert_eq!(file.filename, Some("document.pdf".to_string()));
2700            }
2701            _ => panic!("Expected File variant"),
2702        }
2703    }
2704
2705    #[test]
2706    fn test_user_content_from_rig_document_base64() {
2707        let rig_content = message::UserContent::Document(message::Document {
2708            data: DocumentSourceKind::Base64("JVBERi0xLjQ=".to_string()),
2709            media_type: Some(DocumentMediaType::PDF),
2710            additional_params: None,
2711        });
2712        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2713
2714        match openrouter_content {
2715            UserContent::File { file } => {
2716                assert_eq!(
2717                    file.file_data,
2718                    Some("data:application/pdf;base64,JVBERi0xLjQ=".to_string())
2719                );
2720                assert_eq!(file.filename, Some("document.pdf".to_string()));
2721            }
2722            _ => panic!("Expected File variant"),
2723        }
2724    }
2725
2726    #[test]
2727    fn test_user_content_from_rig_document_file_id() {
2728        let rig_content = message::UserContent::Document(message::Document {
2729            data: DocumentSourceKind::FileId("file_abc".to_string()),
2730            media_type: None,
2731            additional_params: None,
2732        });
2733
2734        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
2735        assert!(matches!(
2736            result,
2737            Err(message::MessageError::ConversionError(message))
2738                if message.contains("Provider file IDs are not supported")
2739        ));
2740    }
2741
2742    #[test]
2743    fn test_openai_file_id_content_round_trips_through_rig_to_openrouter_error() {
2744        let openai_content = openai::UserContent::File {
2745            file: openai::FileData {
2746                file_data: None,
2747                file_id: Some("file_abc".to_string()),
2748                filename: None,
2749            },
2750        };
2751        let rig_content: message::UserContent = openai_content.into();
2752
2753        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
2754        assert!(matches!(
2755            result,
2756            Err(message::MessageError::ConversionError(message))
2757                if message.contains("Provider file IDs are not supported")
2758        ));
2759    }
2760
2761    #[test]
2762    fn test_user_content_from_rig_document_string_becomes_text() {
2763        let rig_content = message::UserContent::Document(message::Document {
2764            data: DocumentSourceKind::String("Plain text document content".to_string()),
2765            media_type: Some(DocumentMediaType::TXT),
2766            additional_params: None,
2767        });
2768        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
2769
2770        assert_eq!(
2771            openrouter_content,
2772            UserContent::Text {
2773                text: "Plain text document content".to_string()
2774            }
2775        );
2776    }
2777
2778    #[test]
2779    fn test_completion_response_with_reasoning_details_maps_to_typed_reasoning() {
2780        let json = json!({
2781            "id": "resp_123",
2782            "object": "chat.completion",
2783            "created": 1,
2784            "model": "openrouter/test-model",
2785            "choices": [{
2786                "index": 0,
2787                "finish_reason": "stop",
2788                "message": {
2789                    "role": "assistant",
2790                    "content": "hello",
2791                    "reasoning": null,
2792                    "reasoning_details": [
2793                        {"type":"reasoning.summary","id":"rs_1","summary":"s1"},
2794                        {"type":"reasoning.text","id":"rs_1","text":"t1","signature":"sig_1"},
2795                        {"type":"reasoning.encrypted","id":"rs_1","data":"enc_1"}
2796                    ]
2797                }
2798            }]
2799        });
2800
2801        let response: CompletionResponse = serde_json::from_value(json).unwrap();
2802        let converted: completion::CompletionResponse<CompletionResponse> =
2803            response.try_into().unwrap();
2804        let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
2805
2806        assert!(items.iter().any(|item| matches!(
2807            item,
2808            completion::AssistantContent::Reasoning(message::Reasoning { id: Some(id), content })
2809                if id == "rs_1" && content.len() == 3
2810        )));
2811    }
2812
2813    #[test]
2814    fn test_assistant_reasoning_emits_openrouter_reasoning_details() {
2815        let reasoning = message::Reasoning {
2816            id: Some("rs_2".to_string()),
2817            content: vec![
2818                message::ReasoningContent::Text {
2819                    text: "step".to_string(),
2820                    signature: Some("sig_step".to_string()),
2821                },
2822                message::ReasoningContent::Summary("summary".to_string()),
2823                message::ReasoningContent::Encrypted("enc_blob".to_string()),
2824            ],
2825        };
2826
2827        let messages = assistant_contents_to_messages(OneOrMany::one(
2828            message::AssistantContent::Reasoning(reasoning),
2829        ))
2830        .unwrap();
2831        let Message::Assistant {
2832            reasoning,
2833            reasoning_details,
2834            ..
2835        } = messages.first().expect("assistant message")
2836        else {
2837            panic!("Expected assistant message");
2838        };
2839
2840        assert!(reasoning.is_none());
2841        assert_eq!(reasoning_details.len(), 3);
2842        assert!(matches!(
2843            reasoning_details.first(),
2844            Some(ReasoningDetails::Text {
2845                id: Some(id),
2846                text: Some(text),
2847                signature: Some(signature),
2848                ..
2849            }) if id == "rs_2" && text == "step" && signature == "sig_step"
2850        ));
2851    }
2852
2853    #[test]
2854    fn test_tool_call_signature_without_params_uses_wire_id_for_encrypted_detail() {
2855        let tool_call = message::ToolCall {
2856            id: "call_wire".to_string(),
2857            call_id: None,
2858            function: message::ToolFunction {
2859                name: "lookup".to_string(),
2860                arguments: json!({}),
2861            },
2862            signature: Some("sig-data".to_string()),
2863            additional_params: None,
2864        };
2865
2866        let messages = assistant_contents_to_messages(OneOrMany::one(
2867            message::AssistantContent::ToolCall(tool_call),
2868        ))
2869        .unwrap();
2870
2871        let Message::Assistant {
2872            reasoning_details, ..
2873        } = messages.first().expect("assistant message")
2874        else {
2875            panic!("Expected assistant message");
2876        };
2877
2878        assert!(matches!(
2879            reasoning_details.first(),
2880            Some(ReasoningDetails::Encrypted {
2881                id: Some(id),
2882                data,
2883                ..
2884            }) if id == "call_wire" && data == "sig-data"
2885        ));
2886    }
2887
2888    #[test]
2889    fn test_tool_call_minimal_params_fall_back_to_wire_id() {
2890        let tool_call = message::ToolCall {
2891            id: "call_wire".to_string(),
2892            call_id: None,
2893            function: message::ToolFunction {
2894                name: "lookup".to_string(),
2895                arguments: json!({}),
2896            },
2897            signature: Some("sig-data".to_string()),
2898            // Minimal params carrying only a format: the detail id must
2899            // still correlate with the wire tool-call id.
2900            additional_params: Some(json!({"format": "anthropic"})),
2901        };
2902
2903        let messages = assistant_contents_to_messages(OneOrMany::one(
2904            message::AssistantContent::ToolCall(tool_call),
2905        ))
2906        .unwrap();
2907
2908        let Message::Assistant {
2909            reasoning_details, ..
2910        } = messages.first().expect("assistant message")
2911        else {
2912            panic!("Expected assistant message");
2913        };
2914
2915        assert!(matches!(
2916            reasoning_details.first(),
2917            Some(ReasoningDetails::Encrypted {
2918                id: Some(id),
2919                format,
2920                data,
2921                ..
2922            }) if id == "call_wire" && data == "sig-data" && format.as_deref() == Some("anthropic")
2923        ));
2924    }
2925
2926    #[test]
2927    fn test_assistant_redacted_reasoning_emits_encrypted_detail_not_text() {
2928        let reasoning = message::Reasoning {
2929            id: Some("rs_redacted".to_string()),
2930            content: vec![message::ReasoningContent::Redacted {
2931                data: "opaque-redacted-data".to_string(),
2932            }],
2933        };
2934
2935        let messages = assistant_contents_to_messages(OneOrMany::one(
2936            message::AssistantContent::Reasoning(reasoning),
2937        ))
2938        .unwrap();
2939
2940        let Message::Assistant {
2941            reasoning_details,
2942            reasoning,
2943            ..
2944        } = messages.first().expect("assistant message")
2945        else {
2946            panic!("Expected assistant message");
2947        };
2948
2949        assert!(reasoning.is_none());
2950        assert_eq!(reasoning_details.len(), 1);
2951        assert!(matches!(
2952            reasoning_details.first(),
2953            Some(ReasoningDetails::Encrypted {
2954                id: Some(id),
2955                data,
2956                ..
2957            }) if id == "rs_redacted" && data == "opaque-redacted-data"
2958        ));
2959    }
2960
2961    #[test]
2962    fn test_completion_response_reasoning_details_respects_index_ordering() {
2963        let json = json!({
2964            "id": "resp_ordering",
2965            "object": "chat.completion",
2966            "created": 1,
2967            "model": "openrouter/test-model",
2968            "choices": [{
2969                "index": 0,
2970                "finish_reason": "stop",
2971                "message": {
2972                    "role": "assistant",
2973                    "content": "hello",
2974                    "reasoning": null,
2975                    "reasoning_details": [
2976                        {"type":"reasoning.summary","id":"rs_order","index":1,"summary":"second"},
2977                        {"type":"reasoning.summary","id":"rs_order","index":0,"summary":"first"}
2978                    ]
2979                }
2980            }]
2981        });
2982
2983        let response: CompletionResponse = serde_json::from_value(json).unwrap();
2984        let converted: completion::CompletionResponse<CompletionResponse> =
2985            response.try_into().unwrap();
2986        let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
2987        let reasoning_blocks: Vec<_> = items
2988            .into_iter()
2989            .filter_map(|item| match item {
2990                completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
2991                _ => None,
2992            })
2993            .collect();
2994
2995        assert_eq!(reasoning_blocks.len(), 1);
2996        assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_order"));
2997        assert_eq!(
2998            reasoning_blocks[0].content,
2999            vec![
3000                message::ReasoningContent::Summary("first".to_string()),
3001                message::ReasoningContent::Summary("second".to_string()),
3002            ]
3003        );
3004    }
3005
3006    #[test]
3007    fn test_user_content_from_rig_image_missing_media_type_error() {
3008        let rig_content = message::UserContent::Image(message::Image {
3009            data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3010            media_type: None, // Missing media type
3011            detail: None,
3012            additional_params: None,
3013        });
3014        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3015
3016        assert!(result.is_err());
3017        let err = result.unwrap_err();
3018        assert!(err.to_string().contains("media type required"));
3019    }
3020
3021    #[test]
3022    fn test_user_content_from_rig_image_raw_bytes_error() {
3023        let rig_content = message::UserContent::Image(message::Image {
3024            data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3025            media_type: Some(message::ImageMediaType::PNG),
3026            detail: None,
3027            additional_params: None,
3028        });
3029        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3030
3031        assert!(result.is_err());
3032        let err = result.unwrap_err();
3033        assert!(err.to_string().contains("base64"));
3034    }
3035
3036    #[test]
3037    fn test_user_content_from_rig_video_url() {
3038        let rig_content = message::UserContent::Video(message::Video {
3039            data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
3040            media_type: Some(message::VideoMediaType::MP4),
3041            additional_params: None,
3042        });
3043        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3044
3045        match openrouter_content {
3046            UserContent::Video { video_url } => {
3047                assert_eq!(video_url.url, "https://example.com/video.mp4");
3048            }
3049            _ => panic!("Expected Video variant"),
3050        }
3051    }
3052
3053    #[test]
3054    fn test_user_content_from_rig_video_base64() {
3055        let rig_content = message::UserContent::Video(message::Video {
3056            data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3057            media_type: Some(message::VideoMediaType::MP4),
3058            additional_params: None,
3059        });
3060        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3061
3062        match openrouter_content {
3063            UserContent::Video { video_url } => {
3064                assert_eq!(video_url.url, "data:video/mp4;base64,SGVsbG8=");
3065            }
3066            _ => panic!("Expected Video variant"),
3067        }
3068    }
3069
3070    #[test]
3071    fn test_user_content_from_rig_video_base64_missing_media_type_error() {
3072        let rig_content = message::UserContent::Video(message::Video {
3073            data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3074            media_type: None,
3075            additional_params: None,
3076        });
3077        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3078
3079        assert!(result.is_err());
3080        let err = result.unwrap_err();
3081        assert!(err.to_string().contains("media type"));
3082    }
3083
3084    #[test]
3085    fn test_user_content_from_rig_video_raw_bytes_error() {
3086        let rig_content = message::UserContent::Video(message::Video {
3087            data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3088            media_type: Some(message::VideoMediaType::MP4),
3089            additional_params: None,
3090        });
3091        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3092
3093        assert!(result.is_err());
3094        let err = result.unwrap_err();
3095        assert!(err.to_string().contains("base64"));
3096    }
3097
3098    #[test]
3099    fn test_user_content_from_rig_audio_base64() {
3100        let rig_content = message::UserContent::Audio(message::Audio {
3101            data: DocumentSourceKind::Base64("audiodata".to_string()),
3102            media_type: Some(message::AudioMediaType::MP3),
3103            additional_params: None,
3104        });
3105        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3106
3107        match openrouter_content {
3108            UserContent::Audio { input_audio } => {
3109                assert_eq!(input_audio.data, "audiodata");
3110                assert_eq!(input_audio.format, message::AudioMediaType::MP3);
3111            }
3112            _ => panic!("Expected Audio variant"),
3113        }
3114    }
3115
3116    #[test]
3117    fn test_user_content_from_rig_audio_missing_media_type_error() {
3118        let rig_content = message::UserContent::Audio(message::Audio {
3119            data: DocumentSourceKind::Base64("audiodata".to_string()),
3120            media_type: None, // missing media type
3121            additional_params: None,
3122        });
3123        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3124
3125        assert!(result.is_err());
3126        let err = result.unwrap_err();
3127        assert!(err.to_string().contains("media type required"));
3128    }
3129
3130    #[test]
3131    fn test_user_content_from_rig_audio_url_error() {
3132        let rig_content = message::UserContent::Audio(message::Audio {
3133            data: DocumentSourceKind::Url("https://example.com/audio.wav".to_string()),
3134            media_type: Some(message::AudioMediaType::WAV),
3135            additional_params: None,
3136        });
3137        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3138
3139        assert!(result.is_err());
3140        let err = result.unwrap_err();
3141        assert!(err.to_string().contains("base64"));
3142    }
3143
3144    #[test]
3145    fn test_user_content_from_rig_audio_raw_bytes_error() {
3146        let rig_content = message::UserContent::Audio(message::Audio {
3147            data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3148            media_type: Some(message::AudioMediaType::WAV),
3149            additional_params: None,
3150        });
3151        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3152
3153        assert!(result.is_err());
3154        let err = result.unwrap_err();
3155        assert!(err.to_string().contains("base64"));
3156    }
3157
3158    #[test]
3159    fn test_user_content_from_rig_video_file_id_error() {
3160        let rig_content = message::UserContent::Video(message::Video {
3161            data: DocumentSourceKind::FileId("file-123".to_string()),
3162            media_type: Some(message::VideoMediaType::MP4),
3163            additional_params: None,
3164        });
3165        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3166
3167        assert!(result.is_err());
3168        let err = result.unwrap_err();
3169        assert!(
3170            err.to_string()
3171                .contains("File IDs are not supported for video")
3172        );
3173    }
3174
3175    #[test]
3176    fn test_user_content_from_rig_audio_file_id_error() {
3177        let rig_content = message::UserContent::Audio(message::Audio {
3178            data: DocumentSourceKind::FileId("file-123".to_string()),
3179            media_type: Some(message::AudioMediaType::MP3),
3180            additional_params: None,
3181        });
3182        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3183
3184        assert!(result.is_err());
3185        let err = result.unwrap_err();
3186        assert!(
3187            err.to_string()
3188                .contains("File IDs are not supported for audio")
3189        );
3190    }
3191
3192    #[test]
3193    fn test_video_helper_converts_to_data_uri() {
3194        // `UserContent::video(..)` carries base64 data and should become a
3195        // `video_url` data URI.
3196        let rig_content =
3197            message::UserContent::video("SGVsbG8=", Some(message::VideoMediaType::MP4));
3198        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3199
3200        match openrouter_content {
3201            UserContent::Video { video_url } => {
3202                assert_eq!(video_url.url, "data:video/mp4;base64,SGVsbG8=");
3203            }
3204            _ => panic!("Expected Video variant"),
3205        }
3206    }
3207
3208    #[test]
3209    fn test_video_url_helper_passes_url_through() {
3210        // `UserContent::video_url(..)` passes the URL through unchanged and does
3211        // not require a media type.
3212        let rig_content = message::UserContent::video_url("https://example.com/video.mp4", None);
3213        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3214
3215        match openrouter_content {
3216            UserContent::Video { video_url } => {
3217                assert_eq!(video_url.url, "https://example.com/video.mp4");
3218            }
3219            _ => panic!("Expected Video variant"),
3220        }
3221    }
3222
3223    #[test]
3224    fn test_video_raw_helper_errors() {
3225        // `UserContent::video_raw(..)` carries raw bytes, which OpenRouter cannot
3226        // accept; the caller must base64-encode first.
3227        let rig_content =
3228            message::UserContent::video_raw(vec![1, 2, 3], Some(message::VideoMediaType::MP4));
3229        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3230
3231        assert!(result.is_err());
3232        let err = result.unwrap_err();
3233        assert!(err.to_string().contains("base64"));
3234    }
3235
3236    #[test]
3237    fn test_message_conversion_with_pdf() {
3238        let rig_message = message::Message::User {
3239            content: OneOrMany::many(vec![
3240                message::UserContent::Text(message::Text::new(
3241                    "Summarize this document".to_string(),
3242                )),
3243                message::UserContent::Document(message::Document {
3244                    data: DocumentSourceKind::Url("https://example.com/paper.pdf".to_string()),
3245                    media_type: Some(DocumentMediaType::PDF),
3246                    additional_params: None,
3247                }),
3248            ])
3249            .unwrap(),
3250        };
3251
3252        let openrouter_messages: Vec<Message> = messages_from_rig_message(rig_message).unwrap();
3253        assert_eq!(openrouter_messages.len(), 1);
3254
3255        match &openrouter_messages[0] {
3256            Message::User { content, .. } => {
3257                assert_eq!(content.len(), 2);
3258
3259                // First should be text
3260                match content.first_ref() {
3261                    UserContent::Text { text, .. } => assert_eq!(text, "Summarize this document"),
3262                    _ => panic!("Expected Text"),
3263                }
3264            }
3265            _ => panic!("Expected User message"),
3266        }
3267    }
3268
3269    #[test]
3270    fn test_user_content_from_string() {
3271        let content: UserContent = "Hello".into();
3272        assert_eq!(
3273            content,
3274            UserContent::Text {
3275                text: "Hello".to_string()
3276            }
3277        );
3278
3279        let content: UserContent = String::from("World").into();
3280        assert_eq!(
3281            content,
3282            UserContent::Text {
3283                text: "World".to_string()
3284            }
3285        );
3286    }
3287
3288    #[test]
3289    fn test_completion_response_reasoning_details_with_multiple_ids_stay_separate() {
3290        let json = json!({
3291            "id": "resp_multi_id",
3292            "object": "chat.completion",
3293            "created": 1,
3294            "model": "openrouter/test-model",
3295            "choices": [{
3296                "index": 0,
3297                "finish_reason": "stop",
3298                "message": {
3299                    "role": "assistant",
3300                    "content": "hello",
3301                    "reasoning": null,
3302                    "reasoning_details": [
3303                        {"type":"reasoning.summary","id":"rs_a","summary":"a1"},
3304                        {"type":"reasoning.summary","id":"rs_b","summary":"b1"},
3305                        {"type":"reasoning.summary","id":"rs_a","summary":"a2"}
3306                    ]
3307                }
3308            }]
3309        });
3310
3311        let response: CompletionResponse = serde_json::from_value(json).unwrap();
3312        let converted: completion::CompletionResponse<CompletionResponse> =
3313            response.try_into().unwrap();
3314        let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3315        let reasoning_blocks: Vec<_> = items
3316            .into_iter()
3317            .filter_map(|item| match item {
3318                completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
3319                _ => None,
3320            })
3321            .collect();
3322
3323        assert_eq!(reasoning_blocks.len(), 2);
3324        assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_a"));
3325        assert_eq!(
3326            reasoning_blocks[0].content,
3327            vec![
3328                message::ReasoningContent::Summary("a1".to_string()),
3329                message::ReasoningContent::Summary("a2".to_string()),
3330            ]
3331        );
3332        assert_eq!(reasoning_blocks[1].id.as_deref(), Some("rs_b"));
3333        assert_eq!(
3334            reasoning_blocks[1].content,
3335            vec![message::ReasoningContent::Summary("b1".to_string())]
3336        );
3337    }
3338
3339    #[test]
3340    fn test_user_content_audio_serialization() {
3341        let content = UserContent::Audio {
3342            input_audio: openai::InputAudio {
3343                data: "SGVsbG8=".to_string(),
3344                format: AudioMediaType::WAV,
3345            },
3346        };
3347        let json = serde_json::to_value(&content).unwrap();
3348
3349        assert_eq!(json["type"], "input_audio");
3350        assert_eq!(json["input_audio"]["data"], "SGVsbG8=");
3351        assert_eq!(json["input_audio"]["format"], "wav");
3352    }
3353
3354    #[test]
3355    fn test_user_content_audio_deserialization() {
3356        let json = json!({
3357            "type": "input_audio",
3358            "input_audio": {
3359                "data": "SGVsbG8=",
3360                "format": "wav"
3361            }
3362        });
3363
3364        let content: UserContent = serde_json::from_value(json).unwrap();
3365        match content {
3366            UserContent::Audio { input_audio } => {
3367                assert_eq!(input_audio.data, "SGVsbG8=");
3368                assert_eq!(input_audio.format, AudioMediaType::WAV);
3369            }
3370            _ => panic!("Expected Audio variant"),
3371        }
3372    }
3373
3374    #[test]
3375    fn test_message_user_with_audio_serialization() {
3376        let msg = Message::User {
3377            content: OneOrMany::many(vec![
3378                UserContent::Text {
3379                    text: "Transcribe this audio:".to_string(),
3380                },
3381                UserContent::Audio {
3382                    input_audio: openai::InputAudio {
3383                        data: "SGVsbG8=".to_string(),
3384                        format: AudioMediaType::MP3,
3385                    },
3386                },
3387            ])
3388            .unwrap(),
3389            name: None,
3390        };
3391        let json = serde_json::to_value(&msg).unwrap();
3392
3393        assert_eq!(json["role"], "user");
3394        let content = json["content"].as_array().unwrap();
3395        assert_eq!(content.len(), 2);
3396        assert_eq!(content[0]["type"], "text");
3397        assert_eq!(content[1]["type"], "input_audio");
3398        assert_eq!(content[1]["input_audio"]["data"], "SGVsbG8=");
3399        assert_eq!(content[1]["input_audio"]["format"], "mp3");
3400    }
3401
3402    #[test]
3403    fn test_user_content_video_url_serialization() {
3404        let content = UserContent::Video {
3405            video_url: VideoUrl {
3406                url: "https://example.com/video.mp4".to_string(),
3407            },
3408        };
3409        let json = serde_json::to_value(&content).unwrap();
3410
3411        assert_eq!(json["type"], "video_url");
3412        assert_eq!(json["video_url"]["url"], "https://example.com/video.mp4");
3413    }
3414
3415    #[test]
3416    fn test_user_content_video_base64_serialization() {
3417        let content = UserContent::Video {
3418            video_url: VideoUrl {
3419                url: format!(
3420                    "data:{};base64,SGVsbG8=",
3421                    VideoMediaType::MP4.to_mime_type()
3422                ),
3423            },
3424        };
3425        let json = serde_json::to_value(&content).unwrap();
3426
3427        assert_eq!(json["type"], "video_url");
3428        assert_eq!(json["video_url"]["url"], "data:video/mp4;base64,SGVsbG8=");
3429    }
3430
3431    #[test]
3432    fn test_user_content_video_url_deserialization() {
3433        let json = json!({
3434            "type": "video_url",
3435            "video_url": {
3436                "url": "https://example.com/video.mp4"
3437            }
3438        });
3439
3440        let content: UserContent = serde_json::from_value(json).unwrap();
3441        match content {
3442            UserContent::Video { video_url } => {
3443                assert_eq!(video_url.url, "https://example.com/video.mp4");
3444            }
3445            _ => panic!("Expected Video variant"),
3446        }
3447    }
3448
3449    #[test]
3450    fn test_message_user_with_video_serialization() {
3451        let msg = Message::User {
3452            content: OneOrMany::many(vec![
3453                UserContent::Text {
3454                    text: "Describe this video:".to_string(),
3455                },
3456                UserContent::Video {
3457                    video_url: VideoUrl {
3458                        url: "https://example.com/video.mp4".to_string(),
3459                    },
3460                },
3461            ])
3462            .unwrap(),
3463            name: None,
3464        };
3465        let json = serde_json::to_value(&msg).unwrap();
3466
3467        assert_eq!(json["role"], "user");
3468        let content = json["content"].as_array().unwrap();
3469        assert_eq!(content.len(), 2);
3470        assert_eq!(content[0]["type"], "text");
3471        assert_eq!(content[1]["type"], "video_url");
3472        assert_eq!(
3473            content[1]["video_url"]["url"],
3474            "https://example.com/video.mp4"
3475        );
3476    }
3477
3478    #[test]
3479    fn test_user_content_video_url_no_media_type_needed() {
3480        let rig_content = message::UserContent::Video(message::Video {
3481            data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
3482            media_type: None,
3483            additional_params: None,
3484        });
3485        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3486
3487        match openrouter_content {
3488            UserContent::Video { video_url } => {
3489                assert_eq!(video_url.url, "https://example.com/video.mp4");
3490            }
3491            _ => panic!("Expected Video variant"),
3492        }
3493    }
3494
3495    fn prompt_caching_completion_request() -> CompletionRequest {
3496        CompletionRequest {
3497            model: None,
3498            preamble: Some("You are a helpful assistant.".to_string()),
3499            chat_history: crate::OneOrMany::one(crate::message::Message::user("Hello")),
3500            documents: vec![],
3501            tools: vec![],
3502            temperature: None,
3503            max_tokens: None,
3504            tool_choice: None,
3505            additional_params: None,
3506            output_schema: None,
3507        }
3508    }
3509
3510    #[test]
3511    fn test_final_request_body_applies_prompt_caching_to_converted_completion_request() {
3512        let request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
3513            model: "anthropic/claude-3.5-sonnet",
3514            request: prompt_caching_completion_request(),
3515            strict_tools: false,
3516        })
3517        .expect("request conversion should succeed");
3518
3519        let body = final_request_body(&request, true).expect("request body should serialize");
3520        let system_block = &body["messages"][0]["content"][0];
3521
3522        assert_eq!(system_block["type"], "text");
3523        assert_eq!(system_block["text"], "You are a helpful assistant.");
3524        assert_eq!(system_block["cache_control"]["type"], "ephemeral");
3525
3526        let body = final_request_body(&request, false).expect("request body should serialize");
3527        assert!(
3528            body["messages"][0]["content"][0]
3529                .get("cache_control")
3530                .is_none(),
3531            "prompt caching should be opt-in"
3532        );
3533    }
3534
3535    #[test]
3536    fn test_final_request_body_preserves_stream_flag_when_prompt_caching_enabled() {
3537        let mut request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
3538            model: "anthropic/claude-3.5-sonnet",
3539            request: prompt_caching_completion_request(),
3540            strict_tools: false,
3541        })
3542        .expect("request conversion should succeed");
3543        request.additional_params = Some(json!({ "stream": true }));
3544
3545        let body = final_request_body(&request, true).expect("request body should serialize");
3546
3547        assert_eq!(body["stream"], true);
3548        assert_eq!(
3549            body["messages"][0]["content"][0]["cache_control"]["type"],
3550            "ephemeral"
3551        );
3552    }
3553
3554    #[test]
3555    fn test_apply_prompt_caching_string_system_message() {
3556        let mut body = json!({
3557            "model": "anthropic/claude-3.5-sonnet",
3558            "messages": [
3559                {"role": "system", "content": "You are a helpful assistant."},
3560                {"role": "user", "content": "Hello"}
3561            ]
3562        });
3563
3564        apply_prompt_caching(&mut body);
3565
3566        let system_content = &body["messages"][0]["content"];
3567        assert!(
3568            system_content.is_array(),
3569            "system content should be an array after caching"
3570        );
3571        let block = &system_content[0];
3572        assert_eq!(block["type"], "text");
3573        assert_eq!(block["text"], "You are a helpful assistant.");
3574        assert_eq!(block["cache_control"]["type"], "ephemeral");
3575
3576        // User message should be unchanged.
3577        assert_eq!(body["messages"][1]["content"], "Hello");
3578    }
3579
3580    #[test]
3581    fn test_apply_prompt_caching_array_system_message_marks_last_block() {
3582        let mut body = json!({
3583            "model": "anthropic/claude-3.5-sonnet",
3584            "messages": [
3585                {
3586                    "role": "system",
3587                    "content": [
3588                        {"type": "text", "text": "Part 1. "},
3589                        {"type": "text", "text": "Part 2."}
3590                    ]
3591                }
3592            ]
3593        });
3594
3595        apply_prompt_caching(&mut body);
3596
3597        let system_content = &body["messages"][0]["content"];
3598        assert!(system_content.is_array());
3599        // Both blocks are preserved; only the last one gets cache_control.
3600        assert_eq!(system_content.as_array().unwrap().len(), 2);
3601        assert_eq!(system_content[0]["text"], "Part 1. ");
3602        assert!(system_content[0].get("cache_control").is_none());
3603        assert_eq!(system_content[1]["text"], "Part 2.");
3604        assert_eq!(system_content[1]["cache_control"]["type"], "ephemeral");
3605    }
3606
3607    #[test]
3608    fn test_apply_prompt_caching_preserves_non_text_blocks() {
3609        let mut body = json!({
3610            "model": "anthropic/claude-3.5-sonnet",
3611            "messages": [
3612                {
3613                    "role": "system",
3614                    "content": [
3615                        {"type": "image", "source": {"type": "url", "url": "https://example.com/img.png"}},
3616                        {"type": "text", "text": "Describe the image."}
3617                    ]
3618                }
3619            ]
3620        });
3621
3622        apply_prompt_caching(&mut body);
3623
3624        let system_content = &body["messages"][0]["content"];
3625        assert_eq!(system_content.as_array().unwrap().len(), 2);
3626        // Non-text block is preserved unchanged.
3627        assert_eq!(system_content[0]["type"], "image");
3628        assert!(system_content[0].get("cache_control").is_none());
3629        // Text block (last) receives the cache boundary.
3630        assert_eq!(system_content[1]["type"], "text");
3631        assert_eq!(system_content[1]["cache_control"]["type"], "ephemeral");
3632    }
3633
3634    #[test]
3635    fn test_apply_prompt_caching_no_system_message_is_noop() {
3636        let mut body = json!({
3637            "model": "openai/gpt-4o",
3638            "messages": [
3639                {"role": "user", "content": "Hello"}
3640            ]
3641        });
3642
3643        let body_before = body.clone();
3644        apply_prompt_caching(&mut body);
3645        assert_eq!(
3646            body, body_before,
3647            "body should be unchanged when no system message exists"
3648        );
3649    }
3650
3651    #[test]
3652    fn test_completion_response_extracts_generated_images() {
3653        let json = json!({
3654            "id": "resp_img",
3655            "object": "chat.completion",
3656            "created": 1,
3657            "model": "google/gemini-flash-image-preview",
3658            "choices": [{
3659                "index": 0,
3660                "finish_reason": "stop",
3661                "message": {
3662                    "role": "assistant",
3663                    "content": "Here is your image.",
3664                    "images": [
3665                        {"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}
3666                    ]
3667                }
3668            }]
3669        });
3670
3671        let response: CompletionResponse = serde_json::from_value(json).unwrap();
3672        let converted: completion::CompletionResponse<CompletionResponse> =
3673            response.try_into().unwrap();
3674        let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3675        assert_eq!(items.len(), 2);
3676
3677        assert!(items.iter().any(|item| matches!(
3678            item,
3679            completion::AssistantContent::Text(t) if t.text == "Here is your image."
3680        )));
3681        assert!(items.iter().any(|item| matches!(
3682            item,
3683            completion::AssistantContent::Image(message::Image {
3684                data: message::DocumentSourceKind::Base64(b64),
3685                media_type: Some(message::ImageMediaType::PNG),
3686                additional_params: Some(_),
3687                ..
3688            }) if b64 == "iVBORw0KGgo="
3689        )));
3690        assert!(
3691            items.iter().any(|item| matches!(
3692                item,
3693                completion::AssistantContent::Image(image)
3694                    if is_openrouter_response_image(image)
3695            )),
3696            "generated images should be marked as OpenRouter response-only artifacts"
3697        );
3698    }
3699
3700    #[test]
3701    fn test_completion_response_extracts_generated_images_url() {
3702        let json = json!({
3703            "id": "resp_img_url",
3704            "object": "chat.completion",
3705            "created": 1,
3706            "model": "google/gemini-flash-image-preview",
3707            "choices": [{
3708                "index": 0,
3709                "finish_reason": "stop",
3710                "message": {
3711                    "role": "assistant",
3712                    "content": "Here is your image.",
3713                    "images": [
3714                        {"type":"image_url","image_url":{"url":"https://example.com/generated.png"}}
3715                    ]
3716                }
3717            }]
3718        });
3719
3720        let response: CompletionResponse = serde_json::from_value(json).unwrap();
3721        let converted: completion::CompletionResponse<CompletionResponse> =
3722            response.try_into().unwrap();
3723        let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3724        assert_eq!(items.len(), 2);
3725
3726        assert!(items.iter().any(|item| matches!(
3727            item,
3728            completion::AssistantContent::Image(message::Image {
3729                data: message::DocumentSourceKind::Url(url),
3730                media_type: None,
3731                additional_params: Some(_),
3732                ..
3733            }) if url == "https://example.com/generated.png"
3734        )));
3735        assert!(
3736            items.iter().any(|item| matches!(
3737                item,
3738                completion::AssistantContent::Image(image)
3739                    if is_openrouter_response_image(image)
3740            )),
3741            "generated URL images should be marked as OpenRouter response-only artifacts"
3742        );
3743    }
3744
3745    #[test]
3746    fn test_generated_images_do_not_break_assistant_history_conversion() {
3747        let generated_image = response_image_to_assistant_content(&ResponseImage {
3748            image_url: ImageUrl {
3749                url: "data:image/png;base64,abc".to_string(),
3750                detail: None,
3751            },
3752        });
3753
3754        let content = OneOrMany::many(vec![
3755            completion::AssistantContent::text("Here is your image."),
3756            generated_image,
3757        ])
3758        .unwrap();
3759        let messages = assistant_contents_to_messages(content).unwrap();
3760
3761        assert_eq!(messages.len(), 1);
3762        assert!(matches!(
3763            &messages[0],
3764            Message::Assistant { content, .. }
3765                if content == &vec![openai::AssistantContent::Text {
3766                    text: "Here is your image.".to_string()
3767                }]
3768        ));
3769    }
3770
3771    #[test]
3772    fn test_image_only_assistant_history_is_omitted_for_openrouter() {
3773        let generated_image = response_image_to_assistant_content(&ResponseImage {
3774            image_url: ImageUrl {
3775                url: "data:image/png;base64,abc".to_string(),
3776                detail: None,
3777            },
3778        });
3779
3780        let messages = assistant_contents_to_messages(OneOrMany::one(generated_image)).unwrap();
3781
3782        assert!(
3783            messages.is_empty(),
3784            "response-only generated image turns should not be replayed as assistant content"
3785        );
3786    }
3787
3788    #[test]
3789    fn test_unmarked_assistant_image_history_errors_for_openrouter() {
3790        let image = completion::AssistantContent::image_base64(
3791            "abc",
3792            Some(message::ImageMediaType::PNG),
3793            None,
3794        );
3795
3796        let err = assistant_contents_to_messages(OneOrMany::one(image)).unwrap_err();
3797
3798        match err {
3799            message::MessageError::ConversionError(message) => assert!(
3800                message.contains("OpenRouter does not support assistant image content"),
3801                "unexpected error: {message}"
3802            ),
3803        }
3804    }
3805
3806    #[test]
3807    fn test_mixed_text_and_generated_image_replays_text_only_for_openrouter() {
3808        let generated_image = response_image_to_assistant_content(&ResponseImage {
3809            image_url: ImageUrl {
3810                url: "https://example.com/generated.png".to_string(),
3811                detail: None,
3812            },
3813        });
3814
3815        let messages = assistant_contents_to_messages(
3816            OneOrMany::many(vec![
3817                completion::AssistantContent::text("Keep this text."),
3818                generated_image,
3819            ])
3820            .unwrap(),
3821        )
3822        .unwrap();
3823
3824        let serialized = serde_json::to_value(&messages).unwrap();
3825        assert_eq!(
3826            serialized,
3827            json!([{
3828                "role": "assistant",
3829                "content": [{"type": "text", "text": "Keep this text."}]
3830            }])
3831        );
3832    }
3833
3834    #[test]
3835    fn test_assistant_images_not_serialized_in_request() {
3836        let msg = Message::Assistant {
3837            content: vec!["Hello".to_string().into()],
3838            refusal: None,
3839            audio: None,
3840            name: None,
3841            tool_calls: vec![],
3842            reasoning: None,
3843            reasoning_details: vec![],
3844            images: vec![ResponseImage {
3845                image_url: ImageUrl {
3846                    url: "data:image/png;base64,abc".to_string(),
3847                    detail: None,
3848                },
3849            }],
3850        };
3851        let serialized = serde_json::to_value(&msg).unwrap();
3852        assert!(
3853            serialized.get("images").is_none(),
3854            "images field must not appear in serialized assistant message"
3855        );
3856    }
3857}