Skip to main content

rig/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::User { content } => {
224                let mut items = Vec::new();
225                let mut text_parts = Vec::new();
226                let mut content_items = Vec::new();
227                let mut has_images = false;
228
229                for c in content {
230                    match c {
231                        UserContent::Text(Text { text }) => text_parts.push(text),
232                        UserContent::Image(img) => {
233                            has_images = true;
234                            content_items.push(image_item(img)?);
235                        }
236                        UserContent::ToolResult(tr) => {
237                            // Flush accumulated text/images as a message first
238                            if has_images {
239                                let mut msg_items: Vec<_> = text_parts
240                                    .drain(..)
241                                    .map(|t| ContentItem::Text { text: t })
242                                    .collect();
243                                msg_items.append(&mut content_items);
244                                if !msg_items.is_empty() {
245                                    items.push(Message::user_with_content(msg_items));
246                                }
247                            } else if !text_parts.is_empty() {
248                                items.push(Message::user(text_parts.join("\n")));
249                            }
250                            has_images = false;
251
252                            // Tool result becomes FunctionCallOutput
253                            let output = tr
254                                .content
255                                .into_iter()
256                                .map(|tc| match tc {
257                                    ToolResultContent::Text(t) => Ok(t.text),
258                                    ToolResultContent::Image(_) => {
259                                        Err(CompletionError::RequestError(
260                                            "xAI does not support images in tool results".into(),
261                                        ))
262                                    }
263                                })
264                                .collect::<Result<Vec<_>, _>>()?
265                                .join("\n");
266                            let call_id = tr.call_id.ok_or_else(|| {
267                                CompletionError::RequestError(
268                                    "Tool result `call_id` is required for xAI Responses API"
269                                        .into(),
270                                )
271                            })?;
272                            items.push(Message::function_call_output(call_id, output));
273                        }
274                        UserContent::Document(doc) => {
275                            has_images = true; // Force array format for files
276                            content_items.push(document_item(doc)?);
277                        }
278                        UserContent::Audio(_) => {
279                            return Err(CompletionError::RequestError(
280                                "xAI does not support audio".into(),
281                            ));
282                        }
283                        UserContent::Video(_) => {
284                            return Err(CompletionError::RequestError(
285                                "xAI does not support video".into(),
286                            ));
287                        }
288                    }
289                }
290
291                // Flush remaining text/images
292                if has_images {
293                    let mut msg_items: Vec<_> = text_parts
294                        .into_iter()
295                        .map(|t| ContentItem::Text { text: t })
296                        .collect();
297                    msg_items.append(&mut content_items);
298                    if !msg_items.is_empty() {
299                        items.push(Message::user_with_content(msg_items));
300                    }
301                } else if !text_parts.is_empty() {
302                    items.push(Message::user(text_parts.join("\n")));
303                }
304
305                Ok(items)
306            }
307            RigMessage::Assistant { content, .. } => {
308                let mut items = Vec::new();
309                let mut text_parts = Vec::new();
310                let flush_assistant_text =
311                    |items: &mut Vec<Message>, text_parts: &mut Vec<String>| {
312                        if !text_parts.is_empty() {
313                            items.push(Message::assistant(text_parts.join("\n")));
314                            text_parts.clear();
315                        }
316                    };
317
318                for c in content {
319                    match c {
320                        AssistantContent::Text(t) => text_parts.push(t.text),
321                        AssistantContent::ToolCall(tc) => {
322                            flush_assistant_text(&mut items, &mut text_parts);
323                            let call_id = tc.call_id.ok_or_else(|| {
324                                CompletionError::RequestError(
325                                    "Assistant tool call `call_id` is required for xAI Responses API"
326                                        .into(),
327                                )
328                            })?;
329                            items.push(Message::function_call(
330                                call_id,
331                                tc.function.name,
332                                tc.function.arguments.to_string(),
333                            ));
334                        }
335                        AssistantContent::Reasoning(r) => {
336                            flush_assistant_text(&mut items, &mut text_parts);
337                            items.push(reasoning_item(r)?);
338                        }
339                        AssistantContent::Image(_) => {
340                            return Err(CompletionError::RequestError(
341                                "xAI does not support images in assistant content".into(),
342                            ));
343                        }
344                    }
345                }
346
347                // Flush remaining text
348                if !text_parts.is_empty() {
349                    items.push(Message::assistant(text_parts.join("\n")));
350                }
351
352                Ok(items)
353            }
354        }
355    }
356}
357
358#[derive(Clone, Debug, Deserialize, Serialize)]
359pub struct ToolDefinition {
360    pub r#type: String,
361    #[serde(flatten)]
362    pub function: completion::ToolDefinition,
363}
364
365impl From<completion::ToolDefinition> for ToolDefinition {
366    fn from(tool: completion::ToolDefinition) -> Self {
367        Self {
368            r#type: "function".to_string(),
369            function: tool,
370        }
371    }
372}
373
374// ================================================================
375// Error Types
376// ================================================================
377
378/// API error response
379#[derive(Debug, Deserialize)]
380pub struct ApiError {
381    pub error: String,
382    pub code: String,
383}
384
385impl ApiError {
386    pub fn message(&self) -> String {
387        format!("Code `{}`: {}", self.code, self.error)
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::Message;
394    use crate::OneOrMany;
395    use crate::completion::CompletionError;
396    use crate::message::{AssistantContent, Message as RigMessage, Reasoning, ReasoningContent};
397    use crate::providers::openai::responses_api::ReasoningSummary;
398
399    #[test]
400    fn assistant_redacted_reasoning_is_serialized_as_encrypted_content() {
401        let reasoning = Reasoning {
402            id: Some("rs_1".to_string()),
403            content: vec![ReasoningContent::Redacted {
404                data: "opaque-redacted".to_string(),
405            }],
406        };
407        let message = RigMessage::Assistant {
408            id: Some("assistant_1".to_string()),
409            content: OneOrMany::one(AssistantContent::Reasoning(reasoning)),
410        };
411
412        let items = Vec::<Message>::try_from(message).expect("convert assistant message");
413        assert_eq!(items.len(), 1);
414        assert!(matches!(
415            items.first(),
416            Some(Message::Reasoning {
417                id,
418                summary,
419                encrypted_content: Some(encrypted_content),
420            }) if id == "rs_1" && summary.is_empty() && encrypted_content == "opaque-redacted"
421        ));
422    }
423
424    #[test]
425    fn assistant_redacted_reasoning_does_not_leak_into_summary_text() {
426        let reasoning = Reasoning {
427            id: Some("rs_2".to_string()),
428            content: vec![
429                ReasoningContent::Text {
430                    text: "explain".to_string(),
431                    signature: None,
432                },
433                ReasoningContent::Redacted {
434                    data: "opaque-redacted".to_string(),
435                },
436            ],
437        };
438        let message = RigMessage::Assistant {
439            id: Some("assistant_2".to_string()),
440            content: OneOrMany::one(AssistantContent::Reasoning(reasoning)),
441        };
442
443        let items = Vec::<Message>::try_from(message).expect("convert assistant message");
444        let Some(Message::Reasoning {
445            summary,
446            encrypted_content,
447            ..
448        }) = items.first()
449        else {
450            panic!("Expected reasoning item");
451        };
452
453        assert_eq!(
454            summary,
455            &vec![ReasoningSummary::SummaryText {
456                text: "explain".to_string()
457            }]
458        );
459        assert_eq!(encrypted_content.as_deref(), Some("opaque-redacted"));
460    }
461
462    #[test]
463    fn assistant_empty_reasoning_content_roundtrips_without_error() {
464        let reasoning = Reasoning {
465            id: Some("rs_empty".to_string()),
466            content: vec![],
467        };
468        let message = RigMessage::Assistant {
469            id: Some("assistant_2b".to_string()),
470            content: OneOrMany::one(AssistantContent::Reasoning(reasoning)),
471        };
472
473        let items = Vec::<Message>::try_from(message).expect("convert assistant message");
474        assert_eq!(items.len(), 1);
475        assert!(matches!(
476            items.first(),
477            Some(Message::Reasoning {
478                id,
479                summary,
480                encrypted_content,
481            }) if id == "rs_empty" && summary.is_empty() && encrypted_content.is_none()
482        ));
483    }
484
485    #[test]
486    fn assistant_reasoning_without_id_returns_request_error() {
487        let message = RigMessage::Assistant {
488            id: Some("assistant_no_reasoning_id".to_string()),
489            content: OneOrMany::one(AssistantContent::Reasoning(Reasoning::new("thinking"))),
490        };
491
492        let converted = Vec::<Message>::try_from(message);
493        assert!(matches!(
494            converted,
495            Err(CompletionError::RequestError(error))
496                if error
497                    .to_string()
498                    .contains("Assistant reasoning `id` is required")
499        ));
500    }
501
502    #[test]
503    fn serialized_message_type_tags_are_snake_case() {
504        let function_call = Message::function_call(
505            "call_1".to_string(),
506            "tool_name".to_string(),
507            "{\"arg\":1}".to_string(),
508        );
509        let user_message = Message::user("hello");
510
511        let function_call_json =
512            serde_json::to_value(function_call).expect("serialize function_call");
513        let user_message_json = serde_json::to_value(user_message).expect("serialize message");
514
515        assert_eq!(
516            function_call_json
517                .get("type")
518                .and_then(|value| value.as_str()),
519            Some("function_call")
520        );
521        assert_eq!(
522            user_message_json
523                .get("type")
524                .and_then(|value| value.as_str()),
525            Some("message")
526        );
527    }
528
529    #[test]
530    fn user_tool_result_without_call_id_returns_request_error() {
531        let message = RigMessage::tool_result("tool_1", "result payload");
532
533        let converted = Vec::<Message>::try_from(message);
534        assert!(matches!(
535            converted,
536            Err(CompletionError::RequestError(error))
537                if error
538                    .to_string()
539                    .contains("Tool result `call_id` is required")
540        ));
541    }
542
543    #[test]
544    fn assistant_tool_call_without_call_id_returns_request_error() {
545        let message = RigMessage::Assistant {
546            id: Some("assistant_3".to_string()),
547            content: OneOrMany::one(AssistantContent::tool_call(
548                "tool_1",
549                "my_tool",
550                serde_json::json!({"arg":"value"}),
551            )),
552        };
553
554        let converted = Vec::<Message>::try_from(message);
555        assert!(matches!(
556            converted,
557            Err(CompletionError::RequestError(error))
558                if error
559                    .to_string()
560                    .contains("Assistant tool call `call_id` is required")
561        ));
562    }
563}
564
565#[derive(Debug, Deserialize)]
566#[serde(untagged)]
567pub enum ApiResponse<T> {
568    Ok(T),
569    Error(ApiError),
570}