Skip to main content

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}