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