use crate::id::{IdentityError, ToolName, MAX_IDENTITY_BYTES};
use crate::limits::InputLimits;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CanonicalInput {
messages: Vec<CanonicalMessage>,
}
impl CanonicalInput {
pub fn try_new(
messages: Vec<CanonicalMessage>,
limits: &InputLimits,
) -> Result<Self, InputValidationError> {
if messages.is_empty() {
return Err(InputValidationError::EmptyMessages);
}
if messages.len() > limits.max_messages {
return Err(InputValidationError::TooManyMessages {
count: messages.len(),
max: limits.max_messages,
});
}
let mut aggregate_text = 0usize;
let mut seen_tool_call_ids: Vec<String> = Vec::new();
for (index, msg) in messages.iter().enumerate() {
msg.validate(limits, index, &mut aggregate_text, &mut seen_tool_call_ids)?;
}
if aggregate_text > limits.max_aggregate_text_bytes {
return Err(InputValidationError::AggregateTextTooLarge {
bytes: aggregate_text,
max: limits.max_aggregate_text_bytes,
});
}
Ok(Self { messages })
}
pub fn messages(&self) -> &[CanonicalMessage] {
&self.messages
}
pub fn into_messages(self) -> Vec<CanonicalMessage> {
self.messages
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum CanonicalMessage {
System {
content: Vec<TextPart>,
name: Option<String>,
},
User {
content: Vec<TextPart>,
name: Option<String>,
},
Assistant {
content: Vec<TextPart>,
tool_calls: Vec<CanonicalAssistantToolCall>,
},
Tool {
tool_call_id: String,
content: Vec<TextPart>,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TextPart {
text: String,
}
impl TextPart {
pub fn try_new(
text: impl Into<String>,
max_bytes: usize,
) -> Result<Self, InputValidationError> {
let text = text.into();
if text.is_empty() {
return Err(InputValidationError::EmptyTextPart);
}
if text.len() > max_bytes {
return Err(InputValidationError::TextPartTooLarge {
bytes: text.len(),
max: max_bytes,
});
}
Ok(Self { text })
}
pub fn text(&self) -> &str {
&self.text
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CanonicalAssistantToolCall {
pub tool_call_id: String,
pub tool_name: ToolName,
pub arguments: serde_json::Value,
}
impl CanonicalMessage {
fn validate(
&self,
limits: &InputLimits,
index: usize,
aggregate_text: &mut usize,
seen_tool_call_ids: &mut Vec<String>,
) -> Result<(), InputValidationError> {
match self {
Self::System { content, name } | Self::User { content, name } => {
validate_name(name, limits)?;
validate_text_parts(content, limits, true, aggregate_text)?;
}
Self::Assistant {
content,
tool_calls,
} => {
if content.is_empty() && tool_calls.is_empty() {
return Err(InputValidationError::EmptyAssistant { index });
}
validate_text_parts(content, limits, false, aggregate_text)?;
if tool_calls.len() > limits.max_tool_calls {
return Err(InputValidationError::TooManyToolCalls {
count: tool_calls.len(),
max: limits.max_tool_calls,
});
}
for call in tool_calls {
validate_tool_call_id(&call.tool_call_id, limits)?;
if seen_tool_call_ids.iter().any(|id| id == &call.tool_call_id) {
return Err(InputValidationError::DuplicateToolCallId {
id: call.tool_call_id.clone(),
});
}
seen_tool_call_ids.push(call.tool_call_id.clone());
validate_json_value(
&call.arguments,
limits.max_json_depth,
limits.max_tool_argument_bytes,
)?;
let _ = call.tool_name.as_str(); }
}
Self::Tool {
tool_call_id,
content,
} => {
validate_tool_call_id(tool_call_id, limits)?;
if !seen_tool_call_ids.iter().any(|id| id == tool_call_id) {
return Err(InputValidationError::UnknownToolCallId {
id: tool_call_id.clone(),
});
}
validate_text_parts(content, limits, true, aggregate_text)?;
}
}
Ok(())
}
}
fn validate_name(name: &Option<String>, limits: &InputLimits) -> Result<(), InputValidationError> {
if let Some(n) = name {
if n.is_empty() {
return Err(InputValidationError::EmptyName);
}
if n.len() > limits.max_name_bytes {
return Err(InputValidationError::NameTooLong {
bytes: n.len(),
max: limits.max_name_bytes,
});
}
if n.chars().any(|c| c.is_control()) {
return Err(InputValidationError::ControlCharacter);
}
}
Ok(())
}
fn validate_tool_call_id(id: &str, limits: &InputLimits) -> Result<(), InputValidationError> {
if id.is_empty() {
return Err(InputValidationError::EmptyToolCallId);
}
if id.len() > limits.max_tool_call_id_bytes {
return Err(InputValidationError::ToolCallIdTooLong {
bytes: id.len(),
max: limits.max_tool_call_id_bytes,
});
}
if id.chars().any(|c| c.is_control()) {
return Err(InputValidationError::ControlCharacter);
}
Ok(())
}
fn validate_text_parts(
parts: &[TextPart],
limits: &InputLimits,
require_non_empty: bool,
aggregate_text: &mut usize,
) -> Result<(), InputValidationError> {
if require_non_empty && parts.is_empty() {
return Err(InputValidationError::EmptyTextParts);
}
if parts.len() > limits.max_content_parts {
return Err(InputValidationError::TooManyContentParts {
count: parts.len(),
max: limits.max_content_parts,
});
}
for p in parts {
if p.text.is_empty() {
return Err(InputValidationError::EmptyTextPart);
}
if p.text.len() > limits.max_text_part_bytes {
return Err(InputValidationError::TextPartTooLarge {
bytes: p.text.len(),
max: limits.max_text_part_bytes,
});
}
*aggregate_text = aggregate_text.saturating_add(p.text.len());
}
Ok(())
}
fn validate_json_value(
value: &serde_json::Value,
max_depth: u32,
max_bytes: usize,
) -> Result<(), InputValidationError> {
let depth = json_depth(value);
if depth > max_depth {
return Err(InputValidationError::JsonTooDeep {
depth,
max: max_depth,
});
}
let encoded = serde_json::to_vec(value).map_err(|_| InputValidationError::JsonEncodeFailed)?;
if encoded.len() > max_bytes {
return Err(InputValidationError::ToolArgumentsTooLarge {
bytes: encoded.len(),
max: max_bytes,
});
}
Ok(())
}
fn json_depth(value: &serde_json::Value) -> u32 {
match value {
serde_json::Value::Array(items) => 1 + items.iter().map(json_depth).max().unwrap_or(0),
serde_json::Value::Object(map) => 1 + map.values().map(json_depth).max().unwrap_or(0),
_ => 1,
}
}
pub fn user_text_input(text: impl Into<String>) -> Result<CanonicalInput, InputValidationError> {
let limits = InputLimits::default();
let part = TextPart::try_new(text, limits.max_text_part_bytes)?;
CanonicalInput::try_new(
vec![CanonicalMessage::User {
content: vec![part],
name: None,
}],
&limits,
)
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum InputValidationError {
#[error("canonical input requires at least one message")]
EmptyMessages,
#[error("message count {count} exceeds max {max}")]
TooManyMessages {
count: usize,
max: usize,
},
#[error("aggregate text bytes {bytes} exceeds max {max}")]
AggregateTextTooLarge {
bytes: usize,
max: usize,
},
#[error("message requires at least one text part")]
EmptyTextParts,
#[error("text part must be non-empty")]
EmptyTextPart,
#[error("text part bytes {bytes} exceeds max {max}")]
TextPartTooLarge {
bytes: usize,
max: usize,
},
#[error("content part count {count} exceeds max {max}")]
TooManyContentParts {
count: usize,
max: usize,
},
#[error("assistant message at index {index} is empty")]
EmptyAssistant {
index: usize,
},
#[error("tool call count {count} exceeds max {max}")]
TooManyToolCalls {
count: usize,
max: usize,
},
#[error("duplicate tool_call_id {id}")]
DuplicateToolCallId {
id: String,
},
#[error("tool message references unknown tool_call_id {id}")]
UnknownToolCallId {
id: String,
},
#[error("tool_call_id must be non-empty")]
EmptyToolCallId,
#[error("tool_call_id bytes {bytes} exceeds max {max}")]
ToolCallIdTooLong {
bytes: usize,
max: usize,
},
#[error("message name must be non-empty when present")]
EmptyName,
#[error("name bytes {bytes} exceeds max {max}")]
NameTooLong {
bytes: usize,
max: usize,
},
#[error("input string must not contain control characters")]
ControlCharacter,
#[error("JSON depth {depth} exceeds max {max}")]
JsonTooDeep {
depth: u32,
max: u32,
},
#[error("tool argument bytes {bytes} exceeds max {max}")]
ToolArgumentsTooLarge {
bytes: usize,
max: usize,
},
#[error("JSON encode failed")]
JsonEncodeFailed,
#[error(transparent)]
Identity(#[from] IdentityError),
}
const _: usize = MAX_IDENTITY_BYTES;
#[cfg(test)]
mod tests {
use super::*;
use crate::id::ToolName;
#[test]
fn requires_messages_and_text() {
let limits = InputLimits::default();
assert!(CanonicalInput::try_new(vec![], &limits).is_err());
let empty_user = CanonicalMessage::User {
content: vec![],
name: None,
};
assert!(CanonicalInput::try_new(vec![empty_user], &limits).is_err());
}
#[test]
fn tool_must_reference_prior_assistant_call() {
let limits = InputLimits::default();
let part = TextPart::try_new("ok", limits.max_text_part_bytes).unwrap();
let bad = CanonicalMessage::Tool {
tool_call_id: "missing".into(),
content: vec![part],
};
assert!(matches!(
CanonicalInput::try_new(vec![bad], &limits),
Err(InputValidationError::UnknownToolCallId { .. })
));
}
#[test]
fn historical_tool_round_trip_ok() {
let limits = InputLimits::default();
let call = CanonicalAssistantToolCall {
tool_call_id: "c1".into(),
tool_name: ToolName::try_new("search").unwrap(),
arguments: serde_json::json!({"q": "x"}),
};
let messages = vec![
CanonicalMessage::User {
content: vec![TextPart::try_new("hi", limits.max_text_part_bytes).unwrap()],
name: None,
},
CanonicalMessage::Assistant {
content: vec![],
tool_calls: vec![call],
},
CanonicalMessage::Tool {
tool_call_id: "c1".into(),
content: vec![TextPart::try_new("result", limits.max_text_part_bytes).unwrap()],
},
];
let input = CanonicalInput::try_new(messages, &limits).unwrap();
let json = serde_json::to_string(&input).unwrap();
let back: CanonicalInput = serde_json::from_str(&json).unwrap();
assert_eq!(input, back);
}
}