use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
pub const OBSERVATION_ROLE: &str = "observation";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChatMessage {
pub role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_details: Option<Vec<Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<ChatToolCall>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChatToolCall {
pub id: String,
pub name: String,
pub arguments: String,
}
const SIGNATURE_REQUIRING_REASONING_FORMATS: [&str; 2] =
["anthropic-claude-v1", "google-gemini-v1"];
fn is_sendable_reasoning_detail(detail: &Value) -> bool {
if detail.get("type").and_then(Value::as_str) != Some("reasoning.text") {
return true;
}
let Some(format) = detail.get("format").and_then(Value::as_str) else {
return true;
};
if !SIGNATURE_REQUIRING_REASONING_FORMATS.contains(&format) {
return true;
}
detail
.get("signature")
.and_then(Value::as_str)
.is_some_and(|signature| !signature.trim().is_empty())
}
fn sendable_reasoning_details(details: &[Value]) -> Vec<Value> {
details
.iter()
.filter(|detail| is_sendable_reasoning_detail(detail))
.cloned()
.collect()
}
pub(crate) fn estimate_message_tokens(message: &ChatMessage) -> usize {
serde_json::to_vec(message)
.map(|encoded| encoded.len().div_ceil(4).max(1))
.unwrap_or(1)
}
pub(crate) fn estimate_context_tokens(messages: &[ChatMessage]) -> usize {
messages
.iter()
.map(estimate_message_tokens)
.sum::<usize>()
.max(1)
}
impl ChatMessage {
pub fn system(content: String) -> Self {
Self {
role: "system".to_owned(),
content: Some(content),
reasoning_details: None,
name: None,
tool_call_id: None,
tool_calls: Vec::new(),
}
}
pub fn user(content: String) -> Self {
Self {
role: "user".to_owned(),
content: Some(content),
reasoning_details: None,
name: None,
tool_call_id: None,
tool_calls: Vec::new(),
}
}
pub fn observation(content: String) -> Self {
Self {
role: OBSERVATION_ROLE.to_owned(),
content: Some(content),
reasoning_details: None,
name: None,
tool_call_id: None,
tool_calls: Vec::new(),
}
}
pub fn assistant(content: String, tool_calls: Vec<ChatToolCall>) -> Self {
Self {
role: "assistant".to_owned(),
content: (!content.is_empty()).then_some(content),
reasoning_details: None,
name: None,
tool_call_id: None,
tool_calls,
}
}
pub fn tool(tool_call_id: String, name: String, content: String) -> Self {
Self {
role: "tool".to_owned(),
content: Some(content),
reasoning_details: None,
name: Some(name),
tool_call_id: Some(tool_call_id),
tool_calls: Vec::new(),
}
}
pub fn to_openai_value(&self) -> Value {
let role = if self.role == OBSERVATION_ROLE {
"user"
} else {
&self.role
};
let mut message = json!({
"role": role,
"content": self.content,
});
if self.role == "assistant" {
if let Some(reasoning_details) = &self.reasoning_details {
let sendable = sendable_reasoning_details(reasoning_details);
if !sendable.is_empty() {
message["reasoning_details"] = Value::Array(sendable);
}
}
}
if let Some(name) = &self.name {
message["name"] = Value::String(name.clone());
}
if let Some(tool_call_id) = &self.tool_call_id {
message["tool_call_id"] = Value::String(tool_call_id.clone());
}
if !self.tool_calls.is_empty() {
message["tool_calls"] = Value::Array(
self.tool_calls
.iter()
.map(|tool_call| {
json!({
"id": tool_call.id,
"type": "function",
"function": {
"name": tool_call.name,
"arguments": tool_call.arguments,
}
})
})
.collect(),
);
}
message
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_token_estimate_is_nonzero_and_grows_with_messages() {
let empty = estimate_context_tokens(&[]);
let one = estimate_context_tokens(&[ChatMessage::user("hello".to_owned())]);
assert_eq!(empty, 1);
assert!(one > empty);
assert!(estimate_message_tokens(&ChatMessage::user("hello".to_owned())) > 0);
}
#[test]
fn tool_assistant_messages_have_openai_compatible_shape() {
let assistant = ChatMessage::assistant(
String::new(),
vec![ChatToolCall {
id: "call-1".to_owned(),
name: "cmd".to_owned(),
arguments: r#"{"command":"pwd"}"#.to_owned(),
}],
);
assert_eq!(assistant.to_openai_value()["content"], Value::Null);
assert_eq!(
assistant.to_openai_value()["tool_calls"][0]["type"],
"function"
);
let tool = ChatMessage::tool(
"call-1".to_owned(),
"cmd".to_owned(),
"{\"exit_code\":0}".to_owned(),
);
assert_eq!(tool.to_openai_value()["tool_call_id"], "call-1");
assert_eq!(tool.to_openai_value()["name"], "cmd");
}
#[test]
fn observation_keeps_its_session_role_but_uses_the_openai_user_role() {
let observation = ChatMessage::observation("untrusted output".to_owned());
assert_eq!(observation.role, OBSERVATION_ROLE);
assert_eq!(
serde_json::to_value(&observation).expect("serialize observation")["role"],
OBSERVATION_ROLE
);
assert_eq!(observation.to_openai_value()["role"], "user");
assert_eq!(observation.to_openai_value()["content"], "untrusted output");
}
#[test]
fn reasoning_details_are_optional_and_only_sent_for_assistant_messages() {
let details = vec![json!({
"type": "reasoning.text",
"text": "private reasoning"
})];
let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
assistant.reasoning_details = Some(details.clone());
assert_eq!(
assistant.to_openai_value()["reasoning_details"],
json!(details)
);
let old_message: ChatMessage = serde_json::from_value(json!({
"role": "assistant",
"content": "old session"
}))
.expect("old message without reasoning details");
assert_eq!(old_message.reasoning_details, None);
let mut user = ChatMessage::user("question".to_owned());
user.reasoning_details = Some(vec![json!({"text": "must not be sent"})]);
assert!(user.to_openai_value().get("reasoning_details").is_none());
}
#[test]
fn unsigned_thinking_fragments_are_not_replayed_to_the_provider() {
let signed = json!({
"type": "reasoning.text",
"format": "anthropic-claude-v1",
"index": 0,
"signature": "CAIS0AIK"
});
let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
assistant.reasoning_details = Some(vec![
json!({
"type": "reasoning.text",
"format": "anthropic-claude-v1",
"index": 0,
"text": "I"
}),
json!({
"type": "reasoning.text",
"format": "google-gemini-v1",
"index": 0,
"text": " need to inspect the repository."
}),
json!({
"type": "reasoning.text",
"format": "anthropic-claude-v1",
"index": 0,
"signature": ""
}),
json!({
"type": "reasoning.text",
"format": "anthropic-claude-v1",
"index": 0,
"signature": " "
}),
json!({
"type": "reasoning.text",
"format": "some-other-provider-v1",
"text": "unsigned but not signature checked"
}),
json!({
"type": "reasoning.encrypted",
"id": "call-1",
"data": "opaque"
}),
signed.clone(),
]);
assert_eq!(
assistant.to_openai_value()["reasoning_details"],
json!([
{
"type": "reasoning.text",
"format": "some-other-provider-v1",
"text": "unsigned but not signature checked"
},
{
"type": "reasoning.encrypted",
"id": "call-1",
"data": "opaque"
},
signed,
])
);
}
#[test]
fn assistant_messages_omit_reasoning_details_when_every_entry_is_unsigned() {
let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
assistant.reasoning_details = Some(vec![json!({
"type": "reasoning.text",
"format": "anthropic-claude-v1",
"index": 0,
"text": "only an unsigned fragment"
})]);
assert!(assistant
.to_openai_value()
.get("reasoning_details")
.is_none());
}
}