use base64::Engine as _;
use rig_core::{
OneOrMany,
completion::Message,
message::{
AssistantContent, DocumentSourceKind, ReasoningContent, ToolResult, ToolResultContent,
UserContent,
},
};
use super::{
Capabilities, MAX_SINGLE_TOOL_ARGUMENT_BYTES, MAX_TOOL_CALL_ID_BYTES, bounded_json_len,
validate_tool_name,
};
use crate::{
engine::{
tokenizer::{AudioContent, ChatMessage, ContentPart, ImageContent, VideoContent},
tools::ToolCall,
},
error::Error,
};
const MAX_SINGLE_CONTENT_BYTES: usize = 128 << 20;
pub(super) fn push_message(
out: &mut Vec<ChatMessage>,
system_parts: &mut Vec<String>,
message: &Message,
capabilities: Capabilities,
) -> Result<(), Error> {
match message {
Message::System { content } => {
if !content.trim().is_empty() {
ensure_bounded(content.len(), "system message")?;
system_parts.push(content.clone());
}
}
Message::User { content } => push_user_content(out, content, capabilities)?,
Message::Assistant { content, .. } => out.push(assistant_message(content)?),
}
Ok(())
}
fn push_user_content(
out: &mut Vec<ChatMessage>,
content: &OneOrMany<UserContent>,
capabilities: Capabilities,
) -> Result<(), Error> {
let mut parts: Vec<ContentPart> = Vec::new();
for item in content.iter() {
match item {
UserContent::Text(text) => {
ensure_bounded(text.text.len(), "text content")?;
push_text(&mut parts, &text.text);
}
UserContent::ToolResult(result) => {
flush_user_parts(out, &mut parts);
out.push(tool_result_message(result)?);
}
UserContent::Image(image) => {
if !capabilities.images {
return Err(Error::UnsupportedContent(
"this model has no vision tower; images are not supported".to_string(),
));
}
parts.push(ContentPart::Image(ImageContent {
bytes: media_bytes(&image.data, "image")?,
}));
}
UserContent::Audio(audio) => {
if !capabilities.audio {
return Err(Error::UnsupportedContent(
"this model has no audio tower; audio is not supported".to_string(),
));
}
parts.push(ContentPart::Audio(AudioContent {
bytes: media_bytes(&audio.data, "audio")?,
}));
}
UserContent::Video(video) => {
if !capabilities.images {
return Err(Error::UnsupportedContent(
"this model has no vision tower; video is not supported".to_string(),
));
}
parts.push(ContentPart::Video(VideoContent {
bytes: media_bytes(&video.data, "video")?,
}));
}
UserContent::Document(document) => {
push_text(&mut parts, &document_text(&document.data)?);
}
}
}
flush_user_parts(out, &mut parts);
Ok(())
}
fn push_text(parts: &mut Vec<ContentPart>, text: &str) {
if let Some(ContentPart::Text(existing)) = parts.last_mut() {
existing.push_str("\n\n");
existing.push_str(text);
} else {
parts.push(ContentPart::Text(text.to_string()));
}
}
fn flush_user_parts(out: &mut Vec<ChatMessage>, parts: &mut Vec<ContentPart>) {
if !parts.is_empty() {
out.push(ChatMessage {
role: "user".to_string(),
content: std::mem::take(parts),
..ChatMessage::default()
});
}
}
fn tool_result_message(result: &ToolResult) -> Result<ChatMessage, Error> {
ensure_identifier(&result.id, "tool result ID")?;
if let Some(call_id) = &result.call_id {
ensure_identifier(call_id, "tool result call ID")?;
}
let mut text_parts = Vec::new();
for content in result.content.iter() {
match content {
ToolResultContent::Text(text) => text_parts.push(text.text.as_str()),
ToolResultContent::Image(_) => {
return Err(Error::UnsupportedContent(format!(
"tool result {:?} contains image content, but local chat templates \
support text tool results only",
result.id
)));
}
}
}
ensure_bounded(joined_len(&text_parts, 1)?, "tool result")?;
let text = text_parts.join("\n");
let call_id = result.call_id.clone().unwrap_or_else(|| result.id.clone());
Ok(ChatMessage::tool_result(call_id, text))
}
fn assistant_message(content: &OneOrMany<AssistantContent>) -> Result<ChatMessage, Error> {
let mut text_parts: Vec<&str> = Vec::new();
let mut tool_calls: Vec<ToolCall> = Vec::new();
let mut reasoning_parts: Vec<&str> = Vec::new();
for item in content.iter() {
match item {
AssistantContent::Text(text) => text_parts.push(&text.text),
AssistantContent::ToolCall(call) => {
ensure_identifier(&call.id, "tool call ID")?;
validate_tool_name(&call.function.name)?;
if !call.function.arguments.is_object() {
return Err(Error::InvalidRequest(format!(
"tool call {:?} arguments must be a JSON object",
call.id
)));
}
bounded_json_len(
&call.function.arguments,
MAX_SINGLE_TOOL_ARGUMENT_BYTES,
&format!("tool call {:?} arguments", call.id),
)?;
tool_calls.push(ToolCall {
id: call.id.clone(),
name: call.function.name.clone(),
arguments: call.function.arguments.clone(),
});
}
AssistantContent::Reasoning(reasoning) => {
for block in &reasoning.content {
match block {
ReasoningContent::Text { text, .. } => {
reasoning_parts.push(text);
}
_ => {
return Err(Error::UnsupportedContent(
"non-text reasoning content cannot be represented by local chat \
templates"
.to_string(),
));
}
}
}
}
AssistantContent::Image(_) => {
return Err(Error::UnsupportedContent(
"assistant image history cannot be represented by the local engine".to_string(),
));
}
}
}
ensure_bounded(joined_len(&text_parts, 0)?, "assistant text")?;
ensure_bounded(joined_len(&reasoning_parts, 1)?, "assistant reasoning")?;
let reasoning_content = if reasoning_parts.is_empty() {
None
} else {
Some(reasoning_parts.join("\n"))
};
Ok(ChatMessage {
role: "assistant".to_string(),
content: vec![ContentPart::Text(text_parts.join(""))],
tool_calls,
tool_call_id: None,
reasoning_content,
})
}
fn media_bytes(data: &DocumentSourceKind, what: &str) -> Result<Vec<u8>, Error> {
match data {
DocumentSourceKind::Raw(bytes) => {
ensure_bounded(bytes.len(), what)?;
Ok(bytes.clone())
}
DocumentSourceKind::Base64(encoded) => {
decode_base64_bounded(encoded, what, MAX_SINGLE_CONTENT_BYTES)
}
other => Err(Error::UnsupportedContent(format!(
"{what} source must be raw or base64 bytes for local inference, got \
{other:?}"
))),
}
}
fn document_text(data: &DocumentSourceKind) -> Result<String, Error> {
match data {
DocumentSourceKind::String(text) => {
ensure_bounded(text.len(), "document")?;
Ok(text.clone())
}
DocumentSourceKind::Raw(bytes) => {
ensure_bounded(bytes.len(), "document")?;
String::from_utf8(bytes.clone()).map_err(|_| {
Error::UnsupportedContent("binary document content is not supported".to_string())
})
}
DocumentSourceKind::Base64(encoded) => {
let bytes = decode_base64_bounded(encoded, "document", MAX_SINGLE_CONTENT_BYTES)?;
String::from_utf8(bytes).map_err(|_| {
Error::UnsupportedContent("binary document content is not supported".to_string())
})
}
other => Err(Error::UnsupportedContent(format!(
"document source must be text for local inference, got {other:?}"
))),
}
}
fn decode_base64_bounded(encoded: &str, what: &str, limit: usize) -> Result<Vec<u8>, Error> {
if base64::decoded_len_estimate(encoded.len()) > limit {
return Err(Error::InvalidRequest(format!(
"{what} base64 payload exceeds {limit} decoded bytes"
)));
}
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|error| Error::UnsupportedContent(format!("invalid base64 {what}: {error}")))?;
if decoded.len() > limit {
return Err(Error::InvalidRequest(format!(
"{what} payload exceeds {limit} bytes"
)));
}
Ok(decoded)
}
fn joined_len(parts: &[&str], separator_len: usize) -> Result<usize, Error> {
let content = parts.iter().try_fold(0_usize, |total, part| {
total
.checked_add(part.len())
.ok_or_else(|| Error::InvalidRequest("content size overflow".to_string()))
})?;
let separators = parts
.len()
.saturating_sub(1)
.checked_mul(separator_len)
.ok_or_else(|| Error::InvalidRequest("content size overflow".to_string()))?;
content
.checked_add(separators)
.ok_or_else(|| Error::InvalidRequest("content size overflow".to_string()))
}
fn ensure_bounded(length: usize, what: &str) -> Result<(), Error> {
if length > MAX_SINGLE_CONTENT_BYTES {
return Err(Error::InvalidRequest(format!(
"{what} cannot exceed 128 MiB"
)));
}
Ok(())
}
fn ensure_identifier(value: &str, what: &str) -> Result<(), Error> {
if value.trim().is_empty() {
return Err(Error::InvalidRequest(format!("{what} cannot be empty")));
}
if value.len() > MAX_TOOL_CALL_ID_BYTES {
return Err(Error::InvalidRequest(format!(
"{what} cannot exceed {MAX_TOOL_CALL_ID_BYTES} bytes"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_preflight_rejects_before_large_decode_allocation() {
let error = decode_base64_bounded("aGVsbG8=", "image", 4)
.expect_err("five decoded bytes exceed the test limit");
assert!(matches!(error, Error::InvalidRequest(_)));
}
}