use crate::chat::{ContentPart, MessageContent, ToolCall, ToolResponse};
use derive_more::From;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
#[skip_serializing_none]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: ChatRole,
pub content: MessageContent,
pub options: Option<MessageOptions>,
}
impl ChatMessage {
pub fn new(role: ChatRole, content: impl Into<MessageContent>) -> Self {
Self {
role,
content: content.into(),
options: None,
}
}
pub fn system(content: impl Into<MessageContent>) -> Self {
Self {
role: ChatRole::System,
content: content.into(),
options: None,
}
}
pub fn assistant(content: impl Into<MessageContent>) -> Self {
Self {
role: ChatRole::Assistant,
content: content.into(),
options: None,
}
}
pub fn user(content: impl Into<MessageContent>) -> Self {
Self {
role: ChatRole::User,
content: content.into(),
options: None,
}
}
pub fn tool(content: impl Into<MessageContent>) -> Self {
Self {
role: ChatRole::Tool,
content: content.into(),
options: None,
}
}
}
impl ChatMessage {
pub fn size(&self) -> usize {
self.content.size()
}
}
impl ChatMessage {
pub fn with_options(mut self, options: impl Into<MessageOptions>) -> Self {
self.options = Some(options.into());
self
}
pub fn with_reasoning_content(mut self, reasoning: Option<String>) -> Self {
if let Some(reasoning) = reasoning {
self.content.push(ContentPart::ReasoningContent(reasoning));
}
self
}
pub fn assistant_tool_calls_with_thoughts(tool_calls: Vec<ToolCall>, thought_signatures: Vec<String>) -> Self {
let mut parts: Vec<ContentPart> = thought_signatures.into_iter().map(ContentPart::ThoughtSignature).collect();
parts.extend(tool_calls.into_iter().map(ContentPart::ToolCall));
ChatMessage::assistant(MessageContent::from_parts(parts))
}
}
#[skip_serializing_none]
#[derive(Debug, Clone, Default, Serialize, Deserialize, From)]
pub struct MessageOptions {
#[from]
pub cache_control: Option<CacheControl>,
}
impl MessageOptions {
pub fn with_cache_control(mut self, cache_control: impl Into<CacheControl>) -> Self {
self.cache_control = Some(cache_control.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum CacheControl {
Ephemeral,
Memory,
Ephemeral5m,
Ephemeral1h,
Ephemeral24h,
}
impl From<CacheControl> for MessageOptions {
fn from(cache_control: CacheControl) -> Self {
Self {
cache_control: Some(cache_control),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, derive_more::Display)]
#[allow(missing_docs)]
pub enum ChatRole {
System,
User,
Assistant,
Tool,
}
impl From<Vec<ToolCall>> for ChatMessage {
fn from(tool_calls: Vec<ToolCall>) -> Self {
Self {
role: ChatRole::Assistant,
content: MessageContent::from(tool_calls),
options: None,
}
}
}
impl From<ToolResponse> for ChatMessage {
fn from(value: ToolResponse) -> Self {
ChatMessage::tool(value)
}
}
impl From<Vec<ToolResponse>> for ChatMessage {
fn from(responses: Vec<ToolResponse>) -> Self {
ChatMessage::tool(MessageContent::from(responses))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::{AdapterDispatcher, AdapterKind, ServiceType};
use crate::chat::{ChatOptionsSet, ChatRequest};
use crate::resolver::AuthData;
use crate::{ModelIden, ServiceTarget};
use serde_json::json;
type Result<T> = core::result::Result<T, Box<dyn std::error::Error>>;
#[test]
fn test_chat_chat_message_options_none_is_omitted() -> Result<()> {
let message = ChatMessage::user("hello");
let serialized = serde_json::to_value(message)?;
assert!(serialized.get("options").is_none());
Ok(())
}
fn signed_tool_calls() -> Vec<ToolCall> {
vec![
ToolCall {
call_id: "call-1".to_string(),
fn_name: "first".to_string(),
fn_arguments: json!({}),
thought_signatures: Some(vec!["sig-1a".to_string(), "sig-1b".to_string()]),
},
ToolCall {
call_id: "call-2".to_string(),
fn_name: "second".to_string(),
fn_arguments: json!({}),
thought_signatures: Some(vec!["sig-2".to_string()]),
},
]
}
#[test]
fn from_tool_calls_keeps_signatures_on_their_calls() {
let message = ChatMessage::from(signed_tool_calls());
let parts = message.content.parts();
assert_eq!(parts.len(), 2);
assert!(matches!(
&parts[0],
ContentPart::ToolCall(call)
if call.thought_signatures.as_deref()
== Some(["sig-1a".to_string(), "sig-1b".to_string()].as_slice())
));
assert!(matches!(
&parts[1],
ContentPart::ToolCall(call)
if call.thought_signatures.as_deref() == Some(["sig-2".to_string()].as_slice())
));
}
#[test]
fn from_tool_calls_does_not_duplicate_openai_reasoning_items() -> Result<()> {
let adapter_kind = AdapterKind::OpenAIResp;
let target = ServiceTarget {
model: ModelIden::new(adapter_kind, "gpt-5.6"),
auth: AuthData::from_single("test-key"),
endpoint: AdapterDispatcher::default_endpoint(adapter_kind),
};
let request = ChatRequest::new(vec![ChatMessage::user("go"), ChatMessage::from(signed_tool_calls())]);
let web_request =
AdapterDispatcher::to_web_request_data(target, ServiceType::Chat, request, ChatOptionsSet::default())?;
let input = web_request.payload["input"].as_array().ok_or("input should be an array")?;
let signatures = input
.iter()
.filter(|item| item["type"] == "reasoning")
.filter_map(|item| item["encrypted_content"].as_str())
.collect::<Vec<_>>();
assert_eq!(signatures, ["sig-1a", "sig-1b", "sig-2"]);
Ok(())
}
#[test]
fn from_tool_calls_keeps_gemini_signatures_with_their_calls() -> Result<()> {
let adapter_kind = AdapterKind::Gemini;
let target = ServiceTarget {
model: ModelIden::new(adapter_kind, "gemini-3-pro"),
auth: AuthData::from_single("test-key"),
endpoint: AdapterDispatcher::default_endpoint(adapter_kind),
};
let request = ChatRequest::new(vec![ChatMessage::user("go"), ChatMessage::from(signed_tool_calls())]);
let web_request =
AdapterDispatcher::to_web_request_data(target, ServiceType::Chat, request, ChatOptionsSet::default())?;
let contents = web_request.payload["contents"]
.as_array()
.ok_or("contents should be an array")?;
let model_turn = contents
.iter()
.find(|content| content["role"] == "model")
.ok_or("model turn should be present")?;
let parts = model_turn["parts"].as_array().ok_or("parts should be an array")?;
let rendered = parts
.iter()
.map(|part| {
(
part["functionCall"]["name"].as_str().unwrap_or("-"),
part["thoughtSignature"].as_str().unwrap_or("-"),
)
})
.collect::<Vec<_>>();
assert_eq!(rendered, [("first", "sig-1a"), ("second", "sig-2")]);
Ok(())
}
}