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    completion::{self, CompletionError, CompletionRequest},
6    json_utils,
7    providers::internal::openai_chat_completions_compatible::map_openai_finish_reason,
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/// Stable descriptor name recorded on telemetry spans and on every normalized
27/// response produced by this provider.
28pub(crate) const PROVIDER_NAME: &str = "openrouter";
29
30// ================================================================
31// Provider Selection and Prioritization
32// ================================================================
33// See: https://openrouter.ai/docs/guides/routing/provider-selection
34
35/// Data collection policy for providers.
36///
37/// Controls whether providers are allowed to collect and store request data.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
39#[serde(rename_all = "lowercase")]
40pub enum DataCollection {
41    /// Allow providers that may collect data (default)
42    #[default]
43    Allow,
44    /// Restrict routing to providers that do not store user data non-transiently
45    Deny,
46}
47
48/// Model quantization levels supported by OpenRouter.
49///
50/// Restrict routing to providers serving a specific quantization level.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum Quantization {
54    /// 4-bit integer quantization
55    #[serde(rename = "int4")]
56    Int4,
57    /// 8-bit integer quantization
58    #[serde(rename = "int8")]
59    Int8,
60    /// 16-bit floating point
61    #[serde(rename = "fp16")]
62    Fp16,
63    /// Brain floating point 16-bit
64    #[serde(rename = "bf16")]
65    Bf16,
66    /// 32-bit floating point (full precision)
67    #[serde(rename = "fp32")]
68    Fp32,
69    /// 8-bit floating point
70    #[serde(rename = "fp8")]
71    Fp8,
72    /// Unknown or custom quantization level
73    #[serde(rename = "unknown")]
74    Unknown,
75}
76
77/// Simple sorting strategy for providers.
78///
79/// Determines how providers should be prioritized when multiple are available.
80/// If you set `sort`, default load balancing is disabled and providers are tried
81/// deterministically in the resulting order.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "lowercase")]
84pub enum ProviderSortStrategy {
85    /// Sort by price (cheapest first)
86    Price,
87    /// Sort by throughput (higher tokens/sec first)
88    Throughput,
89    /// Sort by latency (lower latency first)
90    Latency,
91}
92
93/// Partition strategy for multi-model requests.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "lowercase")]
96pub enum SortPartition {
97    /// Sort providers within each model group (default)
98    Model,
99    /// Sort providers globally across all models
100    None,
101}
102
103/// Complex sorting configuration with partition support.
104///
105/// For multi-model requests, allows control over how providers are sorted.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct ProviderSortConfig {
108    /// Sorting strategy
109    pub by: ProviderSortStrategy,
110
111    /// Partition strategy (optional)
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub partition: Option<SortPartition>,
114}
115
116impl ProviderSortConfig {
117    /// Create a new sort config with the given strategy
118    pub fn new(by: ProviderSortStrategy) -> Self {
119        Self {
120            by,
121            partition: None,
122        }
123    }
124
125    /// Set partition strategy for multi-model requests
126    pub fn partition(mut self, partition: SortPartition) -> Self {
127        self.partition = Some(partition);
128        self
129    }
130}
131
132/// Sort configuration - can be a simple string or a complex object.
133///
134/// Use `ProviderSort::Simple` for basic sorting, or `ProviderSort::Complex`
135/// for multi-model requests with partition control.
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137#[serde(untagged)]
138pub enum ProviderSort {
139    /// Simple sorting by a single strategy
140    Simple(ProviderSortStrategy),
141    /// Complex sorting with partition support
142    Complex(ProviderSortConfig),
143}
144
145impl From<ProviderSortStrategy> for ProviderSort {
146    fn from(strategy: ProviderSortStrategy) -> Self {
147        ProviderSort::Simple(strategy)
148    }
149}
150
151impl From<ProviderSortConfig> for ProviderSort {
152    fn from(config: ProviderSortConfig) -> Self {
153        ProviderSort::Complex(config)
154    }
155}
156
157/// Throughput threshold configuration with percentile support.
158///
159/// Endpoints not meeting the threshold are deprioritized (moved later), not excluded.
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161#[serde(untagged)]
162pub enum ThroughputThreshold {
163    /// Simple threshold in tokens/sec
164    Simple(f64),
165    /// Percentile-based thresholds
166    Percentile(PercentileThresholds),
167}
168
169/// Latency threshold configuration with percentile support.
170///
171/// Endpoints not meeting the threshold are deprioritized, not excluded.
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173#[serde(untagged)]
174pub enum LatencyThreshold {
175    /// Simple threshold in seconds
176    Simple(f64),
177    /// Percentile-based thresholds
178    Percentile(PercentileThresholds),
179}
180
181/// Percentile-based thresholds for throughput or latency.
182#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
183pub struct PercentileThresholds {
184    /// 50th percentile threshold
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub p50: Option<f64>,
187    /// 75th percentile threshold
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub p75: Option<f64>,
190    /// 90th percentile threshold
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub p90: Option<f64>,
193    /// 99th percentile threshold
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub p99: Option<f64>,
196}
197
198impl PercentileThresholds {
199    /// Create new empty percentile thresholds
200    pub fn new() -> Self {
201        Self::default()
202    }
203
204    /// Set p50 threshold
205    pub fn p50(mut self, value: f64) -> Self {
206        self.p50 = Some(value);
207        self
208    }
209
210    /// Set p75 threshold
211    pub fn p75(mut self, value: f64) -> Self {
212        self.p75 = Some(value);
213        self
214    }
215
216    /// Set p90 threshold
217    pub fn p90(mut self, value: f64) -> Self {
218        self.p90 = Some(value);
219        self
220    }
221
222    /// Set p99 threshold
223    pub fn p99(mut self, value: f64) -> Self {
224        self.p99 = Some(value);
225        self
226    }
227}
228
229/// Maximum price configuration for hard ceiling on costs.
230///
231/// If no eligible provider is at or under the ceiling, the request fails.
232/// Units are OpenRouter pricing units (e.g., dollars per million tokens).
233#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
234pub struct MaxPrice {
235    /// Maximum price per prompt token
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub prompt: Option<f64>,
238    /// Maximum price per completion token
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub completion: Option<f64>,
241    /// Maximum price per request
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub request: Option<f64>,
244    /// Maximum price per image
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub image: Option<f64>,
247}
248
249impl MaxPrice {
250    /// Create new empty max price config
251    pub fn new() -> Self {
252        Self::default()
253    }
254
255    /// Set maximum price per prompt token
256    pub fn prompt(mut self, price: f64) -> Self {
257        self.prompt = Some(price);
258        self
259    }
260
261    /// Set maximum price per completion token
262    pub fn completion(mut self, price: f64) -> Self {
263        self.completion = Some(price);
264        self
265    }
266
267    /// Set maximum price per request
268    pub fn request(mut self, price: f64) -> Self {
269        self.request = Some(price);
270        self
271    }
272
273    /// Set maximum price per image
274    pub fn image(mut self, price: f64) -> Self {
275        self.image = Some(price);
276        self
277    }
278}
279
280/// Provider preferences for OpenRouter routing.
281///
282/// This struct allows you to control which providers are used and how they are prioritized
283/// when making requests through OpenRouter.
284///
285/// See: <https://openrouter.ai/docs/guides/routing/provider-selection>
286///
287/// # Example
288///
289/// ```rust
290/// use rig_core::providers::openrouter::{ProviderPreferences, ProviderSortStrategy, Quantization};
291///
292/// // Create preferences for zero data retention providers, sorted by throughput
293/// let prefs = ProviderPreferences::new()
294///     .sort(ProviderSortStrategy::Throughput)
295///     .zdr(true)
296///     .quantizations([Quantization::Int8])
297///     .only(["anthropic", "openai"]);
298/// ```
299#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
300pub struct ProviderPreferences {
301    // === Provider Selection Controls ===
302    /// Try these provider slugs in the given order first.
303    /// If `allow_fallbacks: true`, OpenRouter may try other providers after this list is exhausted.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub order: Option<Vec<String>>,
306
307    /// Hard allowlist. Only these provider slugs are eligible.
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub only: Option<Vec<String>>,
310
311    /// Blocklist. These provider slugs are never used.
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub ignore: Option<Vec<String>>,
314
315    /// If `false`, the router will not use any providers outside what your constraints permit.
316    /// Default is `true`.
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub allow_fallbacks: Option<bool>,
319
320    // === Compatibility and Policy Filters ===
321    /// If `true`, only route to providers that support all parameters in your request.
322    ///
323    /// This is recommended for structured outputs so OpenRouter only selects
324    /// providers that support the generated `response_format` parameter.
325    /// Default is `false`.
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub require_parameters: Option<bool>,
328
329    /// Data collection policy. If [`DataCollection::Deny`], restrict routing to providers
330    /// that do not store user data non-transiently. Default is [`DataCollection::Allow`].
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub data_collection: Option<DataCollection>,
333
334    /// If `true`, restrict routing to Zero Data Retention endpoints only.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub zdr: Option<bool>,
337
338    // === Performance and Cost Preferences ===
339    /// Sorting strategy. Affects ordering, not strict exclusion.
340    /// If set, default load balancing is disabled.
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub sort: Option<ProviderSort>,
343
344    /// Throughput threshold. Endpoints not meeting the threshold are deprioritized.
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub preferred_min_throughput: Option<ThroughputThreshold>,
347
348    /// Latency threshold. Endpoints not meeting the threshold are deprioritized.
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub preferred_max_latency: Option<LatencyThreshold>,
351
352    /// Hard price ceiling. If no provider is at or under, the request fails.
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub max_price: Option<MaxPrice>,
355
356    // === Quantization Filter ===
357    /// Restrict routing to providers serving specific quantization levels.
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub quantizations: Option<Vec<Quantization>>,
360}
361
362impl ProviderPreferences {
363    /// Create a new empty provider preferences struct
364    pub fn new() -> Self {
365        Self::default()
366    }
367
368    // === Provider Selection Controls ===
369
370    /// Try these provider slugs in the given order first.
371    ///
372    /// If `allow_fallbacks` is true (default), OpenRouter may try other providers
373    /// after this list is exhausted.
374    ///
375    /// # Example
376    ///
377    /// ```rust
378    /// use rig_core::providers::openrouter::ProviderPreferences;
379    ///
380    /// let prefs = ProviderPreferences::new()
381    ///     .order(["anthropic", "openai"]);
382    /// ```
383    pub fn order(mut self, providers: impl IntoIterator<Item = impl Into<String>>) -> Self {
384        self.order = Some(providers.into_iter().map(|p| p.into()).collect());
385        self
386    }
387
388    /// Hard allowlist. Only these provider slugs are eligible.
389    ///
390    /// # Example
391    ///
392    /// ```rust
393    /// use rig_core::providers::openrouter::ProviderPreferences;
394    ///
395    /// let prefs = ProviderPreferences::new()
396    ///     .only(["azure", "together"])
397    ///     .allow_fallbacks(false);
398    /// ```
399    pub fn only(mut self, providers: impl IntoIterator<Item = impl Into<String>>) -> Self {
400        self.only = Some(providers.into_iter().map(|p| p.into()).collect());
401        self
402    }
403
404    /// Blocklist. These provider slugs are never used.
405    ///
406    /// # Example
407    ///
408    /// ```rust
409    /// use rig_core::providers::openrouter::ProviderPreferences;
410    ///
411    /// let prefs = ProviderPreferences::new()
412    ///     .ignore(["deepinfra"]);
413    /// ```
414    pub fn ignore(mut self, providers: impl IntoIterator<Item = impl Into<String>>) -> Self {
415        self.ignore = Some(providers.into_iter().map(|p| p.into()).collect());
416        self
417    }
418
419    /// Control whether fallbacks are allowed.
420    ///
421    /// If `false`, the router will not use any providers outside what your constraints permit.
422    /// Default is `true`.
423    pub fn allow_fallbacks(mut self, allow: bool) -> Self {
424        self.allow_fallbacks = Some(allow);
425        self
426    }
427
428    // === Compatibility and Policy Filters ===
429
430    /// If `true`, only route to providers that support all parameters in your request.
431    ///
432    /// Default is `false`, meaning providers may ignore unsupported parameters.
433    pub fn require_parameters(mut self, require: bool) -> Self {
434        self.require_parameters = Some(require);
435        self
436    }
437
438    /// Set data collection policy.
439    ///
440    /// If `Deny`, restrict routing to providers that do not store user data non-transiently.
441    pub fn data_collection(mut self, policy: DataCollection) -> Self {
442        self.data_collection = Some(policy);
443        self
444    }
445
446    /// If `true`, restrict routing to Zero Data Retention endpoints only.
447    ///
448    /// # Example
449    ///
450    /// ```rust
451    /// use rig_core::providers::openrouter::ProviderPreferences;
452    ///
453    /// let prefs = ProviderPreferences::new()
454    ///     .zdr(true);
455    /// ```
456    pub fn zdr(mut self, enable: bool) -> Self {
457        self.zdr = Some(enable);
458        self
459    }
460
461    // === Performance and Cost Preferences ===
462
463    /// Set the sorting strategy for providers.
464    ///
465    /// If set, default load balancing is disabled and providers are tried
466    /// deterministically in the resulting order.
467    ///
468    /// # Example
469    ///
470    /// ```rust
471    /// use rig_core::providers::openrouter::{ProviderPreferences, ProviderSortStrategy};
472    ///
473    /// let prefs = ProviderPreferences::new()
474    ///     .sort(ProviderSortStrategy::Latency);
475    /// ```
476    pub fn sort(mut self, sort: impl Into<ProviderSort>) -> Self {
477        self.sort = Some(sort.into());
478        self
479    }
480
481    /// Set preferred minimum throughput threshold.
482    ///
483    /// Endpoints not meeting the threshold are deprioritized (moved later), not excluded.
484    ///
485    /// # Example
486    ///
487    /// ```rust
488    /// use rig_core::providers::openrouter::{ProviderPreferences, ThroughputThreshold, PercentileThresholds};
489    ///
490    /// // Simple threshold
491    /// let prefs = ProviderPreferences::new()
492    ///     .preferred_min_throughput(ThroughputThreshold::Simple(50.0));
493    ///
494    /// // Percentile threshold
495    /// let prefs = ProviderPreferences::new()
496    ///     .preferred_min_throughput(ThroughputThreshold::Percentile(
497    ///         PercentileThresholds::new().p90(50.0)
498    ///     ));
499    /// ```
500    pub fn preferred_min_throughput(mut self, threshold: ThroughputThreshold) -> Self {
501        self.preferred_min_throughput = Some(threshold);
502        self
503    }
504
505    /// Set preferred maximum latency threshold.
506    ///
507    /// Endpoints not meeting the threshold are deprioritized, not excluded.
508    pub fn preferred_max_latency(mut self, threshold: LatencyThreshold) -> Self {
509        self.preferred_max_latency = Some(threshold);
510        self
511    }
512
513    /// Set maximum price ceiling.
514    ///
515    /// If no eligible provider is at or under the ceiling, the request fails.
516    pub fn max_price(mut self, price: MaxPrice) -> Self {
517        self.max_price = Some(price);
518        self
519    }
520
521    // === Quantization Filter ===
522
523    /// Restrict routing to providers serving specific quantization levels.
524    ///
525    /// # Example
526    ///
527    /// ```rust
528    /// use rig_core::providers::openrouter::{ProviderPreferences, Quantization};
529    ///
530    /// let prefs = ProviderPreferences::new()
531    ///     .quantizations([Quantization::Int8, Quantization::Fp16]);
532    /// ```
533    pub fn quantizations(mut self, quantizations: impl IntoIterator<Item = Quantization>) -> Self {
534        self.quantizations = Some(quantizations.into_iter().collect());
535        self
536    }
537
538    // === Convenience Methods ===
539
540    /// Convenience: Enable Zero Data Retention
541    pub fn zero_data_retention(self) -> Self {
542        self.zdr(true)
543    }
544
545    /// Convenience: Sort by throughput (higher tokens/sec first)
546    pub fn fastest(self) -> Self {
547        self.sort(ProviderSortStrategy::Throughput)
548    }
549
550    /// Convenience: Sort by price (cheapest first)
551    pub fn cheapest(self) -> Self {
552        self.sort(ProviderSortStrategy::Price)
553    }
554
555    /// Convenience: Sort by latency (lower latency first)
556    pub fn lowest_latency(self) -> Self {
557        self.sort(ProviderSortStrategy::Latency)
558    }
559
560    /// Convert to JSON value for use in additional_params
561    pub fn to_json(&self) -> serde_json::Value {
562        serde_json::json!({
563            "provider": self
564        })
565    }
566}
567
568fn deserialize_openrouter_choices_dropping_incomplete_tool_calls<'de, D>(
569    deserializer: D,
570) -> Result<Vec<Choice>, D::Error>
571where
572    D: serde::Deserializer<'de>,
573{
574    crate::providers::internal::openai_chat_completions_compatible::deserialize_choices_dropping_incomplete_tool_calls_when(
575        deserializer,
576        |choice| {
577            let normalized = choice
578                .get("finish_reason")
579                .and_then(serde_json::Value::as_str)
580                .filter(|reason| !reason.is_empty());
581            if let Some(reason) = normalized {
582                return matches!(map_openai_finish_reason(reason), completion::FinishReason::Length);
583            }
584
585            choice
586                .get("native_finish_reason")
587                .and_then(serde_json::Value::as_str)
588                .filter(|reason| !reason.is_empty())
589                .is_some_and(|reason| {
590                    matches!(map_native_finish_reason(reason), completion::FinishReason::Length)
591                })
592        },
593    )
594}
595
596/// A openrouter completion object.
597///
598/// For more information, see the
599/// [OpenRouter Chat Completions reference](https://openrouter.ai/docs/api/api-reference/chat/create-a-chat-completion).
600#[derive(Clone, Debug, Serialize, Deserialize)]
601pub struct CompletionResponse {
602    pub id: String,
603    pub object: String,
604    pub created: u64,
605    pub model: String,
606    #[serde(deserialize_with = "deserialize_openrouter_choices_dropping_incomplete_tool_calls")]
607    pub choices: Vec<Choice>,
608    pub system_fingerprint: Option<String>,
609    /// Upstream provider selected by OpenRouter for this response.
610    #[serde(default, skip_serializing_if = "Option::is_none")]
611    pub provider: Option<String>,
612    /// Service tier reported by the routed provider, when present.
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub service_tier: Option<String>,
615    pub usage: Option<Usage>,
616}
617
618/// Normalize OpenRouter's terminal reason for a choice.
619///
620/// OpenRouter reports two fields: `finish_reason`, normalized by OpenRouter to
621/// the OpenAI Chat Completions vocabulary, and `native_finish_reason`, which is
622/// whatever the upstream provider said (e.g. Gemini's `"STOP"`). The normalized
623/// field wins; the native one is only consulted when OpenRouter omitted the
624/// normalized field, so a reason the gateway could not translate is still
625/// reported rather than lost. The native value must not be read with the OpenAI
626/// vocabulary — routing Gemini's `STOP` or Anthropic's `end_turn` through
627/// [`map_openai_finish_reason`] would report a plain natural stop as
628/// [`completion::FinishReason::Other`]. Either way an unrecognized value is
629/// preserved verbatim in [`FinishReason::Other`](completion::FinishReason::Other).
630pub(crate) fn map_finish_reason(choice: &Choice) -> Option<completion::FinishReason> {
631    if let Some(reason) = choice
632        .finish_reason
633        .as_deref()
634        .filter(|reason| !reason.is_empty())
635    {
636        return Some(map_openai_finish_reason(reason));
637    }
638
639    choice
640        .native_finish_reason
641        .as_deref()
642        .filter(|reason| !reason.is_empty())
643        .map(map_native_finish_reason)
644}
645
646/// Map an upstream provider's own terminal reason, as forwarded by OpenRouter.
647///
648/// This covers the vocabularies OpenRouter routes to, matched
649/// case-insensitively because they disagree on casing (Gemini screams,
650/// Anthropic does not). Anything unrecognized is still carried verbatim in the
651/// spelling the upstream provider used.
652pub(crate) fn map_native_finish_reason(reason: &str) -> completion::FinishReason {
653    match reason.to_ascii_lowercase().as_str() {
654        // OpenAI-compatible upstreams, plus Anthropic's `end_turn`/`stop_sequence`
655        // and Gemini's `STOP`.
656        "stop" | "end_turn" | "stop_sequence" | "complete" | "completed" => {
657            completion::FinishReason::Stop
658        }
659        "length" | "max_tokens" | "max_output_tokens" | "model_length" => {
660            completion::FinishReason::Length
661        }
662        "tool_calls" | "function_call" | "tool_use" => completion::FinishReason::ToolCalls,
663        "content_filter" | "safety" | "blocklist" | "prohibited_content" | "spii" => {
664            completion::FinishReason::ContentFilter
665        }
666        _ => completion::FinishReason::Other(reason.to_owned()),
667    }
668}
669
670/// Normalize an OpenRouter chat completion response.
671///
672/// The provider descriptor name is an *input* because the message model is the
673/// shared OpenAI one; taking it as part of the conversion keeps the shape
674/// consistent with the OpenAI-compatible path even though only OpenRouter
675/// produces this envelope.
676impl crate::completion::NormalizeCompletionResponse for CompletionResponse {
677    fn normalize(self, provider: &str) -> Result<completion::CompletionResponse, CompletionError> {
678        let response = self;
679        let choice = response.choices.first().ok_or_else(|| {
680            CompletionError::ResponseError("Response contained no choices".to_owned())
681        })?;
682        let finish_reason = map_finish_reason(choice);
683
684        let content = match &choice.message {
685            Message::Assistant {
686                content: message_content,
687                tool_calls,
688                reasoning,
689                reasoning_details,
690                images,
691                refusal,
692                ..
693            } => {
694                // A structured-output refusal arrives as a *sibling* of
695                // `content` (`{"content": null, "refusal": "…"}`), not as the
696                // `refusal` content part below — that spelling belongs to the
697                // Responses API, which this wire never sends. Reading only
698                // `content` dropped the refusal outright and normalized the
699                // turn to nothing, while `get_text_response` already fell back
700                // to the field and the streaming path already delivered it.
701                // The rule is shared with the OpenAI chat path so no two
702                // readers of this wire can disagree about it (#2332).
703                let refusal_fallback = openai::completion::assistant_refusal_fallback(
704                    message_content,
705                    refusal.as_deref(),
706                );
707
708                // Match the shared streaming adapter's canonical turn order:
709                // the model reasons, speaks, then acts. OpenRouter may place
710                // reasoning beside text and tool calls in one blocking
711                // message, so appending it after the calls made blocking and
712                // streaming histories disagree for the same provider turn.
713                let mut normalized_content = Vec::new();
714                let mut grouped_reasoning: HashMap<
715                    Option<String>,
716                    Vec<(usize, usize, message::ReasoningContent)>,
717                > = HashMap::new();
718                let mut reasoning_order: Vec<Option<String>> = Vec::new();
719                for (position, detail) in reasoning_details.iter().enumerate() {
720                    let (reasoning_id, sort_index, parsed_content) = match detail {
721                        ReasoningDetails::Summary {
722                            id, index, summary, ..
723                        } => (
724                            id.clone(),
725                            *index,
726                            Some(message::ReasoningContent::Summary(summary.clone())),
727                        ),
728                        ReasoningDetails::Encrypted {
729                            id, index, data, ..
730                        } => (
731                            id.clone(),
732                            *index,
733                            Some(message::ReasoningContent::Encrypted(data.clone())),
734                        ),
735                        ReasoningDetails::Text {
736                            id,
737                            index,
738                            text,
739                            signature,
740                            ..
741                        } => (
742                            id.clone(),
743                            *index,
744                            text.as_ref().map(|text| message::ReasoningContent::Text {
745                                text: text.clone(),
746                                signature: signature.clone(),
747                            }),
748                        ),
749                    };
750
751                    let Some(parsed_content) = parsed_content else {
752                        continue;
753                    };
754                    let sort_index = sort_index.unwrap_or(position);
755
756                    let entry = grouped_reasoning.entry(reasoning_id.clone());
757                    if matches!(entry, std::collections::hash_map::Entry::Vacant(_)) {
758                        reasoning_order.push(reasoning_id);
759                    }
760                    entry
761                        .or_default()
762                        .push((sort_index, position, parsed_content));
763                }
764
765                if grouped_reasoning.is_empty() {
766                    if let Some(reasoning) = reasoning {
767                        normalized_content.push(completion::AssistantContent::reasoning(reasoning));
768                    }
769                } else {
770                    for reasoning_id in reasoning_order {
771                        let Some(mut blocks) = grouped_reasoning.remove(&reasoning_id) else {
772                            continue;
773                        };
774                        blocks.sort_by_key(|(index, position, _)| (*index, *position));
775                        normalized_content.push(completion::AssistantContent::Reasoning(
776                            message::Reasoning {
777                                id: reasoning_id,
778                                content: blocks
779                                    .into_iter()
780                                    .map(|(_, _, content)| content)
781                                    .collect::<Vec<_>>(),
782                            },
783                        ));
784                    }
785                }
786
787                normalized_content.extend(message_content.iter().map(|part| match part {
788                    openai::AssistantContent::Text { text, .. } => {
789                        completion::AssistantContent::text(text)
790                    }
791                    openai::AssistantContent::Refusal { refusal } => {
792                        completion::AssistantContent::text(refusal)
793                    }
794                }));
795
796                if let Some(refusal) = refusal_fallback {
797                    normalized_content.push(completion::AssistantContent::text(refusal));
798                }
799
800                normalized_content.extend(tool_calls.iter().map(|call| {
801                    completion::AssistantContent::tool_call(
802                        &call.id,
803                        &call.function.name,
804                        call.function.arguments.clone(),
805                    )
806                }));
807
808                normalized_content.extend(images.iter().map(response_image_to_assistant_content));
809
810                Ok(normalized_content)
811            }
812            _ => Err(CompletionError::ResponseError(
813                "Response did not contain a valid message or tool call".into(),
814            )),
815        }?;
816
817        // A provider-truncated turn can legitimately have no surviving
818        // content (for example, its only tool call was cut off before the
819        // first usable argument token). Preserve the terminal diagnostic and
820        // metadata exactly as the shared OpenAI-compatible normalizer does;
821        // completed empty turns remain errors.
822        let choice = match &finish_reason {
823            Some(reason) if reason.truncated_output() => content,
824            _ => crate::message::require_non_empty_response(content)?,
825        };
826
827        let usage = response
828            .usage
829            .as_ref()
830            .map(completion::Usage::from)
831            .unwrap_or_default();
832
833        Ok(
834            // OpenRouter's `id` identifies the generation, not an assistant
835            // message, so it is carried as a response ID rather than a
836            // message ID.
837            completion::CompletionResponse::new(choice, usage, provider)
838                .with_response_id(response.id)
839                .with_model(response.model)
840                .with_optional_finish_reason(finish_reason),
841        )
842    }
843}
844
845impl ProviderResponseExt for CompletionResponse {
846    type Usage = Usage;
847
848    fn get_response_id(&self) -> Option<String> {
849        Some(self.id.clone())
850    }
851
852    fn get_response_model_name(&self) -> Option<String> {
853        Some(self.model.clone())
854    }
855
856    fn get_text_response(&self) -> Option<String> {
857        let response = self
858            .choices
859            .iter()
860            .filter_map(|choice| {
861                openai::completion::assistant_message_text_response(&choice.message)
862            })
863            .collect::<Vec<_>>()
864            .join("\n");
865
866        (!response.is_empty()).then_some(response)
867    }
868
869    fn get_usage(&self) -> Option<Self::Usage> {
870        self.usage.clone()
871    }
872}
873
874// OpenRouter shares OpenAI's Chat Completions message model. The request and
875// response *message* types are the shared OpenAI ones; only OpenRouter's
876// response envelope, provider routing preferences, and the conversion rules
877// below are provider-specific.
878pub use crate::providers::openai::completion::{
879    FileData, ImageUrl, Message, ReasoningDetails, ResponseImage, UserContent, VideoUrl,
880};
881
882const OPENROUTER_RESPONSE_ONLY_KEY: &str = "response_only";
883const OPENROUTER_RESPONSE_IMAGE_SOURCE_KEY: &str = "source";
884const OPENROUTER_ASSISTANT_IMAGES_SOURCE: &str = "assistant.images";
885
886/// Split a `data:<mime>;base64,<payload>` URI into `(mime, payload)`.
887/// Returns `None` for plain URLs or non-base64 data URIs.
888fn parse_data_uri(url: &str) -> Option<(&str, &str)> {
889    url.strip_prefix("data:")?.split_once(";base64,")
890}
891
892fn openrouter_response_image_params() -> Option<message::AdditionalParams> {
893    message::AdditionalParams::from_entries([(
894        "openrouter",
895        serde_json::json!({
896            OPENROUTER_RESPONSE_ONLY_KEY: true,
897            OPENROUTER_RESPONSE_IMAGE_SOURCE_KEY: OPENROUTER_ASSISTANT_IMAGES_SOURCE,
898        }),
899    )])
900}
901
902fn response_image_to_assistant_content(image: &ResponseImage) -> completion::AssistantContent {
903    let url = &image.image_url.url;
904    if let Some((mime, b64)) = parse_data_uri(url) {
905        completion::AssistantContent::Image(message::Image {
906            data: message::DocumentSourceKind::Base64(b64.to_string()),
907            media_type: message::ImageMediaType::from_mime_type(mime),
908            detail: None,
909            additional_params: openrouter_response_image_params(),
910        })
911    } else {
912        completion::AssistantContent::Image(message::Image {
913            data: message::DocumentSourceKind::Url(url.clone()),
914            media_type: None,
915            detail: None,
916            additional_params: openrouter_response_image_params(),
917        })
918    }
919}
920
921fn is_openrouter_response_image(image: &message::Image) -> bool {
922    image
923        .additional_params
924        .as_ref()
925        .and_then(|params| params.wire_extras("openrouter"))
926        .is_some_and(|params| {
927            params
928                .get(OPENROUTER_RESPONSE_ONLY_KEY)
929                .and_then(|value| value.as_bool())
930                .unwrap_or(false)
931                && params
932                    .get(OPENROUTER_RESPONSE_IMAGE_SOURCE_KEY)
933                    .and_then(|value| value.as_str())
934                    == Some(OPENROUTER_ASSISTANT_IMAGES_SOURCE)
935        })
936}
937
938/// Convert rig user content into OpenRouter's OpenAI-compatible content parts.
939///
940/// OpenRouter shares OpenAI's content schema but keeps its own conversion
941/// rules:
942/// - image `detail` passes through unchanged, so an absent detail stays
943///   absent on the wire,
944/// - documents accept URLs and non-PDF media types via `file_data`, while
945///   provider file IDs are rejected,
946/// - audio requires an explicit media type instead of defaulting to MP3.
947///
948/// Text and video content use the shared OpenAI conversion.
949fn user_content_to_openai(
950    value: message::UserContent,
951) -> Result<UserContent, message::MessageError> {
952    match value {
953        message::UserContent::Image(message::Image {
954            data,
955            detail,
956            media_type,
957            ..
958        }) => {
959            let url = match data {
960                DocumentSourceKind::Url(url) => url,
961                DocumentSourceKind::Base64(data) => {
962                    let mime = media_type
963                        .ok_or_else(|| {
964                            message::MessageError::ConversionError(
965                                "Image media type required for base64 encoding".into(),
966                            )
967                        })?
968                        .to_mime_type();
969                    format!("data:{mime};base64,{data}")
970                }
971                DocumentSourceKind::Raw(_) => {
972                    return Err(message::MessageError::ConversionError(
973                        "Raw bytes not supported, encode as base64 first".into(),
974                    ));
975                }
976                DocumentSourceKind::FileId(_) => {
977                    return Err(message::MessageError::ConversionError(
978                        "File IDs are not supported for images".into(),
979                    ));
980                }
981                DocumentSourceKind::String(_) => {
982                    return Err(message::MessageError::ConversionError(
983                        "String source not supported for images".into(),
984                    ));
985                }
986                DocumentSourceKind::Unknown => {
987                    return Err(message::MessageError::ConversionError(
988                        "Image has no data".into(),
989                    ));
990                }
991            };
992            Ok(UserContent::Image {
993                image_url: ImageUrl { url, detail },
994            })
995        }
996
997        message::UserContent::Document(message::Document {
998            data, media_type, ..
999        }) => match data {
1000            DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
1001                "Provider file IDs are not supported for OpenRouter document inputs".into(),
1002            )),
1003            DocumentSourceKind::Url(url) => Ok(UserContent::File {
1004                file: FileData {
1005                    file_data: Some(url),
1006                    file_id: None,
1007                    filename: document_filename(media_type.as_ref()),
1008                },
1009            }),
1010            DocumentSourceKind::Base64(data) => {
1011                let mime = media_type
1012                    .as_ref()
1013                    .map(|m| m.to_mime_type())
1014                    .unwrap_or("application/pdf");
1015                let data_uri = format!("data:{mime};base64,{data}");
1016
1017                Ok(UserContent::File {
1018                    file: FileData {
1019                        file_data: Some(data_uri),
1020                        file_id: None,
1021                        filename: document_filename(media_type.as_ref()),
1022                    },
1023                })
1024            }
1025            DocumentSourceKind::String(text) => Ok(UserContent::Text { text }),
1026            DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
1027                "Raw bytes not supported for documents, encode as base64 first".into(),
1028            )),
1029            DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
1030                "Document has no data".into(),
1031            )),
1032        },
1033
1034        message::UserContent::Audio(message::Audio {
1035            data, media_type, ..
1036        }) => match data {
1037            DocumentSourceKind::Base64(data) => {
1038                let format = media_type.ok_or_else(|| {
1039                    message::MessageError::ConversionError(
1040                        "Audio media type required for base64 encoding".into(),
1041                    )
1042                })?;
1043                Ok(UserContent::Audio {
1044                    input_audio: openai::InputAudio { data, format },
1045                })
1046            }
1047            DocumentSourceKind::Url(_) => Err(message::MessageError::ConversionError(
1048                "OpenRouter does not support audio URLs, encode as base64 first".into(),
1049            )),
1050            DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
1051                "Raw bytes not supported for audio, encode as base64 first".into(),
1052            )),
1053            DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
1054                "File IDs are not supported for audio".into(),
1055            )),
1056            DocumentSourceKind::String(_) => Err(message::MessageError::ConversionError(
1057                "String source not supported for audio".into(),
1058            )),
1059            DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
1060                "Audio has no data".into(),
1061            )),
1062        },
1063
1064        message::UserContent::ToolResult(_) => Err(message::MessageError::ConversionError(
1065            "Tool results should be handled as separate messages".into(),
1066        )),
1067
1068        // Text and video conversions are identical to the shared OpenAI ones.
1069        value => UserContent::try_from(value),
1070    }
1071}
1072
1073fn document_filename(media_type: Option<&DocumentMediaType>) -> Option<String> {
1074    media_type.map(|mt| {
1075        match mt {
1076            DocumentMediaType::PDF => "document.pdf",
1077            DocumentMediaType::TXT => "document.txt",
1078            DocumentMediaType::HTML => "document.html",
1079            DocumentMediaType::MARKDOWN => "document.md",
1080            DocumentMediaType::CSV => "document.csv",
1081            DocumentMediaType::XML => "document.xml",
1082            _ => "document",
1083        }
1084        .to_string()
1085    })
1086}
1087
1088fn user_contents_to_messages(
1089    value: Vec<message::UserContent>,
1090) -> Result<Vec<Message>, message::MessageError> {
1091    fn flush_user_content(messages: &mut Vec<Message>, pending: &mut Vec<UserContent>) {
1092        // An empty flush is a legal no-op — it fires between consecutive
1093        // tool-result groups — not a conversion error. This early return is
1094        // the only emptiness decision here; the pushed content is non-empty
1095        // because of it.
1096        if pending.is_empty() {
1097            return;
1098        }
1099
1100        messages.push(Message::User {
1101            content: std::mem::take(pending),
1102            name: None,
1103        });
1104    }
1105
1106    let mut messages = Vec::new();
1107    let mut pending = Vec::new();
1108
1109    for content in value {
1110        match content {
1111            message::UserContent::ToolResult(tool_result) => {
1112                flush_user_content(&mut messages, &mut pending);
1113                // Prefer the provider-issued call id, matching the
1114                // assistant echo (shared From<message::ToolCall>);
1115                // provider-less results fall back to rig's minted
1116                // handle — never empty.
1117                let tool_call_id = tool_result.wire_call_id().to_owned();
1118                let content = tool_result
1119                    .content
1120                    .into_iter()
1121                    .map(|content| match content {
1122                        message::ToolResultContent::Text(message::Text { text, .. }) => Ok(text),
1123                        message::ToolResultContent::Json { value } => Ok(value.to_string()),
1124                        message::ToolResultContent::Image(_) => {
1125                            Err(message::MessageError::ConversionError(
1126                                "OpenRouter does not support images in tool results".into(),
1127                            ))
1128                        }
1129                    })
1130                    .collect::<Result<Vec<_>, _>>()?
1131                    .join("\n");
1132                messages.push(Message::ToolResult {
1133                    tool_call_id,
1134                    content: openai::completion::ToolResultContentValue::String(content),
1135                });
1136            }
1137            content => pending.push(user_content_to_openai(content)?),
1138        }
1139    }
1140
1141    flush_user_content(&mut messages, &mut pending);
1142    Ok(messages)
1143}
1144
1145// ================================================================
1146// Response Types
1147// ================================================================
1148
1149#[derive(Clone, Debug, Deserialize, Serialize)]
1150pub struct Choice {
1151    pub index: usize,
1152    pub native_finish_reason: Option<String>,
1153    pub message: Message,
1154    pub finish_reason: Option<String>,
1155    /// Per-token probability metadata returned when `logprobs` is requested.
1156    ///
1157    /// Normalized completions intentionally omit provider-native
1158    /// probabilities; callers of `raw_completion` retain the complete object.
1159    #[serde(default, skip_serializing_if = "Option::is_none")]
1160    pub logprobs: Option<serde_json::Value>,
1161}
1162
1163#[derive(Debug, Deserialize, PartialEq, Clone)]
1164#[serde(untagged)]
1165enum ToolCallAdditionalParams {
1166    ReasoningDetails(ReasoningDetails),
1167    Minimal {
1168        id: Option<String>,
1169        format: Option<String>,
1170    },
1171}
1172
1173/// Replay assistant history — including structured reasoning — as OpenRouter
1174/// request messages.
1175///
1176/// Maps rig [`message::Reasoning`] blocks back onto the `reasoning_details`
1177/// field of the shared assistant message, and recovers reasoning metadata
1178/// stored on tool calls (signature / `additional_params`) so providers that
1179/// require reasoning to be echoed back on tool-call turns keep working.
1180fn assistant_contents_to_messages(
1181    value: Vec<message::AssistantContent>,
1182) -> Result<Vec<Message>, message::MessageError> {
1183    let mut text_content = Vec::new();
1184    let mut tool_calls = Vec::new();
1185    let mut reasoning = None;
1186    let mut reasoning_details = Vec::new();
1187
1188    for content in value.into_iter() {
1189        match content {
1190            message::AssistantContent::Text(text) => text_content.push(text),
1191            message::AssistantContent::ToolCall(tool_call) => {
1192                // We usually want to provide back the reasoning to OpenRouter since some
1193                // providers require it.
1194                // 1. Full reasoning details passed back the user
1195                // 2. The signature, an id and a format if present
1196                // 3. The signature and the call_id if present
1197                if let Some(additional_params) = &tool_call.additional_params
1198                    && let Ok(additional_params) = serde_json::from_value::<ToolCallAdditionalParams>(
1199                        additional_params.clone(),
1200                    )
1201                {
1202                    match additional_params {
1203                        ToolCallAdditionalParams::ReasoningDetails(full) => {
1204                            reasoning_details.push(full);
1205                        }
1206                        ToolCallAdditionalParams::Minimal { id, format } => {
1207                            // Correlate with the id the wire tool call will
1208                            // carry (provider call id when present, else
1209                            // rig's handle).
1210                            let id = id
1211                                .or_else(|| {
1212                                    tool_call
1213                                        .provider
1214                                        .as_ref()
1215                                        .map(|provider| provider.call_id.clone())
1216                                })
1217                                .unwrap_or_else(|| tool_call.id.as_str().to_owned());
1218                            if let Some(signature) = &tool_call.signature {
1219                                reasoning_details.push(ReasoningDetails::Encrypted {
1220                                    id: Some(id),
1221                                    format,
1222                                    index: None,
1223                                    data: signature.clone(),
1224                                })
1225                            }
1226                        }
1227                    }
1228                } else if let Some(signature) = &tool_call.signature {
1229                    reasoning_details.push(ReasoningDetails::Encrypted {
1230                        id: Some(
1231                            tool_call
1232                                .provider
1233                                .as_ref()
1234                                .map(|provider| provider.call_id.clone())
1235                                .unwrap_or_else(|| tool_call.id.as_str().to_owned()),
1236                        ),
1237                        format: None,
1238                        index: None,
1239                        data: signature.clone(),
1240                    });
1241                }
1242                tool_calls.push(tool_call.into())
1243            }
1244            message::AssistantContent::Reasoning(r) => {
1245                if r.content.is_empty() {
1246                    let display = r.display_text();
1247                    if !display.is_empty() {
1248                        reasoning = Some(display);
1249                    }
1250                } else {
1251                    // A block the stream aggregated without a wire id carries
1252                    // the accumulator's shared "" identity; send it back as a
1253                    // null id, the shape the non-streaming path produces.
1254                    let reasoning_id = r.id.clone().filter(|id| !id.is_empty());
1255                    for reasoning_block in &r.content {
1256                        let index = Some(reasoning_details.len());
1257                        match reasoning_block {
1258                            message::ReasoningContent::Text { text, signature } => {
1259                                reasoning_details.push(ReasoningDetails::Text {
1260                                    id: reasoning_id.clone(),
1261                                    format: None,
1262                                    index,
1263                                    text: Some(text.clone()),
1264                                    signature: signature.clone(),
1265                                });
1266                            }
1267                            message::ReasoningContent::Summary(summary) => {
1268                                reasoning_details.push(ReasoningDetails::Summary {
1269                                    id: reasoning_id.clone(),
1270                                    format: None,
1271                                    index,
1272                                    summary: summary.clone(),
1273                                });
1274                            }
1275                            message::ReasoningContent::Encrypted(data)
1276                            | message::ReasoningContent::Redacted { data } => {
1277                                reasoning_details.push(ReasoningDetails::Encrypted {
1278                                    id: reasoning_id.clone(),
1279                                    format: None,
1280                                    index,
1281                                    data: data.clone(),
1282                                });
1283                            }
1284                        }
1285                    }
1286                }
1287            }
1288            message::AssistantContent::Image(image) if is_openrouter_response_image(&image) => {
1289                // OpenRouter generated images are response artifacts. They remain
1290                // visible in Rig history, but OpenRouter does not define them as
1291                // replayable assistant request content.
1292            }
1293            message::AssistantContent::Image(_) => {
1294                return Err(message::MessageError::ConversionError(
1295                        "OpenRouter does not support assistant image content in request history; pass images as user image inputs instead".into(),
1296                    ));
1297            }
1298        }
1299    }
1300
1301    if text_content.is_empty()
1302        && tool_calls.is_empty()
1303        && reasoning.is_none()
1304        && reasoning_details.is_empty()
1305    {
1306        return Ok(vec![]);
1307    }
1308
1309    Ok(vec![Message::Assistant {
1310        content: text_content
1311            .into_iter()
1312            .map(|content| content.text.into())
1313            .collect::<Vec<_>>(),
1314        refusal: None,
1315        audio: None,
1316        name: None,
1317        tool_calls,
1318        reasoning,
1319        reasoning_details,
1320        images: Vec::new(),
1321    }])
1322}
1323
1324/// Convert a rig message into OpenRouter request messages.
1325///
1326/// OpenRouter shares the OpenAI message model, but keeps its own conversion
1327/// rules for user content (see `user_content_to_openai`) and for replaying
1328/// assistant reasoning, so it does not use the shared
1329/// `TryFrom<message::Message> for Vec<openai::Message>` conversion.
1330pub fn messages_from_rig_message(
1331    message: message::Message,
1332) -> Result<Vec<Message>, message::MessageError> {
1333    match message {
1334        message::Message::System { content } => Ok(vec![Message::system(&content)]),
1335        message::Message::User { content } => user_contents_to_messages(content),
1336        message::Message::Assistant { content, .. } => assistant_contents_to_messages(content),
1337    }
1338}
1339
1340/// Apply explicit prompt-caching markers to an already-serialized OpenRouter
1341/// request body.
1342///
1343/// Finds the first system message in `messages` and converts its `content`
1344/// to a structured text block with `cache_control: {"type": "ephemeral"}`.
1345/// This tells OpenRouter providers that support explicit `cache_control`
1346/// breakpoints to cache the system prompt so subsequent turns that share the
1347/// same prefix can be billed at the cache-hit rate.
1348///
1349/// This is intended for models and providers that support explicit
1350/// `cache_control` breakpoints.
1351pub(super) fn apply_prompt_caching(body: &mut serde_json::Value) {
1352    let Some(obj) = body.as_object_mut() else {
1353        return;
1354    };
1355    let Some(messages) = obj.get_mut("messages").and_then(|v| v.as_array_mut()) else {
1356        return;
1357    };
1358
1359    let Some(system_msg) = messages
1360        .iter_mut()
1361        .find(|m| m.get("role").and_then(|v| v.as_str()) == Some("system"))
1362    else {
1363        return;
1364    };
1365
1366    match system_msg.get("content").cloned() {
1367        Some(serde_json::Value::String(s)) => {
1368            if let Some(obj) = system_msg.as_object_mut() {
1369                obj.insert(
1370                    "content".to_string(),
1371                    serde_json::json!([{
1372                        "type": "text",
1373                        "text": s,
1374                        "cache_control": { "type": "ephemeral" }
1375                    }]),
1376                );
1377            }
1378        }
1379        Some(serde_json::Value::Array(mut arr)) => {
1380            // Mark the last block as the cache boundary; all other blocks (including
1381            // non-text blocks such as images) are preserved unchanged.
1382            if let Some(last) = arr.last_mut()
1383                && let Some(obj) = last.as_object_mut()
1384            {
1385                obj.insert(
1386                    "cache_control".to_string(),
1387                    serde_json::json!({ "type": "ephemeral" }),
1388                );
1389            }
1390            if let Some(obj) = system_msg.as_object_mut() {
1391                obj.insert("content".to_string(), serde_json::Value::Array(arr));
1392            }
1393        }
1394        _ => {}
1395    }
1396}
1397
1398pub(super) fn finalize_openrouter_request_body(body: &mut serde_json::Value, prompt_caching: bool) {
1399    if prompt_caching {
1400        apply_prompt_caching(body);
1401    }
1402
1403    // The shared assistant message serializes hidden reasoning under the
1404    // llama.cpp/DeepSeek key `reasoning_content`; OpenRouter's documented
1405    // assistant field is `reasoning`.
1406    if let Some(messages) = body
1407        .get_mut("messages")
1408        .and_then(serde_json::Value::as_array_mut)
1409    {
1410        for message in messages {
1411            if let Some(message) = message.as_object_mut()
1412                && message.get("role").and_then(serde_json::Value::as_str) == Some("assistant")
1413                && let Some(reasoning) = message.remove("reasoning_content")
1414            {
1415                message.insert("reasoning".to_string(), reasoning);
1416            }
1417        }
1418    }
1419}
1420
1421#[cfg(test)]
1422pub(super) fn final_request_body(
1423    request: &OpenrouterCompletionRequest,
1424    prompt_caching: bool,
1425) -> Result<serde_json::Value, CompletionError> {
1426    let mut body = serde_json::to_value(request)?;
1427    finalize_openrouter_request_body(&mut body, prompt_caching);
1428    Ok(body)
1429}
1430
1431pub(super) type OpenrouterCompletionRequest = openai::completion::CompletionRequest;
1432
1433/// Parameters for building an OpenRouter CompletionRequest
1434pub struct OpenRouterRequestParams<'a> {
1435    pub model: &'a str,
1436    pub request: CompletionRequest,
1437    pub strict_tools: bool,
1438}
1439
1440impl TryFrom<OpenRouterRequestParams<'_>> for OpenrouterCompletionRequest {
1441    type Error = CompletionError;
1442
1443    fn try_from(params: OpenRouterRequestParams) -> Result<Self, Self::Error> {
1444        let OpenRouterRequestParams {
1445            model,
1446            request: req,
1447            strict_tools,
1448        } = params;
1449        let chat_history = req.chat_history_with_documents();
1450        let model = req.model.clone().unwrap_or_else(|| model.to_string());
1451
1452        let mut full_history: Vec<Message> = match &req.preamble {
1453            Some(preamble) => vec![Message::system(preamble)],
1454            None => vec![],
1455        };
1456
1457        let chat_history: Vec<Message> = chat_history
1458            .into_iter()
1459            .map(messages_from_rig_message)
1460            .collect::<Result<Vec<Vec<Message>>, _>>()?
1461            .into_iter()
1462            .flatten()
1463            .collect();
1464
1465        full_history.extend(chat_history);
1466
1467        let tool_choice = req
1468            .tool_choice
1469            .clone()
1470            .map(crate::providers::openai::completion::ToolChoice::try_from)
1471            .transpose()?;
1472
1473        let tools: Vec<crate::providers::openai::completion::ToolDefinition> = req
1474            .tools
1475            .clone()
1476            .into_iter()
1477            .map(|tool| {
1478                let def = crate::providers::openai::completion::ToolDefinition::from(tool);
1479                if strict_tools { def.with_strict() } else { def }
1480            })
1481            .collect();
1482
1483        let additional_params = if let Some(schema) = req.output_schema {
1484            let name = schema
1485                .as_object()
1486                .and_then(|o| o.get("title"))
1487                .and_then(|v| v.as_str())
1488                .unwrap_or("response_schema")
1489                .to_string();
1490            let mut schema_value = schema.to_value();
1491            openai::sanitize_schema(&mut schema_value);
1492            let response_format = serde_json::json!({
1493                "response_format": {
1494                    "type": "json_schema",
1495                    "json_schema": {
1496                        "name": name,
1497                        "strict": true,
1498                        "schema": schema_value
1499                    }
1500                }
1501            });
1502            Some(match req.additional_params {
1503                Some(existing) => json_utils::merge(existing, response_format),
1504                None => response_format,
1505            })
1506        } else {
1507            req.additional_params
1508        };
1509
1510        Ok(Self {
1511            model,
1512            messages: full_history,
1513            temperature: req.temperature,
1514            max_tokens: req.max_tokens,
1515            tools,
1516            tool_choice,
1517            additional_params,
1518        })
1519    }
1520}
1521
1522impl TryFrom<(&str, CompletionRequest)> for OpenrouterCompletionRequest {
1523    type Error = CompletionError;
1524
1525    fn try_from((model, req): (&str, CompletionRequest)) -> Result<Self, Self::Error> {
1526        let model = req.model.clone().unwrap_or_else(|| model.to_string());
1527        OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1528            model: &model,
1529            request: req,
1530            strict_tools: false,
1531        })
1532    }
1533}
1534
1535impl openai::completion::OpenAICompatibleProvider for OpenRouterExt {
1536    const PROVIDER_NAME: &'static str = self::PROVIDER_NAME;
1537
1538    type StreamingUsage = Usage;
1539    type Response = CompletionResponse;
1540
1541    const STREAM_INCLUDE_USAGE: bool = false;
1542
1543    fn map_streaming_finish_reason(
1544        &self,
1545        finish_reason: Option<&str>,
1546        native_finish_reason: Option<&str>,
1547    ) -> Option<crate::completion::FinishReason> {
1548        if let Some(reason) = finish_reason.filter(|reason| !reason.is_empty()) {
1549            return Some(map_openai_finish_reason(reason));
1550        }
1551
1552        native_finish_reason
1553            .filter(|reason| !reason.is_empty())
1554            .map(map_native_finish_reason)
1555    }
1556
1557    fn build_completion_request(
1558        &self,
1559        model: String,
1560        request: CompletionRequest,
1561        options: openai::completion::CompletionModelOptions,
1562    ) -> Result<openai::completion::CompletionRequest, CompletionError> {
1563        OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1564            model: &model,
1565            request,
1566            strict_tools: options.strict_tools,
1567        })
1568    }
1569
1570    fn finalize_request_body_with_options(
1571        &self,
1572        body: &mut serde_json::Value,
1573        options: openai::completion::CompletionModelOptions,
1574    ) -> Result<(), CompletionError> {
1575        finalize_openrouter_request_body(body, options.prompt_caching);
1576        Ok(())
1577    }
1578
1579    /// Encrypted reasoning (`{"type":"reasoning.encrypted"}`) is the turn's own
1580    /// output, not tool-call metadata: it arrives with `reasoning: null` and an
1581    /// `rs_*` id of its own, which never matches a `call_*` tool-call id, and it
1582    /// arrives before any tool call opens. Emitting it as a reasoning block
1583    /// matches the non-streaming path (which maps the same detail to
1584    /// [`message::ReasoningContent::Encrypted`]) and is what lets the blob reach
1585    /// the aggregated choice and be replayed on the next turn.
1586    fn streaming_detail_reasoning(
1587        &self,
1588        detail: &serde_json::Value,
1589    ) -> Option<(
1590        crate::streaming::StreamPartId,
1591        Option<crate::streaming::WireId>,
1592        message::ReasoningContent,
1593    )> {
1594        let Ok(ReasoningDetails::Encrypted { id, data, .. }) =
1595            serde_json::from_value::<ReasoningDetails>(detail.clone())
1596        else {
1597            return None;
1598        };
1599
1600        // The durable handle exists only when the wire issued one; an
1601        // id-less detail keys accumulation by a minted key and replays with
1602        // the id absent — no fabricated empty "wire" id, and no
1603        // per-serializer empty-string filter downstream (84a43e9e #4).
1604        // The mint kind is `EncryptedReasoning`, NOT `Reasoning`: the shared
1605        // compat adapter accumulates `reasoning`/`reasoning_content` text
1606        // under `Minted { Reasoning, 0 }`, and a whole block under that same
1607        // key would restate — i.e. replace — the open text part. Distinct
1608        // content classes get distinct minted keys.
1609        let provider_id = id.and_then(crate::streaming::WireId::new);
1610        let key = provider_id
1611            .as_ref()
1612            .map(|id| crate::streaming::StreamPartId::wire(id.as_str()))
1613            .unwrap_or(crate::streaming::StreamPartId::minted(
1614                crate::streaming::MintKind::EncryptedReasoning,
1615                0,
1616            ));
1617        Some((key, provider_id, message::ReasoningContent::Encrypted(data)))
1618    }
1619
1620    /// Anthropic routes stream the plaintext in `delta.reasoning`, then send
1621    /// its replay-required signature as a final signature-only
1622    /// `reasoning.text` detail immediately before the tool call. Feed that
1623    /// authoritative close into the shared lifecycle so the normalized
1624    /// reasoning block is signed just like the blocking response.
1625    fn streaming_reasoning_signature(&self, detail: &serde_json::Value) -> Option<String> {
1626        let Ok(ReasoningDetails::Text {
1627            signature: Some(signature),
1628            ..
1629        }) = serde_json::from_value::<ReasoningDetails>(detail.clone())
1630        else {
1631            return None;
1632        };
1633        (!signature.is_empty()).then_some(signature)
1634    }
1635}
1636
1637/// OpenRouter completion model, driven by the shared OpenAI Chat Completions path.
1638///
1639/// The provider-native escape hatches come with it:
1640/// [`raw_completion`](openai::completion::GenericCompletionModel::raw_completion)
1641/// returns OpenRouter's own [`CompletionResponse`] and
1642/// [`raw_stream`](openai::completion::GenericCompletionModel::raw_stream) a
1643/// stream whose terminal record stays provider-native — both over the same
1644/// single request path as the normalized methods.
1645pub type CompletionModel<H = reqwest::Client> =
1646    openai::completion::GenericCompletionModel<OpenRouterExt, H>;
1647
1648/// Final streaming response, shared with the OpenAI Chat Completions path.
1649pub type StreamingCompletionResponse =
1650    openai::completion::streaming::StreamingCompletionResponse<Usage>;
1651
1652impl<H> openai::completion::GenericCompletionModel<OpenRouterExt, H> {
1653    /// Enable explicit prompt caching for supported OpenRouter models.
1654    ///
1655    /// Adds `cache_control: {"type": "ephemeral"}` to the system-prompt
1656    /// block so subsequent turns that share the same system prefix can be
1657    /// billed at the cache-hit rate when the selected model/provider supports
1658    /// explicit cache breakpoints.
1659    pub fn with_prompt_caching(mut self) -> Self {
1660        self.prompt_caching = true;
1661        self
1662    }
1663}
1664
1665#[cfg(test)]
1666mod tests {
1667    use super::*;
1668    use crate::completion::NormalizeCompletionResponse;
1669    use crate::message::{AudioMediaType, ImageDetail, VideoMediaType};
1670    use serde_json::json;
1671
1672    #[test]
1673    fn openrouter_client_constructs_a_completion_model() {
1674        // Also a compile guard: it instantiates the shared chat-completions
1675        // model over `OpenRouterExt`, which is what proves this provider's
1676        // response conversion satisfies the normalization bound.
1677        use crate::client::CompletionClient;
1678
1679        let client =
1680            crate::providers::openrouter::Client::new("dummy-key").expect("Client::new() failed");
1681        let model = client.completion_model(GEMINI_FLASH_2_0);
1682
1683        assert_eq!(model.model, GEMINI_FLASH_2_0);
1684    }
1685
1686    #[test]
1687    fn mixed_user_content_preserves_order_around_tool_results() {
1688        let content = vec![
1689            message::UserContent::text("before"),
1690            message::UserContent::tool_result_with_call_id(
1691                "result-id",
1692                "call-id".to_string(),
1693                "tool",
1694                vec![message::ToolResultContent::text("tool output")],
1695            ),
1696            message::UserContent::text("after"),
1697        ];
1698
1699        let messages = user_contents_to_messages(content).expect("message conversion");
1700
1701        assert!(matches!(
1702            messages.as_slice(),
1703            [
1704                Message::User { content: before, .. },
1705                Message::ToolResult { tool_call_id, .. },
1706                Message::User { content: after, .. },
1707            ] if matches!(before.first(), Some(UserContent::Text { text }) if text == "before")
1708                && tool_call_id == "call-id"
1709                && matches!(after.first(), Some(UserContent::Text { text }) if text == "after")
1710        ));
1711    }
1712
1713    #[test]
1714    fn test_openrouter_request_uses_request_model_override() {
1715        let request = CompletionRequest {
1716            model: Some("google/gemini-2.5-flash".to_string()),
1717            preamble: None,
1718            chat_history: vec!["Hello".into()],
1719            documents: vec![],
1720            tools: vec![],
1721            temperature: None,
1722            max_tokens: None,
1723            tool_choice: None,
1724            additional_params: None,
1725            output_schema: None,
1726            record_telemetry_content: false,
1727        };
1728
1729        let openrouter_request =
1730            OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1731                .expect("request conversion should succeed");
1732        let serialized =
1733            serde_json::to_value(openrouter_request).expect("serialization should succeed");
1734
1735        assert_eq!(serialized["model"], "google/gemini-2.5-flash");
1736    }
1737
1738    /// The caller's `max_tokens` must reach the serialized request body —
1739    /// OpenRouter accepts `max_tokens` like OpenAI, and dropping it silently
1740    /// removed the caller's output cap.
1741    #[test]
1742    fn openrouter_request_carries_caller_max_tokens() {
1743        let request = CompletionRequest {
1744            model: None,
1745            preamble: None,
1746            chat_history: vec!["Hello".into()],
1747            documents: vec![],
1748            tools: vec![],
1749            temperature: None,
1750            max_tokens: Some(512),
1751            tool_choice: None,
1752            additional_params: None,
1753            output_schema: None,
1754            record_telemetry_content: false,
1755        };
1756
1757        let openrouter_request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1758            model: "openai/gpt-4o-mini",
1759            request,
1760            strict_tools: false,
1761        })
1762        .expect("request conversion should succeed");
1763        let serialized =
1764            serde_json::to_value(openrouter_request).expect("serialization should succeed");
1765
1766        assert_eq!(serialized["max_tokens"], 512);
1767    }
1768
1769    #[test]
1770    fn openrouter_params_include_direct_request_documents() {
1771        let request = CompletionRequest {
1772            model: None,
1773            preamble: None,
1774            chat_history: vec![crate::message::Message::user("What is glarb-glarb?")],
1775            documents: vec![crate::completion::request::Document {
1776                id: "doc_1".to_string(),
1777                text: "Definition of glarb-glarb: an ancient tool.".to_string(),
1778                additional_props: Default::default(),
1779            }],
1780            tools: vec![],
1781            temperature: None,
1782            max_tokens: None,
1783            tool_choice: None,
1784            additional_params: None,
1785            output_schema: None,
1786            record_telemetry_content: false,
1787        };
1788
1789        let request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1790            model: "openai/gpt-4o-mini",
1791            request,
1792            strict_tools: false,
1793        })
1794        .expect("request conversion should succeed");
1795        let serialized = serde_json::to_value(request).expect("serialization should succeed");
1796
1797        assert!(
1798            serialized["messages"].to_string().contains("glarb-glarb"),
1799            "direct request documents should be normalized through public params"
1800        );
1801    }
1802
1803    #[test]
1804    fn test_openrouter_request_uses_default_model_when_override_unset() {
1805        let request = CompletionRequest {
1806            model: None,
1807            preamble: None,
1808            chat_history: vec!["Hello".into()],
1809            documents: vec![],
1810            tools: vec![],
1811            temperature: None,
1812            max_tokens: None,
1813            tool_choice: None,
1814            additional_params: None,
1815            output_schema: None,
1816            record_telemetry_content: false,
1817        };
1818
1819        let openrouter_request =
1820            OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1821                .expect("request conversion should succeed");
1822        let serialized =
1823            serde_json::to_value(openrouter_request).expect("serialization should succeed");
1824
1825        assert_eq!(serialized["model"], "openai/gpt-4o-mini");
1826    }
1827
1828    #[test]
1829    fn final_request_body_serializes_assistant_reasoning_under_openrouter_key() {
1830        // Reasoning replay normally flows through `reasoning_details`; the
1831        // plain string field must nevertheless hit the wire under
1832        // OpenRouter's `reasoning` key, not the shared `reasoning_content`.
1833        let request = OpenrouterCompletionRequest {
1834            model: "openai/gpt-4o".to_string(),
1835            messages: vec![Message::Assistant {
1836                content: vec![],
1837                reasoning: Some("thinking it through".to_string()),
1838                refusal: None,
1839                audio: None,
1840                name: None,
1841                tool_calls: vec![],
1842                reasoning_details: vec![],
1843                images: vec![],
1844            }],
1845            temperature: None,
1846            max_tokens: None,
1847            tools: vec![],
1848            tool_choice: None,
1849            additional_params: None,
1850        };
1851
1852        let body = final_request_body(&request, false).expect("body should serialize");
1853
1854        assert_eq!(
1855            body["messages"][0]["reasoning"],
1856            serde_json::json!("thinking it through")
1857        );
1858        assert!(
1859            body["messages"][0].get("reasoning_content").is_none(),
1860            "OpenRouter's assistant reasoning key is `reasoning`, not `reasoning_content`"
1861        );
1862    }
1863
1864    #[test]
1865    fn test_openrouter_request_maps_output_schema_to_response_format() {
1866        let schema: schemars::Schema = serde_json::from_value(json!({
1867            "title": "WeatherResponse",
1868            "type": "object",
1869            "properties": {
1870                "city": { "type": "string" },
1871                "weather": { "type": "string" }
1872            }
1873        }))
1874        .expect("schema should deserialize");
1875
1876        let request = CompletionRequest {
1877            model: None,
1878            preamble: None,
1879            chat_history: vec!["Hello".into()],
1880            documents: vec![],
1881            tools: vec![],
1882            temperature: None,
1883            max_tokens: None,
1884            tool_choice: None,
1885            additional_params: None,
1886            output_schema: Some(schema),
1887            record_telemetry_content: false,
1888        };
1889
1890        let openrouter_request =
1891            OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1892                .expect("request conversion should succeed");
1893        let serialized =
1894            serde_json::to_value(openrouter_request).expect("serialization should succeed");
1895
1896        assert_eq!(
1897            serialized["response_format"],
1898            json!({
1899                "type": "json_schema",
1900                "json_schema": {
1901                    "name": "WeatherResponse",
1902                    "strict": true,
1903                    "schema": {
1904                        "title": "WeatherResponse",
1905                        "type": "object",
1906                        "properties": {
1907                            "city": { "type": "string" },
1908                            "weather": { "type": "string" }
1909                        },
1910                        "additionalProperties": false,
1911                        "required": ["city", "weather"]
1912                    }
1913                }
1914            })
1915        );
1916    }
1917
1918    #[test]
1919    fn test_openrouter_request_merges_output_schema_with_provider_preferences() {
1920        let schema: schemars::Schema = serde_json::from_value(json!({
1921            "type": "object",
1922            "properties": {
1923                "answer": { "type": "string" }
1924            }
1925        }))
1926        .expect("schema should deserialize");
1927
1928        let request = CompletionRequest {
1929            model: None,
1930            preamble: None,
1931            chat_history: vec!["Hello".into()],
1932            documents: vec![],
1933            tools: vec![],
1934            temperature: None,
1935            max_tokens: None,
1936            tool_choice: None,
1937            additional_params: Some(
1938                ProviderPreferences::new()
1939                    .require_parameters(true)
1940                    .to_json(),
1941            ),
1942            output_schema: Some(schema),
1943            record_telemetry_content: false,
1944        };
1945
1946        let openrouter_request =
1947            OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1948                .expect("request conversion should succeed");
1949        let serialized =
1950            serde_json::to_value(openrouter_request).expect("serialization should succeed");
1951
1952        assert_eq!(serialized["provider"]["require_parameters"], true);
1953        assert_eq!(serialized["response_format"]["type"], "json_schema");
1954        assert_eq!(
1955            serialized["response_format"]["json_schema"]["name"],
1956            "response_schema"
1957        );
1958        assert_eq!(
1959            serialized["response_format"]["json_schema"]["schema"]["additionalProperties"],
1960            false
1961        );
1962    }
1963
1964    #[test]
1965    fn test_completion_response_deserialization_gemini_flash() {
1966        // Real response from OpenRouter with google/gemini-2.5-flash
1967        let json = json!({
1968            "id": "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA",
1969            "provider": "Google",
1970            "model": "google/gemini-2.5-flash",
1971            "object": "chat.completion",
1972            "created": 1765971703u64,
1973            "choices": [{
1974                "logprobs": null,
1975                "finish_reason": "stop",
1976                "native_finish_reason": "STOP",
1977                "index": 0,
1978                "message": {
1979                    "role": "assistant",
1980                    "content": "CONTENT",
1981                    "refusal": null,
1982                    "reasoning": null
1983                }
1984            }],
1985            "usage": {
1986                "prompt_tokens": 669,
1987                "completion_tokens": 5,
1988                "total_tokens": 674
1989            }
1990        });
1991
1992        let response: CompletionResponse = serde_json::from_value(json).unwrap();
1993        assert_eq!(response.id, "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA");
1994        assert_eq!(response.model, "google/gemini-2.5-flash");
1995        assert_eq!(response.choices.len(), 1);
1996        assert_eq!(response.choices[0].finish_reason, Some("stop".to_string()));
1997        assert_eq!(response.choices[0].logprobs, None);
1998        let serialized = serde_json::to_value(&response).unwrap();
1999        assert!(
2000            serialized["choices"][0].get("logprobs").is_none(),
2001            "an absent optional native field stays absent when serialized"
2002        );
2003    }
2004
2005    #[test]
2006    fn raw_completion_choice_retains_logprobs() {
2007        let logprobs = json!({
2008            "content": [{
2009                "token": "cobalt",
2010                "logprob": -0.01,
2011                "bytes": [99],
2012                "top_logprobs": []
2013            }],
2014            "refusal": null
2015        });
2016        let response: CompletionResponse = serde_json::from_value(json!({
2017            "id": "gen-logprobs",
2018            "object": "chat.completion",
2019            "created": 1,
2020            "model": "openai/gpt-4o-mini",
2021            "system_fingerprint": null,
2022            "choices": [{
2023                "index": 0,
2024                "native_finish_reason": "stop",
2025                "finish_reason": "stop",
2026                "message": {"role": "assistant", "content": "cobalt"},
2027                "logprobs": logprobs
2028            }],
2029            "usage": null
2030        }))
2031        .expect("OpenRouter's documented probability object should decode");
2032
2033        assert_eq!(response.choices[0].logprobs, Some(logprobs));
2034    }
2035
2036    #[test]
2037    fn test_completion_response_usage_prefers_reported_completion_tokens() {
2038        let json = json!({
2039            "id": "gen-usage-divergent",
2040            "object": "chat.completion",
2041            "created": 1,
2042            "model": "anthropic/claude-3.5-sonnet",
2043            "choices": [{
2044                "index": 0,
2045                "message": {"role": "assistant", "content": "ok"},
2046                "finish_reason": "stop"
2047            }],
2048            // Divergent accounting: total != prompt + completion.
2049            "usage": {"prompt_tokens": 500, "completion_tokens": 10, "total_tokens": 505}
2050        });
2051
2052        let response: CompletionResponse = serde_json::from_value(json).unwrap();
2053        let converted = response.normalize(PROVIDER_NAME).unwrap();
2054        assert_eq!(converted.usage.output_tokens, 10);
2055    }
2056
2057    #[test]
2058    fn test_completion_response_usage_falls_back_when_completion_tokens_missing() {
2059        let json = json!({
2060            "id": "gen-usage-omitted",
2061            "object": "chat.completion",
2062            "created": 1,
2063            "model": "some/gateway-model",
2064            "choices": [{
2065                "index": 0,
2066                "message": {"role": "assistant", "content": "ok"},
2067                "finish_reason": "stop"
2068            }],
2069            "usage": {"prompt_tokens": 100, "total_tokens": 110}
2070        });
2071
2072        let response: CompletionResponse = serde_json::from_value(json).unwrap();
2073        let converted = response.normalize(PROVIDER_NAME).unwrap();
2074        assert_eq!(converted.usage.output_tokens, 10);
2075    }
2076
2077    #[test]
2078    fn test_completion_response_maps_cache_token_accounting() {
2079        let json = json!({
2080            "id": "gen-cache-test",
2081            "object": "chat.completion",
2082            "created": 1,
2083            "model": "anthropic/claude-3.5-sonnet",
2084            "choices": [{
2085                "index": 0,
2086                "finish_reason": "stop",
2087                "message": {
2088                    "role": "assistant",
2089                    "content": "Hi"
2090                }
2091            }],
2092            "usage": {
2093                "prompt_tokens": 500,
2094                "completion_tokens": 10,
2095                "total_tokens": 510,
2096                "prompt_tokens_details": {
2097                    "cached_tokens": 400,
2098                    "cache_write_tokens": 50
2099                }
2100            }
2101        });
2102
2103        let response: CompletionResponse = serde_json::from_value(json).unwrap();
2104        let converted = response.normalize(PROVIDER_NAME).unwrap();
2105
2106        assert_eq!(converted.usage.input_tokens, 500);
2107        assert_eq!(converted.usage.output_tokens, 10);
2108        assert_eq!(converted.usage.cached_input_tokens, 400);
2109        assert_eq!(converted.usage.cache_creation_input_tokens, 50);
2110    }
2111
2112    #[test]
2113    fn test_completion_response_cache_tokens_absent_defaults_to_zero() {
2114        let json = json!({
2115            "id": "gen-no-cache",
2116            "object": "chat.completion",
2117            "created": 1,
2118            "model": "openai/gpt-4o",
2119            "choices": [{
2120                "index": 0,
2121                "finish_reason": "stop",
2122                "message": {
2123                    "role": "assistant",
2124                    "content": "Hi"
2125                }
2126            }],
2127            "usage": {
2128                "prompt_tokens": 100,
2129                "completion_tokens": 10,
2130                "total_tokens": 110
2131            }
2132        });
2133
2134        let response: CompletionResponse = serde_json::from_value(json).unwrap();
2135        let converted = response.normalize(PROVIDER_NAME).unwrap();
2136
2137        assert_eq!(converted.usage.cached_input_tokens, 0);
2138        assert_eq!(converted.usage.cache_creation_input_tokens, 0);
2139    }
2140
2141    #[test]
2142    fn test_completion_response_deserialization_gemini_model_role() {
2143        let json = json!({
2144            "id": "gen-BBBBBBBBBB-BBBBBBBBBBBBBBBBBBBB",
2145            "provider": "Google",
2146            "model": "google/gemini-2.5-pro-exp-03-25:free",
2147            "object": "chat.completion",
2148            "created": 1743780565u64,
2149            "choices": [{
2150                "logprobs": null,
2151                "finish_reason": "stop",
2152                "native_finish_reason": "STOP",
2153                "index": 0,
2154                "message": {
2155                    "role": "model",
2156                    "content": "CONTENT",
2157                    "refusal": null,
2158                    "reasoning": null
2159                }
2160            }],
2161            "usage": {
2162                "prompt_tokens": 669,
2163                "completion_tokens": 5,
2164                "total_tokens": 674
2165            }
2166        });
2167
2168        let response: CompletionResponse = serde_json::from_value(json).unwrap();
2169        let converted = response.normalize(PROVIDER_NAME).unwrap();
2170
2171        // The normalized response carries the model OpenRouter reported, which
2172        // is routinely not the one that was requested.
2173        assert_eq!(
2174            converted.model.as_deref(),
2175            Some("google/gemini-2.5-pro-exp-03-25:free")
2176        );
2177        assert_eq!(converted.provider, "openrouter");
2178        assert!(matches!(
2179            converted.choice.first(),
2180            Some(completion::AssistantContent::Text(text)) if text.text == "CONTENT"
2181        ));
2182    }
2183
2184    #[test]
2185    fn openrouter_finish_reasons_map_and_preserve_unknown_values() {
2186        use crate::completion::FinishReason;
2187
2188        let choice = |finish_reason: Option<&str>, native: Option<&str>| Choice {
2189            index: 0,
2190            native_finish_reason: native.map(str::to_string),
2191            message: Message::Assistant {
2192                content: vec![],
2193                reasoning: None,
2194                refusal: None,
2195                audio: None,
2196                name: None,
2197                tool_calls: vec![],
2198                reasoning_details: vec![],
2199                images: vec![],
2200            },
2201            finish_reason: finish_reason.map(str::to_string),
2202            logprobs: None,
2203        };
2204
2205        assert_eq!(
2206            map_finish_reason(&choice(Some("stop"), Some("STOP"))),
2207            Some(FinishReason::Stop)
2208        );
2209        assert_eq!(
2210            map_finish_reason(&choice(Some("length"), None)),
2211            Some(FinishReason::Length)
2212        );
2213        assert_eq!(
2214            map_finish_reason(&choice(Some("tool_calls"), None)),
2215            Some(FinishReason::ToolCalls)
2216        );
2217        assert_eq!(
2218            map_finish_reason(&choice(Some("content_filter"), None)),
2219            Some(FinishReason::ContentFilter)
2220        );
2221        assert_eq!(
2222            map_finish_reason(&choice(None, Some("completed"))),
2223            Some(FinishReason::Stop)
2224        );
2225        assert_eq!(
2226            map_finish_reason(&choice(None, Some("max_output_tokens"))),
2227            Some(FinishReason::Length)
2228        );
2229        // A reason OpenRouter could not translate survives verbatim rather
2230        // than reading as a natural stop.
2231        assert_eq!(
2232            map_finish_reason(&choice(Some("error"), None)),
2233            Some(FinishReason::Other("error".to_string()))
2234        );
2235        // No normalized reason: the upstream provider's own spelling is
2236        // reported, in its own casing.
2237        assert_eq!(
2238            map_finish_reason(&choice(None, Some("MALFORMED_FUNCTION_CALL"))),
2239            Some(FinishReason::Other("MALFORMED_FUNCTION_CALL".to_string()))
2240        );
2241        assert_eq!(map_finish_reason(&choice(None, None)), None);
2242    }
2243
2244    #[test]
2245    fn openrouter_stop_with_tool_call_reports_tool_calls() {
2246        // OpenRouter gateways routinely report a plain `stop` on a turn that
2247        // carried tool calls; the normalized response upgrades it.
2248        let json = json!({
2249            "id": "gen-tool",
2250            "object": "chat.completion",
2251            "created": 1,
2252            "model": "anthropic/claude-3.5-sonnet",
2253            "choices": [{
2254                "index": 0,
2255                "finish_reason": "stop",
2256                "message": {
2257                    "role": "assistant",
2258                    "content": "",
2259                    "tool_calls": [{
2260                        "id": "call_1",
2261                        "type": "function",
2262                        "function": {"name": "lookup", "arguments": "{}"}
2263                    }]
2264                }
2265            }]
2266        });
2267
2268        let response: CompletionResponse = serde_json::from_value(json).unwrap();
2269        let converted = response.normalize(PROVIDER_NAME).unwrap();
2270
2271        assert_eq!(
2272            converted.finish_reason(),
2273            Some(crate::completion::FinishReason::ToolCalls)
2274        );
2275    }
2276
2277    /// The shared choice decoder tolerates the truncated JSON a
2278    /// `max_tokens`-capped turn emits only under `finish_reason: length`, and
2279    /// every normalizer built on it drops the unusable call rather than losing
2280    /// the turn. Reproduced live on
2281    /// DeepSeek (rig#2354) at 24/32/48/64-token budgets; the same wire type
2282    /// backs OpenRouter, so the same turn shape is pinned here.
2283    #[test]
2284    fn openrouter_truncated_tool_arguments_do_not_destroy_the_response() {
2285        let json = json!({
2286            "id": "gen-truncated",
2287            "object": "chat.completion",
2288            "created": 1,
2289            "model": "deepseek/deepseek-chat",
2290            "choices": [{
2291                "index": 0,
2292                "finish_reason": "length",
2293                "message": {
2294                    "role": "assistant",
2295                    "content": "Acknowledged.",
2296                    "tool_calls": [
2297                        {
2298                            "id": "call_1",
2299                            "type": "function",
2300                            "function": {"name": "page", "arguments": "{\"team\":\"platform\"}"}
2301                        },
2302                        {
2303                            "id": "call_2",
2304                            "type": "function",
2305                            "function": {"name": "file_report", "arguments": "{\"summary\": "}
2306                        }
2307                    ]
2308                }
2309            }],
2310            "usage": {"prompt_tokens": 10, "completion_tokens": 24, "total_tokens": 34}
2311        });
2312
2313        let response: CompletionResponse = serde_json::from_value(json).unwrap();
2314        let converted = response.normalize(PROVIDER_NAME).unwrap();
2315
2316        assert_eq!(
2317            converted.finish_reason(),
2318            Some(crate::completion::FinishReason::Length)
2319        );
2320        let names = converted
2321            .choice
2322            .iter()
2323            .filter_map(|content| match content {
2324                completion::AssistantContent::ToolCall(call) => Some(call.function.name.as_str()),
2325                _ => None,
2326            })
2327            .collect::<Vec<_>>();
2328        assert_eq!(names, vec!["page"], "only the truncated call is dropped");
2329        assert!(
2330            converted.choice.iter().any(|content| matches!(
2331                content,
2332                completion::AssistantContent::Text(text) if text.text == "Acknowledged."
2333            )),
2334            "the turn's text survives: {:?}",
2335            converted.choice
2336        );
2337        assert_eq!(converted.usage.total_tokens, 34);
2338    }
2339
2340    #[test]
2341    fn openrouter_native_length_fallback_tolerates_truncated_tool_arguments() {
2342        let json = json!({
2343            "id": "gen-native-truncated",
2344            "object": "chat.completion",
2345            "created": 1,
2346            "model": "anthropic/claude-haiku-4.5",
2347            "choices": [{
2348                "index": 0,
2349                "finish_reason": null,
2350                "native_finish_reason": "max_output_tokens",
2351                "message": {
2352                    "role": "assistant",
2353                    "content": "still useful",
2354                    "tool_calls": [{
2355                        "id": "call_1",
2356                        "type": "function",
2357                        "function": {"name": "lookup", "arguments": "{\"q\":"}
2358                    }]
2359                }
2360            }]
2361        });
2362
2363        let response: CompletionResponse = serde_json::from_value(json)
2364            .expect("the native terminal reason should authorize narrow truncation tolerance");
2365        let converted = response.normalize(PROVIDER_NAME).unwrap();
2366
2367        assert_eq!(
2368            converted.finish_reason(),
2369            Some(crate::completion::FinishReason::Length)
2370        );
2371        assert!(
2372            converted
2373                .choice
2374                .iter()
2375                .all(|content| !matches!(content, completion::AssistantContent::ToolCall(_)))
2376        );
2377        assert!(matches!(
2378            converted.choice.first(),
2379            Some(completion::AssistantContent::Text(text)) if text.text == "still useful"
2380        ));
2381    }
2382
2383    #[test]
2384    fn openrouter_length_preserves_an_empty_turn_after_dropping_its_only_call() {
2385        for (finish_reason, native_finish_reason) in
2386            [(Some("length"), None), (None, Some("max_output_tokens"))]
2387        {
2388            let response: CompletionResponse = serde_json::from_value(json!({
2389                "id": "gen-empty-truncated",
2390                "object": "chat.completion",
2391                "created": 1,
2392                "model": "openai/gpt-4.1-mini",
2393                "choices": [{
2394                    "index": 0,
2395                    "finish_reason": finish_reason,
2396                    "native_finish_reason": native_finish_reason,
2397                    "message": {
2398                        "role": "assistant",
2399                        "content": null,
2400                        "tool_calls": [{
2401                            "id": "call_1",
2402                            "type": "function",
2403                            "function": {"name": "lookup", "arguments": ""}
2404                        }]
2405                    }
2406                }],
2407                "usage": {"prompt_tokens": 10, "completion_tokens": 1, "total_tokens": 11}
2408            }))
2409            .expect("outer length should permit dropping the incomplete call");
2410            let converted = response
2411                .normalize(PROVIDER_NAME)
2412                .expect("an empty truncated turn still carries its diagnostic");
2413
2414            assert!(converted.choice.is_empty());
2415            assert_eq!(
2416                converted.finish_reason(),
2417                Some(crate::completion::FinishReason::Length)
2418            );
2419            assert_eq!(converted.usage.total_tokens, 11);
2420            assert_eq!(
2421                converted.response_id.as_deref(),
2422                Some("gen-empty-truncated")
2423            );
2424        }
2425    }
2426
2427    #[test]
2428    fn openrouter_content_filter_preserves_an_empty_turn() {
2429        let response: CompletionResponse = serde_json::from_value(json!({
2430            "id": "gen-filtered",
2431            "object": "chat.completion",
2432            "created": 1,
2433            "model": "openai/gpt-4.1-mini",
2434            "choices": [{
2435                "index": 0,
2436                "finish_reason": "content_filter",
2437                "message": {"role": "assistant", "content": null}
2438            }]
2439        }))
2440        .unwrap();
2441        let converted = response.normalize(PROVIDER_NAME).unwrap();
2442
2443        assert!(converted.choice.is_empty());
2444        assert_eq!(
2445            converted.finish_reason(),
2446            Some(crate::completion::FinishReason::ContentFilter)
2447        );
2448    }
2449
2450    #[test]
2451    fn openrouter_malformed_completed_tool_arguments_remain_loud() {
2452        let json = json!({
2453            "id": "gen-malformed",
2454            "object": "chat.completion",
2455            "created": 1,
2456            "model": "deepseek/deepseek-chat",
2457            "choices": [{
2458                "index": 0,
2459                "finish_reason": "tool_calls",
2460                "native_finish_reason": "max_output_tokens",
2461                "message": {
2462                    "role": "assistant",
2463                    "content": "",
2464                    "tool_calls": [{
2465                        "id": "call_1",
2466                        "type": "function",
2467                        "function": {"name": "lookup", "arguments": "{\"q\":"}
2468                    }]
2469                }
2470            }]
2471        });
2472
2473        assert!(
2474            serde_json::from_value::<CompletionResponse>(json).is_err(),
2475            "only an outer output-length reason authorizes truncation tolerance"
2476        );
2477    }
2478
2479    #[tokio::test]
2480    async fn streaming_native_length_fallback_drops_partial_tool_call() {
2481        use crate::client::CompletionClient;
2482        use crate::completion::CompletionModel as _;
2483        use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_data_lines;
2484        use crate::streaming::StreamedAssistantContent;
2485        use crate::test_utils::MockStreamingClient;
2486        use futures::StreamExt;
2487
2488        let http_client = MockStreamingClient {
2489            sse_bytes: sse_bytes_from_data_lines([
2490                r#"{"id":"gen-native-truncated","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"still useful","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":"}}]},"finish_reason":null,"native_finish_reason":null}]}"#,
2491                r#"{"id":"gen-native-truncated","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{},"finish_reason":null,"native_finish_reason":"max_output_tokens"}]}"#,
2492                "[DONE]",
2493            ]),
2494        };
2495        let client = crate::providers::openrouter::Client::builder()
2496            .api_key("dummy-key")
2497            .http_client(http_client)
2498            .build()
2499            .expect("client should build");
2500        let model = client.completion_model("anthropic/claude-haiku-4.5");
2501        let request = model.completion_request("lookup").build();
2502        let mut stream = model.stream(request).await.expect("stream should start");
2503
2504        let mut terminal = None;
2505        let mut saw_tool_call = false;
2506        while let Some(item) = stream.next().await {
2507            match item.expect("native max_tokens truncation is tolerated") {
2508                StreamedAssistantContent::ToolCall { .. } => saw_tool_call = true,
2509                StreamedAssistantContent::Final(final_record) => terminal = Some(final_record),
2510                _ => {}
2511            }
2512        }
2513
2514        assert!(
2515            !saw_tool_call,
2516            "the partial call must not become executable"
2517        );
2518        assert_eq!(
2519            terminal.and_then(|record| record.finish_reason),
2520            Some(crate::completion::FinishReason::Length)
2521        );
2522    }
2523
2524    #[tokio::test]
2525    async fn streaming_normalized_reason_wins_over_native_length() {
2526        use crate::client::CompletionClient;
2527        use crate::completion::CompletionModel as _;
2528        use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_data_lines;
2529        use crate::streaming::StreamedAssistantContent;
2530        use crate::test_utils::MockStreamingClient;
2531        use futures::StreamExt;
2532
2533        let http_client = MockStreamingClient {
2534            sse_bytes: sse_bytes_from_data_lines([
2535                r#"{"id":"gen-normalized-wins","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":"}}]},"finish_reason":null,"native_finish_reason":null}]}"#,
2536                r#"{"id":"gen-normalized-wins","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls","native_finish_reason":"max_output_tokens"}]}"#,
2537                "[DONE]",
2538            ]),
2539        };
2540        let client = crate::providers::openrouter::Client::builder()
2541            .api_key("dummy-key")
2542            .http_client(http_client)
2543            .build()
2544            .expect("client should build");
2545        let model = client.completion_model("anthropic/claude-haiku-4.5");
2546        let request = model.completion_request("lookup").build();
2547        let mut stream = model.stream(request).await.expect("stream should start");
2548
2549        let mut terminal = None;
2550        let mut errors = Vec::new();
2551        while let Some(item) = stream.next().await {
2552            match item {
2553                Ok(StreamedAssistantContent::Final(final_record)) => terminal = Some(final_record),
2554                Ok(_) => {}
2555                Err(error) => errors.push(error.to_string()),
2556            }
2557        }
2558
2559        assert_eq!(errors.len(), 1, "the completed malformed call stays loud");
2560        assert!(errors[0].contains("malformed JSON input"), "{}", errors[0]);
2561        assert_eq!(
2562            terminal.and_then(|record| record.finish_reason),
2563            Some(crate::completion::FinishReason::ToolCalls)
2564        );
2565    }
2566
2567    #[test]
2568    fn test_message_assistant_without_reasoning_details() {
2569        // Verify that missing reasoning_details field doesn't cause deserialization failure
2570        let json = json!({
2571            "role": "assistant",
2572            "content": "Hello world",
2573            "refusal": null,
2574            "reasoning": null
2575        });
2576
2577        let message: Message = serde_json::from_value(json).unwrap();
2578        match message {
2579            Message::Assistant {
2580                content,
2581                reasoning_details,
2582                ..
2583            } => {
2584                assert_eq!(content.len(), 1);
2585                assert!(reasoning_details.is_empty());
2586            }
2587            _ => panic!("Expected Assistant message"),
2588        }
2589    }
2590
2591    #[test]
2592    fn test_data_collection_serialization() {
2593        assert_eq!(
2594            serde_json::to_string(&DataCollection::Allow).unwrap(),
2595            r#""allow""#
2596        );
2597        assert_eq!(
2598            serde_json::to_string(&DataCollection::Deny).unwrap(),
2599            r#""deny""#
2600        );
2601    }
2602
2603    #[test]
2604    fn test_data_collection_default() {
2605        assert_eq!(DataCollection::default(), DataCollection::Allow);
2606    }
2607
2608    #[test]
2609    fn test_quantization_serialization() {
2610        assert_eq!(
2611            serde_json::to_string(&Quantization::Int4).unwrap(),
2612            r#""int4""#
2613        );
2614        assert_eq!(
2615            serde_json::to_string(&Quantization::Int8).unwrap(),
2616            r#""int8""#
2617        );
2618        assert_eq!(
2619            serde_json::to_string(&Quantization::Fp16).unwrap(),
2620            r#""fp16""#
2621        );
2622        assert_eq!(
2623            serde_json::to_string(&Quantization::Bf16).unwrap(),
2624            r#""bf16""#
2625        );
2626        assert_eq!(
2627            serde_json::to_string(&Quantization::Fp32).unwrap(),
2628            r#""fp32""#
2629        );
2630        assert_eq!(
2631            serde_json::to_string(&Quantization::Fp8).unwrap(),
2632            r#""fp8""#
2633        );
2634        assert_eq!(
2635            serde_json::to_string(&Quantization::Unknown).unwrap(),
2636            r#""unknown""#
2637        );
2638    }
2639
2640    #[test]
2641    fn test_provider_sort_strategy_serialization() {
2642        assert_eq!(
2643            serde_json::to_string(&ProviderSortStrategy::Price).unwrap(),
2644            r#""price""#
2645        );
2646        assert_eq!(
2647            serde_json::to_string(&ProviderSortStrategy::Throughput).unwrap(),
2648            r#""throughput""#
2649        );
2650        assert_eq!(
2651            serde_json::to_string(&ProviderSortStrategy::Latency).unwrap(),
2652            r#""latency""#
2653        );
2654    }
2655
2656    #[test]
2657    fn test_sort_partition_serialization() {
2658        assert_eq!(
2659            serde_json::to_string(&SortPartition::Model).unwrap(),
2660            r#""model""#
2661        );
2662        assert_eq!(
2663            serde_json::to_string(&SortPartition::None).unwrap(),
2664            r#""none""#
2665        );
2666    }
2667
2668    #[test]
2669    fn test_provider_sort_simple() {
2670        let sort = ProviderSort::Simple(ProviderSortStrategy::Latency);
2671        let json = serde_json::to_value(&sort).unwrap();
2672        assert_eq!(json, "latency");
2673    }
2674
2675    #[test]
2676    fn test_provider_sort_complex() {
2677        let sort = ProviderSort::Complex(
2678            ProviderSortConfig::new(ProviderSortStrategy::Price).partition(SortPartition::None),
2679        );
2680        let json = serde_json::to_value(&sort).unwrap();
2681        assert_eq!(json["by"], "price");
2682        assert_eq!(json["partition"], "none");
2683    }
2684
2685    #[test]
2686    fn test_provider_sort_complex_without_partition() {
2687        let sort = ProviderSort::Complex(ProviderSortConfig::new(ProviderSortStrategy::Throughput));
2688        let json = serde_json::to_value(&sort).unwrap();
2689        assert_eq!(json["by"], "throughput");
2690        assert!(json.get("partition").is_none());
2691    }
2692
2693    #[test]
2694    fn test_provider_sort_from_strategy() {
2695        let sort: ProviderSort = ProviderSortStrategy::Price.into();
2696        assert_eq!(sort, ProviderSort::Simple(ProviderSortStrategy::Price));
2697    }
2698
2699    #[test]
2700    fn test_provider_sort_from_config() {
2701        let config = ProviderSortConfig::new(ProviderSortStrategy::Latency);
2702        let sort: ProviderSort = config.into();
2703        match sort {
2704            ProviderSort::Complex(c) => assert_eq!(c.by, ProviderSortStrategy::Latency),
2705            _ => panic!("Expected Complex variant"),
2706        }
2707    }
2708
2709    #[test]
2710    fn test_percentile_thresholds_builder() {
2711        let thresholds = PercentileThresholds::new()
2712            .p50(10.0)
2713            .p75(25.0)
2714            .p90(50.0)
2715            .p99(100.0);
2716
2717        assert_eq!(thresholds.p50, Some(10.0));
2718        assert_eq!(thresholds.p75, Some(25.0));
2719        assert_eq!(thresholds.p90, Some(50.0));
2720        assert_eq!(thresholds.p99, Some(100.0));
2721    }
2722
2723    #[test]
2724    fn test_percentile_thresholds_default() {
2725        let thresholds = PercentileThresholds::default();
2726        assert_eq!(thresholds.p50, None);
2727        assert_eq!(thresholds.p75, None);
2728        assert_eq!(thresholds.p90, None);
2729        assert_eq!(thresholds.p99, None);
2730    }
2731
2732    #[test]
2733    fn test_throughput_threshold_simple() {
2734        let threshold = ThroughputThreshold::Simple(50.0);
2735        let json = serde_json::to_value(&threshold).unwrap();
2736        assert_eq!(json, 50.0);
2737    }
2738
2739    #[test]
2740    fn test_throughput_threshold_percentile() {
2741        let threshold = ThroughputThreshold::Percentile(PercentileThresholds::new().p90(50.0));
2742        let json = serde_json::to_value(&threshold).unwrap();
2743        assert_eq!(json["p90"], 50.0);
2744    }
2745
2746    #[test]
2747    fn test_latency_threshold_simple() {
2748        let threshold = LatencyThreshold::Simple(0.5);
2749        let json = serde_json::to_value(&threshold).unwrap();
2750        assert_eq!(json, 0.5);
2751    }
2752
2753    #[test]
2754    fn test_latency_threshold_percentile() {
2755        let threshold = LatencyThreshold::Percentile(PercentileThresholds::new().p50(0.1).p99(1.0));
2756        let json = serde_json::to_value(&threshold).unwrap();
2757        assert_eq!(json["p50"], 0.1);
2758        assert_eq!(json["p99"], 1.0);
2759    }
2760
2761    #[test]
2762    fn test_max_price_builder() {
2763        let price = MaxPrice::new().prompt(0.001).completion(0.002);
2764
2765        assert_eq!(price.prompt, Some(0.001));
2766        assert_eq!(price.completion, Some(0.002));
2767        assert_eq!(price.request, None);
2768        assert_eq!(price.image, None);
2769    }
2770
2771    #[test]
2772    fn test_max_price_all_fields() {
2773        let price = MaxPrice::new()
2774            .prompt(0.001)
2775            .completion(0.002)
2776            .request(0.01)
2777            .image(0.05);
2778
2779        let json = serde_json::to_value(&price).unwrap();
2780        assert_eq!(json["prompt"], 0.001);
2781        assert_eq!(json["completion"], 0.002);
2782        assert_eq!(json["request"], 0.01);
2783        assert_eq!(json["image"], 0.05);
2784    }
2785
2786    #[test]
2787    fn test_max_price_default() {
2788        let price = MaxPrice::default();
2789        assert_eq!(price.prompt, None);
2790        assert_eq!(price.completion, None);
2791        assert_eq!(price.request, None);
2792        assert_eq!(price.image, None);
2793    }
2794
2795    #[test]
2796    fn test_provider_preferences_default() {
2797        let prefs = ProviderPreferences::default();
2798        assert!(prefs.order.is_none());
2799        assert!(prefs.only.is_none());
2800        assert!(prefs.ignore.is_none());
2801        assert!(prefs.allow_fallbacks.is_none());
2802        assert!(prefs.require_parameters.is_none());
2803        assert!(prefs.data_collection.is_none());
2804        assert!(prefs.zdr.is_none());
2805        assert!(prefs.sort.is_none());
2806        assert!(prefs.preferred_min_throughput.is_none());
2807        assert!(prefs.preferred_max_latency.is_none());
2808        assert!(prefs.max_price.is_none());
2809        assert!(prefs.quantizations.is_none());
2810    }
2811
2812    #[test]
2813    fn test_provider_preferences_order_with_fallbacks() {
2814        let prefs = ProviderPreferences::new()
2815            .order(["anthropic", "openai"])
2816            .allow_fallbacks(true);
2817
2818        let json = prefs.to_json();
2819        let provider = &json["provider"];
2820
2821        assert_eq!(provider["order"], json!(["anthropic", "openai"]));
2822        assert_eq!(provider["allow_fallbacks"], true);
2823    }
2824
2825    #[test]
2826    fn test_provider_preferences_only_allowlist() {
2827        let prefs = ProviderPreferences::new()
2828            .only(["azure", "together"])
2829            .allow_fallbacks(false);
2830
2831        let json = prefs.to_json();
2832        let provider = &json["provider"];
2833
2834        assert_eq!(provider["only"], json!(["azure", "together"]));
2835        assert_eq!(provider["allow_fallbacks"], false);
2836    }
2837
2838    #[test]
2839    fn test_provider_preferences_ignore() {
2840        let prefs = ProviderPreferences::new().ignore(["deepinfra"]);
2841
2842        let json = prefs.to_json();
2843        let provider = &json["provider"];
2844
2845        assert_eq!(provider["ignore"], json!(["deepinfra"]));
2846    }
2847
2848    #[test]
2849    fn test_provider_preferences_sort_latency() {
2850        let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Latency);
2851
2852        let json = prefs.to_json();
2853        let provider = &json["provider"];
2854
2855        assert_eq!(provider["sort"], "latency");
2856    }
2857
2858    #[test]
2859    fn test_provider_preferences_price_with_throughput() {
2860        let prefs = ProviderPreferences::new()
2861            .sort(ProviderSortStrategy::Price)
2862            .preferred_min_throughput(ThroughputThreshold::Percentile(
2863                PercentileThresholds::new().p90(50.0),
2864            ));
2865
2866        let json = prefs.to_json();
2867        let provider = &json["provider"];
2868
2869        assert_eq!(provider["sort"], "price");
2870        assert_eq!(provider["preferred_min_throughput"]["p90"], 50.0);
2871    }
2872
2873    #[test]
2874    fn test_provider_preferences_require_parameters() {
2875        let prefs = ProviderPreferences::new().require_parameters(true);
2876
2877        let json = prefs.to_json();
2878        let provider = &json["provider"];
2879
2880        assert_eq!(provider["require_parameters"], true);
2881    }
2882
2883    #[test]
2884    fn test_provider_preferences_data_policy_and_zdr() {
2885        let prefs = ProviderPreferences::new()
2886            .data_collection(DataCollection::Deny)
2887            .zdr(true);
2888
2889        let json = prefs.to_json();
2890        let provider = &json["provider"];
2891
2892        assert_eq!(provider["data_collection"], "deny");
2893        assert_eq!(provider["zdr"], true);
2894    }
2895
2896    #[test]
2897    fn test_provider_preferences_quantizations() {
2898        let prefs =
2899            ProviderPreferences::new().quantizations([Quantization::Int8, Quantization::Fp16]);
2900
2901        let json = prefs.to_json();
2902        let provider = &json["provider"];
2903
2904        assert_eq!(provider["quantizations"], json!(["int8", "fp16"]));
2905    }
2906
2907    #[test]
2908    fn test_provider_preferences_convenience_methods() {
2909        let prefs = ProviderPreferences::new().zero_data_retention().fastest();
2910
2911        assert_eq!(prefs.zdr, Some(true));
2912        assert_eq!(
2913            prefs.sort,
2914            Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
2915        );
2916
2917        let prefs2 = ProviderPreferences::new().cheapest();
2918        assert_eq!(
2919            prefs2.sort,
2920            Some(ProviderSort::Simple(ProviderSortStrategy::Price))
2921        );
2922
2923        let prefs3 = ProviderPreferences::new().lowest_latency();
2924        assert_eq!(
2925            prefs3.sort,
2926            Some(ProviderSort::Simple(ProviderSortStrategy::Latency))
2927        );
2928    }
2929
2930    #[test]
2931    fn test_provider_preferences_serialization_skips_none() {
2932        let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Price);
2933
2934        let json = serde_json::to_value(&prefs).unwrap();
2935
2936        assert_eq!(json["sort"], "price");
2937        assert!(json.get("order").is_none());
2938        assert!(json.get("only").is_none());
2939        assert!(json.get("ignore").is_none());
2940        assert!(json.get("zdr").is_none());
2941    }
2942
2943    #[test]
2944    fn test_provider_preferences_deserialization() {
2945        let json = json!({
2946            "order": ["anthropic", "openai"],
2947            "sort": "throughput",
2948            "data_collection": "deny",
2949            "zdr": true,
2950            "quantizations": ["int8", "fp16"]
2951        });
2952
2953        let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
2954
2955        assert_eq!(
2956            prefs.order,
2957            Some(vec!["anthropic".to_string(), "openai".to_string()])
2958        );
2959        assert_eq!(
2960            prefs.sort,
2961            Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
2962        );
2963        assert_eq!(prefs.data_collection, Some(DataCollection::Deny));
2964        assert_eq!(prefs.zdr, Some(true));
2965        assert_eq!(
2966            prefs.quantizations,
2967            Some(vec![Quantization::Int8, Quantization::Fp16])
2968        );
2969    }
2970
2971    #[test]
2972    fn test_provider_preferences_deserialization_complex_sort() {
2973        let json = json!({
2974            "sort": {
2975                "by": "latency",
2976                "partition": "model"
2977            }
2978        });
2979
2980        let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
2981
2982        match prefs.sort {
2983            Some(ProviderSort::Complex(config)) => {
2984                assert_eq!(config.by, ProviderSortStrategy::Latency);
2985                assert_eq!(config.partition, Some(SortPartition::Model));
2986            }
2987            _ => panic!("Expected Complex sort variant"),
2988        }
2989    }
2990
2991    #[test]
2992    fn test_provider_preferences_full_integration() {
2993        let prefs = ProviderPreferences::new()
2994            .order(["anthropic", "openai"])
2995            .only(["anthropic", "openai", "google"])
2996            .sort(ProviderSortStrategy::Throughput)
2997            .data_collection(DataCollection::Deny)
2998            .zdr(true)
2999            .quantizations([Quantization::Int8])
3000            .allow_fallbacks(false);
3001
3002        let json = prefs.to_json();
3003
3004        assert!(json.get("provider").is_some());
3005        let provider = &json["provider"];
3006        assert_eq!(provider["order"], json!(["anthropic", "openai"]));
3007        assert_eq!(provider["only"], json!(["anthropic", "openai", "google"]));
3008        assert_eq!(provider["sort"], "throughput");
3009        assert_eq!(provider["data_collection"], "deny");
3010        assert_eq!(provider["zdr"], true);
3011        assert_eq!(provider["quantizations"], json!(["int8"]));
3012        assert_eq!(provider["allow_fallbacks"], false);
3013    }
3014
3015    #[test]
3016    fn test_provider_preferences_max_price() {
3017        let prefs =
3018            ProviderPreferences::new().max_price(MaxPrice::new().prompt(0.001).completion(0.002));
3019
3020        let json = prefs.to_json();
3021        let provider = &json["provider"];
3022
3023        assert_eq!(provider["max_price"]["prompt"], 0.001);
3024        assert_eq!(provider["max_price"]["completion"], 0.002);
3025    }
3026
3027    #[test]
3028    fn test_provider_preferences_preferred_max_latency() {
3029        let prefs = ProviderPreferences::new().preferred_max_latency(LatencyThreshold::Simple(0.5));
3030
3031        let json = prefs.to_json();
3032        let provider = &json["provider"];
3033
3034        assert_eq!(provider["preferred_max_latency"], 0.5);
3035    }
3036
3037    #[test]
3038    fn test_provider_preferences_empty_arrays() {
3039        let prefs = ProviderPreferences::new()
3040            .order(Vec::<String>::new())
3041            .quantizations(Vec::<Quantization>::new());
3042
3043        let json = prefs.to_json();
3044        let provider = &json["provider"];
3045
3046        assert_eq!(provider["order"], json!([]));
3047        assert_eq!(provider["quantizations"], json!([]));
3048    }
3049
3050    // ================================================================
3051    // File Support Tests
3052    // ================================================================
3053
3054    #[test]
3055    fn test_user_content_text_serialization() {
3056        let content = UserContent::Text {
3057            text: "Hello, world!".to_string(),
3058        };
3059        let json = serde_json::to_value(&content).unwrap();
3060
3061        assert_eq!(json["type"], "text");
3062        assert_eq!(json["text"], "Hello, world!");
3063    }
3064
3065    #[test]
3066    fn test_user_content_image_url_serialization() {
3067        let content = UserContent::Image {
3068            image_url: ImageUrl {
3069                url: "https://example.com/image.png".to_string(),
3070                detail: None,
3071            },
3072        };
3073        let json = serde_json::to_value(&content).unwrap();
3074
3075        assert_eq!(json["type"], "image_url");
3076        assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
3077        assert!(json["image_url"].get("detail").is_none());
3078    }
3079
3080    #[test]
3081    fn test_user_content_image_url_with_detail_serialization() {
3082        let content = UserContent::Image {
3083            image_url: ImageUrl {
3084                url: "https://example.com/image.png".to_string(),
3085                detail: Some(ImageDetail::High),
3086            },
3087        };
3088        let json = serde_json::to_value(&content).unwrap();
3089
3090        assert_eq!(json["type"], "image_url");
3091        assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
3092        assert_eq!(json["image_url"]["detail"], "high");
3093    }
3094
3095    #[test]
3096    fn test_user_content_image_base64_serialization() {
3097        let content = UserContent::Image {
3098            image_url: ImageUrl {
3099                url: "data:image/png;base64,SGVsbG8=".to_string(),
3100                detail: Some(ImageDetail::Low),
3101            },
3102        };
3103        let json = serde_json::to_value(&content).unwrap();
3104
3105        assert_eq!(json["type"], "image_url");
3106        assert_eq!(json["image_url"]["url"], "data:image/png;base64,SGVsbG8=");
3107        assert_eq!(json["image_url"]["detail"], "low");
3108    }
3109
3110    #[test]
3111    fn test_user_content_file_url_serialization() {
3112        let content = UserContent::File {
3113            file: FileData {
3114                file_data: Some("https://example.com/doc.pdf".to_string()),
3115                file_id: None,
3116                filename: Some("document.pdf".to_string()),
3117            },
3118        };
3119        let json = serde_json::to_value(&content).unwrap();
3120
3121        assert_eq!(json["type"], "file");
3122        assert_eq!(json["file"]["file_data"], "https://example.com/doc.pdf");
3123        assert_eq!(json["file"]["filename"], "document.pdf");
3124    }
3125
3126    #[test]
3127    fn test_user_content_file_base64_serialization() {
3128        let content = UserContent::File {
3129            file: FileData {
3130                file_data: Some("data:application/pdf;base64,JVBERi0xLjQ=".to_string()),
3131                file_id: None,
3132                filename: Some("report.pdf".to_string()),
3133            },
3134        };
3135        let json = serde_json::to_value(&content).unwrap();
3136
3137        assert_eq!(json["type"], "file");
3138        assert_eq!(
3139            json["file"]["file_data"],
3140            "data:application/pdf;base64,JVBERi0xLjQ="
3141        );
3142        assert_eq!(json["file"]["filename"], "report.pdf");
3143    }
3144
3145    #[test]
3146    fn test_user_content_text_deserialization() {
3147        let json = json!({
3148            "type": "text",
3149            "text": "Hello!"
3150        });
3151
3152        let content: UserContent = serde_json::from_value(json).unwrap();
3153        assert_eq!(
3154            content,
3155            UserContent::Text {
3156                text: "Hello!".to_string()
3157            }
3158        );
3159    }
3160
3161    #[test]
3162    fn test_user_content_image_url_deserialization() {
3163        let json = json!({
3164            "type": "image_url",
3165            "image_url": {
3166                "url": "https://example.com/img.jpg",
3167                "detail": "high"
3168            }
3169        });
3170
3171        let content: UserContent = serde_json::from_value(json).unwrap();
3172        match content {
3173            UserContent::Image { image_url } => {
3174                assert_eq!(image_url.url, "https://example.com/img.jpg");
3175                assert_eq!(image_url.detail, Some(ImageDetail::High));
3176            }
3177            _ => panic!("Expected Image variant"),
3178        }
3179    }
3180
3181    #[test]
3182    fn test_user_content_file_deserialization() {
3183        let json = json!({
3184            "type": "file",
3185            "file": {
3186                "filename": "doc.pdf",
3187                "file_data": "https://example.com/doc.pdf"
3188            }
3189        });
3190
3191        let content: UserContent = serde_json::from_value(json).unwrap();
3192        match content {
3193            UserContent::File { file } => {
3194                assert_eq!(file.filename, Some("doc.pdf".to_string()));
3195                assert_eq!(
3196                    file.file_data,
3197                    Some("https://example.com/doc.pdf".to_string())
3198                );
3199            }
3200            _ => panic!("Expected File variant"),
3201        }
3202    }
3203
3204    #[test]
3205    fn test_message_user_with_text_serialization() {
3206        let message = Message::User {
3207            content: vec![UserContent::Text {
3208                text: "Hello".to_string(),
3209            }],
3210            name: None,
3211        };
3212        let json = serde_json::to_value(&message).unwrap();
3213
3214        // Single text content should be serialized as a plain string
3215        assert_eq!(json["role"], "user");
3216        assert_eq!(json["content"], "Hello");
3217    }
3218
3219    #[test]
3220    fn test_message_user_with_mixed_content_serialization() {
3221        let message = Message::User {
3222            content: vec![
3223                UserContent::Text {
3224                    text: "Check this image:".to_string(),
3225                },
3226                UserContent::Image {
3227                    image_url: ImageUrl {
3228                        url: "https://example.com/img.png".to_string(),
3229                        detail: None,
3230                    },
3231                },
3232            ],
3233            name: None,
3234        };
3235        let json = serde_json::to_value(&message).unwrap();
3236
3237        assert_eq!(json["role"], "user");
3238        let content = json["content"].as_array().unwrap();
3239        assert_eq!(content.len(), 2);
3240        assert_eq!(content[0]["type"], "text");
3241        assert_eq!(content[1]["type"], "image_url");
3242    }
3243
3244    #[test]
3245    fn test_message_user_with_file_serialization() {
3246        let message = Message::User {
3247            content: vec![
3248                UserContent::Text {
3249                    text: "Analyze this PDF:".to_string(),
3250                },
3251                UserContent::File {
3252                    file: FileData {
3253                        file_data: Some("https://example.com/doc.pdf".to_string()),
3254                        file_id: None,
3255                        filename: Some("document.pdf".to_string()),
3256                    },
3257                },
3258            ],
3259            name: None,
3260        };
3261        let json = serde_json::to_value(&message).unwrap();
3262
3263        assert_eq!(json["role"], "user");
3264        let content = json["content"].as_array().unwrap();
3265        assert_eq!(content.len(), 2);
3266        assert_eq!(content[0]["type"], "text");
3267        assert_eq!(content[1]["type"], "file");
3268        assert_eq!(
3269            content[1]["file"]["file_data"],
3270            "https://example.com/doc.pdf"
3271        );
3272    }
3273
3274    #[test]
3275    fn test_user_content_from_rig_text() {
3276        let rig_content = message::UserContent::Text(message::Text::new("Hello".to_string()));
3277        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3278
3279        assert_eq!(
3280            openrouter_content,
3281            UserContent::Text {
3282                text: "Hello".to_string()
3283            }
3284        );
3285    }
3286
3287    #[test]
3288    fn test_user_content_from_rig_image_url() {
3289        let rig_content = message::UserContent::Image(message::Image {
3290            data: DocumentSourceKind::Url("https://example.com/img.png".to_string()),
3291            media_type: Some(message::ImageMediaType::PNG),
3292            detail: Some(ImageDetail::High),
3293            additional_params: None,
3294        });
3295        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3296
3297        match openrouter_content {
3298            UserContent::Image { image_url } => {
3299                assert_eq!(image_url.url, "https://example.com/img.png");
3300                assert_eq!(image_url.detail, Some(ImageDetail::High));
3301            }
3302            _ => panic!("Expected Image variant"),
3303        }
3304    }
3305
3306    #[test]
3307    fn test_user_content_from_rig_image_base64() {
3308        let rig_content = message::UserContent::Image(message::Image {
3309            data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3310            media_type: Some(message::ImageMediaType::JPEG),
3311            detail: Some(ImageDetail::Low),
3312            additional_params: None,
3313        });
3314        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3315
3316        match openrouter_content {
3317            UserContent::Image { image_url } => {
3318                assert_eq!(image_url.url, "data:image/jpeg;base64,SGVsbG8=");
3319                assert_eq!(image_url.detail, Some(ImageDetail::Low));
3320            }
3321            _ => panic!("Expected Image variant"),
3322        }
3323    }
3324
3325    #[test]
3326    fn test_user_content_from_rig_document_url() {
3327        let rig_content = message::UserContent::Document(message::Document {
3328            data: DocumentSourceKind::Url("https://example.com/doc.pdf".to_string()),
3329            media_type: Some(DocumentMediaType::PDF),
3330            additional_params: None,
3331        });
3332        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3333
3334        match openrouter_content {
3335            UserContent::File { file } => {
3336                assert_eq!(
3337                    file.file_data,
3338                    Some("https://example.com/doc.pdf".to_string())
3339                );
3340                assert_eq!(file.filename, Some("document.pdf".to_string()));
3341            }
3342            _ => panic!("Expected File variant"),
3343        }
3344    }
3345
3346    #[test]
3347    fn test_user_content_from_rig_document_base64() {
3348        let rig_content = message::UserContent::Document(message::Document {
3349            data: DocumentSourceKind::Base64("JVBERi0xLjQ=".to_string()),
3350            media_type: Some(DocumentMediaType::PDF),
3351            additional_params: None,
3352        });
3353        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3354
3355        match openrouter_content {
3356            UserContent::File { file } => {
3357                assert_eq!(
3358                    file.file_data,
3359                    Some("data:application/pdf;base64,JVBERi0xLjQ=".to_string())
3360                );
3361                assert_eq!(file.filename, Some("document.pdf".to_string()));
3362            }
3363            _ => panic!("Expected File variant"),
3364        }
3365    }
3366
3367    #[test]
3368    fn test_user_content_from_rig_document_file_id() {
3369        let rig_content = message::UserContent::Document(message::Document {
3370            data: DocumentSourceKind::FileId("file_abc".to_string()),
3371            media_type: None,
3372            additional_params: None,
3373        });
3374
3375        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3376        assert!(matches!(
3377            result,
3378            Err(message::MessageError::ConversionError(message))
3379                if message.contains("Provider file IDs are not supported")
3380        ));
3381    }
3382
3383    #[test]
3384    fn test_openai_file_id_content_round_trips_through_rig_to_openrouter_error() {
3385        let openai_content = openai::UserContent::File {
3386            file: openai::FileData {
3387                file_data: None,
3388                file_id: Some("file_abc".to_string()),
3389                filename: None,
3390            },
3391        };
3392        let rig_content: message::UserContent = openai_content.into();
3393
3394        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3395        assert!(matches!(
3396            result,
3397            Err(message::MessageError::ConversionError(message))
3398                if message.contains("Provider file IDs are not supported")
3399        ));
3400    }
3401
3402    #[test]
3403    fn test_user_content_from_rig_document_string_becomes_text() {
3404        let rig_content = message::UserContent::Document(message::Document {
3405            data: DocumentSourceKind::String("Plain text document content".to_string()),
3406            media_type: Some(DocumentMediaType::TXT),
3407            additional_params: None,
3408        });
3409        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3410
3411        assert_eq!(
3412            openrouter_content,
3413            UserContent::Text {
3414                text: "Plain text document content".to_string()
3415            }
3416        );
3417    }
3418
3419    #[test]
3420    fn test_completion_response_with_reasoning_details_maps_to_typed_reasoning() {
3421        let json = json!({
3422            "id": "resp_123",
3423            "object": "chat.completion",
3424            "created": 1,
3425            "model": "openrouter/test-model",
3426            "choices": [{
3427                "index": 0,
3428                "finish_reason": "stop",
3429                "message": {
3430                    "role": "assistant",
3431                    "content": "hello",
3432                    "reasoning": null,
3433                    "reasoning_details": [
3434                        {"type":"reasoning.summary","id":"rs_1","summary":"s1"},
3435                        {"type":"reasoning.text","id":"rs_1","text":"t1","signature":"sig_1"},
3436                        {"type":"reasoning.encrypted","id":"rs_1","data":"enc_1"}
3437                    ],
3438                    "tool_calls": [{
3439                        "id": "call_1",
3440                        "type": "function",
3441                        "function": {"name": "lookup", "arguments": "{}"}
3442                    }]
3443                }
3444            }]
3445        });
3446
3447        let response: CompletionResponse = serde_json::from_value(json).unwrap();
3448        let converted = response.normalize(PROVIDER_NAME).unwrap();
3449        let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3450
3451        assert_eq!(items.len(), 3, "reasoning, text, then tool call");
3452        assert!(matches!(
3453            &items[0],
3454            completion::AssistantContent::Reasoning(message::Reasoning { id: Some(id), content })
3455                if id == "rs_1" && content.len() == 3
3456        ));
3457        assert!(matches!(
3458            &items[1],
3459            completion::AssistantContent::Text(text) if text.text == "hello"
3460        ));
3461        assert!(matches!(
3462            &items[2],
3463            completion::AssistantContent::ToolCall(call) if call.function.name == "lookup"
3464        ));
3465    }
3466
3467    /// Encrypted `reasoning_details` on the streaming wire must reach the
3468    /// aggregated choice and replay on the next turn.
3469    ///
3470    /// The SSE below mirrors the recorded OpenRouter shape
3471    /// (`tests/cassettes/openrouter/streaming_tools/raw_stream_decorates_reasoning_tool_call_metadata.yaml`):
3472    /// the detail arrives with `reasoning: null` and an `rs_*` id of its own,
3473    /// one chunk *before* the `call_*` tool call opens. Routed through
3474    /// tool-call decoration those two id namespaces never match, so the blob
3475    /// was dropped on every streaming turn while the non-streaming path kept
3476    /// it.
3477    #[tokio::test]
3478    async fn streaming_encrypted_reasoning_detail_reaches_the_choice_and_replays() {
3479        use crate::client::CompletionClient;
3480        use crate::completion::CompletionModel as _;
3481        use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_data_lines;
3482        use crate::streaming::StreamedAssistantContent;
3483        use crate::test_utils::MockStreamingClient;
3484        use futures::StreamExt;
3485
3486        let http_client = MockStreamingClient {
3487            sse_bytes: sse_bytes_from_data_lines([
3488                r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":null,"reasoning_details":[{"type":"reasoning.encrypted","id":"rs_1","format":"openai-responses-v1","index":0,"data":"enc_blob"}]},"finish_reason":null}]}"#,
3489                r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}"#,
3490                r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":\"Tokyo\"}"}}]},"finish_reason":null}]}"#,
3491                r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#,
3492                "[DONE]",
3493            ]),
3494        };
3495
3496        let client = crate::providers::openrouter::Client::builder()
3497            .api_key("dummy-key")
3498            .http_client(http_client)
3499            .build()
3500            .expect("client should build");
3501        let model = client.completion_model("openai/o4-mini");
3502        let request = model.completion_request("weather?").build();
3503        let mut stream = model.stream(request).await.expect("stream should start");
3504
3505        let mut events: Vec<&'static str> = Vec::new();
3506        let mut streamed_tool_calls = Vec::new();
3507        while let Some(chunk) = stream.next().await {
3508            match chunk.expect("stream item should be ok") {
3509                StreamedAssistantContent::Reasoning { reasoning, .. } => {
3510                    assert_eq!(reasoning.id.as_deref(), Some("rs_1"));
3511                    assert!(matches!(
3512                        reasoning.content.first(),
3513                        Some(message::ReasoningContent::Encrypted(data)) if data == "enc_blob"
3514                    ));
3515                    events.push("reasoning");
3516                }
3517                StreamedAssistantContent::ToolCall { tool_call, .. } => {
3518                    streamed_tool_calls.push(tool_call);
3519                    events.push("tool_call");
3520                }
3521                _ => {}
3522            }
3523        }
3524
3525        // Wire order: the reasoning block precedes the tool call it was
3526        // recorded before.
3527        assert_eq!(events, vec!["reasoning", "tool_call"]);
3528
3529        // The tool call is *not* where the blob lives: decoration by the
3530        // detail's own id could never match the call's id.
3531        let tool_call = streamed_tool_calls.first().expect("streamed tool call");
3532        assert_eq!(tool_call.id, "call_1");
3533        assert!(tool_call.signature.is_none());
3534        assert!(tool_call.additional_params.is_none());
3535
3536        // (a) the encrypted block reaches the aggregated choice ...
3537        let choice: Vec<message::AssistantContent> = stream.choice.clone().into_iter().collect();
3538        assert!(
3539            choice.iter().any(|content| matches!(
3540                content,
3541                message::AssistantContent::Reasoning(message::Reasoning { id: Some(id), content })
3542                    if id == "rs_1"
3543                        && matches!(
3544                            content.first(),
3545                            Some(message::ReasoningContent::Encrypted(data)) if data == "enc_blob"
3546                        )
3547            )),
3548            "encrypted reasoning must reach the aggregated choice: {choice:#?}"
3549        );
3550
3551        // ... and (b) replays into the next turn's request messages.
3552        let messages =
3553            assistant_contents_to_messages(stream.choice.clone()).expect("history conversion");
3554        let Message::Assistant {
3555            reasoning_details, ..
3556        } = messages.first().expect("assistant message")
3557        else {
3558            panic!("Expected assistant message");
3559        };
3560        assert!(
3561            reasoning_details.iter().any(|detail| matches!(
3562                detail,
3563                ReasoningDetails::Encrypted { id: Some(id), data, .. }
3564                    if id == "rs_1" && data == "enc_blob"
3565            )),
3566            "encrypted reasoning must replay as a reasoning_details entry: {reasoning_details:#?}"
3567        );
3568    }
3569
3570    /// Anthropic-routed OpenRouter streams put the replay-required signature
3571    /// in a final `reasoning.text` detail with no text of its own. The shared
3572    /// `delta.reasoning` field carries the preceding plaintext, so the detail
3573    /// must close and sign that same block before the tool call is emitted.
3574    #[tokio::test]
3575    async fn streaming_anthropic_reasoning_signature_reaches_choice_and_replays() {
3576        use crate::client::CompletionClient;
3577        use crate::completion::CompletionModel as _;
3578        use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_data_lines;
3579        use crate::test_utils::MockStreamingClient;
3580        use futures::StreamExt;
3581
3582        let http_client = MockStreamingClient {
3583            sse_bytes: sse_bytes_from_data_lines([
3584                r#"{"id":"chatcmpl-1","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"think first","reasoning_details":[{"type":"reasoning.text","format":"anthropic-claude-v1","index":0,"text":"think first"}]},"finish_reason":null,"native_finish_reason":null}]}"#,
3585                r#"{"id":"chatcmpl-1","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_details":[{"type":"reasoning.text","format":"anthropic-claude-v1","index":0,"signature":"sig-live-shape"}]},"finish_reason":null,"native_finish_reason":null}]}"#,
3586                r#"{"id":"chatcmpl-1","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"toolu_1","type":"function","function":{"name":"lookup","arguments":"{}"}}]},"finish_reason":null,"native_finish_reason":null}]}"#,
3587                r#"{"id":"chatcmpl-1","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls","native_finish_reason":"tool_use"}]}"#,
3588                "[DONE]",
3589            ]),
3590        };
3591
3592        let client = crate::providers::openrouter::Client::builder()
3593            .api_key("dummy-key")
3594            .http_client(http_client)
3595            .build()
3596            .expect("client should build");
3597        let model = client.completion_model("anthropic/claude-haiku-4.5");
3598        let request = model.completion_request("lookup").build();
3599        let mut stream = model.stream(request).await.expect("stream should start");
3600        while let Some(item) = stream.next().await {
3601            item.expect("signed reasoning stream item");
3602        }
3603
3604        let choice = stream.choice.clone().into_iter().collect::<Vec<_>>();
3605        assert!(matches!(
3606            choice.first(),
3607            Some(message::AssistantContent::Reasoning(message::Reasoning { content, .. }))
3608                if matches!(
3609                    content.first(),
3610                    Some(message::ReasoningContent::Text { text, signature: Some(signature) })
3611                        if text == "think first" && signature == "sig-live-shape"
3612                )
3613        ));
3614        assert!(matches!(
3615            choice.get(1),
3616            Some(message::AssistantContent::ToolCall(call)) if call.function.name == "lookup"
3617        ));
3618
3619        let messages = assistant_contents_to_messages(choice).expect("history conversion");
3620        let Message::Assistant {
3621            reasoning_details, ..
3622        } = messages.first().expect("assistant message")
3623        else {
3624            panic!("expected assistant history message");
3625        };
3626        assert!(matches!(
3627            reasoning_details.first(),
3628            Some(ReasoningDetails::Text {
3629                text: Some(text),
3630                signature: Some(signature),
3631                ..
3632            }) if text == "think first" && signature == "sig-live-shape"
3633        ));
3634    }
3635
3636    /// An id-less encrypted detail must not clobber the reasoning text
3637    /// accumulating under the wire's constant minted key.
3638    ///
3639    /// The shared compat adapter keys `reasoning` text deltas by
3640    /// `Minted { Reasoning, 0 }`; the id-less encrypted detail arrives as a
3641    /// whole block while that part is still open. Keyed identically, the
3642    /// whole block would *restate* — replace — the open text part
3643    /// (pre-fix, all accumulated reasoning text was lost). Keyed as
3644    /// `EncryptedReasoning` it is a sibling: both parts reach the
3645    /// aggregated choice.
3646    #[tokio::test]
3647    async fn id_less_encrypted_detail_does_not_replace_open_reasoning_text() {
3648        use crate::client::CompletionClient;
3649        use crate::completion::CompletionModel as _;
3650        use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_data_lines;
3651        use crate::test_utils::MockStreamingClient;
3652        use futures::StreamExt;
3653
3654        let http_client = MockStreamingClient {
3655            sse_bytes: sse_bytes_from_data_lines([
3656                r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"deep "},"finish_reason":null}]}"#,
3657                r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"reasoning":"thought"},"finish_reason":null}]}"#,
3658                r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"reasoning":null,"reasoning_details":[{"type":"reasoning.encrypted","id":null,"format":"openai-responses-v1","index":0,"data":"enc_blob"}]},"finish_reason":null}]}"#,
3659                r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#,
3660                "[DONE]",
3661            ]),
3662        };
3663
3664        let client = crate::providers::openrouter::Client::builder()
3665            .api_key("dummy-key")
3666            .http_client(http_client)
3667            .build()
3668            .expect("client should build");
3669        let model = client.completion_model("openai/o4-mini");
3670        let request = model.completion_request("weather?").build();
3671        let mut stream = model.stream(request).await.expect("stream should start");
3672        while stream.next().await.is_some() {}
3673
3674        let choice: Vec<message::AssistantContent> = stream.choice.clone().into_iter().collect();
3675        assert!(
3676            choice.iter().any(|content| matches!(
3677                content,
3678                message::AssistantContent::Reasoning(message::Reasoning { content, .. })
3679                    if matches!(
3680                        content.first(),
3681                        Some(message::ReasoningContent::Text { text, .. }) if text == "deep thought"
3682                    )
3683            )),
3684            "the accumulated reasoning text must survive the encrypted detail: {choice:#?}"
3685        );
3686        assert!(
3687            choice.iter().any(|content| matches!(
3688                content,
3689                message::AssistantContent::Reasoning(message::Reasoning { id: None, content })
3690                    if matches!(
3691                        content.first(),
3692                        Some(message::ReasoningContent::Encrypted(data)) if data == "enc_blob"
3693                    )
3694            )),
3695            "the encrypted blob must reach the choice as its own part: {choice:#?}"
3696        );
3697    }
3698
3699    /// An encrypted detail the wire sends without an id streams under its
3700    /// own minted `EncryptedReasoning` key; it must still replay, and with
3701    /// a null wire id rather than an empty string.
3702    #[test]
3703    fn id_less_encrypted_reasoning_replays_with_a_null_wire_id() {
3704        use crate::providers::openai::completion::OpenAICompatibleProvider as _;
3705
3706        let detail = json!({
3707            "type": "reasoning.encrypted",
3708            "id": null,
3709            "format": null,
3710            "index": 0,
3711            "data": "enc_blob",
3712        });
3713        let (id, provider_id, content) = OpenRouterExt
3714            .streaming_detail_reasoning(&detail)
3715            .expect("encrypted detail should map to reasoning");
3716        // 84a43e9e #4, closed: an id-less detail keys accumulation by a
3717        // minted (opaque) key and carries NO durable handle — a fabricated
3718        // "wire" empty id is unrepresentable, so no serializer needs an
3719        // empty-string filter.
3720        assert!(
3721            id.is_minted(),
3722            "id-less details key by a minted key: {id:?}"
3723        );
3724        assert!(
3725            provider_id.is_none(),
3726            "absence is None, never a fabricated id"
3727        );
3728        assert!(matches!(
3729            content,
3730            message::ReasoningContent::Encrypted(ref data) if data == "enc_blob"
3731        ));
3732
3733        let messages = assistant_contents_to_messages(vec![message::AssistantContent::Reasoning(
3734            message::Reasoning {
3735                id: provider_id.map(|id| id.into_string()),
3736                content: vec![content],
3737            },
3738        )])
3739        .unwrap();
3740        let Message::Assistant {
3741            reasoning_details, ..
3742        } = messages.first().expect("assistant message")
3743        else {
3744            panic!("Expected assistant message");
3745        };
3746        assert!(matches!(
3747            reasoning_details.first(),
3748            Some(ReasoningDetails::Encrypted { id: None, data, .. }) if data == "enc_blob"
3749        ));
3750    }
3751
3752    #[test]
3753    fn test_assistant_reasoning_emits_openrouter_reasoning_details() {
3754        let reasoning = message::Reasoning {
3755            id: Some("rs_2".to_string()),
3756            content: vec![
3757                message::ReasoningContent::Text {
3758                    text: "step".to_string(),
3759                    signature: Some("sig_step".to_string()),
3760                },
3761                message::ReasoningContent::Summary("summary".to_string()),
3762                message::ReasoningContent::Encrypted("enc_blob".to_string()),
3763            ],
3764        };
3765
3766        let messages =
3767            assistant_contents_to_messages(vec![message::AssistantContent::Reasoning(reasoning)])
3768                .unwrap();
3769        let Message::Assistant {
3770            reasoning,
3771            reasoning_details,
3772            ..
3773        } = messages.first().expect("assistant message")
3774        else {
3775            panic!("Expected assistant message");
3776        };
3777
3778        assert!(reasoning.is_none());
3779        assert_eq!(reasoning_details.len(), 3);
3780        assert!(matches!(
3781            reasoning_details.first(),
3782            Some(ReasoningDetails::Text {
3783                id: Some(id),
3784                text: Some(text),
3785                signature: Some(signature),
3786                ..
3787            }) if id == "rs_2" && text == "step" && signature == "sig_step"
3788        ));
3789    }
3790
3791    #[test]
3792    fn test_tool_call_signature_without_params_uses_wire_id_for_encrypted_detail() {
3793        let tool_call = message::ToolCall::from_wire(
3794            "call_wire",
3795            message::ToolFunction {
3796                name: "lookup".to_string(),
3797                arguments: json!({}),
3798            },
3799        )
3800        .with_signature(Some("sig-data".to_string()));
3801
3802        let messages =
3803            assistant_contents_to_messages(vec![message::AssistantContent::ToolCall(tool_call)])
3804                .unwrap();
3805
3806        let Message::Assistant {
3807            reasoning_details, ..
3808        } = messages.first().expect("assistant message")
3809        else {
3810            panic!("Expected assistant message");
3811        };
3812
3813        assert!(matches!(
3814            reasoning_details.first(),
3815            Some(ReasoningDetails::Encrypted {
3816                id: Some(id),
3817                data,
3818                ..
3819            }) if id == "call_wire" && data == "sig-data"
3820        ));
3821    }
3822
3823    #[test]
3824    fn test_tool_call_minimal_params_fall_back_to_wire_id() {
3825        let tool_call = message::ToolCall::from_wire(
3826            "call_wire",
3827            message::ToolFunction {
3828                name: "lookup".to_string(),
3829                arguments: json!({}),
3830            },
3831        )
3832        .with_signature(Some("sig-data".to_string()))
3833        // Minimal params carrying only a format: the detail id must
3834        // still correlate with the wire tool-call id.
3835        .with_additional_params(Some(json!({"format": "anthropic"})));
3836
3837        let messages =
3838            assistant_contents_to_messages(vec![message::AssistantContent::ToolCall(tool_call)])
3839                .unwrap();
3840
3841        let Message::Assistant {
3842            reasoning_details, ..
3843        } = messages.first().expect("assistant message")
3844        else {
3845            panic!("Expected assistant message");
3846        };
3847
3848        assert!(matches!(
3849            reasoning_details.first(),
3850            Some(ReasoningDetails::Encrypted {
3851                id: Some(id),
3852                format,
3853                data,
3854                ..
3855            }) if id == "call_wire" && data == "sig-data" && format.as_deref() == Some("anthropic")
3856        ));
3857    }
3858
3859    #[test]
3860    fn test_assistant_redacted_reasoning_emits_encrypted_detail_not_text() {
3861        let reasoning = message::Reasoning {
3862            id: Some("rs_redacted".to_string()),
3863            content: vec![message::ReasoningContent::Redacted {
3864                data: "opaque-redacted-data".to_string(),
3865            }],
3866        };
3867
3868        let messages =
3869            assistant_contents_to_messages(vec![message::AssistantContent::Reasoning(reasoning)])
3870                .unwrap();
3871
3872        let Message::Assistant {
3873            reasoning_details,
3874            reasoning,
3875            ..
3876        } = messages.first().expect("assistant message")
3877        else {
3878            panic!("Expected assistant message");
3879        };
3880
3881        assert!(reasoning.is_none());
3882        assert_eq!(reasoning_details.len(), 1);
3883        assert!(matches!(
3884            reasoning_details.first(),
3885            Some(ReasoningDetails::Encrypted {
3886                id: Some(id),
3887                data,
3888                ..
3889            }) if id == "rs_redacted" && data == "opaque-redacted-data"
3890        ));
3891    }
3892
3893    #[test]
3894    fn test_completion_response_reasoning_details_respects_index_ordering() {
3895        let json = json!({
3896            "id": "resp_ordering",
3897            "object": "chat.completion",
3898            "created": 1,
3899            "model": "openrouter/test-model",
3900            "choices": [{
3901                "index": 0,
3902                "finish_reason": "stop",
3903                "message": {
3904                    "role": "assistant",
3905                    "content": "hello",
3906                    "reasoning": null,
3907                    "reasoning_details": [
3908                        {"type":"reasoning.summary","id":"rs_order","index":1,"summary":"second"},
3909                        {"type":"reasoning.summary","id":"rs_order","index":0,"summary":"first"}
3910                    ]
3911                }
3912            }]
3913        });
3914
3915        let response: CompletionResponse = serde_json::from_value(json).unwrap();
3916        let converted = response.normalize(PROVIDER_NAME).unwrap();
3917        let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
3918        let reasoning_blocks: Vec<_> = items
3919            .into_iter()
3920            .filter_map(|item| match item {
3921                completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
3922                _ => None,
3923            })
3924            .collect();
3925
3926        assert_eq!(reasoning_blocks.len(), 1);
3927        assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_order"));
3928        assert_eq!(
3929            reasoning_blocks[0].content,
3930            vec![
3931                message::ReasoningContent::Summary("first".to_string()),
3932                message::ReasoningContent::Summary("second".to_string()),
3933            ]
3934        );
3935    }
3936
3937    #[test]
3938    fn test_user_content_from_rig_image_missing_media_type_error() {
3939        let rig_content = message::UserContent::Image(message::Image {
3940            data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3941            media_type: None, // Missing media type
3942            detail: None,
3943            additional_params: None,
3944        });
3945        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3946
3947        assert!(result.is_err());
3948        let err = result.unwrap_err();
3949        assert!(err.to_string().contains("media type required"));
3950    }
3951
3952    #[test]
3953    fn test_user_content_from_rig_image_raw_bytes_error() {
3954        let rig_content = message::UserContent::Image(message::Image {
3955            data: DocumentSourceKind::Raw(vec![1, 2, 3]),
3956            media_type: Some(message::ImageMediaType::PNG),
3957            detail: None,
3958            additional_params: None,
3959        });
3960        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
3961
3962        assert!(result.is_err());
3963        let err = result.unwrap_err();
3964        assert!(err.to_string().contains("base64"));
3965    }
3966
3967    #[test]
3968    fn test_user_content_from_rig_video_url() {
3969        let rig_content = message::UserContent::Video(message::Video {
3970            data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
3971            media_type: Some(message::VideoMediaType::MP4),
3972            additional_params: None,
3973        });
3974        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3975
3976        match openrouter_content {
3977            UserContent::Video { video_url } => {
3978                assert_eq!(video_url.url, "https://example.com/video.mp4");
3979            }
3980            _ => panic!("Expected Video variant"),
3981        }
3982    }
3983
3984    #[test]
3985    fn test_user_content_from_rig_video_base64() {
3986        let rig_content = message::UserContent::Video(message::Video {
3987            data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
3988            media_type: Some(message::VideoMediaType::MP4),
3989            additional_params: None,
3990        });
3991        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
3992
3993        match openrouter_content {
3994            UserContent::Video { video_url } => {
3995                assert_eq!(video_url.url, "data:video/mp4;base64,SGVsbG8=");
3996            }
3997            _ => panic!("Expected Video variant"),
3998        }
3999    }
4000
4001    #[test]
4002    fn test_user_content_from_rig_video_base64_missing_media_type_error() {
4003        let rig_content = message::UserContent::Video(message::Video {
4004            data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
4005            media_type: None,
4006            additional_params: None,
4007        });
4008        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
4009
4010        assert!(result.is_err());
4011        let err = result.unwrap_err();
4012        assert!(err.to_string().contains("media type"));
4013    }
4014
4015    #[test]
4016    fn test_user_content_from_rig_video_raw_bytes_error() {
4017        let rig_content = message::UserContent::Video(message::Video {
4018            data: DocumentSourceKind::Raw(vec![1, 2, 3]),
4019            media_type: Some(message::VideoMediaType::MP4),
4020            additional_params: None,
4021        });
4022        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
4023
4024        assert!(result.is_err());
4025        let err = result.unwrap_err();
4026        assert!(err.to_string().contains("base64"));
4027    }
4028
4029    #[test]
4030    fn test_user_content_from_rig_audio_base64() {
4031        let rig_content = message::UserContent::Audio(message::Audio {
4032            data: DocumentSourceKind::Base64("audiodata".to_string()),
4033            media_type: Some(message::AudioMediaType::MP3),
4034            additional_params: None,
4035        });
4036        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
4037
4038        match openrouter_content {
4039            UserContent::Audio { input_audio } => {
4040                assert_eq!(input_audio.data, "audiodata");
4041                assert_eq!(input_audio.format, message::AudioMediaType::MP3);
4042            }
4043            _ => panic!("Expected Audio variant"),
4044        }
4045    }
4046
4047    #[test]
4048    fn test_user_content_from_rig_audio_missing_media_type_error() {
4049        let rig_content = message::UserContent::Audio(message::Audio {
4050            data: DocumentSourceKind::Base64("audiodata".to_string()),
4051            media_type: None, // missing media type
4052            additional_params: None,
4053        });
4054        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
4055
4056        assert!(result.is_err());
4057        let err = result.unwrap_err();
4058        assert!(err.to_string().contains("media type required"));
4059    }
4060
4061    #[test]
4062    fn test_user_content_from_rig_audio_url_error() {
4063        let rig_content = message::UserContent::Audio(message::Audio {
4064            data: DocumentSourceKind::Url("https://example.com/audio.wav".to_string()),
4065            media_type: Some(message::AudioMediaType::WAV),
4066            additional_params: None,
4067        });
4068        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
4069
4070        assert!(result.is_err());
4071        let err = result.unwrap_err();
4072        assert!(err.to_string().contains("base64"));
4073    }
4074
4075    #[test]
4076    fn test_user_content_from_rig_audio_raw_bytes_error() {
4077        let rig_content = message::UserContent::Audio(message::Audio {
4078            data: DocumentSourceKind::Raw(vec![1, 2, 3]),
4079            media_type: Some(message::AudioMediaType::WAV),
4080            additional_params: None,
4081        });
4082        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
4083
4084        assert!(result.is_err());
4085        let err = result.unwrap_err();
4086        assert!(err.to_string().contains("base64"));
4087    }
4088
4089    #[test]
4090    fn test_user_content_from_rig_video_file_id_error() {
4091        let rig_content = message::UserContent::Video(message::Video {
4092            data: DocumentSourceKind::FileId("file-123".to_string()),
4093            media_type: Some(message::VideoMediaType::MP4),
4094            additional_params: None,
4095        });
4096        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
4097
4098        assert!(result.is_err());
4099        let err = result.unwrap_err();
4100        assert!(
4101            err.to_string()
4102                .contains("File IDs are not supported for video")
4103        );
4104    }
4105
4106    #[test]
4107    fn test_user_content_from_rig_audio_file_id_error() {
4108        let rig_content = message::UserContent::Audio(message::Audio {
4109            data: DocumentSourceKind::FileId("file-123".to_string()),
4110            media_type: Some(message::AudioMediaType::MP3),
4111            additional_params: None,
4112        });
4113        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
4114
4115        assert!(result.is_err());
4116        let err = result.unwrap_err();
4117        assert!(
4118            err.to_string()
4119                .contains("File IDs are not supported for audio")
4120        );
4121    }
4122
4123    #[test]
4124    fn test_video_helper_converts_to_data_uri() {
4125        // `UserContent::video(..)` carries base64 data and should become a
4126        // `video_url` data URI.
4127        let rig_content =
4128            message::UserContent::video("SGVsbG8=", Some(message::VideoMediaType::MP4));
4129        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
4130
4131        match openrouter_content {
4132            UserContent::Video { video_url } => {
4133                assert_eq!(video_url.url, "data:video/mp4;base64,SGVsbG8=");
4134            }
4135            _ => panic!("Expected Video variant"),
4136        }
4137    }
4138
4139    #[test]
4140    fn test_video_url_helper_passes_url_through() {
4141        // `UserContent::video_url(..)` passes the URL through unchanged and does
4142        // not require a media type.
4143        let rig_content = message::UserContent::video_url("https://example.com/video.mp4", None);
4144        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
4145
4146        match openrouter_content {
4147            UserContent::Video { video_url } => {
4148                assert_eq!(video_url.url, "https://example.com/video.mp4");
4149            }
4150            _ => panic!("Expected Video variant"),
4151        }
4152    }
4153
4154    #[test]
4155    fn test_video_raw_helper_errors() {
4156        // `UserContent::video_raw(..)` carries raw bytes, which OpenRouter cannot
4157        // accept; the caller must base64-encode first.
4158        let rig_content =
4159            message::UserContent::video_raw(vec![1, 2, 3], Some(message::VideoMediaType::MP4));
4160        let result: Result<UserContent, _> = user_content_to_openai(rig_content);
4161
4162        assert!(result.is_err());
4163        let err = result.unwrap_err();
4164        assert!(err.to_string().contains("base64"));
4165    }
4166
4167    #[test]
4168    fn test_message_conversion_with_pdf() {
4169        let rig_message = message::Message::User {
4170            content: vec![
4171                message::UserContent::Text(message::Text::new(
4172                    "Summarize this document".to_string(),
4173                )),
4174                message::UserContent::Document(message::Document {
4175                    data: DocumentSourceKind::Url("https://example.com/paper.pdf".to_string()),
4176                    media_type: Some(DocumentMediaType::PDF),
4177                    additional_params: None,
4178                }),
4179            ],
4180        };
4181
4182        let openrouter_messages: Vec<Message> = messages_from_rig_message(rig_message).unwrap();
4183        assert_eq!(openrouter_messages.len(), 1);
4184
4185        match &openrouter_messages[0] {
4186            Message::User { content, .. } => {
4187                assert_eq!(content.len(), 2);
4188
4189                // First should be text
4190                match content.first() {
4191                    Some(UserContent::Text { text, .. }) => {
4192                        assert_eq!(text, "Summarize this document")
4193                    }
4194                    _ => panic!("Expected Text"),
4195                }
4196            }
4197            _ => panic!("Expected User message"),
4198        }
4199    }
4200
4201    #[test]
4202    fn test_user_content_from_string() {
4203        let content: UserContent = "Hello".into();
4204        assert_eq!(
4205            content,
4206            UserContent::Text {
4207                text: "Hello".to_string()
4208            }
4209        );
4210
4211        let content: UserContent = String::from("World").into();
4212        assert_eq!(
4213            content,
4214            UserContent::Text {
4215                text: "World".to_string()
4216            }
4217        );
4218    }
4219
4220    #[test]
4221    fn test_completion_response_reasoning_details_with_multiple_ids_stay_separate() {
4222        let json = json!({
4223            "id": "resp_multi_id",
4224            "object": "chat.completion",
4225            "created": 1,
4226            "model": "openrouter/test-model",
4227            "choices": [{
4228                "index": 0,
4229                "finish_reason": "stop",
4230                "message": {
4231                    "role": "assistant",
4232                    "content": "hello",
4233                    "reasoning": null,
4234                    "reasoning_details": [
4235                        {"type":"reasoning.summary","id":"rs_a","summary":"a1"},
4236                        {"type":"reasoning.summary","id":"rs_b","summary":"b1"},
4237                        {"type":"reasoning.summary","id":"rs_a","summary":"a2"}
4238                    ]
4239                }
4240            }]
4241        });
4242
4243        let response: CompletionResponse = serde_json::from_value(json).unwrap();
4244        let converted = response.normalize(PROVIDER_NAME).unwrap();
4245        let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
4246        let reasoning_blocks: Vec<_> = items
4247            .into_iter()
4248            .filter_map(|item| match item {
4249                completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
4250                _ => None,
4251            })
4252            .collect();
4253
4254        assert_eq!(reasoning_blocks.len(), 2);
4255        assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_a"));
4256        assert_eq!(
4257            reasoning_blocks[0].content,
4258            vec![
4259                message::ReasoningContent::Summary("a1".to_string()),
4260                message::ReasoningContent::Summary("a2".to_string()),
4261            ]
4262        );
4263        assert_eq!(reasoning_blocks[1].id.as_deref(), Some("rs_b"));
4264        assert_eq!(
4265            reasoning_blocks[1].content,
4266            vec![message::ReasoningContent::Summary("b1".to_string())]
4267        );
4268    }
4269
4270    #[test]
4271    fn test_user_content_audio_serialization() {
4272        let content = UserContent::Audio {
4273            input_audio: openai::InputAudio {
4274                data: "SGVsbG8=".to_string(),
4275                format: AudioMediaType::WAV,
4276            },
4277        };
4278        let json = serde_json::to_value(&content).unwrap();
4279
4280        assert_eq!(json["type"], "input_audio");
4281        assert_eq!(json["input_audio"]["data"], "SGVsbG8=");
4282        assert_eq!(json["input_audio"]["format"], "wav");
4283    }
4284
4285    #[test]
4286    fn test_user_content_audio_deserialization() {
4287        let json = json!({
4288            "type": "input_audio",
4289            "input_audio": {
4290                "data": "SGVsbG8=",
4291                "format": "wav"
4292            }
4293        });
4294
4295        let content: UserContent = serde_json::from_value(json).unwrap();
4296        match content {
4297            UserContent::Audio { input_audio } => {
4298                assert_eq!(input_audio.data, "SGVsbG8=");
4299                assert_eq!(input_audio.format, AudioMediaType::WAV);
4300            }
4301            _ => panic!("Expected Audio variant"),
4302        }
4303    }
4304
4305    #[test]
4306    fn test_message_user_with_audio_serialization() {
4307        let msg = Message::User {
4308            content: vec![
4309                UserContent::Text {
4310                    text: "Transcribe this audio:".to_string(),
4311                },
4312                UserContent::Audio {
4313                    input_audio: openai::InputAudio {
4314                        data: "SGVsbG8=".to_string(),
4315                        format: AudioMediaType::MP3,
4316                    },
4317                },
4318            ],
4319            name: None,
4320        };
4321        let json = serde_json::to_value(&msg).unwrap();
4322
4323        assert_eq!(json["role"], "user");
4324        let content = json["content"].as_array().unwrap();
4325        assert_eq!(content.len(), 2);
4326        assert_eq!(content[0]["type"], "text");
4327        assert_eq!(content[1]["type"], "input_audio");
4328        assert_eq!(content[1]["input_audio"]["data"], "SGVsbG8=");
4329        assert_eq!(content[1]["input_audio"]["format"], "mp3");
4330    }
4331
4332    #[test]
4333    fn test_user_content_video_url_serialization() {
4334        let content = UserContent::Video {
4335            video_url: VideoUrl {
4336                url: "https://example.com/video.mp4".to_string(),
4337            },
4338        };
4339        let json = serde_json::to_value(&content).unwrap();
4340
4341        assert_eq!(json["type"], "video_url");
4342        assert_eq!(json["video_url"]["url"], "https://example.com/video.mp4");
4343    }
4344
4345    #[test]
4346    fn test_user_content_video_base64_serialization() {
4347        let content = UserContent::Video {
4348            video_url: VideoUrl {
4349                url: format!(
4350                    "data:{};base64,SGVsbG8=",
4351                    VideoMediaType::MP4.to_mime_type()
4352                ),
4353            },
4354        };
4355        let json = serde_json::to_value(&content).unwrap();
4356
4357        assert_eq!(json["type"], "video_url");
4358        assert_eq!(json["video_url"]["url"], "data:video/mp4;base64,SGVsbG8=");
4359    }
4360
4361    #[test]
4362    fn test_user_content_video_url_deserialization() {
4363        let json = json!({
4364            "type": "video_url",
4365            "video_url": {
4366                "url": "https://example.com/video.mp4"
4367            }
4368        });
4369
4370        let content: UserContent = serde_json::from_value(json).unwrap();
4371        match content {
4372            UserContent::Video { video_url } => {
4373                assert_eq!(video_url.url, "https://example.com/video.mp4");
4374            }
4375            _ => panic!("Expected Video variant"),
4376        }
4377    }
4378
4379    #[test]
4380    fn test_message_user_with_video_serialization() {
4381        let msg = Message::User {
4382            content: vec![
4383                UserContent::Text {
4384                    text: "Describe this video:".to_string(),
4385                },
4386                UserContent::Video {
4387                    video_url: VideoUrl {
4388                        url: "https://example.com/video.mp4".to_string(),
4389                    },
4390                },
4391            ],
4392            name: None,
4393        };
4394        let json = serde_json::to_value(&msg).unwrap();
4395
4396        assert_eq!(json["role"], "user");
4397        let content = json["content"].as_array().unwrap();
4398        assert_eq!(content.len(), 2);
4399        assert_eq!(content[0]["type"], "text");
4400        assert_eq!(content[1]["type"], "video_url");
4401        assert_eq!(
4402            content[1]["video_url"]["url"],
4403            "https://example.com/video.mp4"
4404        );
4405    }
4406
4407    #[test]
4408    fn test_user_content_video_url_no_media_type_needed() {
4409        let rig_content = message::UserContent::Video(message::Video {
4410            data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
4411            media_type: None,
4412            additional_params: None,
4413        });
4414        let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
4415
4416        match openrouter_content {
4417            UserContent::Video { video_url } => {
4418                assert_eq!(video_url.url, "https://example.com/video.mp4");
4419            }
4420            _ => panic!("Expected Video variant"),
4421        }
4422    }
4423
4424    fn prompt_caching_completion_request() -> CompletionRequest {
4425        CompletionRequest {
4426            model: None,
4427            preamble: Some("You are a helpful assistant.".to_string()),
4428            chat_history: vec![crate::message::Message::user("Hello")],
4429            documents: vec![],
4430            tools: vec![],
4431            temperature: None,
4432            max_tokens: None,
4433            tool_choice: None,
4434            additional_params: None,
4435            output_schema: None,
4436            record_telemetry_content: false,
4437        }
4438    }
4439
4440    #[test]
4441    fn test_final_request_body_applies_prompt_caching_to_converted_completion_request() {
4442        let request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
4443            model: "anthropic/claude-3.5-sonnet",
4444            request: prompt_caching_completion_request(),
4445            strict_tools: false,
4446        })
4447        .expect("request conversion should succeed");
4448
4449        let body = final_request_body(&request, true).expect("request body should serialize");
4450        let system_block = &body["messages"][0]["content"][0];
4451
4452        assert_eq!(system_block["type"], "text");
4453        assert_eq!(system_block["text"], "You are a helpful assistant.");
4454        assert_eq!(system_block["cache_control"]["type"], "ephemeral");
4455
4456        let body = final_request_body(&request, false).expect("request body should serialize");
4457        assert!(
4458            body["messages"][0]["content"][0]
4459                .get("cache_control")
4460                .is_none(),
4461            "prompt caching should be opt-in"
4462        );
4463    }
4464
4465    #[test]
4466    fn test_final_request_body_preserves_stream_flag_when_prompt_caching_enabled() {
4467        let mut request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
4468            model: "anthropic/claude-3.5-sonnet",
4469            request: prompt_caching_completion_request(),
4470            strict_tools: false,
4471        })
4472        .expect("request conversion should succeed");
4473        request.additional_params = Some(json!({ "stream": true }));
4474
4475        let body = final_request_body(&request, true).expect("request body should serialize");
4476
4477        assert_eq!(body["stream"], true);
4478        assert_eq!(
4479            body["messages"][0]["content"][0]["cache_control"]["type"],
4480            "ephemeral"
4481        );
4482    }
4483
4484    #[test]
4485    fn test_apply_prompt_caching_string_system_message() {
4486        let mut body = json!({
4487            "model": "anthropic/claude-3.5-sonnet",
4488            "messages": [
4489                {"role": "system", "content": "You are a helpful assistant."},
4490                {"role": "user", "content": "Hello"}
4491            ]
4492        });
4493
4494        apply_prompt_caching(&mut body);
4495
4496        let system_content = &body["messages"][0]["content"];
4497        assert!(
4498            system_content.is_array(),
4499            "system content should be an array after caching"
4500        );
4501        let block = &system_content[0];
4502        assert_eq!(block["type"], "text");
4503        assert_eq!(block["text"], "You are a helpful assistant.");
4504        assert_eq!(block["cache_control"]["type"], "ephemeral");
4505
4506        // User message should be unchanged.
4507        assert_eq!(body["messages"][1]["content"], "Hello");
4508    }
4509
4510    #[test]
4511    fn test_apply_prompt_caching_array_system_message_marks_last_block() {
4512        let mut body = json!({
4513            "model": "anthropic/claude-3.5-sonnet",
4514            "messages": [
4515                {
4516                    "role": "system",
4517                    "content": [
4518                        {"type": "text", "text": "Part 1. "},
4519                        {"type": "text", "text": "Part 2."}
4520                    ]
4521                }
4522            ]
4523        });
4524
4525        apply_prompt_caching(&mut body);
4526
4527        let system_content = &body["messages"][0]["content"];
4528        assert!(system_content.is_array());
4529        // Both blocks are preserved; only the last one gets cache_control.
4530        assert_eq!(system_content.as_array().unwrap().len(), 2);
4531        assert_eq!(system_content[0]["text"], "Part 1. ");
4532        assert!(system_content[0].get("cache_control").is_none());
4533        assert_eq!(system_content[1]["text"], "Part 2.");
4534        assert_eq!(system_content[1]["cache_control"]["type"], "ephemeral");
4535    }
4536
4537    #[test]
4538    fn test_apply_prompt_caching_preserves_non_text_blocks() {
4539        let mut body = json!({
4540            "model": "anthropic/claude-3.5-sonnet",
4541            "messages": [
4542                {
4543                    "role": "system",
4544                    "content": [
4545                        {"type": "image", "source": {"type": "url", "url": "https://example.com/img.png"}},
4546                        {"type": "text", "text": "Describe the image."}
4547                    ]
4548                }
4549            ]
4550        });
4551
4552        apply_prompt_caching(&mut body);
4553
4554        let system_content = &body["messages"][0]["content"];
4555        assert_eq!(system_content.as_array().unwrap().len(), 2);
4556        // Non-text block is preserved unchanged.
4557        assert_eq!(system_content[0]["type"], "image");
4558        assert!(system_content[0].get("cache_control").is_none());
4559        // Text block (last) receives the cache boundary.
4560        assert_eq!(system_content[1]["type"], "text");
4561        assert_eq!(system_content[1]["cache_control"]["type"], "ephemeral");
4562    }
4563
4564    #[test]
4565    fn test_apply_prompt_caching_no_system_message_is_noop() {
4566        let mut body = json!({
4567            "model": "openai/gpt-4o",
4568            "messages": [
4569                {"role": "user", "content": "Hello"}
4570            ]
4571        });
4572
4573        let body_before = body.clone();
4574        apply_prompt_caching(&mut body);
4575        assert_eq!(
4576            body, body_before,
4577            "body should be unchanged when no system message exists"
4578        );
4579    }
4580
4581    #[test]
4582    fn test_completion_response_extracts_generated_images() {
4583        let json = json!({
4584            "id": "resp_img",
4585            "object": "chat.completion",
4586            "created": 1,
4587            "model": "google/gemini-flash-image-preview",
4588            "choices": [{
4589                "index": 0,
4590                "finish_reason": "stop",
4591                "message": {
4592                    "role": "assistant",
4593                    "content": "Here is your image.",
4594                    "images": [
4595                        {"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}
4596                    ]
4597                }
4598            }]
4599        });
4600
4601        let response: CompletionResponse = serde_json::from_value(json).unwrap();
4602        let converted = response.normalize(PROVIDER_NAME).unwrap();
4603        let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
4604        assert_eq!(items.len(), 2);
4605
4606        assert!(items.iter().any(|item| matches!(
4607            item,
4608            completion::AssistantContent::Text(t) if t.text == "Here is your image."
4609        )));
4610        assert!(items.iter().any(|item| matches!(
4611            item,
4612            completion::AssistantContent::Image(message::Image {
4613                data: message::DocumentSourceKind::Base64(b64),
4614                media_type: Some(message::ImageMediaType::PNG),
4615                additional_params: Some(_),
4616                ..
4617            }) if b64 == "iVBORw0KGgo="
4618        )));
4619        assert!(
4620            items.iter().any(|item| matches!(
4621                item,
4622                completion::AssistantContent::Image(image)
4623                    if is_openrouter_response_image(image)
4624            )),
4625            "generated images should be marked as OpenRouter response-only artifacts"
4626        );
4627    }
4628
4629    #[test]
4630    fn test_completion_response_extracts_generated_images_url() {
4631        let json = json!({
4632            "id": "resp_img_url",
4633            "object": "chat.completion",
4634            "created": 1,
4635            "model": "google/gemini-flash-image-preview",
4636            "choices": [{
4637                "index": 0,
4638                "finish_reason": "stop",
4639                "message": {
4640                    "role": "assistant",
4641                    "content": "Here is your image.",
4642                    "images": [
4643                        {"type":"image_url","image_url":{"url":"https://example.com/generated.png"}}
4644                    ]
4645                }
4646            }]
4647        });
4648
4649        let response: CompletionResponse = serde_json::from_value(json).unwrap();
4650        let converted = response.normalize(PROVIDER_NAME).unwrap();
4651        let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
4652        assert_eq!(items.len(), 2);
4653
4654        assert!(items.iter().any(|item| matches!(
4655            item,
4656            completion::AssistantContent::Image(message::Image {
4657                data: message::DocumentSourceKind::Url(url),
4658                media_type: None,
4659                additional_params: Some(_),
4660                ..
4661            }) if url == "https://example.com/generated.png"
4662        )));
4663        assert!(
4664            items.iter().any(|item| matches!(
4665                item,
4666                completion::AssistantContent::Image(image)
4667                    if is_openrouter_response_image(image)
4668            )),
4669            "generated URL images should be marked as OpenRouter response-only artifacts"
4670        );
4671    }
4672
4673    #[test]
4674    fn test_generated_images_do_not_break_assistant_history_conversion() {
4675        let generated_image = response_image_to_assistant_content(&ResponseImage {
4676            image_url: ImageUrl {
4677                url: "data:image/png;base64,abc".to_string(),
4678                detail: None,
4679            },
4680        });
4681
4682        let content = vec![
4683            completion::AssistantContent::text("Here is your image."),
4684            generated_image,
4685        ];
4686        let messages = assistant_contents_to_messages(content).unwrap();
4687
4688        assert_eq!(messages.len(), 1);
4689        assert!(matches!(
4690            &messages[0],
4691            Message::Assistant { content, .. }
4692                if content == &vec![openai::AssistantContent::Text {
4693                    text: "Here is your image.".to_string()
4694                }]
4695        ));
4696    }
4697
4698    #[test]
4699    fn test_image_only_assistant_history_is_omitted_for_openrouter() {
4700        let generated_image = response_image_to_assistant_content(&ResponseImage {
4701            image_url: ImageUrl {
4702                url: "data:image/png;base64,abc".to_string(),
4703                detail: None,
4704            },
4705        });
4706
4707        let messages = assistant_contents_to_messages(vec![generated_image]).unwrap();
4708
4709        assert!(
4710            messages.is_empty(),
4711            "response-only generated image turns should not be replayed as assistant content"
4712        );
4713    }
4714
4715    #[test]
4716    fn test_unmarked_assistant_image_history_errors_for_openrouter() {
4717        let image = completion::AssistantContent::image_base64(
4718            "abc",
4719            Some(message::ImageMediaType::PNG),
4720            None,
4721        );
4722
4723        let err = assistant_contents_to_messages(vec![image]).unwrap_err();
4724
4725        match err {
4726            message::MessageError::ConversionError(message) => assert!(
4727                message.contains("OpenRouter does not support assistant image content"),
4728                "unexpected error: {message}"
4729            ),
4730        }
4731    }
4732
4733    #[test]
4734    fn test_mixed_text_and_generated_image_replays_text_only_for_openrouter() {
4735        let generated_image = response_image_to_assistant_content(&ResponseImage {
4736            image_url: ImageUrl {
4737                url: "https://example.com/generated.png".to_string(),
4738                detail: None,
4739            },
4740        });
4741
4742        let messages = assistant_contents_to_messages(vec![
4743            completion::AssistantContent::text("Keep this text."),
4744            generated_image,
4745        ])
4746        .unwrap();
4747
4748        let serialized = serde_json::to_value(&messages).unwrap();
4749        assert_eq!(
4750            serialized,
4751            json!([{
4752                "role": "assistant",
4753                "content": [{"type": "text", "text": "Keep this text."}]
4754            }])
4755        );
4756    }
4757
4758    #[test]
4759    fn test_assistant_images_not_serialized_in_request() {
4760        let msg = Message::Assistant {
4761            content: vec!["Hello".to_string().into()],
4762            refusal: None,
4763            audio: None,
4764            name: None,
4765            tool_calls: vec![],
4766            reasoning: None,
4767            reasoning_details: vec![],
4768            images: vec![ResponseImage {
4769                image_url: ImageUrl {
4770                    url: "data:image/png;base64,abc".to_string(),
4771                    detail: None,
4772                },
4773            }],
4774        };
4775        let serialized = serde_json::to_value(&msg).unwrap();
4776        assert!(
4777            serialized.get("images").is_none(),
4778            "images field must not appear in serialized assistant message"
4779        );
4780    }
4781
4782    // -----------------------------------------------------------------------
4783    // Refusal fallback — wire shapes the live gateway will not produce on
4784    // demand. The recorded cells live in
4785    // `tests/providers/openrouter/cassette/refusal_matrix.rs`.
4786    // -----------------------------------------------------------------------
4787
4788    fn refusal_response(message: serde_json::Value) -> CompletionResponse {
4789        serde_json::from_value(json!({
4790            "id": "gen-refusal",
4791            "object": "chat.completion",
4792            "created": 1,
4793            "model": "openai/gpt-4o",
4794            "choices": [{ "index": 0, "message": message, "finish_reason": "stop" }],
4795        }))
4796        .unwrap()
4797    }
4798
4799    #[test]
4800    fn raw_completion_response_retains_routing_metadata() {
4801        let response: CompletionResponse = serde_json::from_value(json!({
4802            "id": "gen-routing",
4803            "object": "chat.completion",
4804            "created": 1,
4805            "model": "openai/gpt-4o-mini",
4806            "provider": "OpenAI",
4807            "service_tier": "default",
4808            "choices": [{
4809                "index": 0,
4810                "message": {"role": "assistant", "content": "ok"},
4811                "finish_reason": "stop"
4812            }]
4813        }))
4814        .expect("live OpenRouter routing metadata should deserialize");
4815
4816        assert_eq!(response.provider.as_deref(), Some("OpenAI"));
4817        assert_eq!(response.service_tier.as_deref(), Some("default"));
4818    }
4819
4820    fn text_parts(response: &completion::CompletionResponse) -> Vec<String> {
4821        response
4822            .choice
4823            .iter()
4824            .filter_map(|part| match part {
4825                completion::AssistantContent::Text(text) => Some(text.text.clone()),
4826                _ => None,
4827            })
4828            .collect()
4829    }
4830
4831    /// The recorded shape: `content` held at `null` with the refusal beside
4832    /// it. Before the fix this normalized to nothing and errored.
4833    #[test]
4834    fn refusal_fallback_surfaces_a_null_content_refusal() {
4835        let response = refusal_response(json!({
4836            "role": "assistant",
4837            "content": null,
4838            "refusal": "I'm very sorry, but I can't assist with that request.",
4839        }));
4840
4841        let converted = response.normalize(PROVIDER_NAME).unwrap();
4842        assert_eq!(
4843            text_parts(&converted),
4844            vec!["I'm very sorry, but I can't assist with that request."]
4845        );
4846    }
4847
4848    /// An absent `content` key reads the same as an explicit `null`.
4849    #[test]
4850    fn refusal_fallback_surfaces_a_missing_content_refusal() {
4851        let response = refusal_response(json!({
4852            "role": "assistant",
4853            "refusal": "No.",
4854        }));
4855
4856        assert_eq!(
4857            text_parts(&response.normalize(PROVIDER_NAME).unwrap()),
4858            vec!["No."]
4859        );
4860    }
4861
4862    /// An empty-string `content` decodes as one empty text part, which carries
4863    /// no text — so the refusal is still the turn's only *visible* content.
4864    ///
4865    /// This path keeps the empty part alongside it: unlike the shared OpenAI
4866    /// normalizer, OpenRouter's does not filter empty content parts. That is
4867    /// pre-existing behavior for any `"content": ""` turn and the fallback
4868    /// neither causes nor changes it; the assertion records both halves rather
4869    /// than claiming a filter this code does not have.
4870    #[test]
4871    fn refusal_fallback_surfaces_a_refusal_beside_empty_content() {
4872        let response = refusal_response(json!({
4873            "role": "assistant",
4874            "content": "",
4875            "refusal": "No.",
4876        }));
4877
4878        let converted = response.normalize(PROVIDER_NAME).unwrap();
4879        assert_eq!(
4880            text_parts(&converted),
4881            vec!["".to_owned(), "No.".to_owned()]
4882        );
4883    }
4884
4885    /// The whole-message rule: real content wins, and the fallback stays out
4886    /// of the way. This is the shape the streaming path would deliver *both*
4887    /// halves of, so pinning it records the difference rather than assuming it
4888    /// away (see `assistant_refusal_fallback`).
4889    #[test]
4890    fn refusal_fallback_defers_to_non_empty_content() {
4891        let response = refusal_response(json!({
4892            "role": "assistant",
4893            "content": "Here is the answer.",
4894            "refusal": "I'm sorry.",
4895        }));
4896
4897        assert_eq!(
4898            text_parts(&response.normalize(PROVIDER_NAME).unwrap()),
4899            vec!["Here is the answer."]
4900        );
4901    }
4902
4903    /// An empty refusal string is not a refusal.
4904    #[test]
4905    fn refusal_fallback_ignores_an_empty_refusal() {
4906        let response = refusal_response(json!({
4907            "role": "assistant",
4908            "content": null,
4909            "refusal": "",
4910            "tool_calls": [{
4911                "id": "call_1",
4912                "type": "function",
4913                "function": { "name": "ping", "arguments": "{}" }
4914            }],
4915        }));
4916
4917        let converted = response.normalize(PROVIDER_NAME).unwrap();
4918        assert!(text_parts(&converted).is_empty(), "{:?}", converted.choice);
4919        assert_eq!(converted.choice.len(), 1);
4920    }
4921
4922    /// A tool-calls-only turn holds `content` at `null` with no refusal: the
4923    /// shape the fallback must leave exactly as it was.
4924    #[test]
4925    fn refusal_fallback_leaves_a_tool_call_turn_alone() {
4926        let response = refusal_response(json!({
4927            "role": "assistant",
4928            "content": null,
4929            "tool_calls": [{
4930                "id": "call_1",
4931                "type": "function",
4932                "function": { "name": "ping", "arguments": "{}" }
4933            }],
4934        }));
4935
4936        let converted = response.normalize(PROVIDER_NAME).unwrap();
4937        assert_eq!(converted.choice.len(), 1);
4938        assert!(matches!(
4939            converted.choice.first(),
4940            Some(completion::AssistantContent::ToolCall(_))
4941        ));
4942    }
4943
4944    /// A refusal can arrive on a turn that also carries tool calls; the
4945    /// refusal is the turn's text and the calls survive beside it.
4946    #[test]
4947    fn refusal_fallback_coexists_with_tool_calls() {
4948        let response = refusal_response(json!({
4949            "role": "assistant",
4950            "content": null,
4951            "refusal": "I can't help with that.",
4952            "tool_calls": [{
4953                "id": "call_1",
4954                "type": "function",
4955                "function": { "name": "ping", "arguments": "{}" }
4956            }],
4957        }));
4958
4959        let converted = response.normalize(PROVIDER_NAME).unwrap();
4960        assert_eq!(text_parts(&converted), vec!["I can't help with that."]);
4961        assert!(
4962            converted
4963                .choice
4964                .iter()
4965                .any(|part| matches!(part, completion::AssistantContent::ToolCall(_)))
4966        );
4967    }
4968
4969    /// Reasoning blocks are not text, so a reasoning-carrying refusal turn
4970    /// still needs the fallback for its visible content.
4971    #[test]
4972    fn refusal_fallback_applies_beside_reasoning_details() {
4973        let response = refusal_response(json!({
4974            "role": "assistant",
4975            "content": null,
4976            "refusal": "I can't help with that.",
4977            "reasoning_details": [
4978                { "type": "reasoning.summary", "id": "rs_1", "format": "openai-responses-v1",
4979                  "index": 0, "summary": "considered" }
4980            ],
4981        }));
4982
4983        let converted = response.normalize(PROVIDER_NAME).unwrap();
4984        assert_eq!(text_parts(&converted), vec!["I can't help with that."]);
4985        assert!(
4986            converted
4987                .choice
4988                .iter()
4989                .any(|part| matches!(part, completion::AssistantContent::Reasoning(_)))
4990        );
4991    }
4992
4993    /// The Responses-API spelling — a `refusal` *content part* — still works;
4994    /// the fix adds the sibling-field rule without displacing it, and the two
4995    /// must not both fire.
4996    #[test]
4997    fn refusal_fallback_does_not_double_up_with_a_refusal_content_part() {
4998        let response = refusal_response(json!({
4999            "role": "assistant",
5000            "content": [{ "type": "refusal", "refusal": "I can't help with that." }],
5001            "refusal": "I can't help with that.",
5002        }));
5003
5004        assert_eq!(
5005            text_parts(&response.normalize(PROVIDER_NAME).unwrap()),
5006            vec!["I can't help with that."]
5007        );
5008    }
5009
5010    /// The raw text view and the normalized response must never disagree
5011    /// about whether a refused turn said anything — the internal
5012    /// inconsistency that made this bug visible.
5013    #[test]
5014    fn refusal_fallback_keeps_raw_and_normalized_text_in_step() {
5015        let response = refusal_response(json!({
5016            "role": "assistant",
5017            "content": null,
5018            "refusal": "I'm sorry, but I can't help with that.",
5019        }));
5020
5021        let raw_text = response.get_text_response().unwrap();
5022        let normalized = response.normalize(PROVIDER_NAME).unwrap();
5023
5024        assert_eq!(text_parts(&normalized), vec![raw_text]);
5025    }
5026}