agent_sdk/context/
estimator.rs1use crate::llm::{Content, ContentBlock, Message};
4
5pub struct TokenEstimator;
13
14impl TokenEstimator {
15 const CHARS_PER_TOKEN: usize = 4;
18
19 const MESSAGE_OVERHEAD: usize = 4;
21
22 const TOOL_USE_OVERHEAD: usize = 20;
24
25 const TOOL_RESULT_OVERHEAD: usize = 10;
27
28 const REDACTED_THINKING_MIN_TOKENS: usize = 512;
33
34 const OPAQUE_REASONING_MIN_TOKENS: usize = 512;
39
40 #[must_use]
42 pub const fn estimate_text(text: &str) -> usize {
43 text.len().div_ceil(Self::CHARS_PER_TOKEN)
45 }
46
47 #[must_use]
49 pub fn estimate_message(message: &Message) -> usize {
50 let content_tokens = match &message.content {
51 Content::Text(text) => Self::estimate_text(text),
52 Content::Blocks(blocks) => blocks.iter().map(Self::estimate_block).sum(),
53 };
54
55 content_tokens + Self::MESSAGE_OVERHEAD
56 }
57
58 #[must_use]
60 pub fn estimate_block(block: &ContentBlock) -> usize {
61 match block {
62 ContentBlock::Text { text } => Self::estimate_text(text),
63 ContentBlock::Thinking { thinking, .. } => Self::estimate_text(thinking),
64 ContentBlock::RedactedThinking { data } => {
65 let raw_bytes = data.len() * 3 / 4;
75 let estimated = raw_bytes.div_ceil(Self::CHARS_PER_TOKEN);
76 estimated.max(Self::REDACTED_THINKING_MIN_TOKENS)
77 }
78 ContentBlock::OpaqueReasoning { provider, data } => {
79 let payload_tokens = Self::estimate_json_len(data)
82 .div_ceil(Self::CHARS_PER_TOKEN)
83 .max(Self::OPAQUE_REASONING_MIN_TOKENS);
84 Self::estimate_text(provider) + payload_tokens
85 }
86 ContentBlock::ToolUse { name, input, .. } => {
87 let input_len = Self::estimate_json_len(input);
94 Self::estimate_text(name)
95 + input_len.div_ceil(Self::CHARS_PER_TOKEN)
96 + Self::TOOL_USE_OVERHEAD
97 }
98 ContentBlock::ToolResult { content, .. } => {
99 Self::estimate_text(content) + Self::TOOL_RESULT_OVERHEAD
100 }
101 ContentBlock::Image { source } | ContentBlock::Document { source } => {
102 source.data.len() / 4 + Self::MESSAGE_OVERHEAD
104 }
105 _ => Self::MESSAGE_OVERHEAD,
108 }
109 }
110
111 #[must_use]
113 pub fn estimate_history(messages: &[Message]) -> usize {
114 messages.iter().map(Self::estimate_message).sum()
115 }
116
117 fn estimate_json_len(value: &serde_json::Value) -> usize {
126 match value {
127 serde_json::Value::Null => 4, serde_json::Value::Bool(b) => {
129 if *b {
130 4 } else {
132 5 }
134 }
135 serde_json::Value::Number(n) => n.as_u64().map_or_else(
136 || {
137 n.as_i64().map_or(
138 8,
141 |i| Self::decimal_digits(i.unsigned_abs()) + usize::from(i < 0),
142 )
143 },
144 Self::decimal_digits,
145 ),
146 serde_json::Value::String(s) => s.len() + 2,
148 serde_json::Value::Array(items) => {
149 2 + items
151 .iter()
152 .map(|item| Self::estimate_json_len(item) + 1)
153 .sum::<usize>()
154 }
155 serde_json::Value::Object(entries) => {
156 2 + entries
158 .iter()
159 .map(|(key, val)| key.len() + 2 + 1 + Self::estimate_json_len(val) + 1)
160 .sum::<usize>()
161 }
162 }
163 }
164
165 const fn decimal_digits(mut n: u64) -> usize {
167 let mut digits = 1;
168 while n >= 10 {
169 n /= 10;
170 digits += 1;
171 }
172 digits
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use crate::llm::Role;
180 use serde_json::json;
181
182 #[test]
183 fn test_estimate_text() {
184 assert_eq!(TokenEstimator::estimate_text(""), 0);
186
187 assert_eq!(TokenEstimator::estimate_text("hi"), 1);
189
190 assert_eq!(TokenEstimator::estimate_text("test"), 1);
192
193 assert_eq!(TokenEstimator::estimate_text("hello"), 2);
195
196 assert_eq!(TokenEstimator::estimate_text("hello world!"), 3); }
199
200 #[test]
201 fn test_estimate_text_message() {
202 let message = Message {
203 role: Role::User,
204 content: Content::Text("Hello, how are you?".to_string()), };
206
207 let estimate = TokenEstimator::estimate_message(&message);
208 assert_eq!(estimate, 9);
210 }
211
212 #[test]
213 fn test_estimate_blocks_message() {
214 let message = Message {
215 role: Role::Assistant,
216 content: Content::Blocks(vec![
217 ContentBlock::Text {
218 text: "Let me help.".to_string(), },
220 ContentBlock::ToolUse {
221 id: "tool_123".to_string(),
222 name: "read".to_string(), input: json!({"path": "/test.txt"}), thought_signature: None,
225 },
226 ]),
227 };
228
229 let estimate = TokenEstimator::estimate_message(&message);
230 assert!(estimate > 25); }
236
237 #[test]
238 fn test_estimate_tool_result() {
239 let message = Message {
240 role: Role::User,
241 content: Content::Blocks(vec![ContentBlock::ToolResult {
242 tool_use_id: "tool_123".to_string(),
243 content: "File contents here...".to_string(), is_error: None,
245 }]),
246 };
247
248 let estimate = TokenEstimator::estimate_message(&message);
249 assert_eq!(estimate, 20);
251 }
252
253 #[test]
254 fn test_estimate_history() {
255 let messages = vec![
256 Message::user("Hello"), Message::assistant("Hi there!"), Message::user("How are you?"), ];
260
261 let estimate = TokenEstimator::estimate_history(&messages);
262 assert_eq!(estimate, 20);
263 }
264
265 #[test]
266 fn test_empty_history() {
267 let messages: Vec<Message> = vec![];
268 assert_eq!(TokenEstimator::estimate_history(&messages), 0);
269 }
270
271 #[test]
272 fn test_estimate_redacted_thinking_uses_data_length() {
273 let data = "A".repeat(8192);
276 let block = ContentBlock::RedactedThinking { data };
277
278 let estimate = TokenEstimator::estimate_block(&block);
279 assert_eq!(estimate, 1536);
280 }
281
282 #[test]
283 fn test_estimate_redacted_thinking_respects_minimum() {
284 let data = "A".repeat(100);
287 let block = ContentBlock::RedactedThinking { data };
288
289 let estimate = TokenEstimator::estimate_block(&block);
290 assert_eq!(estimate, TokenEstimator::REDACTED_THINKING_MIN_TOKENS);
291 }
292
293 #[test]
294 fn test_estimate_redacted_thinking_empty_data() {
295 let block = ContentBlock::RedactedThinking {
297 data: String::new(),
298 };
299
300 let estimate = TokenEstimator::estimate_block(&block);
301 assert_eq!(estimate, TokenEstimator::REDACTED_THINKING_MIN_TOKENS);
302 }
303
304 #[test]
305 fn estimate_opaque_reasoning_uses_payload_size_with_a_floor() {
306 let small = ContentBlock::OpaqueReasoning {
307 provider: "test-provider".to_owned(),
308 data: json!({"encrypted_content": "x"}),
309 };
310 let large = ContentBlock::OpaqueReasoning {
311 provider: "test-provider".to_owned(),
312 data: json!({"encrypted_content": "x".repeat(8_192)}),
313 };
314
315 let small_estimate = TokenEstimator::estimate_block(&small);
316 let large_estimate = TokenEstimator::estimate_block(&large);
317 assert!(small_estimate >= TokenEstimator::OPAQUE_REASONING_MIN_TOKENS);
318 assert!(large_estimate > small_estimate);
319 }
320
321 #[test]
322 fn test_estimate_json_len_tracks_serialized_size() {
323 for value in [
326 json!({"path": "/test.txt"}),
327 json!({"a": 1, "b": [1, 2, 3], "c": {"nested": true}}),
328 json!([null, false, "string", 12_345]),
329 json!("plain string"),
330 json!(9_876_543),
331 ] {
332 let estimated = TokenEstimator::estimate_json_len(&value);
333 let actual = serde_json::to_string(&value).map_or(0, |s| s.len());
334 assert!(
335 estimated >= actual,
336 "estimate {estimated} should be >= actual {actual} for {value}"
337 );
338 assert!(
339 estimated <= actual * 2 + 8,
340 "estimate {estimated} wildly exceeds actual {actual} for {value}"
341 );
342 }
343 }
344
345 #[test]
346 fn test_tool_use_estimate_is_nonzero_for_nonempty_input() {
347 let block = ContentBlock::ToolUse {
351 id: "tool_1".to_string(),
352 name: "bash".to_string(),
353 input: json!({"command": "echo hello world"}),
354 thought_signature: None,
355 };
356
357 let estimate = TokenEstimator::estimate_block(&block);
358 assert!(
360 estimate > 21,
361 "input length must contribute to the estimate"
362 );
363 }
364
365 #[test]
366 fn test_redacted_thinking_accumulates_in_history() {
367 let blocks: Vec<ContentBlock> = (0..5)
370 .map(|_| ContentBlock::RedactedThinking {
371 data: "B".repeat(10_000), })
373 .collect();
374 let message = Message {
375 role: Role::Assistant,
376 content: Content::Blocks(blocks),
377 };
378
379 let estimate = TokenEstimator::estimate_message(&message);
380 assert_eq!(estimate, 9379);
382 }
383}