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};
12
13// ================================================================
14// Request Types
15// ================================================================
16
17/// Input item for xAI Responses API
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(tag = "type", rename = "snake_case")]
20#[allow(clippy::enum_variant_names)]
21pub enum Message {
22    /// A message
23    Message { role: Role, content: Content },
24    /// A function call from the assistant
25    FunctionCall {
26        call_id: String,
27        name: String,
28        arguments: String,
29    },
30    /// A function call output/result
31    FunctionCallOutput { call_id: String, output: String },
32}
33
34#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum Role {
37    System,
38    User,
39    Assistant,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(untagged)]
44pub enum Content {
45    Text(String),
46    Array(Vec<ContentItem>),
47}
48
49/// Content item types for multimodal messages.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(tag = "type")]
52pub enum ContentItem {
53    #[serde(rename = "input_text")]
54    Text { text: String },
55    #[serde(rename = "input_image")]
56    Image {
57        image_url: String,
58        #[serde(skip_serializing_if = "Option::is_none")]
59        detail: Option<String>,
60    },
61    #[serde(rename = "input_file")]
62    File {
63        #[serde(skip_serializing_if = "Option::is_none")]
64        file_url: Option<String>,
65        #[serde(skip_serializing_if = "Option::is_none")]
66        file_data: Option<String>,
67    },
68}
69
70impl Message {
71    pub fn system(content: impl Into<String>) -> Self {
72        Self::Message {
73            role: Role::System,
74            content: Content::Text(content.into()),
75        }
76    }
77
78    pub fn user(content: impl Into<String>) -> Self {
79        Self::Message {
80            role: Role::User,
81            content: Content::Text(content.into()),
82        }
83    }
84
85    pub fn user_with_content(content: Vec<ContentItem>) -> Self {
86        Self::Message {
87            role: Role::User,
88            content: Content::Array(content),
89        }
90    }
91
92    pub fn assistant(content: impl Into<String>) -> Self {
93        Self::Message {
94            role: Role::Assistant,
95            content: Content::Text(content.into()),
96        }
97    }
98
99    pub fn function_call(call_id: String, name: String, arguments: String) -> Self {
100        Self::FunctionCall {
101            call_id,
102            name,
103            arguments,
104        }
105    }
106
107    pub fn function_call_output(call_id: String, output: String) -> Self {
108        Self::FunctionCallOutput { call_id, output }
109    }
110}
111
112impl TryFrom<RigMessage> for Vec<Message> {
113    type Error = CompletionError;
114
115    fn try_from(msg: RigMessage) -> Result<Self, Self::Error> {
116        use crate::message::{
117            AssistantContent, Document, DocumentSourceKind, Image as RigImage, Text,
118            ToolResultContent, UserContent,
119        };
120
121        fn image_item(img: RigImage) -> Result<ContentItem, CompletionError> {
122            let url = match img.data {
123                DocumentSourceKind::Url(u) => u,
124                DocumentSourceKind::Base64(data) => {
125                    let mime = img
126                        .media_type
127                        .map(|m| m.to_mime_type())
128                        .unwrap_or("image/png");
129                    format!("data:{mime};base64,{data}")
130                }
131                _ => {
132                    return Err(CompletionError::RequestError(
133                        "xAI does not support raw image data; use base64 or URL".into(),
134                    ));
135                }
136            };
137            Ok(ContentItem::Image {
138                image_url: url,
139                detail: img.detail.map(|d| format!("{d:?}").to_lowercase()),
140            })
141        }
142
143        fn document_item(doc: Document) -> Result<ContentItem, CompletionError> {
144            let (file_data, file_url) = match doc.data {
145                DocumentSourceKind::Url(url) => (None, Some(url)),
146                DocumentSourceKind::Base64(data) => {
147                    let mime = doc
148                        .media_type
149                        .map(|m| m.to_mime_type())
150                        .unwrap_or("application/pdf");
151                    (Some(format!("data:{mime};base64,{data}")), None)
152                }
153                DocumentSourceKind::String(text) => {
154                    // Plain text document - just return as text
155                    return Ok(ContentItem::Text { text });
156                }
157                _ => {
158                    return Err(CompletionError::RequestError(
159                        "xAI does not support raw document data; use base64 or URL".into(),
160                    ));
161                }
162            };
163            Ok(ContentItem::File {
164                file_url,
165                file_data,
166            })
167        }
168
169        match msg {
170            RigMessage::User { content } => {
171                let mut items = Vec::new();
172                let mut text_parts = Vec::new();
173                let mut content_items = Vec::new();
174                let mut has_images = false;
175
176                for c in content {
177                    match c {
178                        UserContent::Text(Text { text }) => text_parts.push(text),
179                        UserContent::Image(img) => {
180                            has_images = true;
181                            content_items.push(image_item(img)?);
182                        }
183                        UserContent::ToolResult(tr) => {
184                            // Flush accumulated text/images as a message first
185                            if has_images {
186                                let mut msg_items: Vec<_> = text_parts
187                                    .drain(..)
188                                    .map(|t| ContentItem::Text { text: t })
189                                    .collect();
190                                msg_items.append(&mut content_items);
191                                if !msg_items.is_empty() {
192                                    items.push(Message::user_with_content(msg_items));
193                                }
194                            } else if !text_parts.is_empty() {
195                                items.push(Message::user(text_parts.join("\n")));
196                            }
197                            has_images = false;
198
199                            // Tool result becomes FunctionCallOutput
200                            let output = tr
201                                .content
202                                .into_iter()
203                                .map(|tc| match tc {
204                                    ToolResultContent::Text(t) => Ok(t.text),
205                                    ToolResultContent::Image(_) => {
206                                        Err(CompletionError::RequestError(
207                                            "xAI does not support images in tool results".into(),
208                                        ))
209                                    }
210                                })
211                                .collect::<Result<Vec<_>, _>>()?
212                                .join("\n");
213                            items.push(Message::function_call_output(
214                                tr.call_id.unwrap_or_default(),
215                                output,
216                            ));
217                        }
218                        UserContent::Document(doc) => {
219                            has_images = true; // Force array format for files
220                            content_items.push(document_item(doc)?);
221                        }
222                        UserContent::Audio(_) => {
223                            return Err(CompletionError::RequestError(
224                                "xAI does not support audio".into(),
225                            ));
226                        }
227                        UserContent::Video(_) => {
228                            return Err(CompletionError::RequestError(
229                                "xAI does not support video".into(),
230                            ));
231                        }
232                    }
233                }
234
235                // Flush remaining text/images
236                if has_images {
237                    let mut msg_items: Vec<_> = text_parts
238                        .into_iter()
239                        .map(|t| ContentItem::Text { text: t })
240                        .collect();
241                    msg_items.append(&mut content_items);
242                    if !msg_items.is_empty() {
243                        items.push(Message::user_with_content(msg_items));
244                    }
245                } else if !text_parts.is_empty() {
246                    items.push(Message::user(text_parts.join("\n")));
247                }
248
249                Ok(items)
250            }
251            RigMessage::Assistant { content, .. } => {
252                let mut items = Vec::new();
253                let mut text_parts = Vec::new();
254
255                for c in content {
256                    match c {
257                        AssistantContent::Text(t) => text_parts.push(t.text),
258                        AssistantContent::ToolCall(tc) => {
259                            // Flush accumulated text as a message first
260                            if !text_parts.is_empty() {
261                                items.push(Message::assistant(text_parts.join("\n")));
262                            }
263                            // Tool call becomes FunctionCall
264                            items.push(Message::function_call(
265                                tc.call_id.unwrap_or_default(),
266                                tc.function.name,
267                                tc.function.arguments.to_string(),
268                            ));
269                        }
270                        AssistantContent::Reasoning(r) => text_parts.extend(r.reasoning),
271                        AssistantContent::Image(_) => {
272                            return Err(CompletionError::RequestError(
273                                "xAI does not support images in assistant content".into(),
274                            ));
275                        }
276                    }
277                }
278
279                // Flush remaining text
280                if !text_parts.is_empty() {
281                    items.push(Message::assistant(text_parts.join("\n")));
282                }
283
284                Ok(items)
285            }
286        }
287    }
288}
289
290#[derive(Clone, Debug, Deserialize, Serialize)]
291pub struct ToolDefinition {
292    pub r#type: String,
293    #[serde(flatten)]
294    pub function: completion::ToolDefinition,
295}
296
297impl From<completion::ToolDefinition> for ToolDefinition {
298    fn from(tool: completion::ToolDefinition) -> Self {
299        Self {
300            r#type: "function".to_string(),
301            function: tool,
302        }
303    }
304}
305
306// ================================================================
307// Error Types
308// ================================================================
309
310/// API error response
311#[derive(Debug, Deserialize)]
312pub struct ApiError {
313    pub error: String,
314    pub code: String,
315}
316
317impl ApiError {
318    pub fn message(&self) -> String {
319        format!("Code `{}`: {}", self.code, self.error)
320    }
321}
322
323#[derive(Debug, Deserialize)]
324#[serde(untagged)]
325pub enum ApiResponse<T> {
326    Ok(T),
327    Error(ApiError),
328}