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