Skip to main content

cel_brief/
tokenizer.rs

1//! [`Tokenizer`] trait + default impls.
2//!
3//! The builder uses a [`Tokenizer`] to re-measure every
4//! [`crate::source::Contribution`] before pruning, so a source that
5//! over-estimates `estimated_tokens` can't blow the budget.
6//!
7//! Two impls ship in the crate:
8//! - [`CharApproxTokenizer`] — default; 1 token ≈ 4 chars rule of thumb.
9//!   Zero runtime dependencies, fine for development and tests, but not
10//!   accurate enough for production budget enforcement against a real
11//!   provider.
12//! - `TiktokenCl100k` — behind the `tiktoken` feature. Uses
13//!   the `tiktoken-rs` crate for ground-truth OpenAI `cl100k_base` counts. Suitable
14//!   for any provider whose tokenizer is close to GPT-4 / GPT-3.5
15//!   (Anthropic's Claude is close enough for budget purposes; for exact
16//!   Claude counts, implement [`Tokenizer`] with a Claude-specific tokenizer
17//!   and pass it to the builder).
18
19use std::sync::Arc;
20
21/// Counts tokens for a `&str`.
22///
23/// Implementations must be cheap and `Send + Sync` because the builder calls
24/// `count` once per contribution per turn.
25pub trait Tokenizer: Send + Sync {
26    /// Number of tokens the model would see for `text`.
27    fn count(&self, text: &str) -> usize;
28
29    /// Token IDs for providers that need them. Default returns `None`; only
30    /// tokenizers that have a real BPE backing (e.g. tiktoken) override.
31    fn encode(&self, _text: &str) -> Option<Vec<u32>> {
32        None
33    }
34}
35
36/// Trivial tokenizer using the well-known "≈ 4 chars per token" rule of
37/// thumb. Default for the builder so the crate has no runtime model assets.
38///
39/// Counts bytes (not unicode scalar values) divided by 4, rounded up, with
40/// an extra `+1` floor so any non-empty input reports at least 1 token. This
41/// matches what most sources will report in `estimated_tokens`, so the
42/// builder won't surprise sources during pruning at default settings.
43///
44/// **Not accurate for billing or budgeting against a real LLM provider** —
45/// enable the `tiktoken` feature and use `TiktokenCl100k` when accuracy
46/// matters.
47#[derive(Debug, Clone, Copy, Default)]
48pub struct CharApproxTokenizer;
49
50impl CharApproxTokenizer {
51    /// Construct a new [`CharApproxTokenizer`].
52    pub fn new() -> Self {
53        CharApproxTokenizer
54    }
55}
56
57impl Tokenizer for CharApproxTokenizer {
58    fn count(&self, text: &str) -> usize {
59        if text.is_empty() {
60            0
61        } else {
62            text.len().div_ceil(4)
63        }
64    }
65}
66
67/// Convenience: every `Arc<dyn Tokenizer>` already satisfies [`Tokenizer`]
68/// via blanket deref-style forwarding. This impl lets the builder hold an
69/// `Arc<dyn Tokenizer>` and pass it around without unwrapping.
70impl<T: Tokenizer + ?Sized> Tokenizer for Arc<T> {
71    fn count(&self, text: &str) -> usize {
72        (**self).count(text)
73    }
74
75    fn encode(&self, text: &str) -> Option<Vec<u32>> {
76        (**self).encode(text)
77    }
78}
79
80#[cfg(feature = "tiktoken")]
81mod tiktoken_impl {
82    use super::Tokenizer;
83
84    use tiktoken_rs::{cl100k_base, CoreBPE};
85
86    /// Production-grade tokenizer using OpenAI's `cl100k_base` encoding via
87    /// the `tiktoken-rs` crate. Available behind the `tiktoken` feature.
88    ///
89    /// Construction is cheap (BPE tables are loaded lazily by `cl100k_base`),
90    /// but the first `count` call after process start pays the load cost.
91    /// Reuse one instance across turns.
92    pub struct TiktokenCl100k {
93        bpe: CoreBPE,
94    }
95
96    impl TiktokenCl100k {
97        /// Construct a new [`TiktokenCl100k`]. Returns `Err` only if
98        /// `tiktoken-rs` fails to load its embedded BPE assets, which in
99        /// practice never happens for `cl100k_base`.
100        pub fn new() -> Result<Self, String> {
101            cl100k_base()
102                .map(|bpe| TiktokenCl100k { bpe })
103                .map_err(|e| e.to_string())
104        }
105    }
106
107    impl std::fmt::Debug for TiktokenCl100k {
108        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109            f.debug_struct("TiktokenCl100k").finish()
110        }
111    }
112
113    impl Tokenizer for TiktokenCl100k {
114        fn count(&self, text: &str) -> usize {
115            // `encode_with_special_tokens` matches what OpenAI bills.
116            self.bpe.encode_with_special_tokens(text).len()
117        }
118
119        fn encode(&self, text: &str) -> Option<Vec<u32>> {
120            Some(self.bpe.encode_with_special_tokens(text))
121        }
122    }
123}
124
125#[cfg(feature = "tiktoken")]
126pub use tiktoken_impl::TiktokenCl100k;
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn char_approx_returns_zero_for_empty_string() {
134        let tok = CharApproxTokenizer::new();
135        assert_eq!(tok.count(""), 0);
136    }
137
138    #[test]
139    fn char_approx_uses_four_chars_per_token() {
140        let tok = CharApproxTokenizer;
141        assert_eq!(tok.count("a"), 1);
142        assert_eq!(tok.count("abcd"), 1);
143        assert_eq!(tok.count("abcde"), 2);
144        assert_eq!(tok.count("abcdefgh"), 2);
145        assert_eq!(tok.count("abcdefghi"), 3);
146    }
147
148    #[test]
149    fn char_approx_encode_returns_none() {
150        let tok = CharApproxTokenizer;
151        assert!(tok.encode("anything").is_none());
152    }
153
154    #[test]
155    fn arc_dyn_tokenizer_forwards() {
156        let tok: Arc<dyn Tokenizer> = Arc::new(CharApproxTokenizer);
157        assert_eq!(tok.count("abcd"), 1);
158        assert!(tok.encode("abcd").is_none());
159    }
160
161    #[cfg(feature = "tiktoken")]
162    #[test]
163    fn tiktoken_cl100k_counts_a_short_string() {
164        let tok = TiktokenCl100k::new().expect("load cl100k");
165        // "hello world" is 2 tokens in cl100k_base.
166        let count = tok.count("hello world");
167        assert!(
168            count > 0 && count <= 5,
169            "got {count} tokens for 'hello world'"
170        );
171
172        let ids = tok.encode("hello world").expect("ids");
173        assert_eq!(ids.len(), count, "encode/count agreement");
174    }
175}