Skip to main content

yapi_ai/
transcript.rs

1//! The transcript a provider receives: messages in which system messages hold the
2//! prompt and tool declarations.
3//!
4//! The first message, when it is a system message, is the initial prompt. Later
5//! system messages patch sections and add or remove tools. Providers that cannot
6//! take system messages mid-conversation get them collapsed into the first one.
7
8use indexmap::IndexMap;
9use yapi_types::message::{
10    AssistantMessage, ContentBlock, Message, StopReason, SystemMessage, ToolDeclaration,
11    ToolResultMessage,
12};
13use yapi_types::model::Model;
14
15/// The leading system message, if the transcript starts with one.
16pub 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
30/// Tools active at the end of the transcript, in declaration order.
31pub 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
44/// Every tool ever declared, with its latest declaration.
45pub 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
55/// Whether two declarations serialize identically.
56pub 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
60/// Declarations to add and names to remove to get from `previous` to `current`.
61/// A changed declaration is both removed and added.
62pub 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
92/// Whether a tool name is declared twice with different declarations.
93pub 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
107/// Whether any system message removes a tool or declares a name a second time.
108pub 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
128/// Tools for the top-level request field, and whether later system messages keep
129/// their own additions in place. Anchoring needs API support and a transcript that
130/// only ever adds tools. Otherwise the request lists the current tools.
131pub 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
146/// All system messages folded into one: their text joined, sections merged (a
147/// removed then re-added section moves to the end), and the current tools. `None`
148/// when the transcript has no system message and no tools.
149pub 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
188/// The transcript as a provider sees it: unchanged when it accepts mid-conversation
189/// system messages, otherwise with every system message collapsed into the first.
190pub 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
227/// Maps a tool-call id from another model to one the target API accepts, given
228/// the assistant message that made the call.
229pub type NormalizeToolCallId<'a> = dyn Fn(&str, &AssistantMessage) -> String + 'a;
230
231/// Rewrites a transcript for `model`:
232/// - images become placeholders when the model is text-only
233/// - thinking from other models becomes text, or is dropped when empty or redacted
234/// - tool-call ids from other models are normalized for the target API, given the
235///   id and its source message, and tool results follow
236/// - failed and aborted assistant messages are dropped
237/// - tool calls without a result get an error result before the next turn, and system
238///   messages between a call and its results move after the results
239pub 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
426/// Rough token count of one message: characters / 4, images at 4800 characters.
427pub 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        // Other roles are converted to user messages before they reach a provider.
469        _ => 0,
470    }
471}
472
473/// Tokens the transcript occupies: the last successful assistant usage plus
474/// estimates for the messages after it, or estimates throughout.
475pub 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}