Skip to main content

winx_code_agent/utils/
encoder.rs

1//! Claude-compatible token counting.
2//!
3//! WCGW counts tokens with the `Xenova/claude-tokenizer` (Hugging Face `tokenizers`).
4//! We embed that same tokenizer definition in the binary and load it lazily, so token
5//! budgets and truncation match the model that actually runs the agent. Small inputs
6//! use a byte-length upper bound and never initialize it; if loading fails for a larger
7//! input we fall back to a cheap character/word estimate.
8
9use std::sync::OnceLock;
10
11use tokie::Tokenizer;
12
13/// Embedded `Xenova/claude-tokenizer` definition (Hugging Face `tokenizer.json`).
14static CLAUDE_TOKENIZER_JSON: &[u8] =
15    include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/claude-tokenizer.json"));
16
17fn tokenizer() -> Option<&'static Tokenizer> {
18    static TOKENIZER: OnceLock<Option<Tokenizer>> = OnceLock::new();
19    TOKENIZER
20        .get_or_init(|| {
21            let json = match std::str::from_utf8(CLAUDE_TOKENIZER_JSON) {
22                Ok(json) => json,
23                Err(error) => {
24                    tracing::warn!("Embedded Claude tokenizer is not UTF-8: {error}");
25                    return None;
26                }
27            };
28            match tokie::hf::from_json_str(json) {
29                Ok(tokenizer) => Some(tokenizer),
30                Err(error) => {
31                    tracing::warn!(
32                        "Failed to load embedded Claude tokenizer, using estimate: {error}"
33                    );
34                    None
35                }
36            }
37        })
38        .as_ref()
39}
40
41/// Count tokens the way Claude does. Falls back to [`estimate_tokens`] on failure.
42pub fn count_tokens(text: &str) -> usize {
43    match encode_ids(text) {
44        Some(ids) => ids.len(),
45        None => estimate_tokens(text),
46    }
47}
48
49/// Encode `text` into Claude token ids. Returns `None` if the tokenizer is
50/// unavailable so callers can pick a byte-based fallback.
51pub fn encode_ids(text: &str) -> Option<Vec<u32>> {
52    let tokenizer = tokenizer()?;
53    Some(tokenizer.encode_ids(text, false))
54}
55
56/// Decode Claude token ids back into text. Returns `None` on failure.
57pub fn decode_ids(ids: &[u32]) -> Option<String> {
58    let tokenizer = tokenizer()?;
59    tokenizer.decode(ids)
60}
61
62/// Return `true` when byte length alone proves `text` fits the token budget.
63///
64/// The embedded tokenizer is byte-level BPE without added special tokens, so it
65/// cannot emit more tokens than the number of UTF-8 input bytes. This conservative
66/// fast path avoids loading or running the tokenizer for small payloads while exact
67/// tokenization remains in charge near and above the budget boundary.
68pub fn definitely_fits_token_budget(text: &str, max_tokens: usize) -> bool {
69    text.len() <= max_tokens
70}
71
72/// Read a token-budget override from env var `var` (e.g.
73/// `WINX_CODING_TOKEN_BUDGET`), falling back to `default` when unset, zero, or
74/// unparseable. Lets large-context clients tune how much of each file is pulled
75/// into context — and how much saved memory is kept — without a rebuild.
76pub fn budget_from_env(var: &str, default: usize) -> usize {
77    std::env::var(var)
78        .ok()
79        .and_then(|value| value.trim().parse::<usize>().ok())
80        .filter(|&n| n > 0)
81        .unwrap_or(default)
82}
83
84/// Cheap fallback estimate used only when the tokenizer is unavailable.
85pub fn estimate_tokens(text: &str) -> usize {
86    text.chars().count().div_ceil(4).max(text.split_whitespace().count())
87}
88
89#[cfg(test)]
90mod budget_tests {
91    use super::budget_from_env;
92
93    #[test]
94    fn falls_back_to_default_when_unset_or_invalid() {
95        // A name nothing sets -> default. (Avoids mutating process env in tests.)
96        assert_eq!(budget_from_env("WINX_DEFINITELY_UNSET_BUDGET_XYZ", 24_000), 24_000);
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn counts_tokens_for_simple_text() {
106        // Whatever the backend, a non-empty string must produce at least one token.
107        assert!(count_tokens("hello world") >= 1);
108        assert_eq!(count_tokens(""), 0);
109    }
110
111    #[test]
112    fn estimate_is_nonzero_for_words() {
113        assert!(estimate_tokens("a b c d") >= 4);
114    }
115
116    #[test]
117    fn byte_length_is_a_safe_token_upper_bound() {
118        for sample in [
119            "plain English prose",
120            "fn main() { println!(\"hello\"); }",
121            "áéíóú 日本語 🚀",
122            "symbols: !@#$%^&*()[]{}",
123            "line one\nline two\nline three",
124        ] {
125            let ids = encode_ids(sample);
126            assert!(
127                ids.as_ref().is_some_and(|ids| ids.len() <= sample.len()),
128                "sample produced more tokens than UTF-8 bytes"
129            );
130        }
131    }
132
133    #[test]
134    fn embedded_claude_tokenizer_matches_golden_ids() -> anyhow::Result<()> {
135        let cases: &[(&str, &[u32])] = &[
136            ("", &[]),
137            ("hello world", &[9381, 2253]),
138            (
139                "fn main() { println!(\"hello\"); }",
140                &[3258, 1890, 370, 503, 637, 9706, 5, 496, 9381, 5018, 863],
141            ),
142            (
143                "áéíóú 日本語 🚀",
144                &[2273, 1222, 2843, 3024, 8286, 225, 12956, 12163, 23598, 257, 41270, 253, 227],
145            ),
146            ("line one\nline two\nline three", &[936, 813, 203, 936, 1231, 203, 936, 2119]),
147        ];
148        for (sample, expected) in cases {
149            let actual = encode_ids(sample)
150                .ok_or_else(|| anyhow::anyhow!("embedded tokenizer failed for {sample:?}"))?;
151            assert_eq!(&actual, expected, "token ids diverged for {sample:?}");
152            assert_eq!(decode_ids(&actual).as_deref(), Some(*sample));
153        }
154        Ok(())
155    }
156
157    #[test]
158    fn byte_upper_bound_only_accepts_payloads_proven_to_fit() {
159        assert!(definitely_fits_token_budget("four", 4));
160        assert!(!definitely_fits_token_budget("five!", 4));
161        assert!(!definitely_fits_token_budget("é", 1));
162        assert!(definitely_fits_token_budget("é", 2));
163    }
164}