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 text_parts.clear();
251 }
252 has_images = false;
253
254 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; 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 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 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#[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}