use crate::llm::{Content, ContentBlock, Message};
pub struct TokenEstimator;
impl TokenEstimator {
const CHARS_PER_TOKEN: usize = 4;
const MESSAGE_OVERHEAD: usize = 4;
const TOOL_USE_OVERHEAD: usize = 20;
const TOOL_RESULT_OVERHEAD: usize = 10;
const REDACTED_THINKING_MIN_TOKENS: usize = 512;
#[must_use]
pub const fn estimate_text(text: &str) -> usize {
text.len().div_ceil(Self::CHARS_PER_TOKEN)
}
#[must_use]
pub fn estimate_message(message: &Message) -> usize {
let content_tokens = match &message.content {
Content::Text(text) => Self::estimate_text(text),
Content::Blocks(blocks) => blocks.iter().map(Self::estimate_block).sum(),
};
content_tokens + Self::MESSAGE_OVERHEAD
}
#[must_use]
pub fn estimate_block(block: &ContentBlock) -> usize {
match block {
ContentBlock::Text { text } => Self::estimate_text(text),
ContentBlock::Thinking { thinking, .. } => Self::estimate_text(thinking),
ContentBlock::RedactedThinking { data } => {
let raw_bytes = data.len() * 3 / 4;
let estimated = raw_bytes.div_ceil(Self::CHARS_PER_TOKEN);
estimated.max(Self::REDACTED_THINKING_MIN_TOKENS)
}
ContentBlock::ToolUse { name, input, .. } => {
let input_str = serde_json::to_string(input).unwrap_or_default();
Self::estimate_text(name)
+ Self::estimate_text(&input_str)
+ Self::TOOL_USE_OVERHEAD
}
ContentBlock::ToolResult { content, .. } => {
Self::estimate_text(content) + Self::TOOL_RESULT_OVERHEAD
}
ContentBlock::Image { source } | ContentBlock::Document { source } => {
source.data.len() / 4 + Self::MESSAGE_OVERHEAD
}
}
}
#[must_use]
pub fn estimate_history(messages: &[Message]) -> usize {
messages.iter().map(Self::estimate_message).sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::Role;
use serde_json::json;
#[test]
fn test_estimate_text() {
assert_eq!(TokenEstimator::estimate_text(""), 0);
assert_eq!(TokenEstimator::estimate_text("hi"), 1);
assert_eq!(TokenEstimator::estimate_text("test"), 1);
assert_eq!(TokenEstimator::estimate_text("hello"), 2);
assert_eq!(TokenEstimator::estimate_text("hello world!"), 3); }
#[test]
fn test_estimate_text_message() {
let message = Message {
role: Role::User,
content: Content::Text("Hello, how are you?".to_string()), };
let estimate = TokenEstimator::estimate_message(&message);
assert_eq!(estimate, 9);
}
#[test]
fn test_estimate_blocks_message() {
let message = Message {
role: Role::Assistant,
content: Content::Blocks(vec![
ContentBlock::Text {
text: "Let me help.".to_string(), },
ContentBlock::ToolUse {
id: "tool_123".to_string(),
name: "read".to_string(), input: json!({"path": "/test.txt"}), thought_signature: None,
},
]),
};
let estimate = TokenEstimator::estimate_message(&message);
assert!(estimate > 25); }
#[test]
fn test_estimate_tool_result() {
let message = Message {
role: Role::User,
content: Content::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "tool_123".to_string(),
content: "File contents here...".to_string(), is_error: None,
}]),
};
let estimate = TokenEstimator::estimate_message(&message);
assert_eq!(estimate, 20);
}
#[test]
fn test_estimate_history() {
let messages = vec![
Message::user("Hello"), Message::assistant("Hi there!"), Message::user("How are you?"), ];
let estimate = TokenEstimator::estimate_history(&messages);
assert_eq!(estimate, 20);
}
#[test]
fn test_empty_history() {
let messages: Vec<Message> = vec![];
assert_eq!(TokenEstimator::estimate_history(&messages), 0);
}
#[test]
fn test_estimate_redacted_thinking_uses_data_length() {
let data = "A".repeat(8192);
let block = ContentBlock::RedactedThinking { data };
let estimate = TokenEstimator::estimate_block(&block);
assert_eq!(estimate, 1536);
}
#[test]
fn test_estimate_redacted_thinking_respects_minimum() {
let data = "A".repeat(100);
let block = ContentBlock::RedactedThinking { data };
let estimate = TokenEstimator::estimate_block(&block);
assert_eq!(estimate, TokenEstimator::REDACTED_THINKING_MIN_TOKENS);
}
#[test]
fn test_estimate_redacted_thinking_empty_data() {
let block = ContentBlock::RedactedThinking {
data: String::new(),
};
let estimate = TokenEstimator::estimate_block(&block);
assert_eq!(estimate, TokenEstimator::REDACTED_THINKING_MIN_TOKENS);
}
#[test]
fn test_redacted_thinking_accumulates_in_history() {
let blocks: Vec<ContentBlock> = (0..5)
.map(|_| ContentBlock::RedactedThinking {
data: "B".repeat(10_000), })
.collect();
let message = Message {
role: Role::Assistant,
content: Content::Blocks(blocks),
};
let estimate = TokenEstimator::estimate_message(&message);
assert_eq!(estimate, 9379);
}
}