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::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 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 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; 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 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 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#[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}