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};
9
10use crate::completion::{self, CompletionError};
11use crate::message::{Message as RigMessage, MimeType, ReasoningContent};
12use crate::providers::openai::responses_api::ReasoningSummary;
13
14// ================================================================
15// Request Types
16// ================================================================
17
18/// Input item for xAI Responses API
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(tag = "type", rename_all = "snake_case")]
21#[allow(clippy::enum_variant_names)]
22pub enum Message {
23    /// A message
24    Message { role: Role, content: Content },
25    /// A function call from the assistant
26    FunctionCall {
27        call_id: String,
28        name: String,
29        arguments: String,
30    },
31    /// A function call output/result
32    FunctionCallOutput { call_id: String, output: String },
33    /// A reasoning item returned by xAI/OpenAI-compatible Responses APIs.
34    Reasoning {
35        id: String,
36        summary: Vec<ReasoningSummary>,
37        #[serde(skip_serializing_if = "Option::is_none")]
38        encrypted_content: Option<String>,
39    },
40}
41
42#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum Role {
45    System,
46    User,
47    Assistant,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(untagged)]
52pub enum Content {
53    Text(String),
54    Array(Vec<ContentItem>),
55}
56
57/// Content item types for multimodal messages.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(tag = "type")]
60pub enum ContentItem {
61    #[serde(rename = "input_text")]
62    Text { text: String },
63    #[serde(rename = "input_image")]
64    Image {
65        image_url: String,
66        #[serde(skip_serializing_if = "Option::is_none")]
67        detail: Option<String>,
68    },
69    #[serde(rename = "input_file")]
70    File {
71        #[serde(skip_serializing_if = "Option::is_none")]
72        file_url: Option<String>,
73        #[serde(skip_serializing_if = "Option::is_none")]
74        file_data: Option<String>,
75    },
76}
77
78impl Message {
79    pub fn system(content: impl Into<String>) -> Self {
80        Self::Message {
81            role: Role::System,
82            content: Content::Text(content.into()),
83        }
84    }
85
86    pub fn user(content: impl Into<String>) -> Self {
87        Self::Message {
88            role: Role::User,
89            content: Content::Text(content.into()),
90        }
91    }
92
93    pub fn user_with_content(content: Vec<ContentItem>) -> Self {
94        Self::Message {
95            role: Role::User,
96            content: Content::Array(content),
97        }
98    }
99
100    pub fn assistant(content: impl Into<String>) -> Self {
101        Self::Message {
102            role: Role::Assistant,
103            content: Content::Text(content.into()),
104        }
105    }
106
107    pub fn function_call(call_id: String, name: String, arguments: String) -> Self {
108        Self::FunctionCall {
109            call_id,
110            name,
111            arguments,
112        }
113    }
114
115    pub fn function_call_output(call_id: String, output: String) -> Self {
116        Self::FunctionCallOutput { call_id, output }
117    }
118
119    pub fn reasoning(
120        id: String,
121        summary: Vec<ReasoningSummary>,
122        encrypted_content: Option<String>,
123    ) -> Self {
124        Self::Reasoning {
125            id,
126            summary,
127            encrypted_content,
128        }
129    }
130}
131
132impl TryFrom<RigMessage> for Vec<Message> {
133    type Error = CompletionError;
134
135    fn try_from(msg: RigMessage) -> Result<Self, Self::Error> {
136        use crate::message::{
137            AssistantContent, Document, DocumentSourceKind, Image as RigImage, Text,
138            ToolResultContent, UserContent,
139        };
140
141        fn image_item(img: RigImage) -> Result<ContentItem, CompletionError> {
142            let url = match img.data {
143                DocumentSourceKind::Url(u) => u,
144                DocumentSourceKind::Base64(data) => {
145                    let mime = img
146                        .media_type
147                        .map(|m| m.to_mime_type())
148                        .unwrap_or("image/png");
149                    format!("data:{mime};base64,{data}")
150                }
151                _ => {
152                    return Err(CompletionError::RequestError(
153                        "xAI does not support raw image data; use base64 or URL".into(),
154                    ));
155                }
156            };
157            Ok(ContentItem::Image {
158                image_url: url,
159                detail: img.detail.map(|d| format!("{d:?}").to_lowercase()),
160            })
161        }
162
163        fn document_item(doc: Document) -> Result<ContentItem, CompletionError> {
164            let (file_data, file_url) = match doc.data {
165                DocumentSourceKind::Url(url) => (None, Some(url)),
166                DocumentSourceKind::Base64(data) => {
167                    let mime = doc
168                        .media_type
169                        .map(|m| m.to_mime_type())
170                        .unwrap_or("application/pdf");
171                    (Some(format!("data:{mime};base64,{data}")), None)
172                }
173                DocumentSourceKind::String(text) => {
174                    // Plain text document - just return as text
175                    return Ok(ContentItem::Text { text });
176                }
177                _ => {
178                    return Err(CompletionError::RequestError(
179                        "xAI does not support raw document data; use base64 or URL".into(),
180                    ));
181                }
182            };
183            Ok(ContentItem::File {
184                file_url,
185                file_data,
186            })
187        }
188
189        fn reasoning_item(
190            reasoning: crate::message::Reasoning,
191        ) -> Result<Message, CompletionError> {
192            let crate::message::Reasoning { id, content } = reasoning;
193            let id = id.ok_or_else(|| {
194                CompletionError::RequestError(
195                    "Assistant reasoning `id` is required for xAI Responses replay".into(),
196                )
197            })?;
198            let mut encrypted_content = None;
199            let mut summary = Vec::new();
200            for reasoning_content in content {
201                match reasoning_content {
202                    ReasoningContent::Text { text, .. } | ReasoningContent::Summary(text) => {
203                        summary.push(ReasoningSummary::SummaryText { text });
204                    }
205                    // xAI has a single encrypted_content field; only the first
206                    // encrypted/redacted block can be preserved.
207                    ReasoningContent::Redacted { data } | ReasoningContent::Encrypted(data) => {
208                        if encrypted_content.is_some() {
209                            tracing::warn!(
210                                "xAI: dropping additional encrypted/redacted reasoning block \
211                                 (API only supports one encrypted_content per item)"
212                            );
213                        }
214                        encrypted_content.get_or_insert(data);
215                    }
216                }
217            }
218
219            Ok(Message::reasoning(id, summary, encrypted_content))
220        }
221
222        match msg {
223            RigMessage::System { content } => Ok(vec![Message::system(content)]),
224            RigMessage::User { content } => {
225                let mut items = Vec::new();
226                let mut text_parts = Vec::new();
227                let mut content_items = Vec::new();
228                let mut has_images = false;
229
230                for c in content {
231                    match c {
232                        UserContent::Text(Text { text, .. }) => text_parts.push(text),
233                        UserContent::Image(img) => {
234                            has_images = true;
235                            content_items.push(image_item(img)?);
236                        }
237                        UserContent::ToolResult(tr) => {
238                            // Flush accumulated text/images as a message first
239                            if has_images {
240                                let mut msg_items: Vec<_> = text_parts
241                                    .drain(..)
242                                    .map(|t| ContentItem::Text { text: t })
243                                    .collect();
244                                msg_items.append(&mut content_items);
245                                if !msg_items.is_empty() {
246                                    items.push(Message::user_with_content(msg_items));
247                                }
248                            } else if !text_parts.is_empty() {
249                                items.push(Message::user(text_parts.join("\n")));
250                                text_parts.clear();
251                            }
252                            has_images = false;
253
254                            // Tool result becomes FunctionCallOutput
255                            let output = tr
256                                .content
257                                .into_iter()
258                                .map(|tc| match tc {
259                                    ToolResultContent::Text(t) => Ok(t.text),
260                                    ToolResultContent::Json { value } => Ok(value.to_string()),
261                                    ToolResultContent::Image(_) => {
262                                        Err(CompletionError::RequestError(
263                                            "xAI does not support images in tool results".into(),
264                                        ))
265                                    }
266                                })
267                                .collect::<Result<Vec<_>, _>>()?
268                                .join("\n");
269                            let call_id = tr.call_id.ok_or_else(|| {
270                                CompletionError::RequestError(
271                                    "Tool result `call_id` is required for xAI Responses API"
272                                        .into(),
273                                )
274                            })?;
275                            items.push(Message::function_call_output(call_id, output));
276                        }
277                        UserContent::Document(doc) => {
278                            has_images = true; // Force array format for files
279                            content_items.push(document_item(doc)?);
280                        }
281                        UserContent::Audio(_) => {
282                            return Err(CompletionError::RequestError(
283                                "xAI does not support audio".into(),
284                            ));
285                        }
286                        UserContent::Video(_) => {
287                            return Err(CompletionError::RequestError(
288                                "xAI does not support video".into(),
289                            ));
290                        }
291                    }
292                }
293
294                // Flush remaining text/images
295                if has_images {
296                    let mut msg_items: Vec<_> = text_parts
297                        .into_iter()
298                        .map(|t| ContentItem::Text { text: t })
299                        .collect();
300                    msg_items.append(&mut content_items);
301                    if !msg_items.is_empty() {
302                        items.push(Message::user_with_content(msg_items));
303                    }
304                } else if !text_parts.is_empty() {
305                    items.push(Message::user(text_parts.join("\n")));
306                }
307
308                Ok(items)
309            }
310            RigMessage::Assistant { content, .. } => {
311                let mut items = Vec::new();
312                let mut text_parts = Vec::new();
313                let flush_assistant_text =
314                    |items: &mut Vec<Message>, text_parts: &mut Vec<String>| {
315                        if !text_parts.is_empty() {
316                            items.push(Message::assistant(text_parts.join("\n")));
317                            text_parts.clear();
318                        }
319                    };
320
321                for c in content {
322                    match c {
323                        AssistantContent::Text(t) => text_parts.push(t.text),
324                        AssistantContent::ToolCall(tc) => {
325                            flush_assistant_text(&mut items, &mut text_parts);
326                            let call_id = tc.call_id.ok_or_else(|| {
327                                CompletionError::RequestError(
328                                    "Assistant tool call `call_id` is required for xAI Responses API"
329                                        .into(),
330                                )
331                            })?;
332                            items.push(Message::function_call(
333                                call_id,
334                                tc.function.name,
335                                tc.function.arguments.to_string(),
336                            ));
337                        }
338                        AssistantContent::Reasoning(r) => {
339                            flush_assistant_text(&mut items, &mut text_parts);
340                            items.push(reasoning_item(r)?);
341                        }
342                        AssistantContent::Image(_) => {
343                            return Err(CompletionError::RequestError(
344                                "xAI does not support images in assistant content".into(),
345                            ));
346                        }
347                    }
348                }
349
350                // Flush remaining text
351                if !text_parts.is_empty() {
352                    items.push(Message::assistant(text_parts.join("\n")));
353                }
354
355                Ok(items)
356            }
357        }
358    }
359}
360
361#[derive(Clone, Debug, Deserialize, Serialize)]
362pub struct ToolDefinition {
363    pub r#type: String,
364    #[serde(flatten)]
365    pub function: completion::ToolDefinition,
366}
367
368impl From<completion::ToolDefinition> for ToolDefinition {
369    fn from(tool: completion::ToolDefinition) -> Self {
370        Self {
371            r#type: "function".to_string(),
372            function: tool,
373        }
374    }
375}
376
377// ================================================================
378// Error Types
379// ================================================================
380
381/// API error response
382#[derive(Debug, Deserialize)]
383pub struct ApiError {
384    pub error: String,
385    pub code: String,
386}
387
388impl ApiError {
389    pub fn message(&self) -> String {
390        format!("Code `{}`: {}", self.code, self.error)
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::{Content, Message, Role};
397    use crate::OneOrMany;
398    use crate::completion::CompletionError;
399    use crate::message::{
400        AssistantContent, Message as RigMessage, Reasoning, ReasoningContent, ToolResultContent,
401        UserContent,
402    };
403    use crate::providers::openai::responses_api::ReasoningSummary;
404
405    #[test]
406    fn mixed_user_content_preserves_order_without_duplicate_text() {
407        let message = RigMessage::User {
408            content: OneOrMany::many(vec![
409                UserContent::text("before"),
410                UserContent::tool_result_with_call_id(
411                    "result-id",
412                    "call-id".to_string(),
413                    OneOrMany::one(ToolResultContent::json(serde_json::json!({ "ok": true }))),
414                ),
415                UserContent::text("after"),
416            ])
417            .expect("mixed content is non-empty"),
418        };
419
420        let messages = Vec::<Message>::try_from(message).expect("mixed content should convert");
421        assert_eq!(messages.len(), 3);
422        assert!(matches!(
423            &messages[0],
424            Message::Message {
425                role: Role::User,
426                content: Content::Text(text),
427            } if text == "before"
428        ));
429        assert!(matches!(
430            &messages[1],
431            Message::FunctionCallOutput { call_id, output }
432                if call_id == "call-id" && output == r#"{"ok":true}"#
433        ));
434        assert!(matches!(
435            &messages[2],
436            Message::Message {
437                role: Role::User,
438                content: Content::Text(text),
439            } if text == "after"
440        ));
441    }
442
443    #[test]
444    fn assistant_redacted_reasoning_is_serialized_as_encrypted_content() {
445        let reasoning = Reasoning {
446            id: Some("rs_1".to_string()),
447            content: vec![ReasoningContent::Redacted {
448                data: "opaque-redacted".to_string(),
449            }],
450        };
451        let message = RigMessage::Assistant {
452            id: Some("assistant_1".to_string()),
453            content: OneOrMany::one(AssistantContent::Reasoning(reasoning)),
454        };
455
456        let items = Vec::<Message>::try_from(message).expect("convert assistant message");
457        assert_eq!(items.len(), 1);
458        assert!(matches!(
459            items.first(),
460            Some(Message::Reasoning {
461                id,
462                summary,
463                encrypted_content: Some(encrypted_content),
464            }) if id == "rs_1" && summary.is_empty() && encrypted_content == "opaque-redacted"
465        ));
466    }
467
468    #[test]
469    fn assistant_redacted_reasoning_does_not_leak_into_summary_text() {
470        let reasoning = Reasoning {
471            id: Some("rs_2".to_string()),
472            content: vec![
473                ReasoningContent::Text {
474                    text: "explain".to_string(),
475                    signature: None,
476                },
477                ReasoningContent::Redacted {
478                    data: "opaque-redacted".to_string(),
479                },
480            ],
481        };
482        let message = RigMessage::Assistant {
483            id: Some("assistant_2".to_string()),
484            content: OneOrMany::one(AssistantContent::Reasoning(reasoning)),
485        };
486
487        let items = Vec::<Message>::try_from(message).expect("convert assistant message");
488        let Some(Message::Reasoning {
489            summary,
490            encrypted_content,
491            ..
492        }) = items.first()
493        else {
494            panic!("Expected reasoning item");
495        };
496
497        assert_eq!(
498            summary,
499            &vec![ReasoningSummary::SummaryText {
500                text: "explain".to_string()
501            }]
502        );
503        assert_eq!(encrypted_content.as_deref(), Some("opaque-redacted"));
504    }
505
506    #[test]
507    fn assistant_empty_reasoning_content_roundtrips_without_error() {
508        let reasoning = Reasoning {
509            id: Some("rs_empty".to_string()),
510            content: vec![],
511        };
512        let message = RigMessage::Assistant {
513            id: Some("assistant_2b".to_string()),
514            content: OneOrMany::one(AssistantContent::Reasoning(reasoning)),
515        };
516
517        let items = Vec::<Message>::try_from(message).expect("convert assistant message");
518        assert_eq!(items.len(), 1);
519        assert!(matches!(
520            items.first(),
521            Some(Message::Reasoning {
522                id,
523                summary,
524                encrypted_content,
525            }) if id == "rs_empty" && summary.is_empty() && encrypted_content.is_none()
526        ));
527    }
528
529    #[test]
530    fn assistant_reasoning_without_id_returns_request_error() {
531        let message = RigMessage::Assistant {
532            id: Some("assistant_no_reasoning_id".to_string()),
533            content: OneOrMany::one(AssistantContent::Reasoning(Reasoning::new("thinking"))),
534        };
535
536        let converted = Vec::<Message>::try_from(message);
537        assert!(matches!(
538            converted,
539            Err(CompletionError::RequestError(error))
540                if error
541                    .to_string()
542                    .contains("Assistant reasoning `id` is required")
543        ));
544    }
545
546    #[test]
547    fn serialized_message_type_tags_are_snake_case() {
548        let function_call = Message::function_call(
549            "call_1".to_string(),
550            "tool_name".to_string(),
551            "{\"arg\":1}".to_string(),
552        );
553        let user_message = Message::user("hello");
554
555        let function_call_json =
556            serde_json::to_value(function_call).expect("serialize function_call");
557        let user_message_json = serde_json::to_value(user_message).expect("serialize message");
558
559        assert_eq!(
560            function_call_json
561                .get("type")
562                .and_then(|value| value.as_str()),
563            Some("function_call")
564        );
565        assert_eq!(
566            user_message_json
567                .get("type")
568                .and_then(|value| value.as_str()),
569            Some("message")
570        );
571    }
572
573    #[test]
574    fn user_tool_result_without_call_id_returns_request_error() {
575        let message = RigMessage::tool_result("tool_1", "result payload");
576
577        let converted = Vec::<Message>::try_from(message);
578        assert!(matches!(
579            converted,
580            Err(CompletionError::RequestError(error))
581                if error
582                    .to_string()
583                    .contains("Tool result `call_id` is required")
584        ));
585    }
586
587    #[test]
588    fn assistant_tool_call_without_call_id_returns_request_error() {
589        let message = RigMessage::Assistant {
590            id: Some("assistant_3".to_string()),
591            content: OneOrMany::one(AssistantContent::tool_call(
592                "tool_1",
593                "my_tool",
594                serde_json::json!({"arg":"value"}),
595            )),
596        };
597
598        let converted = Vec::<Message>::try_from(message);
599        assert!(matches!(
600            converted,
601            Err(CompletionError::RequestError(error))
602                if error
603                    .to_string()
604                    .contains("Assistant tool call `call_id` is required")
605        ));
606    }
607}
608
609#[derive(Debug, Deserialize)]
610#[serde(untagged)]
611pub enum ApiResponse<T> {
612    Ok(T),
613    Error(ApiError),
614}