1use serde::{Deserialize, Serialize};
2use serde_json::{json, Value};
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
5pub struct ChatMessage {
6 pub role: String,
7 #[serde(default, skip_serializing_if = "Option::is_none")]
8 pub content: Option<String>,
9 #[serde(default, skip_serializing_if = "Option::is_none")]
10 pub reasoning_details: Option<Vec<Value>>,
11 #[serde(default, skip_serializing_if = "Option::is_none")]
12 pub name: Option<String>,
13 #[serde(default, skip_serializing_if = "Option::is_none")]
14 pub tool_call_id: Option<String>,
15 #[serde(default, skip_serializing_if = "Vec::is_empty")]
16 pub tool_calls: Vec<ChatToolCall>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20pub struct ChatToolCall {
21 pub id: String,
22 pub name: String,
23 pub arguments: String,
24}
25
26const SIGNATURE_REQUIRING_REASONING_FORMATS: [&str; 2] =
42 ["anthropic-claude-v1", "google-gemini-v1"];
43
44fn is_sendable_reasoning_detail(detail: &Value) -> bool {
45 if detail.get("type").and_then(Value::as_str) != Some("reasoning.text") {
46 return true;
47 }
48 let Some(format) = detail.get("format").and_then(Value::as_str) else {
49 return true;
50 };
51 if !SIGNATURE_REQUIRING_REASONING_FORMATS.contains(&format) {
52 return true;
53 }
54 detail
55 .get("signature")
56 .and_then(Value::as_str)
57 .is_some_and(|signature| !signature.trim().is_empty())
58}
59
60fn sendable_reasoning_details(details: &[Value]) -> Vec<Value> {
61 details
62 .iter()
63 .filter(|detail| is_sendable_reasoning_detail(detail))
64 .cloned()
65 .collect()
66}
67
68pub(crate) fn estimate_message_tokens(message: &ChatMessage) -> usize {
76 serde_json::to_vec(message)
77 .map(|encoded| encoded.len().div_ceil(4).max(1))
78 .unwrap_or(1)
79}
80
81pub(crate) fn estimate_context_tokens(messages: &[ChatMessage]) -> usize {
82 messages
83 .iter()
84 .map(estimate_message_tokens)
85 .sum::<usize>()
86 .max(1)
87}
88
89impl ChatMessage {
90 pub fn system(content: String) -> Self {
91 Self {
92 role: "system".to_owned(),
93 content: Some(content),
94 reasoning_details: None,
95 name: None,
96 tool_call_id: None,
97 tool_calls: Vec::new(),
98 }
99 }
100
101 pub fn user(content: String) -> Self {
102 Self {
103 role: "user".to_owned(),
104 content: Some(content),
105 reasoning_details: None,
106 name: None,
107 tool_call_id: None,
108 tool_calls: Vec::new(),
109 }
110 }
111
112 pub fn assistant(content: String, tool_calls: Vec<ChatToolCall>) -> Self {
113 Self {
114 role: "assistant".to_owned(),
115 content: (!content.is_empty()).then_some(content),
116 reasoning_details: None,
117 name: None,
118 tool_call_id: None,
119 tool_calls,
120 }
121 }
122
123 pub fn tool(tool_call_id: String, name: String, content: String) -> Self {
124 Self {
125 role: "tool".to_owned(),
126 content: Some(content),
127 reasoning_details: None,
128 name: Some(name),
129 tool_call_id: Some(tool_call_id),
130 tool_calls: Vec::new(),
131 }
132 }
133
134 pub fn to_openai_value(&self) -> Value {
135 let mut message = json!({
136 "role": self.role,
137 "content": self.content,
138 });
139 if self.role == "assistant" {
140 if let Some(reasoning_details) = &self.reasoning_details {
141 let sendable = sendable_reasoning_details(reasoning_details);
142 if !sendable.is_empty() {
143 message["reasoning_details"] = Value::Array(sendable);
144 }
145 }
146 }
147 if let Some(name) = &self.name {
148 message["name"] = Value::String(name.clone());
149 }
150 if let Some(tool_call_id) = &self.tool_call_id {
151 message["tool_call_id"] = Value::String(tool_call_id.clone());
152 }
153 if !self.tool_calls.is_empty() {
154 message["tool_calls"] = Value::Array(
155 self.tool_calls
156 .iter()
157 .map(|tool_call| {
158 json!({
159 "id": tool_call.id,
160 "type": "function",
161 "function": {
162 "name": tool_call.name,
163 "arguments": tool_call.arguments,
164 }
165 })
166 })
167 .collect(),
168 );
169 }
170 message
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn context_token_estimate_is_nonzero_and_grows_with_messages() {
180 let empty = estimate_context_tokens(&[]);
181 let one = estimate_context_tokens(&[ChatMessage::user("hello".to_owned())]);
182
183 assert_eq!(empty, 1);
184 assert!(one > empty);
185 assert!(estimate_message_tokens(&ChatMessage::user("hello".to_owned())) > 0);
186 }
187
188 #[test]
189 fn tool_assistant_messages_have_openai_compatible_shape() {
190 let assistant = ChatMessage::assistant(
191 String::new(),
192 vec![ChatToolCall {
193 id: "call-1".to_owned(),
194 name: "cmd".to_owned(),
195 arguments: r#"{"command":"pwd"}"#.to_owned(),
196 }],
197 );
198 assert_eq!(assistant.to_openai_value()["content"], Value::Null);
199 assert_eq!(
200 assistant.to_openai_value()["tool_calls"][0]["type"],
201 "function"
202 );
203
204 let tool = ChatMessage::tool(
205 "call-1".to_owned(),
206 "cmd".to_owned(),
207 "{\"exit_code\":0}".to_owned(),
208 );
209 assert_eq!(tool.to_openai_value()["tool_call_id"], "call-1");
210 assert_eq!(tool.to_openai_value()["name"], "cmd");
211 }
212
213 #[test]
214 fn reasoning_details_are_optional_and_only_sent_for_assistant_messages() {
215 let details = vec![json!({
216 "type": "reasoning.text",
217 "text": "private reasoning"
218 })];
219 let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
220 assistant.reasoning_details = Some(details.clone());
221 assert_eq!(
222 assistant.to_openai_value()["reasoning_details"],
223 json!(details)
224 );
225
226 let old_message: ChatMessage = serde_json::from_value(json!({
227 "role": "assistant",
228 "content": "old session"
229 }))
230 .expect("old message without reasoning details");
231 assert_eq!(old_message.reasoning_details, None);
232
233 let mut user = ChatMessage::user("question".to_owned());
234 user.reasoning_details = Some(vec![json!({"text": "must not be sent"})]);
235 assert!(user.to_openai_value().get("reasoning_details").is_none());
236 }
237
238 #[test]
239 fn unsigned_thinking_fragments_are_not_replayed_to_the_provider() {
240 let signed = json!({
241 "type": "reasoning.text",
242 "format": "anthropic-claude-v1",
243 "index": 0,
244 "signature": "CAIS0AIK"
245 });
246 let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
247 assistant.reasoning_details = Some(vec![
248 json!({
249 "type": "reasoning.text",
250 "format": "anthropic-claude-v1",
251 "index": 0,
252 "text": "I"
253 }),
254 json!({
255 "type": "reasoning.text",
256 "format": "google-gemini-v1",
257 "index": 0,
258 "text": " need to inspect the repository."
259 }),
260 json!({
261 "type": "reasoning.text",
262 "format": "anthropic-claude-v1",
263 "index": 0,
264 "signature": ""
265 }),
266 json!({
267 "type": "reasoning.text",
268 "format": "anthropic-claude-v1",
269 "index": 0,
270 "signature": " "
271 }),
272 json!({
273 "type": "reasoning.text",
274 "format": "some-other-provider-v1",
275 "text": "unsigned but not signature checked"
276 }),
277 json!({
278 "type": "reasoning.encrypted",
279 "id": "call-1",
280 "data": "opaque"
281 }),
282 signed.clone(),
283 ]);
284
285 assert_eq!(
286 assistant.to_openai_value()["reasoning_details"],
287 json!([
288 {
289 "type": "reasoning.text",
290 "format": "some-other-provider-v1",
291 "text": "unsigned but not signature checked"
292 },
293 {
294 "type": "reasoning.encrypted",
295 "id": "call-1",
296 "data": "opaque"
297 },
298 signed,
299 ])
300 );
301 }
302
303 #[test]
304 fn assistant_messages_omit_reasoning_details_when_every_entry_is_unsigned() {
305 let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
306 assistant.reasoning_details = Some(vec![json!({
307 "type": "reasoning.text",
308 "format": "anthropic-claude-v1",
309 "index": 0,
310 "text": "only an unsigned fragment"
311 })]);
312
313 assert!(assistant
314 .to_openai_value()
315 .get("reasoning_details")
316 .is_none());
317 }
318}