1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(tag = "type", rename_all = "snake_case")]
21#[allow(clippy::enum_variant_names)]
22pub enum Message {
23 Message { role: Role, content: Content },
25 FunctionCall {
27 call_id: String,
28 name: String,
29 arguments: String,
30 },
31 FunctionCallOutput { call_id: String, output: String },
33 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#[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 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 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 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 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; 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 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 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#[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}