use hotl_types::Item;
pub const ITEM_OVERHEAD: u64 = 8;
pub const RESULT_OVERHEAD: u64 = 6;
pub const BLOCK_OVERHEAD: u64 = 4;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TokenProfile {
pub ascii_chars_per_token: f32,
pub bmp_weight: f32,
pub astral_weight: f32,
}
impl TokenProfile {
pub const CONSERVATIVE: TokenProfile = TokenProfile {
ascii_chars_per_token: 3.0,
bmp_weight: 1.0,
astral_weight: 3.0,
};
pub fn from_chars_per_token(ratio: f32) -> Self {
TokenProfile {
ascii_chars_per_token: if ratio.is_finite() && ratio >= 1.0 {
ratio
} else {
1.0
},
..Self::CONSERVATIVE
}
}
}
impl Default for TokenProfile {
fn default() -> Self {
Self::CONSERVATIVE
}
}
pub fn estimate_text(text: &str) -> u64 {
estimate_text_with(text, &TokenProfile::CONSERVATIVE)
}
pub fn estimate_text_with(text: &str, profile: &TokenProfile) -> u64 {
let (mut ascii, mut bmp, mut astral) = (0u64, 0u64, 0u64);
for c in text.chars() {
match c as u32 {
0..=0x7F => ascii += 1,
0x80..=0xFFFF => bmp += 1,
_ => astral += 1,
}
}
let total = (ascii as f64 / profile.ascii_chars_per_token.max(1.0) as f64)
+ (bmp as f64 * profile.bmp_weight as f64)
+ (astral as f64 * profile.astral_weight as f64);
total.ceil() as u64
}
pub fn estimate_item(item: &Item) -> u64 {
estimate_item_with(item, &TokenProfile::CONSERVATIVE)
}
pub fn estimate_item_with(item: &Item, profile: &TokenProfile) -> u64 {
let body = match item {
Item::System { text } | Item::User { text, .. } => estimate_text_with(text, profile),
Item::Assistant { blocks } => blocks
.iter()
.map(|b| BLOCK_OVERHEAD + estimate_block(b, profile))
.sum(),
Item::ToolResults { results } => results
.iter()
.map(|r| RESULT_OVERHEAD + estimate_text_with(&r.content, profile))
.sum(),
Item::Unknown => 0,
};
ITEM_OVERHEAD + body
}
fn estimate_block(block: &serde_json::Value, profile: &TokenProfile) -> u64 {
let Some(obj) = block.as_object() else {
return estimate_text_with(&block.to_string(), profile);
};
obj.iter()
.filter(|(k, _)| k.as_str() != "type")
.map(|(_, v)| match v.as_str() {
Some(s) => estimate_text_with(s, profile),
None => estimate_text_with(&v.to_string(), profile),
})
.sum()
}
pub fn estimate_items(items: &[Item]) -> u64 {
estimate_items_with(items, &TokenProfile::CONSERVATIVE)
}
pub fn estimate_items_with(items: &[Item], profile: &TokenProfile) -> u64 {
items.iter().map(|i| estimate_item_with(i, profile)).sum()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn estimates_overcount_not_undercount() {
let text = "the quick brown fox jumps over the lazy dog. ".repeat(100);
let actual_ish = text.len() as u64 / 4;
assert!(estimate_text(&text) > actual_ish);
let items = vec![
Item::User {
text: text.clone(),
synthetic: None,
},
Item::Assistant {
blocks: vec![serde_json::json!({"type":"text","text":text})],
},
];
assert!(estimate_items(&items) > 2 * actual_ish);
}
#[test]
fn cjk_is_counted_by_character_not_by_byte_and_never_undercounts() {
let cjk = "日本語のテキストです".repeat(10); assert_eq!(cjk.chars().count(), 100);
let est = estimate_text(&cjk);
assert!(
est >= 100,
"undercount is the one unacceptable direction: {est}"
);
assert!(est <= 200, "{est}");
}
#[test]
fn emoji_weigh_more_than_one_token_each() {
let emoji = "🙂🎉🚀".repeat(10); assert!(estimate_text(&emoji) >= 60, "{}", estimate_text(&emoji));
}
#[test]
fn item_overhead_is_charged_once_per_item() {
let empty = Item::User {
text: String::new(),
synthetic: None,
};
assert_eq!(estimate_item(&empty), ITEM_OVERHEAD);
assert_eq!(estimate_items(&[]), 0);
assert_eq!(estimate_items(&[empty.clone(), empty]), 2 * ITEM_OVERHEAD);
}
#[test]
fn tool_results_charge_a_per_result_envelope() {
use hotl_types::ToolResultItem;
let one = Item::ToolResults {
results: vec![ToolResultItem {
tool_use_id: "a".into(),
content: String::new(),
is_error: false,
}],
};
let two = Item::ToolResults {
results: vec![
ToolResultItem {
tool_use_id: "a".into(),
content: String::new(),
is_error: false,
},
ToolResultItem {
tool_use_id: "b".into(),
content: String::new(),
is_error: false,
},
],
};
assert_eq!(estimate_item(&one), ITEM_OVERHEAD + RESULT_OVERHEAD);
assert_eq!(estimate_item(&two), ITEM_OVERHEAD + 2 * RESULT_OVERHEAD);
}
#[test]
fn assistant_blocks_do_not_bill_their_json_framing() {
let body = "x".repeat(3000);
let blocks = vec![serde_json::json!({"type": "text", "text": body})];
let est = estimate_item(&Item::Assistant { blocks });
assert!(est > 3000 / 4, "{est}");
assert!(
est < ITEM_OVERHEAD + 3000 / 2,
"framing is still being billed: {est}"
);
}
#[test]
fn a_denser_profile_estimates_higher() {
let text = "hello world, this is ordinary English prose. ".repeat(50);
let loose = TokenProfile {
ascii_chars_per_token: 4.0,
..TokenProfile::CONSERVATIVE
};
let dense = TokenProfile {
ascii_chars_per_token: 2.5,
..TokenProfile::CONSERVATIVE
};
assert!(estimate_text_with(&text, &dense) > estimate_text_with(&text, &loose));
assert_eq!(
estimate_text(&text),
estimate_text_with(&text, &TokenProfile::CONSERVATIVE)
);
}
#[test]
fn from_chars_per_token_clamps_nonsense() {
assert!(TokenProfile::from_chars_per_token(0.0).ascii_chars_per_token >= 1.0);
assert!(TokenProfile::from_chars_per_token(-3.0).ascii_chars_per_token >= 1.0);
assert!(estimate_text_with("abc", &TokenProfile::from_chars_per_token(0.0)) > 0);
}
}