use crate::types::{ContentBlock, Message, MessagesRequest, SystemPrompt, Tool};
use tiktoken_rs::cl100k_base;
pub struct TokenCounter {
bpe: tiktoken_rs::CoreBPE,
}
impl TokenCounter {
pub fn new() -> Self {
Self {
bpe: cl100k_base().expect("Failed to load cl100k_base tokenizer"),
}
}
pub fn count_text(&self, text: &str) -> usize {
self.bpe.encode_with_special_tokens(text).len()
}
pub fn count_message(&self, message: &Message) -> usize {
let mut total = 3;
total += 1;
for content in &message.content {
total += self.count_content_block(content);
}
total
}
pub fn count_content_block(&self, content: &ContentBlock) -> usize {
match content {
ContentBlock::Text { text, .. } => {
let mut total = 2; total += self.count_text(text);
total
}
ContentBlock::Image { .. } => {
85
}
ContentBlock::Document { title, context, .. } => {
let mut total = 10;
if let Some(t) = title {
total += self.count_text(t);
}
if let Some(c) = context {
total += self.count_text(c);
}
total
}
ContentBlock::ToolUse { name, input, .. } => {
let mut total = 4; total += self.count_text(name);
total += self.count_text(&input.to_string());
total
}
ContentBlock::ToolResult {
tool_use_id,
content,
..
} => {
let mut total = 4; total += self.count_text(tool_use_id);
if let Some(text) = content {
total += self.count_text(text);
}
total
}
ContentBlock::Thinking { thinking, .. } => {
self.count_text(thinking)
}
ContentBlock::RedactedThinking { .. } => {
100 }
ContentBlock::SearchResult {
source,
title,
content,
..
} => {
let mut total = 10; total += self.count_text(source);
total += self.count_text(title);
for text_block in content {
total += self.count_text(&text_block.text);
}
total
}
ContentBlock::Unknown { .. } => 0,
}
}
pub fn count_system_prompt(&self, system: &SystemPrompt) -> usize {
match system {
SystemPrompt::String(s) => self.count_text(s),
SystemPrompt::Blocks(blocks) => blocks
.iter()
.map(|block| self.count_text(&block.text))
.sum(),
}
}
pub fn count_tool(&self, tool: &Tool) -> usize {
let mut total = 10;
total += self.count_text(&tool.name);
total += self.count_text(&tool.description);
total += self.count_text(&tool.input_schema.to_string());
total
}
pub fn count_request(&self, request: &MessagesRequest) -> usize {
let mut total = 0;
if let Some(system) = &request.system {
total += self.count_system_prompt(system);
}
for message in &request.messages {
total += self.count_message(message);
}
if let Some(tools) = &request.tools {
for tool in tools {
total += self.count_tool(tool);
}
}
total += 10;
total
}
pub fn validate_context_window(
&self,
request: &MessagesRequest,
model: &crate::models::Model,
use_extended_context: bool,
) -> Result<(), String> {
let input_tokens = self.count_request(request);
let max_output = request.max_tokens as usize;
let total_tokens = input_tokens + max_output;
let context_limit = if use_extended_context {
model
.max_extended_context()
.unwrap_or(model.max_context_tokens) as usize
} else {
model.max_context_tokens as usize
};
if total_tokens > context_limit {
return Err(format!(
"Request would use ~{} tokens (input: {}, output: {}) but model {} has {} token limit{}",
total_tokens,
input_tokens,
max_output,
model.name,
context_limit,
if use_extended_context { " (extended)" } else { "" }
));
}
Ok(())
}
}
impl Default for TokenCounter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Message, MessagesRequest};
use serde_json::json;
#[test]
fn test_count_text() {
let counter = TokenCounter::new();
let tokens = counter.count_text("Hello, world!");
assert!(tokens > 0);
assert!(tokens < 10);
assert_eq!(counter.count_text(""), 0);
let short = counter.count_text("Hi");
let long = counter.count_text("This is a much longer sentence with more words");
assert!(long > short);
}
#[test]
fn test_count_message() {
let counter = TokenCounter::new();
let msg = Message::user("Hello, Claude!");
let tokens = counter.count_message(&msg);
assert!(tokens > 5); }
#[test]
fn test_count_system_prompt() {
let counter = TokenCounter::new();
let system = SystemPrompt::String("You are a helpful assistant".into());
let tokens = counter.count_system_prompt(&system);
assert!(tokens > 0);
}
#[test]
fn test_count_tool() {
let counter = TokenCounter::new();
let tool = Tool {
name: "get_weather".into(),
description: "Get the current weather".into(),
input_schema: json!({"type": "object"}),
disable_user_input: None,
input_examples: None,
cache_control: None,
};
let tokens = counter.count_tool(&tool);
assert!(tokens > 10); }
#[test]
fn test_count_request() {
let counter = TokenCounter::new();
let request = MessagesRequest::new(
"claude-sonnet-4-5-20250929",
1024,
vec![Message::user("Hello!")],
)
.with_system("You are helpful");
let tokens = counter.count_request(&request);
assert!(tokens > 0);
assert!(tokens < 100); }
#[test]
fn test_validate_context_window() {
let counter = TokenCounter::new();
let model = &crate::models::CLAUDE_SONNET_4_5;
let small_request =
MessagesRequest::new(model.anthropic_id, 1024, vec![Message::user("Hello!")]);
assert!(counter
.validate_context_window(&small_request, model, false)
.is_ok());
let huge_request = MessagesRequest::new(
model.anthropic_id,
200_000, vec![Message::user("Hello!")],
);
assert!(counter
.validate_context_window(&huge_request, model, false)
.is_err());
}
#[test]
fn test_validate_extended_context() {
let counter = TokenCounter::new();
let model = &crate::models::CLAUDE_SONNET_4_5;
let request =
MessagesRequest::new(model.anthropic_id, 201_000, vec![Message::user("Hello!")]);
assert!(
counter
.validate_context_window(&request, model, false)
.is_err(),
"Should fail with standard 200K context"
);
assert!(
counter
.validate_context_window(&request, model, true)
.is_ok(),
"Should pass with extended 1M context"
);
}
}