Skip to main content

cel_brief/
tokenizer.rs

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