use serde::{Deserialize, Serialize};
use crate::completion::{self, CompletionError};
use crate::message::{Message as RigMessage, MimeType, ReasoningContent};
use crate::providers::openai::responses_api::ReasoningSummary;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[allow(clippy::enum_variant_names)]
pub enum Message {
Message { role: Role, content: Content },
FunctionCall {
call_id: String,
name: String,
arguments: String,
},
FunctionCallOutput { call_id: String, output: String },
Reasoning {
id: String,
summary: Vec<ReasoningSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
encrypted_content: Option<String>,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
System,
User,
Assistant,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Content {
Text(String),
Array(Vec<ContentItem>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ContentItem {
#[serde(rename = "input_text")]
Text { text: String },
#[serde(rename = "input_image")]
Image {
image_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
},
#[serde(rename = "input_file")]
File {
#[serde(skip_serializing_if = "Option::is_none")]
file_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
file_data: Option<String>,
},
}
impl Message {
pub fn system(content: impl Into<String>) -> Self {
Self::Message {
role: Role::System,
content: Content::Text(content.into()),
}
}
pub fn user(content: impl Into<String>) -> Self {
Self::Message {
role: Role::User,
content: Content::Text(content.into()),
}
}
pub fn user_with_content(content: Vec<ContentItem>) -> Self {
Self::Message {
role: Role::User,
content: Content::Array(content),
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self::Message {
role: Role::Assistant,
content: Content::Text(content.into()),
}
}
pub fn function_call(call_id: String, name: String, arguments: String) -> Self {
Self::FunctionCall {
call_id,
name,
arguments,
}
}
pub fn function_call_output(call_id: String, output: String) -> Self {
Self::FunctionCallOutput { call_id, output }
}
pub fn reasoning(
id: String,
summary: Vec<ReasoningSummary>,
encrypted_content: Option<String>,
) -> Self {
Self::Reasoning {
id,
summary,
encrypted_content,
}
}
}
impl TryFrom<RigMessage> for Vec<Message> {
type Error = CompletionError;
fn try_from(msg: RigMessage) -> Result<Self, Self::Error> {
use crate::message::{
AssistantContent, Document, DocumentSourceKind, Image as RigImage, Text,
ToolResultContent, UserContent,
};
fn image_item(img: RigImage) -> Result<ContentItem, CompletionError> {
let url = match img.data {
DocumentSourceKind::Url(u) => u,
DocumentSourceKind::Base64(data) => {
let mime = img
.media_type
.map(|m| m.to_mime_type())
.unwrap_or("image/png");
format!("data:{mime};base64,{data}")
}
_ => {
return Err(CompletionError::RequestError(
"xAI does not support raw image data; use base64 or URL".into(),
));
}
};
Ok(ContentItem::Image {
image_url: url,
detail: img.detail.map(|d| format!("{d:?}").to_lowercase()),
})
}
fn document_item(doc: Document) -> Result<ContentItem, CompletionError> {
let (file_data, file_url) = match doc.data {
DocumentSourceKind::Url(url) => (None, Some(url)),
DocumentSourceKind::Base64(data) => {
let mime = doc
.media_type
.map(|m| m.to_mime_type())
.unwrap_or("application/pdf");
(Some(format!("data:{mime};base64,{data}")), None)
}
DocumentSourceKind::String(text) => {
return Ok(ContentItem::Text { text });
}
_ => {
return Err(CompletionError::RequestError(
"xAI does not support raw document data; use base64 or URL".into(),
));
}
};
Ok(ContentItem::File {
file_url,
file_data,
})
}
fn reasoning_item(
reasoning: crate::message::Reasoning,
) -> Result<Message, CompletionError> {
let crate::message::Reasoning { id, content } = reasoning;
let id = id.ok_or_else(|| {
CompletionError::RequestError(
"Assistant reasoning `id` is required for xAI Responses replay".into(),
)
})?;
let mut encrypted_content = None;
let mut summary = Vec::new();
for reasoning_content in content {
match reasoning_content {
ReasoningContent::Text { text, .. } | ReasoningContent::Summary(text) => {
summary.push(ReasoningSummary::SummaryText { text });
}
ReasoningContent::Redacted { data } | ReasoningContent::Encrypted(data) => {
if encrypted_content.is_some() {
tracing::warn!(
"xAI: dropping additional encrypted/redacted reasoning block \
(API only supports one encrypted_content per item)"
);
}
encrypted_content.get_or_insert(data);
}
}
}
Ok(Message::reasoning(id, summary, encrypted_content))
}
match msg {
RigMessage::System { content } => Ok(vec![Message::system(content)]),
RigMessage::User { content } => {
let mut items = Vec::new();
let mut text_parts = Vec::new();
let mut content_items = Vec::new();
let mut has_images = false;
for c in content {
match c {
UserContent::Text(Text { text }) => text_parts.push(text),
UserContent::Image(img) => {
has_images = true;
content_items.push(image_item(img)?);
}
UserContent::ToolResult(tr) => {
if has_images {
let mut msg_items: Vec<_> = text_parts
.drain(..)
.map(|t| ContentItem::Text { text: t })
.collect();
msg_items.append(&mut content_items);
if !msg_items.is_empty() {
items.push(Message::user_with_content(msg_items));
}
} else if !text_parts.is_empty() {
items.push(Message::user(text_parts.join("\n")));
}
has_images = false;
let output = tr
.content
.into_iter()
.map(|tc| match tc {
ToolResultContent::Text(t) => Ok(t.text),
ToolResultContent::Image(_) => {
Err(CompletionError::RequestError(
"xAI does not support images in tool results".into(),
))
}
})
.collect::<Result<Vec<_>, _>>()?
.join("\n");
let call_id = tr.call_id.ok_or_else(|| {
CompletionError::RequestError(
"Tool result `call_id` is required for xAI Responses API"
.into(),
)
})?;
items.push(Message::function_call_output(call_id, output));
}
UserContent::Document(doc) => {
has_images = true; content_items.push(document_item(doc)?);
}
UserContent::Audio(_) => {
return Err(CompletionError::RequestError(
"xAI does not support audio".into(),
));
}
UserContent::Video(_) => {
return Err(CompletionError::RequestError(
"xAI does not support video".into(),
));
}
}
}
if has_images {
let mut msg_items: Vec<_> = text_parts
.into_iter()
.map(|t| ContentItem::Text { text: t })
.collect();
msg_items.append(&mut content_items);
if !msg_items.is_empty() {
items.push(Message::user_with_content(msg_items));
}
} else if !text_parts.is_empty() {
items.push(Message::user(text_parts.join("\n")));
}
Ok(items)
}
RigMessage::Assistant { content, .. } => {
let mut items = Vec::new();
let mut text_parts = Vec::new();
let flush_assistant_text =
|items: &mut Vec<Message>, text_parts: &mut Vec<String>| {
if !text_parts.is_empty() {
items.push(Message::assistant(text_parts.join("\n")));
text_parts.clear();
}
};
for c in content {
match c {
AssistantContent::Text(t) => text_parts.push(t.text),
AssistantContent::ToolCall(tc) => {
flush_assistant_text(&mut items, &mut text_parts);
let call_id = tc.call_id.ok_or_else(|| {
CompletionError::RequestError(
"Assistant tool call `call_id` is required for xAI Responses API"
.into(),
)
})?;
items.push(Message::function_call(
call_id,
tc.function.name,
tc.function.arguments.to_string(),
));
}
AssistantContent::Reasoning(r) => {
flush_assistant_text(&mut items, &mut text_parts);
items.push(reasoning_item(r)?);
}
AssistantContent::Image(_) => {
return Err(CompletionError::RequestError(
"xAI does not support images in assistant content".into(),
));
}
}
}
if !text_parts.is_empty() {
items.push(Message::assistant(text_parts.join("\n")));
}
Ok(items)
}
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ToolDefinition {
pub r#type: String,
#[serde(flatten)]
pub function: completion::ToolDefinition,
}
impl From<completion::ToolDefinition> for ToolDefinition {
fn from(tool: completion::ToolDefinition) -> Self {
Self {
r#type: "function".to_string(),
function: tool,
}
}
}
#[derive(Debug, Deserialize)]
pub struct ApiError {
pub error: String,
pub code: String,
}
impl ApiError {
pub fn message(&self) -> String {
format!("Code `{}`: {}", self.code, self.error)
}
}
#[cfg(test)]
mod tests {
use super::Message;
use crate::OneOrMany;
use crate::completion::CompletionError;
use crate::message::{AssistantContent, Message as RigMessage, Reasoning, ReasoningContent};
use crate::providers::openai::responses_api::ReasoningSummary;
#[test]
fn assistant_redacted_reasoning_is_serialized_as_encrypted_content() {
let reasoning = Reasoning {
id: Some("rs_1".to_string()),
content: vec![ReasoningContent::Redacted {
data: "opaque-redacted".to_string(),
}],
};
let message = RigMessage::Assistant {
id: Some("assistant_1".to_string()),
content: OneOrMany::one(AssistantContent::Reasoning(reasoning)),
};
let items = Vec::<Message>::try_from(message).expect("convert assistant message");
assert_eq!(items.len(), 1);
assert!(matches!(
items.first(),
Some(Message::Reasoning {
id,
summary,
encrypted_content: Some(encrypted_content),
}) if id == "rs_1" && summary.is_empty() && encrypted_content == "opaque-redacted"
));
}
#[test]
fn assistant_redacted_reasoning_does_not_leak_into_summary_text() {
let reasoning = Reasoning {
id: Some("rs_2".to_string()),
content: vec![
ReasoningContent::Text {
text: "explain".to_string(),
signature: None,
},
ReasoningContent::Redacted {
data: "opaque-redacted".to_string(),
},
],
};
let message = RigMessage::Assistant {
id: Some("assistant_2".to_string()),
content: OneOrMany::one(AssistantContent::Reasoning(reasoning)),
};
let items = Vec::<Message>::try_from(message).expect("convert assistant message");
let Some(Message::Reasoning {
summary,
encrypted_content,
..
}) = items.first()
else {
panic!("Expected reasoning item");
};
assert_eq!(
summary,
&vec![ReasoningSummary::SummaryText {
text: "explain".to_string()
}]
);
assert_eq!(encrypted_content.as_deref(), Some("opaque-redacted"));
}
#[test]
fn assistant_empty_reasoning_content_roundtrips_without_error() {
let reasoning = Reasoning {
id: Some("rs_empty".to_string()),
content: vec![],
};
let message = RigMessage::Assistant {
id: Some("assistant_2b".to_string()),
content: OneOrMany::one(AssistantContent::Reasoning(reasoning)),
};
let items = Vec::<Message>::try_from(message).expect("convert assistant message");
assert_eq!(items.len(), 1);
assert!(matches!(
items.first(),
Some(Message::Reasoning {
id,
summary,
encrypted_content,
}) if id == "rs_empty" && summary.is_empty() && encrypted_content.is_none()
));
}
#[test]
fn assistant_reasoning_without_id_returns_request_error() {
let message = RigMessage::Assistant {
id: Some("assistant_no_reasoning_id".to_string()),
content: OneOrMany::one(AssistantContent::Reasoning(Reasoning::new("thinking"))),
};
let converted = Vec::<Message>::try_from(message);
assert!(matches!(
converted,
Err(CompletionError::RequestError(error))
if error
.to_string()
.contains("Assistant reasoning `id` is required")
));
}
#[test]
fn serialized_message_type_tags_are_snake_case() {
let function_call = Message::function_call(
"call_1".to_string(),
"tool_name".to_string(),
"{\"arg\":1}".to_string(),
);
let user_message = Message::user("hello");
let function_call_json =
serde_json::to_value(function_call).expect("serialize function_call");
let user_message_json = serde_json::to_value(user_message).expect("serialize message");
assert_eq!(
function_call_json
.get("type")
.and_then(|value| value.as_str()),
Some("function_call")
);
assert_eq!(
user_message_json
.get("type")
.and_then(|value| value.as_str()),
Some("message")
);
}
#[test]
fn user_tool_result_without_call_id_returns_request_error() {
let message = RigMessage::tool_result("tool_1", "result payload");
let converted = Vec::<Message>::try_from(message);
assert!(matches!(
converted,
Err(CompletionError::RequestError(error))
if error
.to_string()
.contains("Tool result `call_id` is required")
));
}
#[test]
fn assistant_tool_call_without_call_id_returns_request_error() {
let message = RigMessage::Assistant {
id: Some("assistant_3".to_string()),
content: OneOrMany::one(AssistantContent::tool_call(
"tool_1",
"my_tool",
serde_json::json!({"arg":"value"}),
)),
};
let converted = Vec::<Message>::try_from(message);
assert!(matches!(
converted,
Err(CompletionError::RequestError(error))
if error
.to_string()
.contains("Assistant tool call `call_id` is required")
));
}
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ApiResponse<T> {
Ok(T),
Error(ApiError),
}