Files
tkmind_go/crates/goose/src/token_counter.rs
T

356 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 doesnt exist, well 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<Tokenizer, Box<dyn Error>> {
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<Self, Box<dyn Error>> {
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<dyn Error>> {
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::<dyn Error>::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");
}
}