use include_dir::{include_dir, Dir}; use mcp_core::Tool; use std::error::Error; use std::fs; use std::path::Path; use tokenizers::tokenizer::Tokenizer; use crate::message::Message; // The embedded directory with all possible tokenizer files. // If one of them doesn’t exist, we’ll download it at startup. static TOKENIZER_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../tokenizer_files"); /// The `TokenCounter` now stores exactly one `Tokenizer`. pub struct TokenCounter { tokenizer: Tokenizer, } impl TokenCounter { /// Creates a new `TokenCounter` using the given HuggingFace tokenizer name. /// /// * `tokenizer_name` might look like "Xenova--gpt-4o" /// or "Qwen--Qwen2.5-Coder-32B-Instruct", etc. pub fn new(tokenizer_name: &str) -> Self { match Self::load_from_embedded(tokenizer_name) { Ok(tokenizer) => Self { tokenizer }, Err(e) => { println!( "Tokenizer '{}' not found in embedded dir: {}", tokenizer_name, e ); println!("Attempting to download tokenizer and load..."); // Fallback to download tokenizer and load from disk match Self::download_and_load(tokenizer_name) { Ok(counter) => counter, Err(e) => panic!("Failed to initialize tokenizer: {}", e), } } } } /// Load tokenizer bytes from the embedded directory (via `include_dir!`). fn load_from_embedded(tokenizer_name: &str) -> Result> { let tokenizer_file_path = format!("{}/tokenizer.json", tokenizer_name); let file = TOKENIZER_FILES .get_file(&tokenizer_file_path) .ok_or_else(|| { format!( "Tokenizer file not found in embedded: {}", tokenizer_file_path ) })?; let contents = file.contents(); let tokenizer = Tokenizer::from_bytes(contents) .map_err(|e| format!("Failed to parse tokenizer bytes: {}", e))?; Ok(tokenizer) } /// Fallback: If not found in embedded, we look in `base_dir` on disk. /// If not on disk, we download from Hugging Face, then load from disk. fn download_and_load(tokenizer_name: &str) -> Result> { let local_dir = std::env::temp_dir().join(tokenizer_name); let local_json_path = local_dir.join("tokenizer.json"); // If the file doesn't already exist, we download from HF if !Path::new(&local_json_path).exists() { eprintln!("Tokenizer file not on disk, downloading…"); let repo_id = tokenizer_name.replace("--", "/"); // e.g. "Xenova--llama3-tokenizer" -> "Xenova/llama3-tokenizer" Self::download_tokenizer(&repo_id, &local_dir)?; } // Load from disk let file_content = fs::read(&local_json_path)?; let tokenizer = Tokenizer::from_bytes(&file_content) .map_err(|e| format!("Failed to parse tokenizer after download: {}", e))?; Ok(Self { tokenizer }) } /// Download from Hugging Face into the local directory if not already present. /// Synchronous version using a blocking runtime for simplicity. fn download_tokenizer(repo_id: &str, download_dir: &Path) -> Result<(), Box> { fs::create_dir_all(download_dir)?; let file_url = format!( "https://huggingface.co/{}/resolve/main/tokenizer.json", repo_id ); let file_path = download_dir.join("tokenizer.json"); // Blocking for example: just spawn a short-lived runtime let content = tokio::runtime::Runtime::new()?.block_on(async { let response = reqwest::get(&file_url).await?; if !response.status().is_success() { let error_msg = format!("Failed to download tokenizer: status {}", response.status()); return Err(Box::::from(error_msg)); } let bytes = response.bytes().await?; Ok(bytes) })?; fs::write(&file_path, content)?; Ok(()) } /// Count tokens for a piece of text using our single tokenizer. pub fn count_tokens(&self, text: &str) -> usize { let encoding = self.tokenizer.encode(text, false).unwrap(); encoding.len() } pub fn count_tokens_for_tools(&self, tools: &[Tool]) -> usize { // Token counts for different function components let func_init = 7; // Tokens for function initialization let prop_init = 3; // Tokens for properties initialization let prop_key = 3; // Tokens for each property key let enum_init: isize = -3; // Tokens adjustment for enum list start let enum_item = 3; // Tokens for each enum item let func_end = 12; // Tokens for function ending let mut func_token_count = 0; if !tools.is_empty() { for tool in tools { func_token_count += func_init; // Add tokens for start of each function let name = &tool.name; let description = &tool.description.trim_end_matches('.'); let line = format!("{}:{}", name, description); func_token_count += self.count_tokens(&line); // Add tokens for name and description if let serde_json::Value::Object(properties) = &tool.input_schema["properties"] { if !properties.is_empty() { func_token_count += prop_init; // Add tokens for start of properties for (key, value) in properties { func_token_count += prop_key; // Add tokens for each property let p_name = key; let p_type = value["type"].as_str().unwrap_or(""); let p_desc = value["description"] .as_str() .unwrap_or("") .trim_end_matches('.'); let line = format!("{}:{}:{}", p_name, p_type, p_desc); func_token_count += self.count_tokens(&line); if let Some(enum_values) = value["enum"].as_array() { func_token_count = func_token_count.saturating_add_signed(enum_init); // Add tokens if property has enum list for item in enum_values { if let Some(item_str) = item.as_str() { func_token_count += enum_item; func_token_count += self.count_tokens(item_str); } } } } } } } func_token_count += func_end; } func_token_count } pub fn count_chat_tokens( &self, system_prompt: &str, messages: &[Message], tools: &[Tool], ) -> usize { // <|im_start|>ROLE<|im_sep|>MESSAGE<|im_end|> let tokens_per_message = 4; // Count tokens in the system prompt let mut num_tokens = 0; if !system_prompt.is_empty() { num_tokens += self.count_tokens(system_prompt) + tokens_per_message; } for message in messages { num_tokens += tokens_per_message; // Count tokens in the content for content in &message.content { // content can either be text response or tool request if let Some(content_text) = content.as_text() { num_tokens += self.count_tokens(content_text); } else if let Some(tool_request) = content.as_tool_request() { // TODO: count tokens for tool request let tool_call = tool_request.tool_call.as_ref().unwrap(); let text = format!( "{}:{}:{}", tool_request.id, tool_call.name, tool_call.arguments ); num_tokens += self.count_tokens(&text); } else if let Some(tool_response_text) = content.as_tool_response_text() { num_tokens += self.count_tokens(&tool_response_text); } else { // unsupported content type such as image - pass continue; } } } // Count tokens for tools if provided if !tools.is_empty() { num_tokens += self.count_tokens_for_tools(tools); } // Every reply is primed with <|start|>assistant<|message|> num_tokens += 3; num_tokens } pub fn count_everything( &self, system_prompt: &str, messages: &[Message], tools: &[Tool], resources: &[String], ) -> usize { let mut num_tokens = self.count_chat_tokens(system_prompt, messages, tools); if !resources.is_empty() { for resource in resources { num_tokens += self.count_tokens(resource); } } num_tokens } } #[cfg(test)] mod tests { use super::*; use crate::message::{Message, MessageContent}; // or however your `Message` is imported use crate::model::{CLAUDE_TOKENIZER, GPT_4O_TOKENIZER}; use mcp_core::role::Role; use mcp_core::tool::Tool; use serde_json::json; #[test] fn test_claude_tokenizer() { let counter = TokenCounter::new(CLAUDE_TOKENIZER); let text = "Hello, how are you?"; let count = counter.count_tokens(text); println!("Token count for '{}': {:?}", text, count); // The old test expected 6 tokens assert_eq!(count, 6, "Claude tokenizer token count mismatch"); } #[test] fn test_gpt_4o_tokenizer() { let counter = TokenCounter::new(GPT_4O_TOKENIZER); let text = "Hey there!"; let count = counter.count_tokens(text); println!("Token count for '{}': {:?}", text, count); // The old test expected 3 tokens assert_eq!(count, 3, "GPT-4o tokenizer token count mismatch"); } #[test] fn test_count_chat_tokens() { let counter = TokenCounter::new(GPT_4O_TOKENIZER); let system_prompt = "You are a helpful assistant that can answer questions about the weather."; let messages = vec![ Message { role: Role::User, created: 0, content: vec![MessageContent::text( "What's the weather like in San Francisco?", )], }, Message { role: Role::Assistant, created: 1, content: vec![MessageContent::text( "Looks like it's 60 degrees Fahrenheit in San Francisco.", )], }, Message { role: Role::User, created: 2, content: vec![MessageContent::text("How about New York?")], }, ]; let tools = vec![Tool { name: "get_current_weather".to_string(), description: "Get the current weather in a given location".to_string(), input_schema: json!({ "properties": { "location": { "type": "string", "description": "The city and state, e.g. San Francisco, CA" }, "unit": { "type": "string", "description": "The unit of temperature to return", "enum": ["celsius", "fahrenheit"] } }, "required": ["location"] }), annotations: None, }]; let token_count_without_tools = counter.count_chat_tokens(system_prompt, &messages, &[]); println!("Total tokens without tools: {}", token_count_without_tools); let token_count_with_tools = counter.count_chat_tokens(system_prompt, &messages, &tools); println!("Total tokens with tools: {}", token_count_with_tools); // The old test used 56 / 124 for GPT-4o. Adjust if your actual tokenizer changes assert_eq!(token_count_without_tools, 56); assert_eq!(token_count_with_tools, 124); } #[test] #[should_panic] fn test_panic_if_provided_tokenizer_doesnt_exist() { // This should panic because the tokenizer doesn't exist // in the embedded directory and the download fails TokenCounter::new("nonexistent-tokenizer"); } // Optional test to confirm that fallback download works if not found in embedded: // Ignored cause this actually downloads a tokenizer from Hugging Face #[test] #[ignore] fn test_download_tokenizer_successfully_if_not_embedded() { let non_embedded_key = "openai-community/gpt2"; let counter = TokenCounter::new(non_embedded_key); // If it downloads successfully, we can do a quick count to ensure it's valid let text = "print('hello world')"; let count = counter.count_tokens(text); println!( "Downloaded tokenizer, token count for '{}': {}", text, count ); // https://tiktokenizer.vercel.app/?model=gpt2 assert!(count == 5, "Expected 5 tokens from downloaded tokenizer"); } }