1use indexmap::IndexMap;
9use yapi_types::message::{
10 AssistantMessage, ContentBlock, Message, StopReason, SystemMessage, ToolDeclaration,
11 ToolResultMessage,
12};
13use yapi_types::model::Model;
14
15pub fn initial_system_message(messages: &[Message]) -> Option<&SystemMessage> {
17 match messages.first() {
18 Some(Message::System(system)) => Some(system),
19 _ => None,
20 }
21}
22
23fn system_messages(messages: &[Message]) -> impl Iterator<Item = &SystemMessage> {
24 messages.iter().filter_map(|message| match message {
25 Message::System(system) => Some(system),
26 _ => None,
27 })
28}
29
30pub fn current_tools(messages: &[Message]) -> Vec<ToolDeclaration> {
32 let mut tools: IndexMap<String, ToolDeclaration> = IndexMap::new();
33 for system in system_messages(messages) {
34 for tool in system.tools_removed.iter().flatten() {
35 tools.shift_remove(&tool.name);
36 }
37 for tool in system.tools_added.iter().flatten() {
38 tools.insert(tool.name.clone(), tool.clone());
39 }
40 }
41 tools.into_values().collect()
42}
43
44pub fn declared_tools(messages: &[Message]) -> Vec<ToolDeclaration> {
46 let mut tools: IndexMap<String, ToolDeclaration> = IndexMap::new();
47 for system in system_messages(messages) {
48 for tool in system.tools_added.iter().flatten() {
49 tools.insert(tool.name.clone(), tool.clone());
50 }
51 }
52 tools.into_values().collect()
53}
54
55pub fn declarations_equal(left: &ToolDeclaration, right: &ToolDeclaration) -> bool {
57 yapi_types::json::to_string(left).ok() == yapi_types::json::to_string(right).ok()
58}
59
60pub fn tool_state_changes(
63 previous: &[ToolDeclaration],
64 current: &[ToolDeclaration],
65) -> (
66 Vec<ToolDeclaration>,
67 Vec<yapi_types::message::ToolReference>,
68) {
69 let added = current
70 .iter()
71 .filter(|tool| {
72 !previous
73 .iter()
74 .any(|old| old.name == tool.name && declarations_equal(old, tool))
75 })
76 .cloned()
77 .collect();
78 let removed = previous
79 .iter()
80 .filter(|tool| {
81 !current
82 .iter()
83 .any(|new| new.name == tool.name && declarations_equal(tool, new))
84 })
85 .map(|tool| yapi_types::message::ToolReference {
86 name: tool.name.clone(),
87 })
88 .collect();
89 (added, removed)
90}
91
92pub fn has_tool_redefinitions(messages: &[Message]) -> bool {
94 let mut declared: IndexMap<&str, &ToolDeclaration> = IndexMap::new();
95 for system in system_messages(messages) {
96 for tool in system.tools_added.iter().flatten() {
97 if let Some(previous) = declared.insert(&tool.name, tool)
98 && !declarations_equal(previous, tool)
99 {
100 return true;
101 }
102 }
103 }
104 false
105}
106
107pub fn has_non_additive_tool_changes(messages: &[Message]) -> bool {
109 let mut declared: Vec<&str> = Vec::new();
110 for system in system_messages(messages) {
111 if system
112 .tools_removed
113 .as_ref()
114 .is_some_and(|removed| !removed.is_empty())
115 {
116 return true;
117 }
118 for tool in system.tools_added.iter().flatten() {
119 if declared.contains(&tool.name.as_str()) {
120 return true;
121 }
122 declared.push(&tool.name);
123 }
124 }
125 false
126}
127
128pub fn resolve_transcript_tools(
132 messages: &[Message],
133 supports_tool_additions: bool,
134) -> (Vec<ToolDeclaration>, bool) {
135 let anchors = supports_tool_additions && !has_non_additive_tool_changes(messages);
136 let tools = if anchors {
137 initial_system_message(messages)
138 .and_then(|system| system.tools_added.clone())
139 .unwrap_or_default()
140 } else {
141 current_tools(messages)
142 };
143 (tools, anchors)
144}
145
146pub fn current_system_message(messages: &[Message]) -> Option<SystemMessage> {
150 let mut content = Vec::new();
151 let mut sections: IndexMap<String, String> = IndexMap::new();
152 let mut timestamp = None;
153 for system in system_messages(messages) {
154 timestamp.get_or_insert(system.timestamp);
155 let text = system.content.text("\n");
156 if !text.is_empty() {
157 content.push(text);
158 }
159 for (name, value) in system.sections.iter().flatten() {
160 match value {
161 None => {
162 sections.shift_remove(name);
163 }
164 Some(value) => {
165 sections.insert(name.clone(), value.clone());
166 }
167 }
168 }
169 }
170 let tools = current_tools(messages);
171 if timestamp.is_none() && tools.is_empty() {
172 return None;
173 }
174 Some(SystemMessage {
175 content: yapi_types::message::Content::Text(content.join("\n\n")),
176 sections: (!sections.is_empty()).then(|| {
177 sections
178 .into_iter()
179 .map(|(name, text)| (name, Some(text)))
180 .collect()
181 }),
182 timestamp: timestamp.unwrap_or(0),
183 tools_added: (!tools.is_empty()).then_some(tools),
184 tools_removed: None,
185 })
186}
187
188pub fn resolve_transcript(messages: &[Message], supports_mid_convo_system: bool) -> Vec<Message> {
191 if supports_mid_convo_system {
192 return messages.to_vec();
193 }
194 let head = current_system_message(messages);
195 head.map(Message::System)
196 .into_iter()
197 .chain(
198 messages
199 .iter()
200 .filter(|message| !matches!(message, Message::System(_)))
201 .cloned(),
202 )
203 .collect()
204}
205
206const NON_VISION_USER_IMAGE: &str = "(image omitted: model does not support images)";
207const NON_VISION_TOOL_IMAGE: &str = "(tool image omitted: model does not support images)";
208
209fn replace_images(content: &[ContentBlock], placeholder: &str) -> Vec<ContentBlock> {
210 let mut result = Vec::new();
211 let mut previous_was_placeholder = false;
212 for block in content {
213 if matches!(block, ContentBlock::Image(_)) {
214 if !previous_was_placeholder {
215 result.push(ContentBlock::text(placeholder));
216 }
217 previous_was_placeholder = true;
218 continue;
219 }
220 previous_was_placeholder =
221 matches!(block, ContentBlock::Text(text) if text.text == placeholder);
222 result.push(block.clone());
223 }
224 result
225}
226
227pub type NormalizeToolCallId<'a> = dyn Fn(&str, &AssistantMessage) -> String + 'a;
230
231pub fn transform_messages(
240 messages: &[Message],
241 model: &Model,
242 normalize_tool_call_id: Option<&NormalizeToolCallId<'_>>,
243 now_ms: u64,
244) -> Vec<Message> {
245 let mut id_map: IndexMap<String, String> = IndexMap::new();
246 let text_only = !model.accepts_images();
247
248 let transformed: Vec<Message> = messages
249 .iter()
250 .map(|message| match message {
251 Message::User(user) if text_only => {
252 let mut user = user.clone();
253 if let yapi_types::message::Content::Blocks(blocks) = &user.content {
254 user.content = yapi_types::message::Content::Blocks(replace_images(
255 blocks,
256 NON_VISION_USER_IMAGE,
257 ));
258 }
259 Message::User(user)
260 }
261 Message::ToolResult(result) => {
262 let mut result = result.clone();
263 if text_only {
264 result.content = replace_images(&result.content, NON_VISION_TOOL_IMAGE);
265 }
266 if let Some(id) = id_map.get(&result.tool_call_id) {
267 result.tool_call_id = id.clone();
268 }
269 Message::ToolResult(result)
270 }
271 Message::Assistant(assistant) => Message::Assistant(Box::new(transform_assistant(
272 assistant,
273 model,
274 normalize_tool_call_id,
275 &mut id_map,
276 ))),
277 other => other.clone(),
278 })
279 .collect();
280
281 let mut result = Vec::new();
282 let mut pending: Vec<(String, String)> = Vec::new();
283 let mut answered: Vec<String> = Vec::new();
284 let mut held: Vec<Message> = Vec::new();
285 let close = |result: &mut Vec<Message>,
286 pending: &mut Vec<(String, String)>,
287 answered: &mut Vec<String>,
288 held: &mut Vec<Message>| {
289 for (id, name) in pending.drain(..) {
290 if !answered.contains(&id) {
291 result.push(Message::ToolResult(ToolResultMessage {
292 tool_call_id: id,
293 tool_name: name,
294 content: vec![ContentBlock::text("No result provided")],
295 details: None,
296 usage: None,
297 is_error: true,
298 timestamp: now_ms,
299 nested_calls: None,
300 }));
301 }
302 }
303 answered.clear();
304 result.append(held);
305 };
306
307 for message in transformed {
308 match &message {
309 Message::Assistant(assistant) => {
310 close(&mut result, &mut pending, &mut answered, &mut held);
311 if matches!(
312 assistant.stop_reason,
313 StopReason::Error | StopReason::Aborted
314 ) {
315 continue;
316 }
317 let calls: Vec<(String, String)> = assistant
318 .content
319 .iter()
320 .filter_map(|block| match block {
321 ContentBlock::ToolCall(call) => Some((call.id.clone(), call.name.clone())),
322 _ => None,
323 })
324 .collect();
325 if !calls.is_empty() {
326 pending = calls;
327 answered.clear();
328 }
329 result.push(message);
330 }
331 Message::ToolResult(tool_result) => {
332 answered.push(tool_result.tool_call_id.clone());
333 result.push(message);
334 }
335 Message::System(_) if !pending.is_empty() => held.push(message),
336 Message::User(_) => {
337 close(&mut result, &mut pending, &mut answered, &mut held);
338 result.push(message);
339 }
340 _ => result.push(message),
341 }
342 }
343 close(&mut result, &mut pending, &mut answered, &mut held);
344 result
345}
346
347fn transform_assistant(
348 assistant: &AssistantMessage,
349 model: &Model,
350 normalize_tool_call_id: Option<&NormalizeToolCallId<'_>>,
351 id_map: &mut IndexMap<String, String>,
352) -> AssistantMessage {
353 let same_model = assistant.provider == model.provider
354 && assistant.api == model.api
355 && assistant.model == model.id;
356 let mut content = Vec::new();
357 for block in &assistant.content {
358 match block {
359 ContentBlock::Thinking(thinking) => {
360 if thinking.redacted == Some(true) {
361 if same_model {
362 content.push(block.clone());
363 }
364 } else if same_model
365 && thinking
366 .thinking_signature
367 .as_deref()
368 .is_some_and(|signature| !signature.is_empty())
369 {
370 content.push(block.clone());
371 } else if thinking.thinking.trim().is_empty() {
372 } else if same_model {
373 content.push(block.clone());
374 } else {
375 content.push(ContentBlock::text(&thinking.thinking));
376 }
377 }
378 ContentBlock::Text(text) => {
379 if same_model {
380 content.push(block.clone());
381 } else {
382 content.push(ContentBlock::text(&text.text));
383 }
384 }
385 ContentBlock::ToolCall(call) => {
386 let mut call = call.clone();
387 if !same_model {
388 call.thought_signature = None;
389 if let Some(normalize) = normalize_tool_call_id {
390 let id = normalize(&call.id, assistant);
391 if id != call.id {
392 id_map.insert(call.id.clone(), id.clone());
393 call.id = id;
394 }
395 }
396 }
397 content.push(ContentBlock::ToolCall(call));
398 }
399 ContentBlock::Image(_) => content.push(block.clone()),
400 }
401 }
402 AssistantMessage {
403 content,
404 ..assistant.clone()
405 }
406}
407
408const CHARS_PER_TOKEN: usize = 4;
409const ESTIMATED_IMAGE_CHARS: usize = 4800;
410
411fn tokens(chars: usize) -> u64 {
412 chars.div_ceil(CHARS_PER_TOKEN) as u64
413}
414
415fn content_chars(blocks: &[ContentBlock]) -> usize {
416 blocks
417 .iter()
418 .map(|block| match block {
419 ContentBlock::Text(text) => yapi_types::js::len(&text.text),
420 ContentBlock::Image(_) => ESTIMATED_IMAGE_CHARS,
421 _ => 0,
422 })
423 .sum()
424}
425
426pub fn estimate_message_tokens(message: &Message) -> u64 {
428 match message {
429 Message::System(system) => {
430 let tools =
431 |json: Option<String>| json.map_or(0, |json| tokens(yapi_types::js::len(&json)));
432 tokens(yapi_types::js::len(&system.text()))
433 + tools(
434 system
435 .tools_added
436 .as_ref()
437 .filter(|tools| !tools.is_empty())
438 .and_then(|tools| yapi_types::json::to_string(tools).ok()),
439 )
440 + tools(
441 system
442 .tools_removed
443 .as_ref()
444 .filter(|tools| !tools.is_empty())
445 .and_then(|tools| yapi_types::json::to_string(tools).ok()),
446 )
447 }
448 Message::User(user) => match &user.content {
449 yapi_types::message::Content::Text(text) => tokens(yapi_types::js::len(text)),
450 yapi_types::message::Content::Blocks(blocks) => tokens(content_chars(blocks)),
451 },
452 Message::ToolResult(result) => tokens(content_chars(&result.content)),
453 Message::Assistant(assistant) => tokens(
454 assistant
455 .content
456 .iter()
457 .map(|block| match block {
458 ContentBlock::Text(text) => yapi_types::js::len(&text.text),
459 ContentBlock::Thinking(thinking) => yapi_types::js::len(&thinking.thinking),
460 ContentBlock::ToolCall(call) => {
461 yapi_types::js::len(&call.name)
462 + yapi_types::js::len(&yapi_types::json::stringify(&call.arguments))
463 }
464 ContentBlock::Image(_) => 0,
465 })
466 .sum(),
467 ),
468 _ => 0,
470 }
471}
472
473pub fn estimate_context_tokens(messages: &[Message]) -> u64 {
476 let mut latest_prefix_timestamp: Option<u64> = None;
477 let mut last_usage: Option<(u64, usize)> = None;
478 for (index, message) in messages.iter().enumerate() {
479 if let Message::Assistant(assistant) = message {
480 let applies =
481 latest_prefix_timestamp.is_none_or(|latest| assistant.timestamp >= latest);
482 let usage = &assistant.usage;
483 let context = usage
484 .total_tokens
485 .filter(|total| *total > 0)
486 .unwrap_or(usage.input + usage.output + usage.cache_read + usage.cache_write);
487 if applies
488 && !matches!(
489 assistant.stop_reason,
490 StopReason::Aborted | StopReason::Error
491 )
492 && context > 0
493 {
494 last_usage = Some((context, index));
495 }
496 }
497 let timestamp = message.timestamp();
498 latest_prefix_timestamp =
499 Some(latest_prefix_timestamp.map_or(timestamp, |latest| latest.max(timestamp)));
500 }
501 match last_usage {
502 Some((usage_tokens, index)) => {
503 usage_tokens
504 + messages[index + 1..]
505 .iter()
506 .map(estimate_message_tokens)
507 .sum::<u64>()
508 }
509 None => messages.iter().map(estimate_message_tokens).sum(),
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use serde_json::json;
517 use yapi_types::message::{Content, ToolReference};
518
519 fn system(value: serde_json::Value) -> Message {
520 serde_json::from_value(value).unwrap()
521 }
522
523 fn tool(name: &str) -> serde_json::Value {
524 json!({"name": name, "description": name, "parameters": {"type": "object"}})
525 }
526
527 #[test]
528 fn collapses_system_messages() {
529 let messages = vec![
530 system(
531 json!({"role":"system","content":"","sections":{"a":"A","b":"B"},"timestamp":5,"toolsAdded":[tool("read"),tool("bash")]}),
532 ),
533 system(json!({"role":"user","content":"hi","timestamp":6})),
534 system(
535 json!({"role":"system","content":"","sections":{"a":null},"timestamp":7,"toolsRemoved":[{"name":"read"}]}),
536 ),
537 system(
538 json!({"role":"system","content":"note","sections":{"a":"A2"},"timestamp":8,"toolsAdded":[tool("read")]}),
539 ),
540 ];
541 let collapsed = resolve_transcript(&messages, false);
542 assert_eq!(collapsed.len(), 2);
543 let Message::System(head) = &collapsed[0] else {
544 panic!("head is not a system message")
545 };
546 assert_eq!(head.timestamp, 5);
547 assert_eq!(head.content, Content::Text("note".into()));
548 let names: Vec<_> = head.sections.as_ref().unwrap().keys().cloned().collect();
549 assert_eq!(names, ["b", "a"]);
550 assert_eq!(head.text(), "note\n\nB\n\nA2");
551 let tools: Vec<_> = head
552 .tools_added
553 .as_ref()
554 .unwrap()
555 .iter()
556 .map(|t| t.name.clone())
557 .collect();
558 assert_eq!(tools, ["bash", "read"]);
559 assert_eq!(resolve_transcript(&messages, true), messages);
560 let _ = ToolReference {
561 name: String::new(),
562 };
563 }
564
565 #[test]
566 fn closes_calls_drops_failures() {
567 let model: Model = serde_json::from_value(json!({
568 "id":"m","name":"M","api":"anthropic-messages","provider":"p","baseUrl":"u","reasoning":false,
569 "input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1,"maxTokens":1
570 }))
571 .unwrap();
572 let assistant = |stop: &str, calls: serde_json::Value| {
573 system(
574 json!({"role":"assistant","content":calls,"api":"openai-completions","provider":"o","model":"x",
575 "usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},
576 "stopReason":stop,"timestamp":1}),
577 )
578 };
579 let messages = vec![
580 assistant(
581 "toolUse",
582 json!([{"type":"thinking","thinking":"hmm","thinkingSignature":"sig"},{"type":"toolCall","id":"call|1","name":"read","arguments":{}}]),
583 ),
584 system(json!({"role":"system","content":"later","timestamp":2})),
585 system(
586 json!({"role":"user","content":[{"type":"image","data":"AA","mimeType":"image/png"}],"timestamp":3}),
587 ),
588 assistant("error", json!([])),
589 ];
590 let normalize = |id: &str, _: &AssistantMessage| id.replace('|', "_");
591 let out = transform_messages(&messages, &model, Some(&normalize), 9);
592 let roles: Vec<_> = out
593 .iter()
594 .map(|m| match m {
595 Message::Assistant(_) => "assistant",
596 Message::ToolResult(_) => "toolResult",
597 Message::System(_) => "system",
598 Message::User(_) => "user",
599 _ => "other",
600 })
601 .collect();
602 assert_eq!(roles, ["assistant", "toolResult", "system", "user"]);
603 let Message::Assistant(first) = &out[0] else {
604 panic!()
605 };
606 assert_eq!(first.content[0], ContentBlock::text("hmm"));
607 let Message::ToolResult(result) = &out[1] else {
608 panic!()
609 };
610 assert_eq!(result.tool_call_id, "call_1");
611 assert!(result.is_error);
612 let Message::User(user) = &out[3] else {
613 panic!()
614 };
615 assert_eq!(
616 user.content,
617 Content::Blocks(vec![ContentBlock::text(NON_VISION_USER_IMAGE)])
618 );
619 }
620
621 #[test]
622 fn estimates_like_pi() {
623 let messages = vec![system(
624 json!({"role":"user","content":"héllo wörld!","timestamp":1}),
625 )];
626 assert_eq!(estimate_context_tokens(&messages), 3);
627 }
628}