use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash as _, Hasher as _};
use std::sync::{Arc, LazyLock, RwLock};
use ahash::AHashMap;
use crate::XbergError;
pub const DEFAULT_COUNT_TOKENS_MODEL: &str = "Xenova/gpt-4o";
#[cfg(not(target_arch = "wasm32"))]
const DEFAULT_COUNT_TOKENS_REVISION: &str = "7956d98f2a83b2751a98ea7136fdf7fe6cf54e69";
pub enum TokenizerSource<'a> {
Pretrained(&'a str),
PretrainedRevision {
model: &'a str,
revision: &'a str,
},
File(&'a std::path::Path),
Bytes(&'a [u8]),
}
fn cache_key(source: &TokenizerSource<'_>) -> String {
match source {
TokenizerSource::Pretrained(model) => format!("pretrained:{model}"),
TokenizerSource::PretrainedRevision { model, revision } => {
format!("pretrained:{model}@{revision}")
}
TokenizerSource::File(path) => format!("file:{}", path.display()),
TokenizerSource::Bytes(b) => {
let mut h = DefaultHasher::new();
b.hash(&mut h);
format!("bytes:{:016x}", h.finish())
}
}
}
static TOKENIZER_CACHE: LazyLock<RwLock<AHashMap<String, Arc<tokenizers::Tokenizer>>>> =
LazyLock::new(|| RwLock::new(AHashMap::new()));
fn load_tokenizer(source: &TokenizerSource<'_>) -> crate::Result<tokenizers::Tokenizer> {
match source {
#[cfg(not(target_arch = "wasm32"))]
TokenizerSource::Pretrained(model) => {
let revision = (*model == DEFAULT_COUNT_TOKENS_MODEL).then_some(DEFAULT_COUNT_TOKENS_REVISION);
let path = crate::model_download::hf_resolve_file(model, "tokenizer.json", revision, None, None)
.map_err(|e| XbergError::validation(format!("Failed to resolve tokenizer '{model}': {e}")))?;
tokenizers::Tokenizer::from_file(&path).map_err(|e| {
XbergError::validation(format!(
"Failed to load tokenizer '{}' from '{}': {e}",
model,
path.display()
))
})
}
#[cfg(not(target_arch = "wasm32"))]
TokenizerSource::PretrainedRevision { model, revision } => {
let path = crate::model_download::hf_resolve_file(model, "tokenizer.json", Some(revision), None, None)
.map_err(|e| {
XbergError::validation(format!("Failed to resolve tokenizer '{model}@{revision}': {e}"))
})?;
tokenizers::Tokenizer::from_file(&path).map_err(|e| {
XbergError::validation(format!(
"Failed to load tokenizer '{model}@{revision}' from '{}': {e}",
path.display()
))
})
}
#[cfg(target_arch = "wasm32")]
TokenizerSource::Pretrained(model) => Err(XbergError::validation(format!(
"pretrained tokenizer '{model}' requires network access, unavailable on this platform"
))),
#[cfg(target_arch = "wasm32")]
TokenizerSource::PretrainedRevision { model, revision } => Err(XbergError::validation(format!(
"pretrained tokenizer '{model}@{revision}' requires network access, unavailable on this platform"
))),
TokenizerSource::File(path) => tokenizers::Tokenizer::from_file(path)
.map_err(|e| XbergError::validation(format!("Failed to load tokenizer from '{}': {e}", path.display()))),
TokenizerSource::Bytes(b) => tokenizers::Tokenizer::from_bytes(b)
.map_err(|e| XbergError::validation(format!("Failed to parse tokenizer from bytes: {e}"))),
}
}
pub(crate) fn get_or_init_tokenizer_from_source(
source: &TokenizerSource<'_>,
) -> crate::Result<Arc<tokenizers::Tokenizer>> {
let key = cache_key(source);
{
let cache = TOKENIZER_CACHE
.read()
.map_err(|e| XbergError::Other(format!("Tokenizer cache read lock poisoned: {e}")))?;
if let Some(tok) = cache.get(&key) {
return Ok(Arc::clone(tok));
}
}
let mut cache = TOKENIZER_CACHE
.write()
.map_err(|e| XbergError::Other(format!("Tokenizer cache write lock poisoned: {e}")))?;
if let Some(tok) = cache.get(&key) {
return Ok(Arc::clone(tok));
}
let tokenizer = load_tokenizer(source)?;
let arc = Arc::new(tokenizer);
cache.insert(key, Arc::clone(&arc));
Ok(arc)
}
pub(crate) fn get_or_init_tokenizer(model: &str) -> crate::Result<Arc<tokenizers::Tokenizer>> {
get_or_init_tokenizer_from_source(&TokenizerSource::Pretrained(model))
}
#[cfg_attr(alef, alef(skip))]
pub fn count_tokens(text: &str, model: Option<&str>) -> usize {
let model = model.unwrap_or(DEFAULT_COUNT_TOKENS_MODEL);
match get_or_init_tokenizer(model) {
Ok(tokenizer) => match tokenizer.encode(text, false) {
Ok(encoding) => encoding.len(),
Err(_) => whitespace_token_estimate(text),
},
Err(_) => whitespace_token_estimate(text),
}
}
#[cfg_attr(alef, alef(skip))]
pub fn try_count_tokens(text: &str, source: TokenizerSource<'_>) -> crate::Result<usize> {
let tok = get_or_init_tokenizer_from_source(&source)?;
tok.encode(text, false)
.map(|e| e.len())
.map_err(|e| XbergError::Other(format!("encode: {e}")))
}
#[cfg_attr(alef, alef(skip))]
pub fn preload_tokenizer(source: TokenizerSource<'_>) -> crate::Result<()> {
get_or_init_tokenizer_from_source(&source).map(|_| ())
}
fn whitespace_token_estimate(text: &str) -> usize {
text.split_whitespace().count()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_returns_same_instance() {
if std::env::var("CI").is_ok() {
return;
}
let model = "bert-base-uncased";
let tok1 = get_or_init_tokenizer(model).unwrap();
let tok2 = get_or_init_tokenizer(model).unwrap();
assert!(Arc::ptr_eq(&tok1, &tok2));
}
#[test]
fn test_count_tokens_none_defaults_to_gpt4o_and_returns_nonzero() {
if std::env::var("CI").is_ok() {
return;
}
let text = "Hello, world! This is a test sentence for token counting.";
let count_via_none = count_tokens(text, None);
assert!(
count_via_none > 0,
"count_tokens(text, None) must return a non-zero count"
);
let count_via_explicit = count_tokens(text, Some(DEFAULT_COUNT_TOKENS_MODEL));
assert_eq!(
count_via_none, count_via_explicit,
"None and Some(DEFAULT_COUNT_TOKENS_MODEL) must produce the same count"
);
}
#[test]
fn test_count_tokens_falls_back_gracefully_on_invalid_model() {
let text = "six distinct whitespace separated words here";
let count = count_tokens(text, Some("__invalid_model_that_does_not_exist__"));
assert_eq!(count, 6, "fallback whitespace estimator should count 6 words");
}
#[test]
fn test_whitespace_token_estimate_edge_cases() {
assert_eq!(whitespace_token_estimate(""), 0);
assert_eq!(whitespace_token_estimate(" "), 0);
assert_eq!(whitespace_token_estimate("one"), 1);
assert_eq!(whitespace_token_estimate("one two three"), 3);
}
const BERT_TOKENIZER_BYTES: &[u8] = include_bytes!("testdata/bert-base-uncased.tokenizer.json");
#[test]
fn test_bytes_source_parses_offline() {
let source = TokenizerSource::Bytes(BERT_TOKENIZER_BYTES);
let tok =
get_or_init_tokenizer_from_source(&source).expect("Bytes source must parse bert tokenizer without network");
assert!(
tok.get_vocab_size(true) > 1000,
"expected a non-trivial vocabulary, got {}",
tok.get_vocab_size(true)
);
}
#[test]
fn test_try_count_tokens_bytes_source_deterministic() {
let n = try_count_tokens("Hello, world!", TokenizerSource::Bytes(BERT_TOKENIZER_BYTES))
.expect("try_count_tokens with Bytes must not fail");
assert_eq!(n, 4, "expected 4 tokens for 'Hello, world!' via bert WordPiece");
}
#[test]
fn test_bytes_source_cache_hit() {
let tok1 = get_or_init_tokenizer_from_source(&TokenizerSource::Bytes(BERT_TOKENIZER_BYTES))
.expect("first call must succeed");
let tok2 = get_or_init_tokenizer_from_source(&TokenizerSource::Bytes(BERT_TOKENIZER_BYTES))
.expect("second call must succeed");
assert!(Arc::ptr_eq(&tok1, &tok2), "second call must return cached Arc");
}
#[test]
fn test_file_source_loads_offline() {
use std::io::Write as _;
let mut tmp = tempfile::NamedTempFile::new().expect("create tempfile");
tmp.write_all(BERT_TOKENIZER_BYTES).expect("write tokenizer bytes");
let path = tmp.path();
let n = try_count_tokens("Hello, world!", TokenizerSource::File(path))
.expect("try_count_tokens with File must not fail");
assert_eq!(n, 4, "File source must produce the same count as Bytes source");
}
#[test]
fn test_preload_tokenizer_bytes_offline() {
preload_tokenizer(TokenizerSource::Bytes(BERT_TOKENIZER_BYTES))
.expect("preload_tokenizer(Bytes) must succeed offline");
}
#[test]
fn test_try_count_tokens_pretrained_invalid_model_errors() {
let result = try_count_tokens("some text", TokenizerSource::Pretrained("__invalid_model__"));
assert!(
result.is_err(),
"try_count_tokens must surface errors for invalid Pretrained model"
);
}
#[test]
fn test_count_tokens_backcmpat_fallback_when_offline() {
let text = "hello world test";
let n = count_tokens(text, None);
assert!(
n > 0,
"count_tokens must return > 0 for non-empty text (whitespace fallback)"
);
}
#[test]
fn test_cache_key_discriminant() {
let k_pretrained = cache_key(&TokenizerSource::Pretrained("model-a"));
let k_file = cache_key(&TokenizerSource::File(std::path::Path::new("model-a")));
let k_bytes = cache_key(&TokenizerSource::Bytes(b"model-a"));
assert_ne!(k_pretrained, k_file);
assert_ne!(k_pretrained, k_bytes);
assert_ne!(k_file, k_bytes);
assert_eq!(
cache_key(&TokenizerSource::Pretrained("model-a")),
cache_key(&TokenizerSource::Pretrained("model-a"))
);
assert_ne!(
cache_key(&TokenizerSource::PretrainedRevision {
model: "model-a",
revision: "revision-a",
}),
cache_key(&TokenizerSource::PretrainedRevision {
model: "model-a",
revision: "revision-b",
})
);
assert_eq!(
cache_key(&TokenizerSource::Bytes(b"abc")),
cache_key(&TokenizerSource::Bytes(b"abc"))
);
}
}