Skip to main content

rig_core/providers/xai/
api.rs

1//! xAI Responses API types
2//!
3//! Types for the xAI Responses API: <https://docs.x.ai/docs/guides/chat>
4//!
5//! This module reuses OpenAI's Responses API types where compatible,
6//! since xAI's API format is designed to be compatible with OpenAI.
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::completion::{self, CompletionError};
12use crate::message::{Message as RigMessage, MimeType, ReasoningContent};
13use crate::providers::openai::responses_api::ReasoningSummary;
14
15#[derive(Debug, Serialize, Deserialize)]
16struct CompletionRequest {
17    model: String,
18    input: Vec<Message>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    temperature: Option<f64>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    max_output_tokens: Option<u64>,
23    #[serde(skip_serializing_if = "Vec::is_empty")]
24    tools: Vec<Value>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    tool_choice: Option<crate::providers::openai::responses_api::ToolChoice>,
27    #[serde(flatten, skip_serializing_if = "Option::is_none")]
28    additional_params: Option<Value>,
29}
30
31fn normalize_strict_tool(mut tool: Value) -> Value {
32    if tool.get("type").and_then(Value::as_str) == Some("function") {
33        if let Some(parameters) = tool.get_mut("parameters") {
34            crate::providers::openai::sanitize_schema(parameters);
35        }
36        if let Some(tool) = tool.as_object_mut() {
37            tool.insert("strict".to_string(), Value::Bool(true));
38        }
39    }
40    tool
41}
42
43pub(crate) fn create_completion_request(
44    model: String,
45    req: crate::completion::CompletionRequest,
46    default_tools: &[crate::providers::openai::responses_api::ResponsesToolDefinition],
47    strict_tools: bool,
48    stream: bool,
49) -> Result<(String, Value), CompletionError> {
50    let chat_history = req.chat_history_with_documents();
51    if req.output_schema.is_some() {
52        tracing::warn!("Structured outputs currently not supported for xAI");
53    }
54    let model = req.model.clone().unwrap_or(model);
55    let mut input = req
56        .preamble
57        .as_ref()
58        .map_or_else(Vec::new, |p| vec![Message::system(p)]);
59    for message in chat_history {
60        input.extend(Vec::<Message>::try_from(message)?);
61    }
62    let input = crate::message::require_non_empty(input, || {
63        CompletionError::RequestError(
64            "no message in the chat history converted to xAI input \
65             (id-less reasoning-only content has no xAI representation)"
66                .into(),
67        )
68    })?;
69
70    let mut additional_params = req.additional_params.unwrap_or(Value::Null);
71    let mut additional_tools = if let Some(map) = additional_params.as_object_mut()
72        && let Some(raw_tools) = map.remove("tools")
73    {
74        serde_json::from_value::<Vec<Value>>(raw_tools).map_err(|err| {
75            CompletionError::RequestError(
76                format!("Invalid xAI `additional_params.tools` payload: {err}").into(),
77            )
78        })?
79    } else {
80        Vec::new()
81    };
82    let mut tools = req
83        .tools
84        .into_iter()
85        .map(ToolDefinition::from)
86        .map(serde_json::to_value)
87        .collect::<Result<Vec<_>, _>>()?;
88    tools.append(&mut additional_tools);
89    tools.extend(
90        default_tools
91            .iter()
92            .map(serde_json::to_value)
93            .collect::<Result<Vec<_>, _>>()?,
94    );
95    if strict_tools {
96        tools = tools.into_iter().map(normalize_strict_tool).collect();
97    }
98    if stream {
99        if additional_params.is_null() {
100            additional_params = serde_json::json!({});
101        }
102        crate::json_utils::merge_inplace(
103            &mut additional_params,
104            serde_json::json!({"stream": true}),
105        );
106    }
107
108    let request = CompletionRequest {
109        model: model.clone(),
110        input,
111        temperature: req.temperature,
112        max_output_tokens: req.max_tokens,
113        tools,
114        tool_choice: req
115            .tool_choice
116            .map(crate::providers::openai::responses_api::ToolChoice::try_from)
117            .transpose()?,
118        additional_params: (!additional_params.is_null()).then_some(additional_params),
119    };
120    Ok((model, serde_json::to_value(request)?))
121}
122
123// ================================================================
124// Request Types
125// ================================================================
126
127/// Input item for xAI Responses API
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(tag = "type", rename_all = "snake_case")]
130#[allow(clippy::enum_variant_names)]
131pub enum Message {
132    /// A message
133    Message { role: Role, content: Content },
134    /// A function call from the assistant
135    FunctionCall {
136        call_id: String,
137        name: String,
138        arguments: String,
139    },
140    /// A function call output/result
141    FunctionCallOutput { call_id: String, output: String },
142    /// A reasoning item returned by xAI/OpenAI-compatible Responses APIs.
143    Reasoning {
144        id: String,
145        summary: Vec<ReasoningSummary>,
146        #[serde(skip_serializing_if = "Option::is_none")]
147        encrypted_content: Option<String>,
148    },
149}
150
151#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
152#[serde(rename_all = "lowercase")]
153pub enum Role {
154    System,
155    User,
156    Assistant,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(untagged)]
161pub enum Content {
162    Text(String),
163    Array(Vec<ContentItem>),
164}
165
166/// Content item types for multimodal messages.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(tag = "type")]
169pub enum ContentItem {
170    #[serde(rename = "input_text")]
171    Text { text: String },
172    #[serde(rename = "input_image")]
173    Image {
174        image_url: String,
175        #[serde(skip_serializing_if = "Option::is_none")]
176        detail: Option<String>,
177    },
178    #[serde(rename = "input_file")]
179    File {
180        #[serde(skip_serializing_if = "Option::is_none")]
181        file_url: Option<String>,
182        #[serde(skip_serializing_if = "Option::is_none")]
183        file_data: Option<String>,
184    },
185}
186
187impl Message {
188    pub fn system(content: impl Into<String>) -> Self {
189        Self::Message {
190            role: Role::System,
191            content: Content::Text(content.into()),
192        }
193    }
194
195    pub fn user(content: impl Into<String>) -> Self {
196        Self::Message {
197            role: Role::User,
198            content: Content::Text(content.into()),
199        }
200    }
201
202    pub fn user_with_content(content: Vec<ContentItem>) -> Self {
203        Self::Message {
204            role: Role::User,
205            content: Content::Array(content),
206        }
207    }
208
209    pub fn assistant(content: impl Into<String>) -> Self {
210        Self::Message {
211            role: Role::Assistant,
212            content: Content::Text(content.into()),
213        }
214    }
215
216    pub fn function_call(call_id: String, name: String, arguments: String) -> Self {
217        Self::FunctionCall {
218            call_id,
219            name,
220            arguments,
221        }
222    }
223
224    pub fn function_call_output(call_id: String, output: String) -> Self {
225        Self::FunctionCallOutput { call_id, output }
226    }
227
228    pub fn reasoning(
229        id: String,
230        summary: Vec<ReasoningSummary>,
231        encrypted_content: Option<String>,
232    ) -> Self {
233        Self::Reasoning {
234            id,
235            summary,
236            encrypted_content,
237        }
238    }
239}
240
241impl TryFrom<RigMessage> for Vec<Message> {
242    type Error = CompletionError;
243
244    fn try_from(msg: RigMessage) -> Result<Self, Self::Error> {
245        use crate::message::{
246            AssistantContent, Document, DocumentSourceKind, Image as RigImage, Text,
247            ToolResultContent, UserContent,
248        };
249
250        fn image_item(img: RigImage) -> Result<ContentItem, CompletionError> {
251            let url = match img.data {
252                DocumentSourceKind::Url(u) => u,
253                DocumentSourceKind::Base64(data) => {
254                    let mime = img
255                        .media_type
256                        .map(|m| m.to_mime_type())
257                        .unwrap_or("image/png");
258                    format!("data:{mime};base64,{data}")
259                }
260                _ => {
261                    return Err(CompletionError::RequestError(
262                        "xAI does not support raw image data; use base64 or URL".into(),
263                    ));
264                }
265            };
266            Ok(ContentItem::Image {
267                image_url: url,
268                detail: img.detail.map(|d| format!("{d:?}").to_lowercase()),
269            })
270        }
271
272        fn document_item(doc: Document) -> Result<ContentItem, CompletionError> {
273            let (file_data, file_url) = match doc.data {
274                DocumentSourceKind::Url(url) => (None, Some(url)),
275                DocumentSourceKind::Base64(data) => {
276                    let mime = doc
277                        .media_type
278                        .map(|m| m.to_mime_type())
279                        .unwrap_or("application/pdf");
280                    (Some(format!("data:{mime};base64,{data}")), None)
281                }
282                DocumentSourceKind::String(text) => {
283                    // Plain text document - just return as text
284                    return Ok(ContentItem::Text { text });
285                }
286                _ => {
287                    return Err(CompletionError::RequestError(
288                        "xAI does not support raw document data; use base64 or URL".into(),
289                    ));
290                }
291            };
292            Ok(ContentItem::File {
293                file_url,
294                file_data,
295            })
296        }
297
298        fn reasoning_item(
299            reasoning: crate::message::Reasoning,
300        ) -> Result<Option<Message>, CompletionError> {
301            let crate::message::Reasoning { id, content } = reasoning;
302            // Only wire-genuine ids exist in durable histories (the streaming
303            // layer populates `Reasoning::id` exclusively from
304            // `StreamPartId::Wire`). An id-less reasoning item — a cross-provider
305            // replay from a wire that issues no reasoning ids — drops from
306            // request input, mirroring the OpenAI Responses handling, rather
307            // than failing the whole request locally.
308            let Some(id) = id else {
309                tracing::warn!(
310                    "xAI: dropping id-less reasoning item from request input \
311                     (cross-provider replay; xAI reasoning requires a wire id)"
312                );
313                return Ok(None);
314            };
315            let mut encrypted_content = None;
316            let mut summary = Vec::new();
317            for reasoning_content in content {
318                match reasoning_content {
319                    ReasoningContent::Text { text, .. } | ReasoningContent::Summary(text) => {
320                        summary.push(ReasoningSummary::SummaryText { text });
321                    }
322                    // xAI has a single encrypted_content field; only the first
323                    // encrypted/redacted block can be preserved.
324                    ReasoningContent::Redacted { data } | ReasoningContent::Encrypted(data) => {
325                        if encrypted_content.is_some() {
326                            tracing::warn!(
327                                "xAI: dropping additional encrypted/redacted reasoning block \
328                                 (API only supports one encrypted_content per item)"
329                            );
330                        }
331                        encrypted_content.get_or_insert(data);
332                    }
333                }
334            }
335
336            Ok(Some(Message::reasoning(id, summary, encrypted_content)))
337        }
338
339        match msg {
340            RigMessage::System { content } => Ok(vec![Message::system(content)]),
341            RigMessage::User { content } => {
342                let mut items = Vec::new();
343                let mut text_parts = Vec::new();
344                let mut content_items = Vec::new();
345                let mut has_images = false;
346
347                for c in content {
348                    match c {
349                        UserContent::Text(Text { text, .. }) => text_parts.push(text),
350                        UserContent::Image(img) => {
351                            has_images = true;
352                            content_items.push(image_item(img)?);
353                        }
354                        UserContent::ToolResult(tr) => {
355                            // Flush accumulated text/images as a message first
356                            if has_images {
357                                let mut msg_items: Vec<_> = text_parts
358                                    .drain(..)
359                                    .map(|t| ContentItem::Text { text: t })
360                                    .collect();
361                                msg_items.append(&mut content_items);
362                                if !msg_items.is_empty() {
363                                    items.push(Message::user_with_content(msg_items));
364                                }
365                            } else if !text_parts.is_empty() {
366                                items.push(Message::user(text_parts.join("\n")));
367                                text_parts.clear();
368                            }
369                            has_images = false;
370
371                            // Provider-issued call id when one exists,
372                            // else rig's minted handle — always present.
373                            let call_id = tr.wire_call_id().to_owned();
374                            // Tool result becomes FunctionCallOutput
375                            let output = tr
376                                .content
377                                .into_iter()
378                                .map(|tc| match tc {
379                                    ToolResultContent::Text(t) => Ok(t.text),
380                                    ToolResultContent::Json { value } => Ok(value.to_string()),
381                                    ToolResultContent::Image(_) => {
382                                        Err(CompletionError::RequestError(
383                                            "xAI does not support images in tool results".into(),
384                                        ))
385                                    }
386                                })
387                                .collect::<Result<Vec<_>, _>>()?
388                                .join("\n");
389                            items.push(Message::function_call_output(call_id, output));
390                        }
391                        UserContent::Document(doc) => {
392                            has_images = true; // Force array format for files
393                            content_items.push(document_item(doc)?);
394                        }
395                        UserContent::Audio(_) => {
396                            return Err(CompletionError::RequestError(
397                                "xAI does not support audio".into(),
398                            ));
399                        }
400                        UserContent::Video(_) => {
401                            return Err(CompletionError::RequestError(
402                                "xAI does not support video".into(),
403                            ));
404                        }
405                    }
406                }
407
408                // Flush remaining text/images
409                if has_images {
410                    let mut msg_items: Vec<_> = text_parts
411                        .into_iter()
412                        .map(|t| ContentItem::Text { text: t })
413                        .collect();
414                    msg_items.append(&mut content_items);
415                    if !msg_items.is_empty() {
416                        items.push(Message::user_with_content(msg_items));
417                    }
418                } else if !text_parts.is_empty() {
419                    items.push(Message::user(text_parts.join("\n")));
420                }
421
422                Ok(items)
423            }
424            RigMessage::Assistant { content, .. } => {
425                let mut items = Vec::new();
426                let mut text_parts = Vec::new();
427                let flush_assistant_text =
428                    |items: &mut Vec<Message>, text_parts: &mut Vec<String>| {
429                        if !text_parts.is_empty() {
430                            items.push(Message::assistant(text_parts.join("\n")));
431                            text_parts.clear();
432                        }
433                    };
434
435                for c in content {
436                    match c {
437                        AssistantContent::Text(t) => text_parts.push(t.text),
438                        AssistantContent::ToolCall(tc) => {
439                            flush_assistant_text(&mut items, &mut text_parts);
440                            let call_id = tc.wire_call_id().to_owned();
441                            items.push(Message::function_call(
442                                call_id,
443                                tc.function.name,
444                                tc.function.arguments.to_string(),
445                            ));
446                        }
447                        AssistantContent::Reasoning(r) => {
448                            flush_assistant_text(&mut items, &mut text_parts);
449                            if let Some(item) = reasoning_item(r)? {
450                                items.push(item);
451                            }
452                        }
453                        AssistantContent::Image(_) => {
454                            return Err(CompletionError::RequestError(
455                                "xAI does not support images in assistant content".into(),
456                            ));
457                        }
458                    }
459                }
460
461                // Flush remaining text
462                if !text_parts.is_empty() {
463                    items.push(Message::assistant(text_parts.join("\n")));
464                }
465
466                Ok(items)
467            }
468        }
469    }
470}
471
472#[derive(Clone, Debug, Deserialize, Serialize)]
473pub struct ToolDefinition {
474    pub r#type: String,
475    #[serde(flatten)]
476    pub function: completion::ToolDefinition,
477}
478
479impl From<completion::ToolDefinition> for ToolDefinition {
480    fn from(tool: completion::ToolDefinition) -> Self {
481        Self {
482            r#type: "function".to_string(),
483            function: tool,
484        }
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::{Content, Message, Role, create_completion_request};
491    use crate::completion::{CompletionRequest, CompletionRequestBuilder, Document};
492    use crate::message::{
493        AssistantContent, Message as RigMessage, Reasoning, ReasoningContent, ToolChoice,
494        ToolResultContent, UserContent,
495    };
496    use crate::providers::openai::responses_api::ReasoningSummary;
497    use crate::test_utils::MockCompletionModel;
498
499    fn request_value(request: CompletionRequest) -> serde_json::Value {
500        create_completion_request("grok-4-0709".to_string(), request, &[], false, false)
501            .expect("request conversion should succeed")
502            .1
503    }
504
505    #[test]
506    fn xai_request_includes_normalized_documents() {
507        let request = CompletionRequestBuilder::new(
508            MockCompletionModel::default(),
509            "What does glarb-glarb mean?",
510        )
511        .document(Document {
512            id: "doc_1".to_string(),
513            text: "Definition of glarb-glarb: an ancient tool.".to_string(),
514            additional_props: Default::default(),
515        })
516        .build();
517
518        let serialized = request_value(request);
519        let input = serialized["input"]
520            .as_array()
521            .expect("xAI request input should be an array");
522
523        assert!(
524            input
525                .iter()
526                .any(|message| message.to_string().contains("glarb-glarb")),
527            "normalized documents should be forwarded into xAI input"
528        );
529    }
530
531    #[test]
532    fn xai_direct_request_keeps_documents_after_system_messages() {
533        let request = CompletionRequest {
534            model: None,
535            preamble: None,
536            chat_history: vec![
537                RigMessage::system("System prompt"),
538                RigMessage::assistant("Earlier assistant turn"),
539                RigMessage::system("Mid-conversation instruction"),
540                RigMessage::user("What is glarb-glarb?"),
541            ],
542            documents: vec![Document {
543                id: "doc_1".to_string(),
544                text: "Definition of glarb-glarb: an ancient tool.".to_string(),
545                additional_props: Default::default(),
546            }],
547            tools: vec![],
548            temperature: None,
549            max_tokens: None,
550            tool_choice: None,
551            additional_params: None,
552            output_schema: None,
553            record_telemetry_content: false,
554        };
555
556        let serialized = request_value(request);
557        let input = serialized["input"]
558            .as_array()
559            .expect("xAI request input should be an array");
560
561        assert_eq!(input.len(), 5);
562        assert_eq!(input[0]["role"], "system");
563        assert_eq!(input[1]["role"], "user");
564        assert!(input[1].to_string().contains("<file id: doc_1>"));
565        assert_eq!(input[2]["role"], "assistant");
566        assert_eq!(input[3]["role"], "system");
567        assert_eq!(input[4]["role"], "user");
568        assert_eq!(
569            input
570                .iter()
571                .filter(|message| message.to_string().contains("<file id: doc_1>"))
572                .count(),
573            1,
574            "document input should appear exactly once: {input:?}"
575        );
576    }
577
578    #[test]
579    fn xai_request_uses_responses_tool_choice_for_specific_tool() {
580        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Use a tool.")
581            .tool(crate::completion::ToolDefinition {
582                name: "alpha".to_string(),
583                description: "Alpha tool".to_string(),
584                parameters: serde_json::json!({
585                    "type": "object",
586                    "properties": {},
587                    "required": []
588                }),
589            })
590            .tool(crate::completion::ToolDefinition {
591                name: "beta".to_string(),
592                description: "Beta tool".to_string(),
593                parameters: serde_json::json!({
594                    "type": "object",
595                    "properties": {},
596                    "required": []
597                }),
598            })
599            .tool_choice(ToolChoice::Specific {
600                function_names: vec!["beta".to_string()],
601            })
602            .build();
603
604        let serialized = request_value(request);
605        assert_eq!(
606            serialized["tool_choice"],
607            serde_json::json!({"type": "function", "name": "beta"})
608        );
609    }
610
611    #[test]
612    fn xai_stream_request_sets_stream_without_additional_params() {
613        let request =
614            CompletionRequestBuilder::new(MockCompletionModel::default(), "hello").build();
615        let (_, serialized) =
616            create_completion_request("grok-4-0709".to_string(), request, &[], false, true)
617                .expect("streaming request conversion should succeed");
618
619        assert_eq!(serialized["stream"], true);
620    }
621
622    #[test]
623    fn xai_strict_mode_normalizes_function_tools_from_every_source() {
624        let mut request =
625            CompletionRequestBuilder::new(MockCompletionModel::default(), "Use one of the tools.")
626                .tool(crate::completion::ToolDefinition {
627                    name: "request_tool".to_string(),
628                    description: "A request tool".to_string(),
629                    parameters: serde_json::json!({
630                        "type": "object",
631                        "properties": {"request": {"type": "string"}}
632                    }),
633                })
634                .build();
635        request.additional_params = Some(serde_json::json!({
636            "tools": [
637                {
638                    "type": "function",
639                    "name": "additional_tool",
640                    "description": "An additional_params tool",
641                    "parameters": {
642                        "type": "object",
643                        "properties": {"additional": {"type": "string"}}
644                    }
645                },
646                {"type": "web_search"}
647            ]
648        }));
649        let default_tools = [
650            crate::providers::openai::responses_api::ResponsesToolDefinition::function(
651                "default_tool",
652                "A model-level default tool",
653                serde_json::json!({
654                    "type": "object",
655                    "properties": {"default": {"type": "string"}}
656                }),
657            ),
658        ];
659
660        let (_, serialized) = create_completion_request(
661            "grok-4-0709".to_string(),
662            request,
663            &default_tools,
664            true,
665            false,
666        )
667        .expect("request conversion should succeed");
668        let tools = serialized["tools"]
669            .as_array()
670            .expect("tools should be an array");
671
672        assert_eq!(tools.len(), 4);
673        for tool in tools.iter().filter(|tool| tool["type"] == "function") {
674            assert_eq!(tool["strict"], true);
675            assert_eq!(tool["parameters"]["additionalProperties"], false);
676            assert_eq!(
677                tool["parameters"]["required"]
678                    .as_array()
679                    .expect("strict object schema should require every property")
680                    .len(),
681                1
682            );
683        }
684        assert_eq!(tools[2], serde_json::json!({"type": "web_search"}));
685    }
686
687    #[test]
688    fn mixed_user_content_preserves_order_without_duplicate_text() {
689        let message = RigMessage::User {
690            content: vec![
691                UserContent::text("before"),
692                UserContent::tool_result_with_call_id(
693                    "result-id",
694                    "call-id".to_string(),
695                    "tool",
696                    vec![ToolResultContent::json(serde_json::json!({ "ok": true }))],
697                ),
698                UserContent::text("after"),
699            ],
700        };
701
702        let messages = Vec::<Message>::try_from(message).expect("mixed content should convert");
703        assert_eq!(messages.len(), 3);
704        assert!(matches!(
705            &messages[0],
706            Message::Message {
707                role: Role::User,
708                content: Content::Text(text),
709            } if text == "before"
710        ));
711        assert!(matches!(
712            &messages[1],
713            Message::FunctionCallOutput { call_id, output }
714                if call_id == "call-id" && output == r#"{"ok":true}"#
715        ));
716        assert!(matches!(
717            &messages[2],
718            Message::Message {
719                role: Role::User,
720                content: Content::Text(text),
721            } if text == "after"
722        ));
723    }
724
725    #[test]
726    fn assistant_redacted_reasoning_is_serialized_as_encrypted_content() {
727        let reasoning = Reasoning {
728            id: Some("rs_1".to_string()),
729            content: vec![ReasoningContent::Redacted {
730                data: "opaque-redacted".to_string(),
731            }],
732        };
733        let message = RigMessage::Assistant {
734            id: Some("assistant_1".to_string()),
735            content: vec![AssistantContent::Reasoning(reasoning)],
736        };
737
738        let items = Vec::<Message>::try_from(message).expect("convert assistant message");
739        assert_eq!(items.len(), 1);
740        assert!(matches!(
741            items.first(),
742            Some(Message::Reasoning {
743                id,
744                summary,
745                encrypted_content: Some(encrypted_content),
746            }) if id == "rs_1" && summary.is_empty() && encrypted_content == "opaque-redacted"
747        ));
748    }
749
750    #[test]
751    fn assistant_redacted_reasoning_does_not_leak_into_summary_text() {
752        let reasoning = Reasoning {
753            id: Some("rs_2".to_string()),
754            content: vec![
755                ReasoningContent::Text {
756                    text: "explain".to_string(),
757                    signature: None,
758                },
759                ReasoningContent::Redacted {
760                    data: "opaque-redacted".to_string(),
761                },
762            ],
763        };
764        let message = RigMessage::Assistant {
765            id: Some("assistant_2".to_string()),
766            content: vec![AssistantContent::Reasoning(reasoning)],
767        };
768
769        let items = Vec::<Message>::try_from(message).expect("convert assistant message");
770        let Some(Message::Reasoning {
771            summary,
772            encrypted_content,
773            ..
774        }) = items.first()
775        else {
776            panic!("Expected reasoning item");
777        };
778
779        assert_eq!(
780            summary,
781            &vec![ReasoningSummary::SummaryText {
782                text: "explain".to_string()
783            }]
784        );
785        assert_eq!(encrypted_content.as_deref(), Some("opaque-redacted"));
786    }
787
788    #[test]
789    fn assistant_empty_reasoning_content_roundtrips_without_error() {
790        let reasoning = Reasoning {
791            id: Some("rs_empty".to_string()),
792            content: vec![],
793        };
794        let message = RigMessage::Assistant {
795            id: Some("assistant_2b".to_string()),
796            content: vec![AssistantContent::Reasoning(reasoning)],
797        };
798
799        let items = Vec::<Message>::try_from(message).expect("convert assistant message");
800        assert_eq!(items.len(), 1);
801        assert!(matches!(
802            items.first(),
803            Some(Message::Reasoning {
804                id,
805                summary,
806                encrypted_content,
807            }) if id == "rs_empty" && summary.is_empty() && encrypted_content.is_none()
808        ));
809    }
810
811    #[test]
812    fn assistant_reasoning_without_id_is_dropped_from_request_input() {
813        // Only wire-genuine ids exist in durable histories; an id-less
814        // reasoning item (a cross-provider replay from a wire that issues no
815        // reasoning ids) drops from request input — mirroring the OpenAI
816        // Responses handling — instead of failing the whole request or,
817        // worse, fabricating an identifier xAI never issued (#2258 A1).
818        let message = RigMessage::Assistant {
819            id: Some("assistant_no_reasoning_id".to_string()),
820            content: vec![AssistantContent::Reasoning(Reasoning::new("thinking"))],
821        };
822
823        let converted = Vec::<Message>::try_from(message).expect("conversion must not fail");
824        assert!(
825            converted
826                .iter()
827                .all(|item| !matches!(item, Message::Reasoning { .. })),
828            "an id-less reasoning item must not reach the request: {converted:?}"
829        );
830    }
831
832    #[test]
833    fn serialized_message_type_tags_are_snake_case() {
834        let function_call = Message::function_call(
835            "call_1".to_string(),
836            "tool_name".to_string(),
837            "{\"arg\":1}".to_string(),
838        );
839        let user_message = Message::user("hello");
840
841        let function_call_json =
842            serde_json::to_value(function_call).expect("serialize function_call");
843        let user_message_json = serde_json::to_value(user_message).expect("serialize message");
844
845        assert_eq!(
846            function_call_json
847                .get("type")
848                .and_then(|value| value.as_str()),
849            Some("function_call")
850        );
851        assert_eq!(
852            user_message_json
853                .get("type")
854                .and_then(|value| value.as_str()),
855            Some("message")
856        );
857    }
858
859    #[test]
860    fn user_tool_result_without_call_id_replays_the_minted_handle() {
861        // An empty wire id records no provider id and mints the correlation
862        // handle; the minted handle (never an empty string) goes on the wire.
863        let message = RigMessage::tool_result("", "tool_1", "result payload");
864
865        let converted = Vec::<Message>::try_from(message).expect("id-less tool results convert");
866        assert!(matches!(
867            converted.as_slice(),
868            [Message::FunctionCallOutput { call_id, output }]
869                if !call_id.is_empty() && output == "result payload"
870        ));
871    }
872
873    #[test]
874    fn assistant_tool_call_without_call_id_replays_the_minted_handle() {
875        // An empty wire id records no provider id and mints the correlation
876        // handle; the minted handle (never an empty string) goes on the wire.
877        let message = RigMessage::Assistant {
878            id: Some("assistant_3".to_string()),
879            content: vec![AssistantContent::tool_call(
880                "",
881                "my_tool",
882                serde_json::json!({"arg":"value"}),
883            )],
884        };
885
886        let converted = Vec::<Message>::try_from(message).expect("id-less tool calls convert");
887        assert!(matches!(
888            converted.as_slice(),
889            [Message::FunctionCall { call_id, name, .. }]
890                if !call_id.is_empty() && name == "my_tool"
891        ));
892    }
893}