talos_agent/token.rs
1//! Token estimation and usage tracking for agent sessions.
2//!
3//! This module provides approximate token counting for messages and cumulative
4//! usage tracking across turns. Token estimation uses character-based heuristics:
5//! - ASCII text: ~4 characters per token
6//! - Non-ASCII text (CJK, etc.): ~2 characters per token
7//!
8//! The request admission layer adds an explicit safety margin for text. Image
9//! parts use a conservative declared-size policy because dimensions are not
10//! always available at this boundary.
11
12use talos_core::message::{Message, Usage};
13
14/// Pricing information for a language model, expressed as cost per 1,000 tokens.
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct ModelPricing {
17 /// Cost per 1,000 input tokens.
18 pub input_per_1k: f64,
19 /// Cost per 1,000 output tokens.
20 pub output_per_1k: f64,
21 /// Cost per 1,000 cache read tokens.
22 pub cache_read_per_1k: f64,
23 /// Cost per 1,000 cache write tokens.
24 pub cache_write_per_1k: f64,
25}
26
27/// Estimates token counts for messages and tracks cumulative usage across turns.
28///
29/// # Token Estimation Strategy
30///
31/// Uses character-based approximation:
32/// - ASCII characters: 4 chars ≈ 1 token
33/// - Non-ASCII characters (CJK, emoji, etc.): 2 chars ≈ 1 token
34///
35/// This provides a reasonable estimate within ~20% of actual token counts
36/// for most common text patterns.
37///
38/// # Example
39///
40/// ```
41/// use talos_agent::token::{TokenEstimator, ModelPricing};
42/// use talos_core::message::{Message, Usage};
43///
44/// let mut estimator = TokenEstimator::new();
45///
46/// // Estimate tokens for a set of messages
47/// let messages = vec![
48/// Message::User { content: "Hello, world!".into() },
49/// Message::Assistant { content: "Hi there!".into(), tool_calls: vec![], reasoning: None },
50/// ];
51/// let estimated = estimator.estimate(&messages);
52///
53/// // Track actual usage from a turn
54/// estimator.track_usage(Usage {
55/// input_tokens: 100,
56/// output_tokens: 50,
57/// cache_read_tokens: 80,
58/// cache_write_tokens: 20,
59/// reasoning_tokens: 0,
60/// });
61///
62/// // Get cumulative usage
63/// let total = estimator.total_usage();
64/// assert_eq!(total.input_tokens, 100);
65///
66/// // Estimate cost
67/// let pricing = ModelPricing {
68/// input_per_1k: 0.003,
69/// output_per_1k: 0.015,
70/// cache_read_per_1k: 0.001,
71/// cache_write_per_1k: 0.002,
72/// };
73/// let cost = estimator.estimated_cost(&pricing);
74/// ```
75#[derive(Debug, Clone, Default)]
76pub struct TokenEstimator {
77 /// Cumulative usage across all tracked turns.
78 total: Usage,
79}
80
81impl TokenEstimator {
82 /// Creates a new token estimator with zero cumulative usage.
83 #[must_use]
84 pub fn new() -> Self {
85 Self::default()
86 }
87
88 /// Estimates the token count for a slice of messages.
89 ///
90 /// Iterates over all message content (user text, assistant text, tool results)
91 /// and applies character-based heuristics to approximate token count.
92 ///
93 /// # Arguments
94 ///
95 /// * `messages` — The messages to estimate tokens for.
96 ///
97 /// # Returns
98 ///
99 /// The estimated total token count across all messages.
100 ///
101 /// # Example
102 ///
103 /// ```
104 /// use talos_agent::token::TokenEstimator;
105 /// use talos_core::message::Message;
106 ///
107 /// let estimator = TokenEstimator::new();
108 /// let messages = vec![
109 /// Message::User { content: "Hello!".into() },
110 /// ];
111 /// let tokens = estimator.estimate(&messages);
112 /// assert!(tokens > 0);
113 /// ```
114 pub fn estimate(&self, messages: &[Message]) -> u32 {
115 messages.iter().fold(0_u32, |total, msg| {
116 let message_tokens = match msg {
117 Message::User { content } => Self::estimate_text(content),
118 Message::System { content, .. } => Self::estimate_text(content),
119 Message::Context { content } => Self::estimate_text(content),
120 Message::Assistant {
121 content,
122 tool_calls,
123 ..
124 } => tool_calls
125 .iter()
126 .fold(Self::estimate_text(content), |tokens, call| {
127 tokens
128 .saturating_add(Self::estimate_text(&call.name))
129 .saturating_add(Self::estimate_text(&call.input.to_string()))
130 }),
131 Message::Tool { result } => Self::estimate_text(&result.content)
132 .saturating_add(Self::estimate_text(&result.tool_use_id)),
133 Message::Multimodal { parts } => parts.iter().fold(0_u32, |tokens, part| {
134 let part_tokens = match part {
135 talos_core::message::ContentPart::Text { text } => {
136 Self::estimate_text(text)
137 }
138 talos_core::message::ContentPart::Image {
139 mime, byte_count, ..
140 } => {
141 // Conservative provider-independent fallback: reserve
142 // one token per three declared bytes plus a fixed
143 // framing/vision overhead. This intentionally
144 // overestimates encoded image cost when exact
145 // dimensions/provider tile rules are unavailable.
146 let declared = byte_count.div_ceil(3);
147 Self::estimate_text(mime)
148 .saturating_add(u32::try_from(declared).unwrap_or(u32::MAX))
149 .saturating_add(1024)
150 }
151 };
152 tokens.saturating_add(part_tokens)
153 }),
154 };
155 total.saturating_add(message_tokens)
156 })
157 }
158
159 /// Estimates the token count for a single string of text.
160 ///
161 /// Uses character-based heuristics:
162 /// - ASCII characters: 4 chars ≈ 1 token
163 /// - Non-ASCII characters: 2 chars ≈ 1 token
164 ///
165 /// Empty strings return 0 tokens.
166 ///
167 /// # Arguments
168 ///
169 /// * `text` — The text to estimate tokens for.
170 ///
171 /// # Returns
172 ///
173 /// The estimated token count.
174 ///
175 /// # Example
176 ///
177 /// ```
178 /// use talos_agent::token::TokenEstimator;
179 ///
180 /// // English text: ~4 chars per token
181 /// let english = TokenEstimator::estimate_text("Hello, world!");
182 /// assert!(english > 0);
183 ///
184 /// // CJK text: ~2 chars per token
185 /// let cjk = TokenEstimator::estimate_text("你好世界");
186 /// assert!(cjk > 0);
187 ///
188 /// // Empty text: 0 tokens
189 /// let empty = TokenEstimator::estimate_text("");
190 /// assert_eq!(empty, 0);
191 /// ```
192 pub fn estimate_text(text: &str) -> u32 {
193 if text.is_empty() {
194 return 0;
195 }
196
197 let mut ascii_chars: u32 = 0;
198 let mut non_ascii_chars: u32 = 0;
199
200 for ch in text.chars() {
201 if ch.is_ascii() {
202 ascii_chars += 1;
203 } else {
204 non_ascii_chars += 1;
205 }
206 }
207
208 // ASCII: 4 chars ≈ 1 token, Non-ASCII: 2 chars ≈ 1 token
209 let ascii_tokens = ascii_chars.div_ceil(4);
210 let non_ascii_tokens = non_ascii_chars.div_ceil(2);
211
212 ascii_tokens + non_ascii_tokens
213 }
214
215 /// Accumulates usage from a single turn into the cumulative total.
216 ///
217 /// # Arguments
218 ///
219 /// * `turn_usage` — The usage statistics from a single turn.
220 ///
221 /// # Example
222 ///
223 /// ```
224 /// use talos_agent::token::TokenEstimator;
225 /// use talos_core::message::Usage;
226 ///
227 /// let mut estimator = TokenEstimator::new();
228 /// estimator.track_usage(Usage {
229 /// input_tokens: 100,
230 /// output_tokens: 50,
231 /// cache_read_tokens: 80,
232 /// cache_write_tokens: 20,
233 /// reasoning_tokens: 0,
234 /// });
235 ///
236 /// let total = estimator.total_usage();
237 /// assert_eq!(total.input_tokens, 100);
238 /// assert_eq!(total.output_tokens, 50);
239 /// ```
240 pub fn track_usage(&mut self, turn_usage: Usage) {
241 self.total.input_tokens += turn_usage.input_tokens;
242 self.total.output_tokens += turn_usage.output_tokens;
243 self.total.cache_read_tokens += turn_usage.cache_read_tokens;
244 self.total.cache_write_tokens += turn_usage.cache_write_tokens;
245 self.total.reasoning_tokens += turn_usage.reasoning_tokens;
246 }
247
248 /// Returns the cumulative usage across all tracked turns.
249 ///
250 /// # Returns
251 ///
252 /// A [`Usage`] struct with the sum of all tracked turn usage.
253 ///
254 /// # Example
255 ///
256 /// ```
257 /// use talos_agent::token::TokenEstimator;
258 /// use talos_core::message::Usage;
259 ///
260 /// let mut estimator = TokenEstimator::new();
261 /// estimator.track_usage(Usage {
262 /// input_tokens: 100,
263 /// output_tokens: 50,
264 /// cache_read_tokens: 0,
265 /// cache_write_tokens: 0,
266 /// reasoning_tokens: 0,
267 /// });
268 /// estimator.track_usage(Usage {
269 /// input_tokens: 200,
270 /// output_tokens: 75,
271 /// cache_read_tokens: 100,
272 /// cache_write_tokens: 50,
273 /// reasoning_tokens: 0,
274 /// });
275 ///
276 /// let total = estimator.total_usage();
277 /// assert_eq!(total.input_tokens, 300);
278 /// assert_eq!(total.output_tokens, 125);
279 /// assert_eq!(total.cache_read_tokens, 100);
280 /// assert_eq!(total.cache_write_tokens, 50);
281 /// ```
282 pub fn total_usage(&self) -> Usage {
283 self.total.clone()
284 }
285
286 /// Calculates the estimated cost based on cumulative usage and model pricing.
287 ///
288 /// Uses simple multiplication: `(tokens / 1000) * price_per_1k` for each
289 /// usage category.
290 ///
291 /// # Arguments
292 ///
293 /// * `pricing` — The pricing information for the model.
294 ///
295 /// # Returns
296 ///
297 /// The estimated total cost in the currency unit of the pricing.
298 ///
299 /// # Example
300 ///
301 /// ```
302 /// use talos_agent::token::{TokenEstimator, ModelPricing};
303 /// use talos_core::message::Usage;
304 ///
305 /// let mut estimator = TokenEstimator::new();
306 /// estimator.track_usage(Usage {
307 /// input_tokens: 1000,
308 /// output_tokens: 500,
309 /// cache_read_tokens: 800,
310 /// cache_write_tokens: 200,
311 /// reasoning_tokens: 0,
312 /// });
313 ///
314 /// let pricing = ModelPricing {
315 /// input_per_1k: 0.003,
316 /// output_per_1k: 0.015,
317 /// cache_read_per_1k: 0.001,
318 /// cache_write_per_1k: 0.002,
319 /// };
320 ///
321 /// let cost = estimator.estimated_cost(&pricing);
322 /// // (1000/1000)*0.003 + (500/1000)*0.015 + (800/1000)*0.001 + (200/1000)*0.002
323 /// // = 0.003 + 0.0075 + 0.0008 + 0.0004 = 0.0117
324 /// assert!((cost - 0.0117).abs() < 0.0001);
325 /// ```
326 pub fn estimated_cost(&self, pricing: &ModelPricing) -> f64 {
327 let input_cost = (self.total.input_tokens as f64 / 1000.0) * pricing.input_per_1k;
328 let output_cost = (self.total.output_tokens as f64 / 1000.0) * pricing.output_per_1k;
329 let cache_read_cost =
330 (self.total.cache_read_tokens as f64 / 1000.0) * pricing.cache_read_per_1k;
331 let cache_write_cost =
332 (self.total.cache_write_tokens as f64 / 1000.0) * pricing.cache_write_per_1k;
333
334 input_cost + output_cost + cache_read_cost + cache_write_cost
335 }
336}
337
338#[cfg(test)]
339#[allow(warnings)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn test_estimate_text_empty() {
345 assert_eq!(TokenEstimator::estimate_text(""), 0);
346 }
347
348 #[test]
349 fn test_estimate_text_english() {
350 // "Hello, world!" = 13 ASCII chars → 13/4 = 3.25 → ceil = 4 tokens
351 let tokens = TokenEstimator::estimate_text("Hello, world!");
352 assert_eq!(tokens, 4);
353 }
354
355 #[test]
356 fn test_estimate_text_english_within_20_percent() {
357 // Longer English text: ~121 chars → ~31 tokens estimated
358 let text = "The quick brown fox jumps over the lazy dog. This is a longer sentence to test token estimation accuracy for English text.";
359 let tokens = TokenEstimator::estimate_text(text);
360 // Verify it's in a reasonable range (actual would be ~25-35)
361 assert!(
362 tokens >= 20 && tokens <= 40,
363 "English estimation should be reasonable"
364 );
365 }
366
367 #[test]
368 fn test_estimate_text_cjk() {
369 // "你好世界" = 4 non-ASCII chars → 4/2 = 2 tokens
370 let tokens = TokenEstimator::estimate_text("你好世界");
371 assert_eq!(tokens, 2);
372 }
373
374 #[test]
375 fn test_estimate_text_cjk_single_char() {
376 // Single CJK char → ceil(1/2) = 1 token
377 let tokens = TokenEstimator::estimate_text("中");
378 assert_eq!(tokens, 1);
379 }
380
381 #[test]
382 fn test_estimate_text_mixed() {
383 // "Hello你好" = 5 ASCII + 2 non-ASCII
384 // ASCII: ceil(5/4) = 2, Non-ASCII: ceil(2/2) = 1 → total = 3
385 let tokens = TokenEstimator::estimate_text("Hello你好");
386 assert_eq!(tokens, 3);
387 }
388
389 #[test]
390 fn test_estimate_text_mixed_complex() {
391 // "Hi 你好世界!" = 4 ASCII (H, i, space, !) + 4 non-ASCII (你, 好, 世, 界)
392 // ASCII: ceil(4/4) = 1, Non-ASCII: ceil(4/2) = 2 → total = 3
393 let tokens = TokenEstimator::estimate_text("Hi 你好世界!");
394 assert_eq!(tokens, 3);
395 }
396
397 #[test]
398 fn test_estimate_text_only_whitespace() {
399 // 4 spaces → ceil(4/4) = 1 token
400 let tokens = TokenEstimator::estimate_text(" ");
401 assert_eq!(tokens, 1);
402 }
403
404 #[test]
405 fn test_estimate_empty_messages() {
406 let estimator = TokenEstimator::new();
407 let messages: Vec<Message> = vec![];
408 assert_eq!(estimator.estimate(&messages), 0);
409 }
410
411 #[test]
412 fn test_estimate_user_message() {
413 let estimator = TokenEstimator::new();
414 let messages = vec![Message::User {
415 content: "Hello, world!".into(),
416 }];
417 let tokens = estimator.estimate(&messages);
418 assert_eq!(tokens, 4);
419 }
420
421 #[test]
422 fn test_estimate_assistant_message() {
423 let estimator = TokenEstimator::new();
424 let messages = vec![Message::Assistant {
425 content: "Hi there!".into(),
426 tool_calls: vec![],
427 reasoning: None,
428 }];
429 // "Hi there!" = 9 ASCII chars → ceil(9/4) = 3 tokens
430 let tokens = estimator.estimate(&messages);
431 assert_eq!(tokens, 3);
432 }
433
434 #[test]
435 fn test_estimate_tool_message() {
436 let estimator = TokenEstimator::new();
437 let messages = vec![Message::Tool {
438 result: talos_core::message::MessageToolResult {
439 tool_use_id: "call_1".into(),
440 content: "file content here".into(),
441 is_error: false,
442 },
443 }];
444 // "file content here" = 17 ASCII → ceil(17/4) = 5
445 // "call_1" = 6 ASCII → ceil(6/4) = 2
446 // total = 7
447 let tokens = estimator.estimate(&messages);
448 assert_eq!(tokens, 7);
449 }
450
451 #[test]
452 fn test_estimate_multiple_messages() {
453 let estimator = TokenEstimator::new();
454 let messages = vec![
455 Message::User {
456 content: "Hello!".into(),
457 },
458 Message::Assistant {
459 content: "Hi!".into(),
460 tool_calls: vec![],
461 reasoning: None,
462 },
463 ];
464 // "Hello!" = 6 → ceil(6/4) = 2
465 // "Hi!" = 3 → ceil(3/4) = 1
466 // total = 3
467 let tokens = estimator.estimate(&messages);
468 assert_eq!(tokens, 3);
469 }
470
471 #[test]
472 fn test_estimate_assistant_with_tool_calls() {
473 let estimator = TokenEstimator::new();
474 let messages = vec![Message::Assistant {
475 content: "Let me read that file.".into(),
476 tool_calls: vec![talos_core::message::ToolCall {
477 id: "call_1".into(),
478 name: "read_file".into(),
479 input: serde_json::json!({"path": "src/main.rs"}),
480 }],
481 reasoning: None,
482 }];
483 // Content: "Let me read that file." = 22 ASCII → ceil(22/4) = 6
484 // Tool name: "read_file" = 9 → ceil(9/4) = 3
485 // Tool input: {"path":"src/main.rs"} ≈ 23 chars → ceil(23/4) = 6
486 // Total = 6 + 3 + 6 = 15
487 let tokens = estimator.estimate(&messages);
488 assert_eq!(tokens, 15);
489 }
490
491 #[test]
492 fn test_track_usage_single_turn() {
493 let mut estimator = TokenEstimator::new();
494 estimator.track_usage(Usage {
495 input_tokens: 100,
496 output_tokens: 50,
497 cache_read_tokens: 80,
498 cache_write_tokens: 20,
499 reasoning_tokens: 0,
500 });
501
502 let total = estimator.total_usage();
503 assert_eq!(total.input_tokens, 100);
504 assert_eq!(total.output_tokens, 50);
505 assert_eq!(total.cache_read_tokens, 80);
506 assert_eq!(total.cache_write_tokens, 20);
507 }
508
509 #[test]
510 fn test_track_usage_cumulative() {
511 let mut estimator = TokenEstimator::new();
512
513 estimator.track_usage(Usage {
514 input_tokens: 100,
515 output_tokens: 50,
516 cache_read_tokens: 0,
517 cache_write_tokens: 0,
518 reasoning_tokens: 0,
519 });
520
521 estimator.track_usage(Usage {
522 input_tokens: 200,
523 output_tokens: 75,
524 cache_read_tokens: 100,
525 cache_write_tokens: 50,
526 reasoning_tokens: 0,
527 });
528
529 estimator.track_usage(Usage {
530 input_tokens: 50,
531 output_tokens: 25,
532 cache_read_tokens: 30,
533 cache_write_tokens: 10,
534 reasoning_tokens: 0,
535 });
536
537 let total = estimator.total_usage();
538 assert_eq!(total.input_tokens, 350);
539 assert_eq!(total.output_tokens, 150);
540 assert_eq!(total.cache_read_tokens, 130);
541 assert_eq!(total.cache_write_tokens, 60);
542 }
543
544 #[test]
545 fn test_total_usage_initial_is_zero() {
546 let estimator = TokenEstimator::new();
547 let total = estimator.total_usage();
548 assert_eq!(total.input_tokens, 0);
549 assert_eq!(total.output_tokens, 0);
550 assert_eq!(total.cache_read_tokens, 0);
551 assert_eq!(total.cache_write_tokens, 0);
552 }
553
554 #[test]
555 fn test_estimated_cost_zero_usage() {
556 let estimator = TokenEstimator::new();
557 let pricing = ModelPricing {
558 input_per_1k: 0.003,
559 output_per_1k: 0.015,
560 cache_read_per_1k: 0.001,
561 cache_write_per_1k: 0.002,
562 };
563
564 let cost = estimator.estimated_cost(&pricing);
565 assert!((cost - 0.0).abs() < f64::EPSILON);
566 }
567
568 #[test]
569 fn test_estimated_cost_simple() {
570 let mut estimator = TokenEstimator::new();
571 estimator.track_usage(Usage {
572 input_tokens: 1000,
573 output_tokens: 500,
574 cache_read_tokens: 0,
575 cache_write_tokens: 0,
576 reasoning_tokens: 0,
577 });
578
579 let pricing = ModelPricing {
580 input_per_1k: 0.003,
581 output_per_1k: 0.015,
582 cache_read_per_1k: 0.001,
583 cache_write_per_1k: 0.002,
584 };
585
586 let cost = estimator.estimated_cost(&pricing);
587 // (1000/1000)*0.003 + (500/1000)*0.015 = 0.003 + 0.0075 = 0.0105
588 assert!((cost - 0.0105).abs() < 0.0001);
589 }
590
591 #[test]
592 fn test_estimated_cost_with_cache() {
593 let mut estimator = TokenEstimator::new();
594 estimator.track_usage(Usage {
595 input_tokens: 1000,
596 output_tokens: 500,
597 cache_read_tokens: 800,
598 cache_write_tokens: 200,
599 reasoning_tokens: 0,
600 });
601
602 let pricing = ModelPricing {
603 input_per_1k: 0.003,
604 output_per_1k: 0.015,
605 cache_read_per_1k: 0.001,
606 cache_write_per_1k: 0.002,
607 };
608
609 let cost = estimator.estimated_cost(&pricing);
610 // (1000/1000)*0.003 + (500/1000)*0.015 + (800/1000)*0.001 + (200/1000)*0.002
611 // = 0.003 + 0.0075 + 0.0008 + 0.0004 = 0.0117
612 assert!((cost - 0.0117).abs() < 0.0001);
613 }
614
615 #[test]
616 fn test_estimated_cost_claude_sonnet_pricing() {
617 // Real-world pricing example: Claude Sonnet 4
618 let mut estimator = TokenEstimator::new();
619 estimator.track_usage(Usage {
620 input_tokens: 50_000,
621 output_tokens: 10_000,
622 cache_read_tokens: 40_000,
623 cache_write_tokens: 10_000,
624 reasoning_tokens: 0,
625 });
626
627 let pricing = ModelPricing {
628 input_per_1k: 0.003,
629 output_per_1k: 0.015,
630 cache_read_per_1k: 0.0003,
631 cache_write_per_1k: 0.00375,
632 };
633
634 let cost = estimator.estimated_cost(&pricing);
635 // (50000/1000)*0.003 + (10000/1000)*0.015 + (40000/1000)*0.0003 + (10000/1000)*0.00375
636 // = 0.15 + 0.15 + 0.012 + 0.0375 = 0.3495
637 assert!((cost - 0.3495).abs() < 0.0001);
638 }
639
640 #[test]
641 fn test_model_pricing_copy() {
642 let pricing = ModelPricing {
643 input_per_1k: 0.003,
644 output_per_1k: 0.015,
645 cache_read_per_1k: 0.001,
646 cache_write_per_1k: 0.002,
647 };
648
649 let pricing2 = pricing; // Copy, not move
650 assert!((pricing.input_per_1k - pricing2.input_per_1k).abs() < f64::EPSILON);
651 }
652
653 #[test]
654 fn test_model_pricing_debug() {
655 let pricing = ModelPricing {
656 input_per_1k: 0.003,
657 output_per_1k: 0.015,
658 cache_read_per_1k: 0.001,
659 cache_write_per_1k: 0.002,
660 };
661
662 let debug_str = format!("{:?}", pricing);
663 assert!(debug_str.contains("input_per_1k"));
664 assert!(debug_str.contains("0.003"));
665 }
666}