Skip to main content

rig/providers/openrouter/
completion.rs

1use super::{
2    client::{ApiErrorResponse, ApiResponse, Client, Usage},
3    streaming::StreamingCompletionResponse,
4};
5use crate::message::{self, DocumentMediaType, DocumentSourceKind, ImageDetail, MimeType};
6use crate::telemetry::SpanCombinator;
7use crate::{
8    OneOrMany,
9    completion::{self, CompletionError, CompletionRequest},
10    http_client::HttpClientExt,
11    json_utils,
12    one_or_many::string_or_one_or_many,
13    providers::openai,
14};
15use bytes::Bytes;
16use serde::{Deserialize, Serialize, Serializer};
17use std::collections::HashMap;
18use tracing::{Instrument, Level, enabled, info_span};
19
20// ================================================================
21// OpenRouter Completion API
22// ================================================================
23
24/// The `qwen/qwq-32b` model. Find more models at <https://openrouter.ai/models>.
25pub const QWEN_QWQ_32B: &str = "qwen/qwq-32b";
26/// The `anthropic/claude-3.7-sonnet` model. Find more models at <https://openrouter.ai/models>.
27pub const CLAUDE_3_7_SONNET: &str = "anthropic/claude-3.7-sonnet";
28/// The `perplexity/sonar-pro` model. Find more models at <https://openrouter.ai/models>.
29pub const PERPLEXITY_SONAR_PRO: &str = "perplexity/sonar-pro";
30/// The `google/gemini-2.0-flash-001` model. Find more models at <https://openrouter.ai/models>.
31pub const GEMINI_FLASH_2_0: &str = "google/gemini-2.0-flash-001";
32
33// ================================================================
34// Provider Selection and Prioritization
35// ================================================================
36// See: https://openrouter.ai/docs/guides/routing/provider-selection
37
38/// Data collection policy for providers.
39///
40/// Controls whether providers are allowed to collect and store request data.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
42#[serde(rename_all = "lowercase")]
43pub enum DataCollection {
44    /// Allow providers that may collect data (default)
45    #[default]
46    Allow,
47    /// Restrict routing to providers that do not store user data non-transiently
48    Deny,
49}
50
51/// Model quantization levels supported by OpenRouter.
52///
53/// Restrict routing to providers serving a specific quantization level.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum Quantization {
57    /// 4-bit integer quantization
58    #[serde(rename = "int4")]
59    Int4,
60    /// 8-bit integer quantization
61    #[serde(rename = "int8")]
62    Int8,
63    /// 16-bit floating point
64    #[serde(rename = "fp16")]
65    Fp16,
66    /// Brain floating point 16-bit
67    #[serde(rename = "bf16")]
68    Bf16,
69    /// 32-bit floating point (full precision)
70    #[serde(rename = "fp32")]
71    Fp32,
72    /// 8-bit floating point
73    #[serde(rename = "fp8")]
74    Fp8,
75    /// Unknown or custom quantization level
76    #[serde(rename = "unknown")]
77    Unknown,
78}
79
80/// Simple sorting strategy for providers.
81///
82/// Determines how providers should be prioritized when multiple are available.
83/// If you set `sort`, default load balancing is disabled and providers are tried
84/// deterministically in the resulting order.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "lowercase")]
87pub enum ProviderSortStrategy {
88    /// Sort by price (cheapest first)
89    Price,
90    /// Sort by throughput (higher tokens/sec first)
91    Throughput,
92    /// Sort by latency (lower latency first)
93    Latency,
94}
95
96/// Partition strategy for multi-model requests.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "lowercase")]
99pub enum SortPartition {
100    /// Sort providers within each model group (default)
101    Model,
102    /// Sort providers globally across all models
103    None,
104}
105
106/// Complex sorting configuration with partition support.
107///
108/// For multi-model requests, allows control over how providers are sorted.
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110pub struct ProviderSortConfig {
111    /// Sorting strategy
112    pub by: ProviderSortStrategy,
113
114    /// Partition strategy (optional)
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub partition: Option<SortPartition>,
117}
118
119impl ProviderSortConfig {
120    /// Create a new sort config with the given strategy
121    pub fn new(by: ProviderSortStrategy) -> Self {
122        Self {
123            by,
124            partition: None,
125        }
126    }
127
128    /// Set partition strategy for multi-model requests
129    pub fn partition(mut self, partition: SortPartition) -> Self {
130        self.partition = Some(partition);
131        self
132    }
133}
134
135/// Sort configuration - can be a simple string or a complex object.
136///
137/// Use `ProviderSort::Simple` for basic sorting, or `ProviderSort::Complex`
138/// for multi-model requests with partition control.
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[serde(untagged)]
141pub enum ProviderSort {
142    /// Simple sorting by a single strategy
143    Simple(ProviderSortStrategy),
144    /// Complex sorting with partition support
145    Complex(ProviderSortConfig),
146}
147
148impl From<ProviderSortStrategy> for ProviderSort {
149    fn from(strategy: ProviderSortStrategy) -> Self {
150        ProviderSort::Simple(strategy)
151    }
152}
153
154impl From<ProviderSortConfig> for ProviderSort {
155    fn from(config: ProviderSortConfig) -> Self {
156        ProviderSort::Complex(config)
157    }
158}
159
160/// Throughput threshold configuration with percentile support.
161///
162/// Endpoints not meeting the threshold are deprioritized (moved later), not excluded.
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164#[serde(untagged)]
165pub enum ThroughputThreshold {
166    /// Simple threshold in tokens/sec
167    Simple(f64),
168    /// Percentile-based thresholds
169    Percentile(PercentileThresholds),
170}
171
172/// Latency threshold configuration with percentile support.
173///
174/// Endpoints not meeting the threshold are deprioritized, not excluded.
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176#[serde(untagged)]
177pub enum LatencyThreshold {
178    /// Simple threshold in seconds
179    Simple(f64),
180    /// Percentile-based thresholds
181    Percentile(PercentileThresholds),
182}
183
184/// Percentile-based thresholds for throughput or latency.
185#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
186pub struct PercentileThresholds {
187    /// 50th percentile threshold
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub p50: Option<f64>,
190    /// 75th percentile threshold
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub p75: Option<f64>,
193    /// 90th percentile threshold
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub p90: Option<f64>,
196    /// 99th percentile threshold
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub p99: Option<f64>,
199}
200
201impl PercentileThresholds {
202    /// Create new empty percentile thresholds
203    pub fn new() -> Self {
204        Self::default()
205    }
206
207    /// Set p50 threshold
208    pub fn p50(mut self, value: f64) -> Self {
209        self.p50 = Some(value);
210        self
211    }
212
213    /// Set p75 threshold
214    pub fn p75(mut self, value: f64) -> Self {
215        self.p75 = Some(value);
216        self
217    }
218
219    /// Set p90 threshold
220    pub fn p90(mut self, value: f64) -> Self {
221        self.p90 = Some(value);
222        self
223    }
224
225    /// Set p99 threshold
226    pub fn p99(mut self, value: f64) -> Self {
227        self.p99 = Some(value);
228        self
229    }
230}
231
232/// Maximum price configuration for hard ceiling on costs.
233///
234/// If no eligible provider is at or under the ceiling, the request fails.
235/// Units are OpenRouter pricing units (e.g., dollars per million tokens).
236#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
237pub struct MaxPrice {
238    /// Maximum price per prompt token
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub prompt: Option<f64>,
241    /// Maximum price per completion token
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub completion: Option<f64>,
244    /// Maximum price per request
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub request: Option<f64>,
247    /// Maximum price per image
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub image: Option<f64>,
250}
251
252impl MaxPrice {
253    /// Create new empty max price config
254    pub fn new() -> Self {
255        Self::default()
256    }
257
258    /// Set maximum price per prompt token
259    pub fn prompt(mut self, price: f64) -> Self {
260        self.prompt = Some(price);
261        self
262    }
263
264    /// Set maximum price per completion token
265    pub fn completion(mut self, price: f64) -> Self {
266        self.completion = Some(price);
267        self
268    }
269
270    /// Set maximum price per request
271    pub fn request(mut self, price: f64) -> Self {
272        self.request = Some(price);
273        self
274    }
275
276    /// Set maximum price per image
277    pub fn image(mut self, price: f64) -> Self {
278        self.image = Some(price);
279        self
280    }
281}
282
283/// Provider preferences for OpenRouter routing.
284///
285/// This struct allows you to control which providers are used and how they are prioritized
286/// when making requests through OpenRouter.
287///
288/// See: <https://openrouter.ai/docs/guides/routing/provider-selection>
289///
290/// # Example
291///
292/// ```rust
293/// use rig::providers::openrouter::{ProviderPreferences, ProviderSortStrategy, Quantization};
294///
295/// // Create preferences for zero data retention providers, sorted by throughput
296/// let prefs = ProviderPreferences::new()
297///     .sort(ProviderSortStrategy::Throughput)
298///     .zdr(true)
299///     .quantizations([Quantization::Int8])
300///     .only(["anthropic", "openai"]);
301/// ```
302#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
303pub struct ProviderPreferences {
304    // === Provider Selection Controls ===
305    /// Try these provider slugs in the given order first.
306    /// If `allow_fallbacks: true`, OpenRouter may try other providers after this list is exhausted.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub order: Option<Vec<String>>,
309
310    /// Hard allowlist. Only these provider slugs are eligible.
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub only: Option<Vec<String>>,
313
314    /// Blocklist. These provider slugs are never used.
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub ignore: Option<Vec<String>>,
317
318    /// If `false`, the router will not use any providers outside what your constraints permit.
319    /// Default is `true`.
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub allow_fallbacks: Option<bool>,
322
323    // === Compatibility and Policy Filters ===
324    /// If `true`, only route to providers that support all parameters in your request.
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::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::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::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::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::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::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::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
568/// A openrouter completion object.
569///
570/// For more information, see this link: <https://docs.openrouter.xyz/reference/create_chat_completion_v1_chat_completions_post>
571#[derive(Debug, Serialize, Deserialize)]
572pub struct CompletionResponse {
573    pub id: String,
574    pub object: String,
575    pub created: u64,
576    pub model: String,
577    pub choices: Vec<Choice>,
578    pub system_fingerprint: Option<String>,
579    pub usage: Option<Usage>,
580}
581
582impl From<ApiErrorResponse> for CompletionError {
583    fn from(err: ApiErrorResponse) -> Self {
584        CompletionError::ProviderError(err.message)
585    }
586}
587
588impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionResponse> {
589    type Error = CompletionError;
590
591    fn try_from(response: CompletionResponse) -> Result<Self, Self::Error> {
592        let choice = response.choices.first().ok_or_else(|| {
593            CompletionError::ResponseError("Response contained no choices".to_owned())
594        })?;
595
596        let content = match &choice.message {
597            Message::Assistant {
598                content,
599                tool_calls,
600                reasoning,
601                reasoning_details,
602                ..
603            } => {
604                let mut content = content
605                    .iter()
606                    .map(|c| match c {
607                        openai::AssistantContent::Text { text } => {
608                            completion::AssistantContent::text(text)
609                        }
610                        openai::AssistantContent::Refusal { refusal } => {
611                            completion::AssistantContent::text(refusal)
612                        }
613                    })
614                    .collect::<Vec<_>>();
615
616                content.extend(tool_calls.iter().map(|call| {
617                    completion::AssistantContent::tool_call(
618                        &call.id,
619                        &call.function.name,
620                        call.function.arguments.clone(),
621                    )
622                }));
623
624                let mut grouped_reasoning: HashMap<
625                    Option<String>,
626                    Vec<(usize, usize, message::ReasoningContent)>,
627                > = HashMap::new();
628                let mut reasoning_order: Vec<Option<String>> = Vec::new();
629                for (position, detail) in reasoning_details.iter().enumerate() {
630                    let (reasoning_id, sort_index, parsed_content) = match detail {
631                        ReasoningDetails::Summary {
632                            id, index, summary, ..
633                        } => (
634                            id.clone(),
635                            *index,
636                            Some(message::ReasoningContent::Summary(summary.clone())),
637                        ),
638                        ReasoningDetails::Encrypted {
639                            id, index, data, ..
640                        } => (
641                            id.clone(),
642                            *index,
643                            Some(message::ReasoningContent::Encrypted(data.clone())),
644                        ),
645                        ReasoningDetails::Text {
646                            id,
647                            index,
648                            text,
649                            signature,
650                            ..
651                        } => (
652                            id.clone(),
653                            *index,
654                            text.as_ref().map(|text| message::ReasoningContent::Text {
655                                text: text.clone(),
656                                signature: signature.clone(),
657                            }),
658                        ),
659                    };
660
661                    let Some(parsed_content) = parsed_content else {
662                        continue;
663                    };
664                    let sort_index = sort_index.unwrap_or(position);
665
666                    let entry = grouped_reasoning.entry(reasoning_id.clone());
667                    if matches!(entry, std::collections::hash_map::Entry::Vacant(_)) {
668                        reasoning_order.push(reasoning_id);
669                    }
670                    entry
671                        .or_default()
672                        .push((sort_index, position, parsed_content));
673                }
674
675                if grouped_reasoning.is_empty() {
676                    if let Some(reasoning) = reasoning {
677                        content.push(completion::AssistantContent::reasoning(reasoning));
678                    }
679                } else {
680                    for reasoning_id in reasoning_order {
681                        let Some(mut blocks) = grouped_reasoning.remove(&reasoning_id) else {
682                            continue;
683                        };
684                        blocks.sort_by_key(|(index, position, _)| (*index, *position));
685                        content.push(completion::AssistantContent::Reasoning(
686                            message::Reasoning {
687                                id: reasoning_id,
688                                content: blocks
689                                    .into_iter()
690                                    .map(|(_, _, content)| content)
691                                    .collect::<Vec<_>>(),
692                            },
693                        ));
694                    }
695                }
696
697                Ok(content)
698            }
699            _ => Err(CompletionError::ResponseError(
700                "Response did not contain a valid message or tool call".into(),
701            )),
702        }?;
703
704        let choice = OneOrMany::many(content).map_err(|_| {
705            CompletionError::ResponseError(
706                "Response contained no message or tool call (empty)".to_owned(),
707            )
708        })?;
709
710        let usage = response
711            .usage
712            .as_ref()
713            .map(|usage| completion::Usage {
714                input_tokens: usage.prompt_tokens as u64,
715                output_tokens: (usage.total_tokens - usage.prompt_tokens) as u64,
716                total_tokens: usage.total_tokens as u64,
717                cached_input_tokens: 0,
718            })
719            .unwrap_or_default();
720
721        Ok(completion::CompletionResponse {
722            choice,
723            usage,
724            raw_response: response,
725            message_id: None,
726        })
727    }
728}
729
730/// User content types supported by OpenRouter.
731///
732/// OpenRouter uses different content type structures than OpenAI's Chat Completions API,
733/// particularly for file/document content. This enum matches OpenRouter's API specification.
734///
735/// # Supported Content Types
736///
737/// - **Text**: Plain text content
738/// - **ImageUrl**: Images via URL or base64 data URI
739/// - **File**: PDF documents and other files via URL or base64 data URI
740///
741/// # Example
742///
743/// ```rust
744/// use rig::providers::openrouter::UserContent;
745///
746/// // Text content
747/// let text = UserContent::text("Hello, world!");
748///
749/// // Image from URL
750/// let image = UserContent::image_url("https://example.com/image.png");
751///
752/// // PDF from URL
753/// let pdf = UserContent::file_url("https://example.com/document.pdf", Some("document.pdf".to_string()));
754/// ```
755#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
756#[serde(tag = "type", rename_all = "snake_case")]
757pub enum UserContent {
758    /// Plain text content
759    Text { text: String },
760
761    /// Image content (URL or base64 data URI)
762    ///
763    /// Supports: image/png, image/jpeg, image/webp, image/gif
764    #[serde(rename = "image_url")]
765    ImageUrl { image_url: ImageUrl },
766
767    /// File content (for PDFs and other documents)
768    ///
769    /// Uses `file_data` field which accepts either a publicly accessible URL
770    /// or base64-encoded content as a data URI.
771    File { file: FileContent },
772}
773
774impl UserContent {
775    /// Create text content
776    pub fn text(text: impl Into<String>) -> Self {
777        UserContent::Text { text: text.into() }
778    }
779
780    /// Create image content from URL
781    pub fn image_url(url: impl Into<String>) -> Self {
782        UserContent::ImageUrl {
783            image_url: ImageUrl {
784                url: url.into(),
785                detail: None,
786            },
787        }
788    }
789
790    /// Create image content from URL with detail level
791    pub fn image_url_with_detail(url: impl Into<String>, detail: ImageDetail) -> Self {
792        UserContent::ImageUrl {
793            image_url: ImageUrl {
794                url: url.into(),
795                detail: Some(detail),
796            },
797        }
798    }
799
800    /// Create image content from base64 data
801    ///
802    /// # Arguments
803    /// * `data` - Base64-encoded image data
804    /// * `mime_type` - MIME type (e.g., "image/png", "image/jpeg")
805    /// * `detail` - Optional detail level for image processing
806    pub fn image_base64(
807        data: impl Into<String>,
808        mime_type: &str,
809        detail: Option<ImageDetail>,
810    ) -> Self {
811        let data_uri = format!("data:{};base64,{}", mime_type, data.into());
812        UserContent::ImageUrl {
813            image_url: ImageUrl {
814                url: data_uri,
815                detail,
816            },
817        }
818    }
819
820    /// Create file content from URL
821    ///
822    /// # Arguments
823    /// * `url` - URL to the file (must be publicly accessible)
824    /// * `filename` - Optional filename for the document
825    pub fn file_url(url: impl Into<String>, filename: Option<String>) -> Self {
826        UserContent::File {
827            file: FileContent {
828                filename,
829                file_data: Some(url.into()),
830            },
831        }
832    }
833
834    /// Create file content from base64 data
835    ///
836    /// # Arguments
837    /// * `data` - Base64-encoded file data
838    /// * `mime_type` - MIME type (e.g., "application/pdf")
839    /// * `filename` - Optional filename for the document
840    pub fn file_base64(data: impl Into<String>, mime_type: &str, filename: Option<String>) -> Self {
841        let data_uri = format!("data:{};base64,{}", mime_type, data.into());
842        UserContent::File {
843            file: FileContent {
844                filename,
845                file_data: Some(data_uri),
846            },
847        }
848    }
849}
850
851impl From<String> for UserContent {
852    fn from(text: String) -> Self {
853        UserContent::Text { text }
854    }
855}
856
857impl From<&str> for UserContent {
858    fn from(text: &str) -> Self {
859        UserContent::Text {
860            text: text.to_string(),
861        }
862    }
863}
864
865impl std::str::FromStr for UserContent {
866    type Err = std::convert::Infallible;
867
868    fn from_str(s: &str) -> Result<Self, Self::Err> {
869        Ok(UserContent::Text {
870            text: s.to_string(),
871        })
872    }
873}
874
875/// Image URL structure for OpenRouter
876#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
877pub struct ImageUrl {
878    /// URL or data URI (data:image/png;base64,...)
879    pub url: String,
880    /// Image detail level (optional)
881    #[serde(skip_serializing_if = "Option::is_none")]
882    pub detail: Option<ImageDetail>,
883}
884
885/// File content structure for OpenRouter PDF/document support
886///
887/// OpenRouter supports sending files (particularly PDFs) to models via the `file_data` field,
888/// which accepts either:
889/// - A publicly accessible URL to the file
890/// - A base64-encoded data URI (e.g., `data:application/pdf;base64,...`)
891#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
892pub struct FileContent {
893    /// Filename (e.g., "document.pdf")
894    #[serde(skip_serializing_if = "Option::is_none")]
895    pub filename: Option<String>,
896    /// File data source - URL or base64-encoded data URI
897    #[serde(skip_serializing_if = "Option::is_none")]
898    pub file_data: Option<String>,
899}
900
901/// Serializes user content as a plain string when there's a single text item,
902/// otherwise as an array of content parts.
903fn serialize_user_content<S>(
904    content: &OneOrMany<UserContent>,
905    serializer: S,
906) -> Result<S::Ok, S::Error>
907where
908    S: Serializer,
909{
910    if content.len() == 1
911        && let UserContent::Text { text } = content.first_ref()
912    {
913        return serializer.serialize_str(text);
914    }
915    content.serialize(serializer)
916}
917
918impl TryFrom<message::UserContent> for UserContent {
919    type Error = message::MessageError;
920
921    fn try_from(value: message::UserContent) -> Result<Self, Self::Error> {
922        match value {
923            message::UserContent::Text(message::Text { text }) => Ok(UserContent::Text { text }),
924
925            message::UserContent::Image(message::Image {
926                data,
927                detail,
928                media_type,
929                ..
930            }) => {
931                let url = match data {
932                    DocumentSourceKind::Url(url) => url,
933                    DocumentSourceKind::Base64(data) => {
934                        let mime = media_type
935                            .ok_or_else(|| {
936                                message::MessageError::ConversionError(
937                                    "Image media type required for base64 encoding".into(),
938                                )
939                            })?
940                            .to_mime_type();
941                        format!("data:{mime};base64,{data}")
942                    }
943                    DocumentSourceKind::Raw(_) => {
944                        return Err(message::MessageError::ConversionError(
945                            "Raw bytes not supported, encode as base64 first".into(),
946                        ));
947                    }
948                    DocumentSourceKind::String(_) => {
949                        return Err(message::MessageError::ConversionError(
950                            "String source not supported for images".into(),
951                        ));
952                    }
953                    DocumentSourceKind::Unknown => {
954                        return Err(message::MessageError::ConversionError(
955                            "Image has no data".into(),
956                        ));
957                    }
958                };
959                Ok(UserContent::ImageUrl {
960                    image_url: ImageUrl { url, detail },
961                })
962            }
963
964            message::UserContent::Document(message::Document {
965                data, media_type, ..
966            }) => match data {
967                DocumentSourceKind::Url(url) => {
968                    let filename = media_type.as_ref().map(|mt| match mt {
969                        DocumentMediaType::PDF => "document.pdf",
970                        DocumentMediaType::TXT => "document.txt",
971                        DocumentMediaType::HTML => "document.html",
972                        DocumentMediaType::MARKDOWN => "document.md",
973                        DocumentMediaType::CSV => "document.csv",
974                        DocumentMediaType::XML => "document.xml",
975                        _ => "document",
976                    });
977                    Ok(UserContent::File {
978                        file: FileContent {
979                            filename: filename.map(String::from),
980                            file_data: Some(url),
981                        },
982                    })
983                }
984                DocumentSourceKind::Base64(data) => {
985                    let mime = media_type
986                        .as_ref()
987                        .map(|m| m.to_mime_type())
988                        .unwrap_or("application/pdf");
989                    let data_uri = format!("data:{mime};base64,{data}");
990
991                    let filename = media_type.as_ref().map(|mt| match mt {
992                        DocumentMediaType::PDF => "document.pdf",
993                        DocumentMediaType::TXT => "document.txt",
994                        DocumentMediaType::HTML => "document.html",
995                        DocumentMediaType::MARKDOWN => "document.md",
996                        DocumentMediaType::CSV => "document.csv",
997                        DocumentMediaType::XML => "document.xml",
998                        _ => "document",
999                    });
1000
1001                    Ok(UserContent::File {
1002                        file: FileContent {
1003                            filename: filename.map(String::from),
1004                            file_data: Some(data_uri),
1005                        },
1006                    })
1007                }
1008                DocumentSourceKind::String(text) => Ok(UserContent::Text { text }),
1009                DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
1010                    "Raw bytes not supported for documents, encode as base64 first".into(),
1011                )),
1012                DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
1013                    "Document has no data".into(),
1014                )),
1015            },
1016
1017            message::UserContent::Audio(_) => Err(message::MessageError::ConversionError(
1018                "Audio content not supported by OpenRouter file implementation. \
1019                 Use the OpenAI-compatible audio types for audio support."
1020                    .into(),
1021            )),
1022
1023            message::UserContent::Video(_) => Err(message::MessageError::ConversionError(
1024                "Video content not supported by OpenRouter file implementation".into(),
1025            )),
1026
1027            message::UserContent::ToolResult(_) => Err(message::MessageError::ConversionError(
1028                "Tool results should be handled as separate messages".into(),
1029            )),
1030        }
1031    }
1032}
1033
1034impl TryFrom<OneOrMany<message::UserContent>> for Vec<Message> {
1035    type Error = message::MessageError;
1036
1037    fn try_from(value: OneOrMany<message::UserContent>) -> Result<Self, Self::Error> {
1038        let (tool_results, other_content): (Vec<_>, Vec<_>) = value
1039            .into_iter()
1040            .partition(|content| matches!(content, message::UserContent::ToolResult(_)));
1041
1042        // If there are messages with both tool results and user content, we handle
1043        // tool results first. It's unlikely that there will be both.
1044        if !tool_results.is_empty() {
1045            tool_results
1046                .into_iter()
1047                .map(|content| match content {
1048                    message::UserContent::ToolResult(tool_result) => Ok(Message::ToolResult {
1049                        tool_call_id: tool_result.id,
1050                        content: tool_result
1051                            .content
1052                            .into_iter()
1053                            .map(|c| match c {
1054                                message::ToolResultContent::Text(message::Text { text }) => text,
1055                                message::ToolResultContent::Image(_) => {
1056                                    "[Image content not supported in tool results]".to_string()
1057                                }
1058                            })
1059                            .collect::<Vec<_>>()
1060                            .join("\n"),
1061                    }),
1062                    _ => unreachable!(),
1063                })
1064                .collect::<Result<Vec<_>, _>>()
1065        } else {
1066            let user_content: Vec<UserContent> = other_content
1067                .into_iter()
1068                .map(|content| content.try_into())
1069                .collect::<Result<Vec<_>, _>>()?;
1070
1071            let content = OneOrMany::many(user_content)
1072                .expect("There must be content here if there were no tool result content");
1073
1074            Ok(vec![Message::User {
1075                content,
1076                name: None,
1077            }])
1078        }
1079    }
1080}
1081
1082// ================================================================
1083// Response Types
1084// ================================================================
1085
1086#[derive(Debug, Deserialize, Serialize)]
1087pub struct Choice {
1088    pub index: usize,
1089    pub native_finish_reason: Option<String>,
1090    pub message: Message,
1091    pub finish_reason: Option<String>,
1092}
1093
1094/// OpenRouter message.
1095///
1096/// Almost identical to OpenAI's Message, but supports more parameters
1097/// for some providers like `reasoning`, and uses OpenRouter-specific
1098/// content types that support images, PDFs, and other file types.
1099#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1100#[serde(tag = "role", rename_all = "lowercase")]
1101pub enum Message {
1102    #[serde(alias = "developer")]
1103    System {
1104        #[serde(deserialize_with = "string_or_one_or_many")]
1105        content: OneOrMany<openai::SystemContent>,
1106        #[serde(skip_serializing_if = "Option::is_none")]
1107        name: Option<String>,
1108    },
1109    User {
1110        #[serde(
1111            deserialize_with = "string_or_one_or_many",
1112            serialize_with = "serialize_user_content"
1113        )]
1114        content: OneOrMany<UserContent>,
1115        #[serde(skip_serializing_if = "Option::is_none")]
1116        name: Option<String>,
1117    },
1118    Assistant {
1119        #[serde(default, deserialize_with = "json_utils::string_or_vec")]
1120        content: Vec<openai::AssistantContent>,
1121        #[serde(skip_serializing_if = "Option::is_none")]
1122        refusal: Option<String>,
1123        #[serde(skip_serializing_if = "Option::is_none")]
1124        audio: Option<openai::AudioAssistant>,
1125        #[serde(skip_serializing_if = "Option::is_none")]
1126        name: Option<String>,
1127        #[serde(
1128            default,
1129            deserialize_with = "json_utils::null_or_vec",
1130            skip_serializing_if = "Vec::is_empty"
1131        )]
1132        tool_calls: Vec<openai::ToolCall>,
1133        #[serde(skip_serializing_if = "Option::is_none")]
1134        reasoning: Option<String>,
1135        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1136        reasoning_details: Vec<ReasoningDetails>,
1137    },
1138    #[serde(rename = "tool")]
1139    ToolResult {
1140        tool_call_id: String,
1141        content: String,
1142    },
1143}
1144
1145impl Message {
1146    pub fn system(content: &str) -> Self {
1147        Message::System {
1148            content: OneOrMany::one(content.to_owned().into()),
1149            name: None,
1150        }
1151    }
1152}
1153
1154#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1155#[serde(tag = "type", rename_all = "snake_case")]
1156pub enum ReasoningDetails {
1157    #[serde(rename = "reasoning.summary")]
1158    Summary {
1159        id: Option<String>,
1160        format: Option<String>,
1161        index: Option<usize>,
1162        summary: String,
1163    },
1164    #[serde(rename = "reasoning.encrypted")]
1165    Encrypted {
1166        id: Option<String>,
1167        format: Option<String>,
1168        index: Option<usize>,
1169        data: String,
1170    },
1171    #[serde(rename = "reasoning.text")]
1172    Text {
1173        id: Option<String>,
1174        format: Option<String>,
1175        index: Option<usize>,
1176        text: Option<String>,
1177        signature: Option<String>,
1178    },
1179}
1180
1181#[derive(Debug, Deserialize, PartialEq, Clone)]
1182#[serde(untagged)]
1183enum ToolCallAdditionalParams {
1184    ReasoningDetails(ReasoningDetails),
1185    Minimal {
1186        id: Option<String>,
1187        format: Option<String>,
1188    },
1189}
1190
1191/// Convert OpenAI's UserContent to OpenRouter's UserContent
1192impl From<openai::UserContent> for UserContent {
1193    fn from(value: openai::UserContent) -> Self {
1194        match value {
1195            openai::UserContent::Text { text } => UserContent::Text { text },
1196            openai::UserContent::Image { image_url } => UserContent::ImageUrl {
1197                image_url: ImageUrl {
1198                    url: image_url.url,
1199                    detail: Some(image_url.detail),
1200                },
1201            },
1202            openai::UserContent::Audio { input_audio } => {
1203                // Audio is not directly supported - convert to text placeholder
1204                // Users should use the native audio support if needed
1205                UserContent::Text {
1206                    text: format!("[Audio content: format={:?}]", input_audio.format),
1207                }
1208            }
1209        }
1210    }
1211}
1212
1213impl From<openai::Message> for Message {
1214    fn from(value: openai::Message) -> Self {
1215        match value {
1216            openai::Message::System { content, name } => Self::System { content, name },
1217            openai::Message::User { content, name } => {
1218                // Convert OpenAI UserContent to OpenRouter UserContent
1219                let converted_content = content.map(UserContent::from);
1220                Self::User {
1221                    content: converted_content,
1222                    name,
1223                }
1224            }
1225            openai::Message::Assistant {
1226                content,
1227                refusal,
1228                audio,
1229                name,
1230                tool_calls,
1231            } => Self::Assistant {
1232                content,
1233                refusal,
1234                audio,
1235                name,
1236                tool_calls,
1237                reasoning: None,
1238                reasoning_details: Vec::new(),
1239            },
1240            openai::Message::ToolResult {
1241                tool_call_id,
1242                content,
1243            } => Self::ToolResult {
1244                tool_call_id,
1245                content: content.as_text(),
1246            },
1247        }
1248    }
1249}
1250
1251impl TryFrom<OneOrMany<message::AssistantContent>> for Vec<Message> {
1252    type Error = message::MessageError;
1253
1254    fn try_from(value: OneOrMany<message::AssistantContent>) -> Result<Self, Self::Error> {
1255        let mut text_content = Vec::new();
1256        let mut tool_calls = Vec::new();
1257        let mut reasoning = None;
1258        let mut reasoning_details = Vec::new();
1259
1260        for content in value.into_iter() {
1261            match content {
1262                message::AssistantContent::Text(text) => text_content.push(text),
1263                message::AssistantContent::ToolCall(tool_call) => {
1264                    // We usually want to provide back the reasoning to OpenRouter since some
1265                    // providers require it.
1266                    // 1. Full reasoning details passed back the user
1267                    // 2. The signature, an id and a format if present
1268                    // 3. The signature and the call_id if present
1269                    if let Some(additional_params) = &tool_call.additional_params
1270                        && let Ok(additional_params) =
1271                            serde_json::from_value::<ToolCallAdditionalParams>(
1272                                additional_params.clone(),
1273                            )
1274                    {
1275                        match additional_params {
1276                            ToolCallAdditionalParams::ReasoningDetails(full) => {
1277                                reasoning_details.push(full);
1278                            }
1279                            ToolCallAdditionalParams::Minimal { id, format } => {
1280                                let id = id.or_else(|| tool_call.call_id.clone());
1281                                if let Some(signature) = &tool_call.signature
1282                                    && let Some(id) = id
1283                                {
1284                                    reasoning_details.push(ReasoningDetails::Encrypted {
1285                                        id: Some(id),
1286                                        format,
1287                                        index: None,
1288                                        data: signature.clone(),
1289                                    })
1290                                }
1291                            }
1292                        }
1293                    } else if let Some(signature) = &tool_call.signature {
1294                        reasoning_details.push(ReasoningDetails::Encrypted {
1295                            id: tool_call.call_id.clone(),
1296                            format: None,
1297                            index: None,
1298                            data: signature.clone(),
1299                        });
1300                    }
1301                    tool_calls.push(tool_call.into())
1302                }
1303                message::AssistantContent::Reasoning(r) => {
1304                    if r.content.is_empty() {
1305                        let display = r.display_text();
1306                        if !display.is_empty() {
1307                            reasoning = Some(display);
1308                        }
1309                    } else {
1310                        for reasoning_block in &r.content {
1311                            let index = Some(reasoning_details.len());
1312                            match reasoning_block {
1313                                message::ReasoningContent::Text { text, signature } => {
1314                                    reasoning_details.push(ReasoningDetails::Text {
1315                                        id: r.id.clone(),
1316                                        format: None,
1317                                        index,
1318                                        text: Some(text.clone()),
1319                                        signature: signature.clone(),
1320                                    });
1321                                }
1322                                message::ReasoningContent::Summary(summary) => {
1323                                    reasoning_details.push(ReasoningDetails::Summary {
1324                                        id: r.id.clone(),
1325                                        format: None,
1326                                        index,
1327                                        summary: summary.clone(),
1328                                    });
1329                                }
1330                                message::ReasoningContent::Encrypted(data)
1331                                | message::ReasoningContent::Redacted { data } => {
1332                                    reasoning_details.push(ReasoningDetails::Encrypted {
1333                                        id: r.id.clone(),
1334                                        format: None,
1335                                        index,
1336                                        data: data.clone(),
1337                                    });
1338                                }
1339                            }
1340                        }
1341                    }
1342                }
1343                message::AssistantContent::Image(_) => {
1344                    return Err(Self::Error::ConversionError(
1345                        "OpenRouter currently doesn't support images.".into(),
1346                    ));
1347                }
1348            }
1349        }
1350
1351        // `OneOrMany` ensures at least one `AssistantContent::Text` or `ToolCall` exists,
1352        //  so either `content` or `tool_calls` will have some content.
1353        Ok(vec![Message::Assistant {
1354            content: text_content
1355                .into_iter()
1356                .map(|content| content.text.into())
1357                .collect::<Vec<_>>(),
1358            refusal: None,
1359            audio: None,
1360            name: None,
1361            tool_calls,
1362            reasoning,
1363            reasoning_details,
1364        }])
1365    }
1366}
1367
1368// OpenRouter uses its own content types for User messages to support
1369// images and PDFs. Assistant messages still use OpenAI-compatible types.
1370impl TryFrom<message::Message> for Vec<Message> {
1371    type Error = message::MessageError;
1372
1373    fn try_from(message: message::Message) -> Result<Self, Self::Error> {
1374        match message {
1375            message::Message::User { content } => {
1376                // Use OpenRouter's own conversion for User content
1377                // This supports images and PDF files via the file content type
1378                content.try_into()
1379            }
1380            message::Message::Assistant { content, .. } => content.try_into(),
1381        }
1382    }
1383}
1384
1385#[derive(Debug, Serialize, Deserialize)]
1386#[serde(untagged, rename_all = "snake_case")]
1387pub enum ToolChoice {
1388    None,
1389    Auto,
1390    Required,
1391    Function(Vec<ToolChoiceFunctionKind>),
1392}
1393
1394impl TryFrom<crate::message::ToolChoice> for ToolChoice {
1395    type Error = CompletionError;
1396
1397    fn try_from(value: crate::message::ToolChoice) -> Result<Self, Self::Error> {
1398        let res = match value {
1399            crate::message::ToolChoice::None => Self::None,
1400            crate::message::ToolChoice::Auto => Self::Auto,
1401            crate::message::ToolChoice::Required => Self::Required,
1402            crate::message::ToolChoice::Specific { function_names } => {
1403                let vec: Vec<ToolChoiceFunctionKind> = function_names
1404                    .into_iter()
1405                    .map(|name| ToolChoiceFunctionKind::Function { name })
1406                    .collect();
1407
1408                Self::Function(vec)
1409            }
1410        };
1411
1412        Ok(res)
1413    }
1414}
1415
1416#[derive(Debug, Serialize, Deserialize)]
1417#[serde(tag = "type", content = "function")]
1418pub enum ToolChoiceFunctionKind {
1419    Function { name: String },
1420}
1421
1422#[derive(Debug, Serialize, Deserialize)]
1423pub(super) struct OpenrouterCompletionRequest {
1424    model: String,
1425    pub messages: Vec<Message>,
1426    #[serde(skip_serializing_if = "Option::is_none")]
1427    temperature: Option<f64>,
1428    #[serde(skip_serializing_if = "Vec::is_empty")]
1429    tools: Vec<crate::providers::openai::completion::ToolDefinition>,
1430    #[serde(skip_serializing_if = "Option::is_none")]
1431    tool_choice: Option<crate::providers::openai::completion::ToolChoice>,
1432    #[serde(flatten, skip_serializing_if = "Option::is_none")]
1433    pub additional_params: Option<serde_json::Value>,
1434}
1435
1436/// Parameters for building an OpenRouter CompletionRequest
1437pub struct OpenRouterRequestParams<'a> {
1438    pub model: &'a str,
1439    pub request: CompletionRequest,
1440    pub strict_tools: bool,
1441}
1442
1443impl TryFrom<OpenRouterRequestParams<'_>> for OpenrouterCompletionRequest {
1444    type Error = CompletionError;
1445
1446    fn try_from(params: OpenRouterRequestParams) -> Result<Self, Self::Error> {
1447        let OpenRouterRequestParams {
1448            model,
1449            request: req,
1450            strict_tools,
1451        } = params;
1452        let model = req.model.clone().unwrap_or_else(|| model.to_string());
1453
1454        if req.output_schema.is_some() {
1455            tracing::warn!("Structured outputs currently not supported for OpenRouter");
1456        }
1457
1458        let mut full_history: Vec<Message> = match &req.preamble {
1459            Some(preamble) => vec![Message::system(preamble)],
1460            None => vec![],
1461        };
1462        if let Some(docs) = req.normalized_documents() {
1463            let docs: Vec<Message> = docs.try_into()?;
1464            full_history.extend(docs);
1465        }
1466
1467        let chat_history: Vec<Message> = req
1468            .chat_history
1469            .clone()
1470            .into_iter()
1471            .map(|message| message.try_into())
1472            .collect::<Result<Vec<Vec<Message>>, _>>()?
1473            .into_iter()
1474            .flatten()
1475            .collect();
1476
1477        full_history.extend(chat_history);
1478
1479        let tool_choice = req
1480            .tool_choice
1481            .clone()
1482            .map(crate::providers::openai::completion::ToolChoice::try_from)
1483            .transpose()?;
1484
1485        let tools: Vec<crate::providers::openai::completion::ToolDefinition> = req
1486            .tools
1487            .clone()
1488            .into_iter()
1489            .map(|tool| {
1490                let def = crate::providers::openai::completion::ToolDefinition::from(tool);
1491                if strict_tools { def.with_strict() } else { def }
1492            })
1493            .collect();
1494
1495        Ok(Self {
1496            model,
1497            messages: full_history,
1498            temperature: req.temperature,
1499            tools,
1500            tool_choice,
1501            additional_params: req.additional_params,
1502        })
1503    }
1504}
1505
1506impl TryFrom<(&str, CompletionRequest)> for OpenrouterCompletionRequest {
1507    type Error = CompletionError;
1508
1509    fn try_from((model, req): (&str, CompletionRequest)) -> Result<Self, Self::Error> {
1510        let model = req.model.clone().unwrap_or_else(|| model.to_string());
1511        OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1512            model: &model,
1513            request: req,
1514            strict_tools: false,
1515        })
1516    }
1517}
1518
1519#[derive(Clone)]
1520pub struct CompletionModel<T = reqwest::Client> {
1521    pub(crate) client: Client<T>,
1522    pub model: String,
1523    /// Enable strict mode for tool schemas.
1524    /// When enabled, tool schemas are sanitized to meet OpenAI's strict mode requirements.
1525    pub strict_tools: bool,
1526}
1527
1528impl<T> CompletionModel<T> {
1529    pub fn new(client: Client<T>, model: impl Into<String>) -> Self {
1530        Self {
1531            client,
1532            model: model.into(),
1533            strict_tools: false,
1534        }
1535    }
1536
1537    /// Enable strict mode for tool schemas.
1538    ///
1539    /// When enabled, tool schemas are automatically sanitized to meet OpenAI's strict mode requirements:
1540    /// - `additionalProperties: false` is added to all objects
1541    /// - All properties are marked as required
1542    /// - `strict: true` is set on each function definition
1543    ///
1544    /// Note: Not all models on OpenRouter support strict mode. This works best with OpenAI models.
1545    pub fn with_strict_tools(mut self) -> Self {
1546        self.strict_tools = true;
1547        self
1548    }
1549}
1550
1551impl<T> completion::CompletionModel for CompletionModel<T>
1552where
1553    T: HttpClientExt + Clone + std::fmt::Debug + Default + 'static,
1554{
1555    type Response = CompletionResponse;
1556    type StreamingResponse = StreamingCompletionResponse;
1557
1558    type Client = Client<T>;
1559
1560    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
1561        Self::new(client.clone(), model)
1562    }
1563
1564    async fn completion(
1565        &self,
1566        completion_request: CompletionRequest,
1567    ) -> Result<completion::CompletionResponse<CompletionResponse>, CompletionError> {
1568        let request_model = completion_request
1569            .model
1570            .clone()
1571            .unwrap_or_else(|| self.model.clone());
1572        let preamble = completion_request.preamble.clone();
1573        let request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
1574            model: request_model.as_ref(),
1575            request: completion_request,
1576            strict_tools: self.strict_tools,
1577        })?;
1578
1579        if enabled!(Level::TRACE) {
1580            tracing::trace!(
1581                target: "rig::completions",
1582                "OpenRouter completion request: {}",
1583                serde_json::to_string_pretty(&request)?
1584            );
1585        }
1586
1587        let span = if tracing::Span::current().is_disabled() {
1588            info_span!(
1589                target: "rig::completions",
1590                "chat",
1591                gen_ai.operation.name = "chat",
1592                gen_ai.provider.name = "openrouter",
1593                gen_ai.request.model = &request_model,
1594                gen_ai.system_instructions = preamble,
1595                gen_ai.response.id = tracing::field::Empty,
1596                gen_ai.response.model = tracing::field::Empty,
1597                gen_ai.usage.output_tokens = tracing::field::Empty,
1598                gen_ai.usage.input_tokens = tracing::field::Empty,
1599            )
1600        } else {
1601            tracing::Span::current()
1602        };
1603
1604        let body = serde_json::to_vec(&request)?;
1605
1606        let req = self
1607            .client
1608            .post("/chat/completions")?
1609            .body(body)
1610            .map_err(|x| CompletionError::HttpError(x.into()))?;
1611
1612        async move {
1613            let response = self.client.send::<_, Bytes>(req).await?;
1614            let status = response.status();
1615            let response_body = response.into_body().into_future().await?.to_vec();
1616
1617            if status.is_success() {
1618                match serde_json::from_slice::<ApiResponse<CompletionResponse>>(&response_body)? {
1619                    ApiResponse::Ok(response) => {
1620                        let span = tracing::Span::current();
1621                        span.record_token_usage(&response.usage);
1622                        span.record("gen_ai.response.id", &response.id);
1623                        span.record("gen_ai.response.model_name", &response.model);
1624
1625                        tracing::debug!(target: "rig::completions",
1626                            "OpenRouter response: {response:?}");
1627                        response.try_into()
1628                    }
1629                    ApiResponse::Err(err) => Err(CompletionError::ProviderError(err.message)),
1630                }
1631            } else {
1632                Err(CompletionError::ProviderError(
1633                    String::from_utf8_lossy(&response_body).to_string(),
1634                ))
1635            }
1636        }
1637        .instrument(span)
1638        .await
1639    }
1640
1641    async fn stream(
1642        &self,
1643        completion_request: CompletionRequest,
1644    ) -> Result<
1645        crate::streaming::StreamingCompletionResponse<Self::StreamingResponse>,
1646        CompletionError,
1647    > {
1648        CompletionModel::stream(self, completion_request).await
1649    }
1650}
1651
1652#[cfg(test)]
1653mod tests {
1654    use super::*;
1655    use serde_json::json;
1656
1657    #[test]
1658    fn test_openrouter_request_uses_request_model_override() {
1659        let request = CompletionRequest {
1660            model: Some("google/gemini-2.5-flash".to_string()),
1661            preamble: None,
1662            chat_history: crate::OneOrMany::one("Hello".into()),
1663            documents: vec![],
1664            tools: vec![],
1665            temperature: None,
1666            max_tokens: None,
1667            tool_choice: None,
1668            additional_params: None,
1669            output_schema: None,
1670        };
1671
1672        let openrouter_request =
1673            OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1674                .expect("request conversion should succeed");
1675        let serialized =
1676            serde_json::to_value(openrouter_request).expect("serialization should succeed");
1677
1678        assert_eq!(serialized["model"], "google/gemini-2.5-flash");
1679    }
1680
1681    #[test]
1682    fn test_openrouter_request_uses_default_model_when_override_unset() {
1683        let request = CompletionRequest {
1684            model: None,
1685            preamble: None,
1686            chat_history: crate::OneOrMany::one("Hello".into()),
1687            documents: vec![],
1688            tools: vec![],
1689            temperature: None,
1690            max_tokens: None,
1691            tool_choice: None,
1692            additional_params: None,
1693            output_schema: None,
1694        };
1695
1696        let openrouter_request =
1697            OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
1698                .expect("request conversion should succeed");
1699        let serialized =
1700            serde_json::to_value(openrouter_request).expect("serialization should succeed");
1701
1702        assert_eq!(serialized["model"], "openai/gpt-4o-mini");
1703    }
1704
1705    #[test]
1706    fn test_completion_response_deserialization_gemini_flash() {
1707        // Real response from OpenRouter with google/gemini-2.5-flash
1708        let json = json!({
1709            "id": "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA",
1710            "provider": "Google",
1711            "model": "google/gemini-2.5-flash",
1712            "object": "chat.completion",
1713            "created": 1765971703u64,
1714            "choices": [{
1715                "logprobs": null,
1716                "finish_reason": "stop",
1717                "native_finish_reason": "STOP",
1718                "index": 0,
1719                "message": {
1720                    "role": "assistant",
1721                    "content": "CONTENT",
1722                    "refusal": null,
1723                    "reasoning": null
1724                }
1725            }],
1726            "usage": {
1727                "prompt_tokens": 669,
1728                "completion_tokens": 5,
1729                "total_tokens": 674
1730            }
1731        });
1732
1733        let response: CompletionResponse = serde_json::from_value(json).unwrap();
1734        assert_eq!(response.id, "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA");
1735        assert_eq!(response.model, "google/gemini-2.5-flash");
1736        assert_eq!(response.choices.len(), 1);
1737        assert_eq!(response.choices[0].finish_reason, Some("stop".to_string()));
1738    }
1739
1740    #[test]
1741    fn test_message_assistant_without_reasoning_details() {
1742        // Verify that missing reasoning_details field doesn't cause deserialization failure
1743        let json = json!({
1744            "role": "assistant",
1745            "content": "Hello world",
1746            "refusal": null,
1747            "reasoning": null
1748        });
1749
1750        let message: Message = serde_json::from_value(json).unwrap();
1751        match message {
1752            Message::Assistant {
1753                content,
1754                reasoning_details,
1755                ..
1756            } => {
1757                assert_eq!(content.len(), 1);
1758                assert!(reasoning_details.is_empty());
1759            }
1760            _ => panic!("Expected Assistant message"),
1761        }
1762    }
1763
1764    #[test]
1765    fn test_data_collection_serialization() {
1766        assert_eq!(
1767            serde_json::to_string(&DataCollection::Allow).unwrap(),
1768            r#""allow""#
1769        );
1770        assert_eq!(
1771            serde_json::to_string(&DataCollection::Deny).unwrap(),
1772            r#""deny""#
1773        );
1774    }
1775
1776    #[test]
1777    fn test_data_collection_default() {
1778        assert_eq!(DataCollection::default(), DataCollection::Allow);
1779    }
1780
1781    #[test]
1782    fn test_quantization_serialization() {
1783        assert_eq!(
1784            serde_json::to_string(&Quantization::Int4).unwrap(),
1785            r#""int4""#
1786        );
1787        assert_eq!(
1788            serde_json::to_string(&Quantization::Int8).unwrap(),
1789            r#""int8""#
1790        );
1791        assert_eq!(
1792            serde_json::to_string(&Quantization::Fp16).unwrap(),
1793            r#""fp16""#
1794        );
1795        assert_eq!(
1796            serde_json::to_string(&Quantization::Bf16).unwrap(),
1797            r#""bf16""#
1798        );
1799        assert_eq!(
1800            serde_json::to_string(&Quantization::Fp32).unwrap(),
1801            r#""fp32""#
1802        );
1803        assert_eq!(
1804            serde_json::to_string(&Quantization::Fp8).unwrap(),
1805            r#""fp8""#
1806        );
1807        assert_eq!(
1808            serde_json::to_string(&Quantization::Unknown).unwrap(),
1809            r#""unknown""#
1810        );
1811    }
1812
1813    #[test]
1814    fn test_provider_sort_strategy_serialization() {
1815        assert_eq!(
1816            serde_json::to_string(&ProviderSortStrategy::Price).unwrap(),
1817            r#""price""#
1818        );
1819        assert_eq!(
1820            serde_json::to_string(&ProviderSortStrategy::Throughput).unwrap(),
1821            r#""throughput""#
1822        );
1823        assert_eq!(
1824            serde_json::to_string(&ProviderSortStrategy::Latency).unwrap(),
1825            r#""latency""#
1826        );
1827    }
1828
1829    #[test]
1830    fn test_sort_partition_serialization() {
1831        assert_eq!(
1832            serde_json::to_string(&SortPartition::Model).unwrap(),
1833            r#""model""#
1834        );
1835        assert_eq!(
1836            serde_json::to_string(&SortPartition::None).unwrap(),
1837            r#""none""#
1838        );
1839    }
1840
1841    #[test]
1842    fn test_provider_sort_simple() {
1843        let sort = ProviderSort::Simple(ProviderSortStrategy::Latency);
1844        let json = serde_json::to_value(&sort).unwrap();
1845        assert_eq!(json, "latency");
1846    }
1847
1848    #[test]
1849    fn test_provider_sort_complex() {
1850        let sort = ProviderSort::Complex(
1851            ProviderSortConfig::new(ProviderSortStrategy::Price).partition(SortPartition::None),
1852        );
1853        let json = serde_json::to_value(&sort).unwrap();
1854        assert_eq!(json["by"], "price");
1855        assert_eq!(json["partition"], "none");
1856    }
1857
1858    #[test]
1859    fn test_provider_sort_complex_without_partition() {
1860        let sort = ProviderSort::Complex(ProviderSortConfig::new(ProviderSortStrategy::Throughput));
1861        let json = serde_json::to_value(&sort).unwrap();
1862        assert_eq!(json["by"], "throughput");
1863        assert!(json.get("partition").is_none());
1864    }
1865
1866    #[test]
1867    fn test_provider_sort_from_strategy() {
1868        let sort: ProviderSort = ProviderSortStrategy::Price.into();
1869        assert_eq!(sort, ProviderSort::Simple(ProviderSortStrategy::Price));
1870    }
1871
1872    #[test]
1873    fn test_provider_sort_from_config() {
1874        let config = ProviderSortConfig::new(ProviderSortStrategy::Latency);
1875        let sort: ProviderSort = config.into();
1876        match sort {
1877            ProviderSort::Complex(c) => assert_eq!(c.by, ProviderSortStrategy::Latency),
1878            _ => panic!("Expected Complex variant"),
1879        }
1880    }
1881
1882    #[test]
1883    fn test_percentile_thresholds_builder() {
1884        let thresholds = PercentileThresholds::new()
1885            .p50(10.0)
1886            .p75(25.0)
1887            .p90(50.0)
1888            .p99(100.0);
1889
1890        assert_eq!(thresholds.p50, Some(10.0));
1891        assert_eq!(thresholds.p75, Some(25.0));
1892        assert_eq!(thresholds.p90, Some(50.0));
1893        assert_eq!(thresholds.p99, Some(100.0));
1894    }
1895
1896    #[test]
1897    fn test_percentile_thresholds_default() {
1898        let thresholds = PercentileThresholds::default();
1899        assert_eq!(thresholds.p50, None);
1900        assert_eq!(thresholds.p75, None);
1901        assert_eq!(thresholds.p90, None);
1902        assert_eq!(thresholds.p99, None);
1903    }
1904
1905    #[test]
1906    fn test_throughput_threshold_simple() {
1907        let threshold = ThroughputThreshold::Simple(50.0);
1908        let json = serde_json::to_value(&threshold).unwrap();
1909        assert_eq!(json, 50.0);
1910    }
1911
1912    #[test]
1913    fn test_throughput_threshold_percentile() {
1914        let threshold = ThroughputThreshold::Percentile(PercentileThresholds::new().p90(50.0));
1915        let json = serde_json::to_value(&threshold).unwrap();
1916        assert_eq!(json["p90"], 50.0);
1917    }
1918
1919    #[test]
1920    fn test_latency_threshold_simple() {
1921        let threshold = LatencyThreshold::Simple(0.5);
1922        let json = serde_json::to_value(&threshold).unwrap();
1923        assert_eq!(json, 0.5);
1924    }
1925
1926    #[test]
1927    fn test_latency_threshold_percentile() {
1928        let threshold = LatencyThreshold::Percentile(PercentileThresholds::new().p50(0.1).p99(1.0));
1929        let json = serde_json::to_value(&threshold).unwrap();
1930        assert_eq!(json["p50"], 0.1);
1931        assert_eq!(json["p99"], 1.0);
1932    }
1933
1934    #[test]
1935    fn test_max_price_builder() {
1936        let price = MaxPrice::new().prompt(0.001).completion(0.002);
1937
1938        assert_eq!(price.prompt, Some(0.001));
1939        assert_eq!(price.completion, Some(0.002));
1940        assert_eq!(price.request, None);
1941        assert_eq!(price.image, None);
1942    }
1943
1944    #[test]
1945    fn test_max_price_all_fields() {
1946        let price = MaxPrice::new()
1947            .prompt(0.001)
1948            .completion(0.002)
1949            .request(0.01)
1950            .image(0.05);
1951
1952        let json = serde_json::to_value(&price).unwrap();
1953        assert_eq!(json["prompt"], 0.001);
1954        assert_eq!(json["completion"], 0.002);
1955        assert_eq!(json["request"], 0.01);
1956        assert_eq!(json["image"], 0.05);
1957    }
1958
1959    #[test]
1960    fn test_max_price_default() {
1961        let price = MaxPrice::default();
1962        assert_eq!(price.prompt, None);
1963        assert_eq!(price.completion, None);
1964        assert_eq!(price.request, None);
1965        assert_eq!(price.image, None);
1966    }
1967
1968    #[test]
1969    fn test_provider_preferences_default() {
1970        let prefs = ProviderPreferences::default();
1971        assert!(prefs.order.is_none());
1972        assert!(prefs.only.is_none());
1973        assert!(prefs.ignore.is_none());
1974        assert!(prefs.allow_fallbacks.is_none());
1975        assert!(prefs.require_parameters.is_none());
1976        assert!(prefs.data_collection.is_none());
1977        assert!(prefs.zdr.is_none());
1978        assert!(prefs.sort.is_none());
1979        assert!(prefs.preferred_min_throughput.is_none());
1980        assert!(prefs.preferred_max_latency.is_none());
1981        assert!(prefs.max_price.is_none());
1982        assert!(prefs.quantizations.is_none());
1983    }
1984
1985    #[test]
1986    fn test_provider_preferences_order_with_fallbacks() {
1987        let prefs = ProviderPreferences::new()
1988            .order(["anthropic", "openai"])
1989            .allow_fallbacks(true);
1990
1991        let json = prefs.to_json();
1992        let provider = &json["provider"];
1993
1994        assert_eq!(provider["order"], json!(["anthropic", "openai"]));
1995        assert_eq!(provider["allow_fallbacks"], true);
1996    }
1997
1998    #[test]
1999    fn test_provider_preferences_only_allowlist() {
2000        let prefs = ProviderPreferences::new()
2001            .only(["azure", "together"])
2002            .allow_fallbacks(false);
2003
2004        let json = prefs.to_json();
2005        let provider = &json["provider"];
2006
2007        assert_eq!(provider["only"], json!(["azure", "together"]));
2008        assert_eq!(provider["allow_fallbacks"], false);
2009    }
2010
2011    #[test]
2012    fn test_provider_preferences_ignore() {
2013        let prefs = ProviderPreferences::new().ignore(["deepinfra"]);
2014
2015        let json = prefs.to_json();
2016        let provider = &json["provider"];
2017
2018        assert_eq!(provider["ignore"], json!(["deepinfra"]));
2019    }
2020
2021    #[test]
2022    fn test_provider_preferences_sort_latency() {
2023        let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Latency);
2024
2025        let json = prefs.to_json();
2026        let provider = &json["provider"];
2027
2028        assert_eq!(provider["sort"], "latency");
2029    }
2030
2031    #[test]
2032    fn test_provider_preferences_price_with_throughput() {
2033        let prefs = ProviderPreferences::new()
2034            .sort(ProviderSortStrategy::Price)
2035            .preferred_min_throughput(ThroughputThreshold::Percentile(
2036                PercentileThresholds::new().p90(50.0),
2037            ));
2038
2039        let json = prefs.to_json();
2040        let provider = &json["provider"];
2041
2042        assert_eq!(provider["sort"], "price");
2043        assert_eq!(provider["preferred_min_throughput"]["p90"], 50.0);
2044    }
2045
2046    #[test]
2047    fn test_provider_preferences_require_parameters() {
2048        let prefs = ProviderPreferences::new().require_parameters(true);
2049
2050        let json = prefs.to_json();
2051        let provider = &json["provider"];
2052
2053        assert_eq!(provider["require_parameters"], true);
2054    }
2055
2056    #[test]
2057    fn test_provider_preferences_data_policy_and_zdr() {
2058        let prefs = ProviderPreferences::new()
2059            .data_collection(DataCollection::Deny)
2060            .zdr(true);
2061
2062        let json = prefs.to_json();
2063        let provider = &json["provider"];
2064
2065        assert_eq!(provider["data_collection"], "deny");
2066        assert_eq!(provider["zdr"], true);
2067    }
2068
2069    #[test]
2070    fn test_provider_preferences_quantizations() {
2071        let prefs =
2072            ProviderPreferences::new().quantizations([Quantization::Int8, Quantization::Fp16]);
2073
2074        let json = prefs.to_json();
2075        let provider = &json["provider"];
2076
2077        assert_eq!(provider["quantizations"], json!(["int8", "fp16"]));
2078    }
2079
2080    #[test]
2081    fn test_provider_preferences_convenience_methods() {
2082        let prefs = ProviderPreferences::new().zero_data_retention().fastest();
2083
2084        assert_eq!(prefs.zdr, Some(true));
2085        assert_eq!(
2086            prefs.sort,
2087            Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
2088        );
2089
2090        let prefs2 = ProviderPreferences::new().cheapest();
2091        assert_eq!(
2092            prefs2.sort,
2093            Some(ProviderSort::Simple(ProviderSortStrategy::Price))
2094        );
2095
2096        let prefs3 = ProviderPreferences::new().lowest_latency();
2097        assert_eq!(
2098            prefs3.sort,
2099            Some(ProviderSort::Simple(ProviderSortStrategy::Latency))
2100        );
2101    }
2102
2103    #[test]
2104    fn test_provider_preferences_serialization_skips_none() {
2105        let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Price);
2106
2107        let json = serde_json::to_value(&prefs).unwrap();
2108
2109        assert_eq!(json["sort"], "price");
2110        assert!(json.get("order").is_none());
2111        assert!(json.get("only").is_none());
2112        assert!(json.get("ignore").is_none());
2113        assert!(json.get("zdr").is_none());
2114    }
2115
2116    #[test]
2117    fn test_provider_preferences_deserialization() {
2118        let json = json!({
2119            "order": ["anthropic", "openai"],
2120            "sort": "throughput",
2121            "data_collection": "deny",
2122            "zdr": true,
2123            "quantizations": ["int8", "fp16"]
2124        });
2125
2126        let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
2127
2128        assert_eq!(
2129            prefs.order,
2130            Some(vec!["anthropic".to_string(), "openai".to_string()])
2131        );
2132        assert_eq!(
2133            prefs.sort,
2134            Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
2135        );
2136        assert_eq!(prefs.data_collection, Some(DataCollection::Deny));
2137        assert_eq!(prefs.zdr, Some(true));
2138        assert_eq!(
2139            prefs.quantizations,
2140            Some(vec![Quantization::Int8, Quantization::Fp16])
2141        );
2142    }
2143
2144    #[test]
2145    fn test_provider_preferences_deserialization_complex_sort() {
2146        let json = json!({
2147            "sort": {
2148                "by": "latency",
2149                "partition": "model"
2150            }
2151        });
2152
2153        let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
2154
2155        match prefs.sort {
2156            Some(ProviderSort::Complex(config)) => {
2157                assert_eq!(config.by, ProviderSortStrategy::Latency);
2158                assert_eq!(config.partition, Some(SortPartition::Model));
2159            }
2160            _ => panic!("Expected Complex sort variant"),
2161        }
2162    }
2163
2164    #[test]
2165    fn test_provider_preferences_full_integration() {
2166        let prefs = ProviderPreferences::new()
2167            .order(["anthropic", "openai"])
2168            .only(["anthropic", "openai", "google"])
2169            .sort(ProviderSortStrategy::Throughput)
2170            .data_collection(DataCollection::Deny)
2171            .zdr(true)
2172            .quantizations([Quantization::Int8])
2173            .allow_fallbacks(false);
2174
2175        let json = prefs.to_json();
2176
2177        assert!(json.get("provider").is_some());
2178        let provider = &json["provider"];
2179        assert_eq!(provider["order"], json!(["anthropic", "openai"]));
2180        assert_eq!(provider["only"], json!(["anthropic", "openai", "google"]));
2181        assert_eq!(provider["sort"], "throughput");
2182        assert_eq!(provider["data_collection"], "deny");
2183        assert_eq!(provider["zdr"], true);
2184        assert_eq!(provider["quantizations"], json!(["int8"]));
2185        assert_eq!(provider["allow_fallbacks"], false);
2186    }
2187
2188    #[test]
2189    fn test_provider_preferences_max_price() {
2190        let prefs =
2191            ProviderPreferences::new().max_price(MaxPrice::new().prompt(0.001).completion(0.002));
2192
2193        let json = prefs.to_json();
2194        let provider = &json["provider"];
2195
2196        assert_eq!(provider["max_price"]["prompt"], 0.001);
2197        assert_eq!(provider["max_price"]["completion"], 0.002);
2198    }
2199
2200    #[test]
2201    fn test_provider_preferences_preferred_max_latency() {
2202        let prefs = ProviderPreferences::new().preferred_max_latency(LatencyThreshold::Simple(0.5));
2203
2204        let json = prefs.to_json();
2205        let provider = &json["provider"];
2206
2207        assert_eq!(provider["preferred_max_latency"], 0.5);
2208    }
2209
2210    #[test]
2211    fn test_provider_preferences_empty_arrays() {
2212        let prefs = ProviderPreferences::new()
2213            .order(Vec::<String>::new())
2214            .quantizations(Vec::<Quantization>::new());
2215
2216        let json = prefs.to_json();
2217        let provider = &json["provider"];
2218
2219        assert_eq!(provider["order"], json!([]));
2220        assert_eq!(provider["quantizations"], json!([]));
2221    }
2222
2223    // ================================================================
2224    // File Support Tests
2225    // ================================================================
2226
2227    #[test]
2228    fn test_user_content_text_serialization() {
2229        let content = UserContent::text("Hello, world!");
2230        let json = serde_json::to_value(&content).unwrap();
2231
2232        assert_eq!(json["type"], "text");
2233        assert_eq!(json["text"], "Hello, world!");
2234    }
2235
2236    #[test]
2237    fn test_user_content_image_url_serialization() {
2238        let content = UserContent::image_url("https://example.com/image.png");
2239        let json = serde_json::to_value(&content).unwrap();
2240
2241        assert_eq!(json["type"], "image_url");
2242        assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
2243        assert!(json["image_url"].get("detail").is_none());
2244    }
2245
2246    #[test]
2247    fn test_user_content_image_url_with_detail_serialization() {
2248        let content =
2249            UserContent::image_url_with_detail("https://example.com/image.png", ImageDetail::High);
2250        let json = serde_json::to_value(&content).unwrap();
2251
2252        assert_eq!(json["type"], "image_url");
2253        assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
2254        assert_eq!(json["image_url"]["detail"], "high");
2255    }
2256
2257    #[test]
2258    fn test_user_content_image_base64_serialization() {
2259        let content = UserContent::image_base64("SGVsbG8=", "image/png", Some(ImageDetail::Low));
2260        let json = serde_json::to_value(&content).unwrap();
2261
2262        assert_eq!(json["type"], "image_url");
2263        assert_eq!(json["image_url"]["url"], "data:image/png;base64,SGVsbG8=");
2264        assert_eq!(json["image_url"]["detail"], "low");
2265    }
2266
2267    #[test]
2268    fn test_user_content_file_url_serialization() {
2269        let content = UserContent::file_url(
2270            "https://example.com/doc.pdf",
2271            Some("document.pdf".to_string()),
2272        );
2273        let json = serde_json::to_value(&content).unwrap();
2274
2275        assert_eq!(json["type"], "file");
2276        assert_eq!(json["file"]["file_data"], "https://example.com/doc.pdf");
2277        assert_eq!(json["file"]["filename"], "document.pdf");
2278    }
2279
2280    #[test]
2281    fn test_user_content_file_base64_serialization() {
2282        let content = UserContent::file_base64(
2283            "JVBERi0xLjQ=",
2284            "application/pdf",
2285            Some("report.pdf".to_string()),
2286        );
2287        let json = serde_json::to_value(&content).unwrap();
2288
2289        assert_eq!(json["type"], "file");
2290        assert_eq!(
2291            json["file"]["file_data"],
2292            "data:application/pdf;base64,JVBERi0xLjQ="
2293        );
2294        assert_eq!(json["file"]["filename"], "report.pdf");
2295    }
2296
2297    #[test]
2298    fn test_user_content_text_deserialization() {
2299        let json = json!({
2300            "type": "text",
2301            "text": "Hello!"
2302        });
2303
2304        let content: UserContent = serde_json::from_value(json).unwrap();
2305        assert_eq!(
2306            content,
2307            UserContent::Text {
2308                text: "Hello!".to_string()
2309            }
2310        );
2311    }
2312
2313    #[test]
2314    fn test_user_content_image_url_deserialization() {
2315        let json = json!({
2316            "type": "image_url",
2317            "image_url": {
2318                "url": "https://example.com/img.jpg",
2319                "detail": "high"
2320            }
2321        });
2322
2323        let content: UserContent = serde_json::from_value(json).unwrap();
2324        match content {
2325            UserContent::ImageUrl { image_url } => {
2326                assert_eq!(image_url.url, "https://example.com/img.jpg");
2327                assert_eq!(image_url.detail, Some(ImageDetail::High));
2328            }
2329            _ => panic!("Expected ImageUrl variant"),
2330        }
2331    }
2332
2333    #[test]
2334    fn test_user_content_file_deserialization() {
2335        let json = json!({
2336            "type": "file",
2337            "file": {
2338                "filename": "doc.pdf",
2339                "file_data": "https://example.com/doc.pdf"
2340            }
2341        });
2342
2343        let content: UserContent = serde_json::from_value(json).unwrap();
2344        match content {
2345            UserContent::File { file } => {
2346                assert_eq!(file.filename, Some("doc.pdf".to_string()));
2347                assert_eq!(
2348                    file.file_data,
2349                    Some("https://example.com/doc.pdf".to_string())
2350                );
2351            }
2352            _ => panic!("Expected File variant"),
2353        }
2354    }
2355
2356    #[test]
2357    fn test_message_user_with_text_serialization() {
2358        let message = Message::User {
2359            content: OneOrMany::one(UserContent::text("Hello")),
2360            name: None,
2361        };
2362        let json = serde_json::to_value(&message).unwrap();
2363
2364        // Single text content should be serialized as a plain string
2365        assert_eq!(json["role"], "user");
2366        assert_eq!(json["content"], "Hello");
2367    }
2368
2369    #[test]
2370    fn test_message_user_with_mixed_content_serialization() {
2371        let message = Message::User {
2372            content: OneOrMany::many(vec![
2373                UserContent::text("Check this image:"),
2374                UserContent::image_url("https://example.com/img.png"),
2375            ])
2376            .unwrap(),
2377            name: None,
2378        };
2379        let json = serde_json::to_value(&message).unwrap();
2380
2381        assert_eq!(json["role"], "user");
2382        let content = json["content"].as_array().unwrap();
2383        assert_eq!(content.len(), 2);
2384        assert_eq!(content[0]["type"], "text");
2385        assert_eq!(content[1]["type"], "image_url");
2386    }
2387
2388    #[test]
2389    fn test_message_user_with_file_serialization() {
2390        let message = Message::User {
2391            content: OneOrMany::many(vec![
2392                UserContent::text("Analyze this PDF:"),
2393                UserContent::file_url(
2394                    "https://example.com/doc.pdf",
2395                    Some("document.pdf".to_string()),
2396                ),
2397            ])
2398            .unwrap(),
2399            name: None,
2400        };
2401        let json = serde_json::to_value(&message).unwrap();
2402
2403        assert_eq!(json["role"], "user");
2404        let content = json["content"].as_array().unwrap();
2405        assert_eq!(content.len(), 2);
2406        assert_eq!(content[0]["type"], "text");
2407        assert_eq!(content[1]["type"], "file");
2408        assert_eq!(
2409            content[1]["file"]["file_data"],
2410            "https://example.com/doc.pdf"
2411        );
2412    }
2413
2414    #[test]
2415    fn test_user_content_from_rig_text() {
2416        let rig_content = message::UserContent::Text(message::Text {
2417            text: "Hello".to_string(),
2418        });
2419        let openrouter_content: UserContent = rig_content.try_into().unwrap();
2420
2421        assert_eq!(
2422            openrouter_content,
2423            UserContent::Text {
2424                text: "Hello".to_string()
2425            }
2426        );
2427    }
2428
2429    #[test]
2430    fn test_user_content_from_rig_image_url() {
2431        let rig_content = message::UserContent::Image(message::Image {
2432            data: DocumentSourceKind::Url("https://example.com/img.png".to_string()),
2433            media_type: Some(message::ImageMediaType::PNG),
2434            detail: Some(ImageDetail::High),
2435            additional_params: None,
2436        });
2437        let openrouter_content: UserContent = rig_content.try_into().unwrap();
2438
2439        match openrouter_content {
2440            UserContent::ImageUrl { image_url } => {
2441                assert_eq!(image_url.url, "https://example.com/img.png");
2442                assert_eq!(image_url.detail, Some(ImageDetail::High));
2443            }
2444            _ => panic!("Expected ImageUrl variant"),
2445        }
2446    }
2447
2448    #[test]
2449    fn test_user_content_from_rig_image_base64() {
2450        let rig_content = message::UserContent::Image(message::Image {
2451            data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
2452            media_type: Some(message::ImageMediaType::JPEG),
2453            detail: Some(ImageDetail::Low),
2454            additional_params: None,
2455        });
2456        let openrouter_content: UserContent = rig_content.try_into().unwrap();
2457
2458        match openrouter_content {
2459            UserContent::ImageUrl { image_url } => {
2460                assert_eq!(image_url.url, "data:image/jpeg;base64,SGVsbG8=");
2461                assert_eq!(image_url.detail, Some(ImageDetail::Low));
2462            }
2463            _ => panic!("Expected ImageUrl variant"),
2464        }
2465    }
2466
2467    #[test]
2468    fn test_user_content_from_rig_document_url() {
2469        let rig_content = message::UserContent::Document(message::Document {
2470            data: DocumentSourceKind::Url("https://example.com/doc.pdf".to_string()),
2471            media_type: Some(DocumentMediaType::PDF),
2472            additional_params: None,
2473        });
2474        let openrouter_content: UserContent = rig_content.try_into().unwrap();
2475
2476        match openrouter_content {
2477            UserContent::File { file } => {
2478                assert_eq!(
2479                    file.file_data,
2480                    Some("https://example.com/doc.pdf".to_string())
2481                );
2482                assert_eq!(file.filename, Some("document.pdf".to_string()));
2483            }
2484            _ => panic!("Expected File variant"),
2485        }
2486    }
2487
2488    #[test]
2489    fn test_user_content_from_rig_document_base64() {
2490        let rig_content = message::UserContent::Document(message::Document {
2491            data: DocumentSourceKind::Base64("JVBERi0xLjQ=".to_string()),
2492            media_type: Some(DocumentMediaType::PDF),
2493            additional_params: None,
2494        });
2495        let openrouter_content: UserContent = rig_content.try_into().unwrap();
2496
2497        match openrouter_content {
2498            UserContent::File { file } => {
2499                assert_eq!(
2500                    file.file_data,
2501                    Some("data:application/pdf;base64,JVBERi0xLjQ=".to_string())
2502                );
2503                assert_eq!(file.filename, Some("document.pdf".to_string()));
2504            }
2505            _ => panic!("Expected File variant"),
2506        }
2507    }
2508
2509    #[test]
2510    fn test_user_content_from_rig_document_string_becomes_text() {
2511        let rig_content = message::UserContent::Document(message::Document {
2512            data: DocumentSourceKind::String("Plain text document content".to_string()),
2513            media_type: Some(DocumentMediaType::TXT),
2514            additional_params: None,
2515        });
2516        let openrouter_content: UserContent = rig_content.try_into().unwrap();
2517
2518        assert_eq!(
2519            openrouter_content,
2520            UserContent::Text {
2521                text: "Plain text document content".to_string()
2522            }
2523        );
2524    }
2525
2526    #[test]
2527    fn test_completion_response_with_reasoning_details_maps_to_typed_reasoning() {
2528        let json = json!({
2529            "id": "resp_123",
2530            "object": "chat.completion",
2531            "created": 1,
2532            "model": "openrouter/test-model",
2533            "choices": [{
2534                "index": 0,
2535                "finish_reason": "stop",
2536                "message": {
2537                    "role": "assistant",
2538                    "content": "hello",
2539                    "reasoning": null,
2540                    "reasoning_details": [
2541                        {"type":"reasoning.summary","id":"rs_1","summary":"s1"},
2542                        {"type":"reasoning.text","id":"rs_1","text":"t1","signature":"sig_1"},
2543                        {"type":"reasoning.encrypted","id":"rs_1","data":"enc_1"}
2544                    ]
2545                }
2546            }]
2547        });
2548
2549        let response: CompletionResponse = serde_json::from_value(json).unwrap();
2550        let converted: completion::CompletionResponse<CompletionResponse> =
2551            response.try_into().unwrap();
2552        let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
2553
2554        assert!(items.iter().any(|item| matches!(
2555            item,
2556            completion::AssistantContent::Reasoning(message::Reasoning { id: Some(id), content })
2557                if id == "rs_1" && content.len() == 3
2558        )));
2559    }
2560
2561    #[test]
2562    fn test_assistant_reasoning_emits_openrouter_reasoning_details() {
2563        let reasoning = message::Reasoning {
2564            id: Some("rs_2".to_string()),
2565            content: vec![
2566                message::ReasoningContent::Text {
2567                    text: "step".to_string(),
2568                    signature: Some("sig_step".to_string()),
2569                },
2570                message::ReasoningContent::Summary("summary".to_string()),
2571                message::ReasoningContent::Encrypted("enc_blob".to_string()),
2572            ],
2573        };
2574
2575        let messages = Vec::<Message>::try_from(OneOrMany::one(
2576            message::AssistantContent::Reasoning(reasoning),
2577        ))
2578        .unwrap();
2579        let Message::Assistant {
2580            reasoning,
2581            reasoning_details,
2582            ..
2583        } = messages.first().expect("assistant message")
2584        else {
2585            panic!("Expected assistant message");
2586        };
2587
2588        assert!(reasoning.is_none());
2589        assert_eq!(reasoning_details.len(), 3);
2590        assert!(matches!(
2591            reasoning_details.first(),
2592            Some(ReasoningDetails::Text {
2593                id: Some(id),
2594                text: Some(text),
2595                signature: Some(signature),
2596                ..
2597            }) if id == "rs_2" && text == "step" && signature == "sig_step"
2598        ));
2599    }
2600
2601    #[test]
2602    fn test_assistant_redacted_reasoning_emits_encrypted_detail_not_text() {
2603        let reasoning = message::Reasoning {
2604            id: Some("rs_redacted".to_string()),
2605            content: vec![message::ReasoningContent::Redacted {
2606                data: "opaque-redacted-data".to_string(),
2607            }],
2608        };
2609
2610        let messages = Vec::<Message>::try_from(OneOrMany::one(
2611            message::AssistantContent::Reasoning(reasoning),
2612        ))
2613        .unwrap();
2614
2615        let Message::Assistant {
2616            reasoning_details,
2617            reasoning,
2618            ..
2619        } = messages.first().expect("assistant message")
2620        else {
2621            panic!("Expected assistant message");
2622        };
2623
2624        assert!(reasoning.is_none());
2625        assert_eq!(reasoning_details.len(), 1);
2626        assert!(matches!(
2627            reasoning_details.first(),
2628            Some(ReasoningDetails::Encrypted {
2629                id: Some(id),
2630                data,
2631                ..
2632            }) if id == "rs_redacted" && data == "opaque-redacted-data"
2633        ));
2634    }
2635
2636    #[test]
2637    fn test_completion_response_reasoning_details_respects_index_ordering() {
2638        let json = json!({
2639            "id": "resp_ordering",
2640            "object": "chat.completion",
2641            "created": 1,
2642            "model": "openrouter/test-model",
2643            "choices": [{
2644                "index": 0,
2645                "finish_reason": "stop",
2646                "message": {
2647                    "role": "assistant",
2648                    "content": "hello",
2649                    "reasoning": null,
2650                    "reasoning_details": [
2651                        {"type":"reasoning.summary","id":"rs_order","index":1,"summary":"second"},
2652                        {"type":"reasoning.summary","id":"rs_order","index":0,"summary":"first"}
2653                    ]
2654                }
2655            }]
2656        });
2657
2658        let response: CompletionResponse = serde_json::from_value(json).unwrap();
2659        let converted: completion::CompletionResponse<CompletionResponse> =
2660            response.try_into().unwrap();
2661        let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
2662        let reasoning_blocks: Vec<_> = items
2663            .into_iter()
2664            .filter_map(|item| match item {
2665                completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
2666                _ => None,
2667            })
2668            .collect();
2669
2670        assert_eq!(reasoning_blocks.len(), 1);
2671        assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_order"));
2672        assert_eq!(
2673            reasoning_blocks[0].content,
2674            vec![
2675                message::ReasoningContent::Summary("first".to_string()),
2676                message::ReasoningContent::Summary("second".to_string()),
2677            ]
2678        );
2679    }
2680
2681    #[test]
2682    fn test_user_content_from_rig_image_missing_media_type_error() {
2683        let rig_content = message::UserContent::Image(message::Image {
2684            data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
2685            media_type: None, // Missing media type
2686            detail: None,
2687            additional_params: None,
2688        });
2689        let result: Result<UserContent, _> = rig_content.try_into();
2690
2691        assert!(result.is_err());
2692        let err = result.unwrap_err();
2693        assert!(err.to_string().contains("media type required"));
2694    }
2695
2696    #[test]
2697    fn test_user_content_from_rig_image_raw_bytes_error() {
2698        let rig_content = message::UserContent::Image(message::Image {
2699            data: DocumentSourceKind::Raw(vec![1, 2, 3]),
2700            media_type: Some(message::ImageMediaType::PNG),
2701            detail: None,
2702            additional_params: None,
2703        });
2704        let result: Result<UserContent, _> = rig_content.try_into();
2705
2706        assert!(result.is_err());
2707        let err = result.unwrap_err();
2708        assert!(err.to_string().contains("base64"));
2709    }
2710
2711    #[test]
2712    fn test_user_content_from_rig_video_not_supported() {
2713        let rig_content = message::UserContent::Video(message::Video {
2714            data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
2715            media_type: Some(message::VideoMediaType::MP4),
2716            additional_params: None,
2717        });
2718        let result: Result<UserContent, _> = rig_content.try_into();
2719
2720        assert!(result.is_err());
2721        let err = result.unwrap_err();
2722        assert!(err.to_string().contains("Video"));
2723    }
2724
2725    #[test]
2726    fn test_user_content_from_rig_audio_not_supported() {
2727        let rig_content = message::UserContent::Audio(message::Audio {
2728            data: DocumentSourceKind::Base64("audiodata".to_string()),
2729            media_type: Some(message::AudioMediaType::MP3),
2730            additional_params: None,
2731        });
2732        let result: Result<UserContent, _> = rig_content.try_into();
2733
2734        assert!(result.is_err());
2735        let err = result.unwrap_err();
2736        assert!(err.to_string().contains("Audio"));
2737    }
2738
2739    #[test]
2740    fn test_message_conversion_with_pdf() {
2741        let rig_message = message::Message::User {
2742            content: OneOrMany::many(vec![
2743                message::UserContent::Text(message::Text {
2744                    text: "Summarize this document".to_string(),
2745                }),
2746                message::UserContent::Document(message::Document {
2747                    data: DocumentSourceKind::Url("https://example.com/paper.pdf".to_string()),
2748                    media_type: Some(DocumentMediaType::PDF),
2749                    additional_params: None,
2750                }),
2751            ])
2752            .unwrap(),
2753        };
2754
2755        let openrouter_messages: Vec<Message> = rig_message.try_into().unwrap();
2756        assert_eq!(openrouter_messages.len(), 1);
2757
2758        match &openrouter_messages[0] {
2759            Message::User { content, .. } => {
2760                assert_eq!(content.len(), 2);
2761
2762                // First should be text
2763                match content.first_ref() {
2764                    UserContent::Text { text } => assert_eq!(text, "Summarize this document"),
2765                    _ => panic!("Expected Text"),
2766                }
2767            }
2768            _ => panic!("Expected User message"),
2769        }
2770    }
2771
2772    #[test]
2773    fn test_user_content_from_string() {
2774        let content: UserContent = "Hello".into();
2775        assert_eq!(
2776            content,
2777            UserContent::Text {
2778                text: "Hello".to_string()
2779            }
2780        );
2781
2782        let content: UserContent = String::from("World").into();
2783        assert_eq!(
2784            content,
2785            UserContent::Text {
2786                text: "World".to_string()
2787            }
2788        );
2789    }
2790
2791    #[test]
2792    fn test_openai_user_content_conversion() {
2793        // Test that OpenAI UserContent can be converted to OpenRouter UserContent
2794        let openai_text = openai::UserContent::Text {
2795            text: "Hello".to_string(),
2796        };
2797        let converted: UserContent = openai_text.into();
2798        assert_eq!(
2799            converted,
2800            UserContent::Text {
2801                text: "Hello".to_string()
2802            }
2803        );
2804
2805        let openai_image = openai::UserContent::Image {
2806            image_url: openai::ImageUrl {
2807                url: "https://example.com/img.png".to_string(),
2808                detail: ImageDetail::Auto,
2809            },
2810        };
2811        let converted: UserContent = openai_image.into();
2812        match converted {
2813            UserContent::ImageUrl { image_url } => {
2814                assert_eq!(image_url.url, "https://example.com/img.png");
2815                assert_eq!(image_url.detail, Some(ImageDetail::Auto));
2816            }
2817            _ => panic!("Expected ImageUrl"),
2818        }
2819    }
2820
2821    #[test]
2822    fn test_completion_response_reasoning_details_with_multiple_ids_stay_separate() {
2823        let json = json!({
2824            "id": "resp_multi_id",
2825            "object": "chat.completion",
2826            "created": 1,
2827            "model": "openrouter/test-model",
2828            "choices": [{
2829                "index": 0,
2830                "finish_reason": "stop",
2831                "message": {
2832                    "role": "assistant",
2833                    "content": "hello",
2834                    "reasoning": null,
2835                    "reasoning_details": [
2836                        {"type":"reasoning.summary","id":"rs_a","summary":"a1"},
2837                        {"type":"reasoning.summary","id":"rs_b","summary":"b1"},
2838                        {"type":"reasoning.summary","id":"rs_a","summary":"a2"}
2839                    ]
2840                }
2841            }]
2842        });
2843
2844        let response: CompletionResponse = serde_json::from_value(json).unwrap();
2845        let converted: completion::CompletionResponse<CompletionResponse> =
2846            response.try_into().unwrap();
2847        let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
2848        let reasoning_blocks: Vec<_> = items
2849            .into_iter()
2850            .filter_map(|item| match item {
2851                completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
2852                _ => None,
2853            })
2854            .collect();
2855
2856        assert_eq!(reasoning_blocks.len(), 2);
2857        assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_a"));
2858        assert_eq!(
2859            reasoning_blocks[0].content,
2860            vec![
2861                message::ReasoningContent::Summary("a1".to_string()),
2862                message::ReasoningContent::Summary("a2".to_string()),
2863            ]
2864        );
2865        assert_eq!(reasoning_blocks[1].id.as_deref(), Some("rs_b"));
2866        assert_eq!(
2867            reasoning_blocks[1].content,
2868            vec![message::ReasoningContent::Summary("b1".to_string())]
2869        );
2870    }
2871}