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}