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}