use talos_core::message::{Message, Usage};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ModelPricing {
pub input_per_1k: f64,
pub output_per_1k: f64,
pub cache_read_per_1k: f64,
pub cache_write_per_1k: f64,
}
#[derive(Debug, Clone, Default)]
pub struct TokenEstimator {
total: Usage,
}
impl TokenEstimator {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn estimate(&self, messages: &[Message]) -> u32 {
messages.iter().fold(0_u32, |total, msg| {
let message_tokens = match msg {
Message::User { content } => Self::estimate_text(content),
Message::System { content, .. } => Self::estimate_text(content),
Message::Context { content } => Self::estimate_text(content),
Message::Assistant {
content,
tool_calls,
..
} => tool_calls
.iter()
.fold(Self::estimate_text(content), |tokens, call| {
tokens
.saturating_add(Self::estimate_text(&call.name))
.saturating_add(Self::estimate_text(&call.input.to_string()))
}),
Message::Tool { result } => Self::estimate_text(&result.content)
.saturating_add(Self::estimate_text(&result.tool_use_id)),
Message::Multimodal { parts } => parts.iter().fold(0_u32, |tokens, part| {
let part_tokens = match part {
talos_core::message::ContentPart::Text { text } => {
Self::estimate_text(text)
}
talos_core::message::ContentPart::Image {
mime, byte_count, ..
} => {
let declared = byte_count.div_ceil(3);
Self::estimate_text(mime)
.saturating_add(u32::try_from(declared).unwrap_or(u32::MAX))
.saturating_add(1024)
}
};
tokens.saturating_add(part_tokens)
}),
};
total.saturating_add(message_tokens)
})
}
pub fn estimate_text(text: &str) -> u32 {
if text.is_empty() {
return 0;
}
let mut ascii_chars: u32 = 0;
let mut non_ascii_chars: u32 = 0;
for ch in text.chars() {
if ch.is_ascii() {
ascii_chars += 1;
} else {
non_ascii_chars += 1;
}
}
let ascii_tokens = ascii_chars.div_ceil(4);
let non_ascii_tokens = non_ascii_chars.div_ceil(2);
ascii_tokens + non_ascii_tokens
}
pub fn track_usage(&mut self, turn_usage: Usage) {
self.total.input_tokens += turn_usage.input_tokens;
self.total.output_tokens += turn_usage.output_tokens;
self.total.cache_read_tokens += turn_usage.cache_read_tokens;
self.total.cache_write_tokens += turn_usage.cache_write_tokens;
self.total.reasoning_tokens += turn_usage.reasoning_tokens;
}
pub fn total_usage(&self) -> Usage {
self.total.clone()
}
pub fn estimated_cost(&self, pricing: &ModelPricing) -> f64 {
let input_cost = (self.total.input_tokens as f64 / 1000.0) * pricing.input_per_1k;
let output_cost = (self.total.output_tokens as f64 / 1000.0) * pricing.output_per_1k;
let cache_read_cost =
(self.total.cache_read_tokens as f64 / 1000.0) * pricing.cache_read_per_1k;
let cache_write_cost =
(self.total.cache_write_tokens as f64 / 1000.0) * pricing.cache_write_per_1k;
input_cost + output_cost + cache_read_cost + cache_write_cost
}
}
#[cfg(test)]
#[allow(warnings)]
mod tests {
use super::*;
#[test]
fn test_estimate_text_empty() {
assert_eq!(TokenEstimator::estimate_text(""), 0);
}
#[test]
fn test_estimate_text_english() {
let tokens = TokenEstimator::estimate_text("Hello, world!");
assert_eq!(tokens, 4);
}
#[test]
fn test_estimate_text_english_within_20_percent() {
let text = "The quick brown fox jumps over the lazy dog. This is a longer sentence to test token estimation accuracy for English text.";
let tokens = TokenEstimator::estimate_text(text);
assert!(
tokens >= 20 && tokens <= 40,
"English estimation should be reasonable"
);
}
#[test]
fn test_estimate_text_cjk() {
let tokens = TokenEstimator::estimate_text("你好世界");
assert_eq!(tokens, 2);
}
#[test]
fn test_estimate_text_cjk_single_char() {
let tokens = TokenEstimator::estimate_text("中");
assert_eq!(tokens, 1);
}
#[test]
fn test_estimate_text_mixed() {
let tokens = TokenEstimator::estimate_text("Hello你好");
assert_eq!(tokens, 3);
}
#[test]
fn test_estimate_text_mixed_complex() {
let tokens = TokenEstimator::estimate_text("Hi 你好世界!");
assert_eq!(tokens, 3);
}
#[test]
fn test_estimate_text_only_whitespace() {
let tokens = TokenEstimator::estimate_text(" ");
assert_eq!(tokens, 1);
}
#[test]
fn test_estimate_empty_messages() {
let estimator = TokenEstimator::new();
let messages: Vec<Message> = vec![];
assert_eq!(estimator.estimate(&messages), 0);
}
#[test]
fn test_estimate_user_message() {
let estimator = TokenEstimator::new();
let messages = vec![Message::User {
content: "Hello, world!".into(),
}];
let tokens = estimator.estimate(&messages);
assert_eq!(tokens, 4);
}
#[test]
fn test_estimate_assistant_message() {
let estimator = TokenEstimator::new();
let messages = vec![Message::Assistant {
content: "Hi there!".into(),
tool_calls: vec![],
reasoning: None,
}];
let tokens = estimator.estimate(&messages);
assert_eq!(tokens, 3);
}
#[test]
fn test_estimate_tool_message() {
let estimator = TokenEstimator::new();
let messages = vec![Message::Tool {
result: talos_core::message::MessageToolResult {
tool_use_id: "call_1".into(),
content: "file content here".into(),
is_error: false,
},
}];
let tokens = estimator.estimate(&messages);
assert_eq!(tokens, 7);
}
#[test]
fn test_estimate_multiple_messages() {
let estimator = TokenEstimator::new();
let messages = vec![
Message::User {
content: "Hello!".into(),
},
Message::Assistant {
content: "Hi!".into(),
tool_calls: vec![],
reasoning: None,
},
];
let tokens = estimator.estimate(&messages);
assert_eq!(tokens, 3);
}
#[test]
fn test_estimate_assistant_with_tool_calls() {
let estimator = TokenEstimator::new();
let messages = vec![Message::Assistant {
content: "Let me read that file.".into(),
tool_calls: vec![talos_core::message::ToolCall {
id: "call_1".into(),
name: "read_file".into(),
input: serde_json::json!({"path": "src/main.rs"}),
}],
reasoning: None,
}];
let tokens = estimator.estimate(&messages);
assert_eq!(tokens, 15);
}
#[test]
fn test_track_usage_single_turn() {
let mut estimator = TokenEstimator::new();
estimator.track_usage(Usage {
input_tokens: 100,
output_tokens: 50,
cache_read_tokens: 80,
cache_write_tokens: 20,
reasoning_tokens: 0,
});
let total = estimator.total_usage();
assert_eq!(total.input_tokens, 100);
assert_eq!(total.output_tokens, 50);
assert_eq!(total.cache_read_tokens, 80);
assert_eq!(total.cache_write_tokens, 20);
}
#[test]
fn test_track_usage_cumulative() {
let mut estimator = TokenEstimator::new();
estimator.track_usage(Usage {
input_tokens: 100,
output_tokens: 50,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
});
estimator.track_usage(Usage {
input_tokens: 200,
output_tokens: 75,
cache_read_tokens: 100,
cache_write_tokens: 50,
reasoning_tokens: 0,
});
estimator.track_usage(Usage {
input_tokens: 50,
output_tokens: 25,
cache_read_tokens: 30,
cache_write_tokens: 10,
reasoning_tokens: 0,
});
let total = estimator.total_usage();
assert_eq!(total.input_tokens, 350);
assert_eq!(total.output_tokens, 150);
assert_eq!(total.cache_read_tokens, 130);
assert_eq!(total.cache_write_tokens, 60);
}
#[test]
fn test_total_usage_initial_is_zero() {
let estimator = TokenEstimator::new();
let total = estimator.total_usage();
assert_eq!(total.input_tokens, 0);
assert_eq!(total.output_tokens, 0);
assert_eq!(total.cache_read_tokens, 0);
assert_eq!(total.cache_write_tokens, 0);
}
#[test]
fn test_estimated_cost_zero_usage() {
let estimator = TokenEstimator::new();
let pricing = ModelPricing {
input_per_1k: 0.003,
output_per_1k: 0.015,
cache_read_per_1k: 0.001,
cache_write_per_1k: 0.002,
};
let cost = estimator.estimated_cost(&pricing);
assert!((cost - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_estimated_cost_simple() {
let mut estimator = TokenEstimator::new();
estimator.track_usage(Usage {
input_tokens: 1000,
output_tokens: 500,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
});
let pricing = ModelPricing {
input_per_1k: 0.003,
output_per_1k: 0.015,
cache_read_per_1k: 0.001,
cache_write_per_1k: 0.002,
};
let cost = estimator.estimated_cost(&pricing);
assert!((cost - 0.0105).abs() < 0.0001);
}
#[test]
fn test_estimated_cost_with_cache() {
let mut estimator = TokenEstimator::new();
estimator.track_usage(Usage {
input_tokens: 1000,
output_tokens: 500,
cache_read_tokens: 800,
cache_write_tokens: 200,
reasoning_tokens: 0,
});
let pricing = ModelPricing {
input_per_1k: 0.003,
output_per_1k: 0.015,
cache_read_per_1k: 0.001,
cache_write_per_1k: 0.002,
};
let cost = estimator.estimated_cost(&pricing);
assert!((cost - 0.0117).abs() < 0.0001);
}
#[test]
fn test_estimated_cost_claude_sonnet_pricing() {
let mut estimator = TokenEstimator::new();
estimator.track_usage(Usage {
input_tokens: 50_000,
output_tokens: 10_000,
cache_read_tokens: 40_000,
cache_write_tokens: 10_000,
reasoning_tokens: 0,
});
let pricing = ModelPricing {
input_per_1k: 0.003,
output_per_1k: 0.015,
cache_read_per_1k: 0.0003,
cache_write_per_1k: 0.00375,
};
let cost = estimator.estimated_cost(&pricing);
assert!((cost - 0.3495).abs() < 0.0001);
}
#[test]
fn test_model_pricing_copy() {
let pricing = ModelPricing {
input_per_1k: 0.003,
output_per_1k: 0.015,
cache_read_per_1k: 0.001,
cache_write_per_1k: 0.002,
};
let pricing2 = pricing; assert!((pricing.input_per_1k - pricing2.input_per_1k).abs() < f64::EPSILON);
}
#[test]
fn test_model_pricing_debug() {
let pricing = ModelPricing {
input_per_1k: 0.003,
output_per_1k: 0.015,
cache_read_per_1k: 0.001,
cache_write_per_1k: 0.002,
};
let debug_str = format!("{:?}", pricing);
assert!(debug_str.contains("input_per_1k"));
assert!(debug_str.contains("0.003"));
}
}