Skip to main content

openai_harmony/tiktoken_ext/
public_encodings.rs

1use std::{
2    collections::HashMap,
3    fs::File,
4    io::{BufReader, BufWriter, Read as _, Write as _},
5    path::{Path, PathBuf},
6    sync::OnceLock,
7};
8
9use base64::{prelude::BASE64_STANDARD, Engine as _};
10
11use crate::tiktoken::{CoreBPE, Rank};
12use sha1::Sha1;
13use sha2::{Digest as _, Sha256};
14
15#[derive(Debug, thiserror::Error)]
16pub enum LoadError {
17    #[error("the env var TIKTOKEN_ENCODINGS_BASE is not set, or invalid")]
18    InvalidEncodingBaseDirEnvVar,
19
20    #[error("unknown encoding name: {0}")]
21    UnknownEncodingName(String),
22
23    #[error("invalid tiktoken vocab file: {0}")]
24    InvalidTiktokenVocabFile(#[source] std::io::Error),
25
26    #[error("failed to create CoreBPE: {0}")]
27    CoreBPECreationFailed(#[source] Box<dyn std::error::Error + Send + Sync>),
28
29    #[error("error downloading or loading vocab file: {0}")]
30    DownloadOrLoadVocabFile(
31        #[source]
32        #[from]
33        RemoteVocabFileError,
34    ),
35
36    #[error("failed to extend encoding")]
37    FailedToExtendEncoding(#[source] Box<dyn std::error::Error + Send + Sync>),
38}
39
40#[derive(Debug, thiserror::Error)]
41pub enum RemoteVocabFileError {
42    #[error("failed to download or load vocab file")]
43    FailedToDownloadOrLoadVocabFile(#[source] Box<dyn std::error::Error + Send + Sync>),
44
45    #[error("an underlying IO error occurred while {0}: {1}")]
46    IOError(String, #[source] std::io::Error),
47
48    #[error("hash mismatch for remote file {file_url}")]
49    HashMismatch {
50        file_url: String,
51        expected_hash: String,
52        computed_hash: String,
53    },
54}
55
56const TIKTOKEN_ENCODINGS_BASE_VAR: &str = "TIKTOKEN_ENCODINGS_BASE";
57const DEFAULT_TIKTOKEN_BASE_URL: &str = "https://openaipublic.blob.core.windows.net/encodings/";
58
59static TIKTOKEN_BASE_URL_OVERRIDE: OnceLock<String> = OnceLock::new();
60
61pub fn set_tiktoken_base_url(base_url: impl Into<String>) {
62    let mut base = base_url.into();
63    if !base.ends_with('/') {
64        base.push('/');
65    }
66    // ignore error if already set
67    let _ = TIKTOKEN_BASE_URL_OVERRIDE.set(base);
68}
69
70fn tiktoken_base_url() -> &'static str {
71    TIKTOKEN_BASE_URL_OVERRIDE
72        .get()
73        .map(|s| s.as_str())
74        .unwrap_or(DEFAULT_TIKTOKEN_BASE_URL)
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Encoding {
79    O200kBase,
80    O200kHarmony,
81    Cl100kBase,
82}
83
84impl Encoding {
85    pub fn all() -> &'static [Self] {
86        &[Self::O200kBase, Self::O200kHarmony, Self::Cl100kBase]
87    }
88
89    pub fn from_name(name: impl AsRef<str>) -> Option<Self> {
90        let name_str = name.as_ref();
91        for encoding in Self::all() {
92            if encoding.name() == name_str {
93                return Some(*encoding);
94            }
95        }
96        None
97    }
98
99    #[cfg(not(target_arch = "wasm32"))]
100    pub fn load_from_name(name: impl AsRef<str>) -> Result<CoreBPE, LoadError> {
101        let name = name.as_ref();
102        Self::from_name(name)
103            .ok_or_else(|| LoadError::UnknownEncodingName(name.to_string()))?
104            .load()
105    }
106
107    #[cfg(target_arch = "wasm32")]
108    pub async fn load_from_name(name: impl AsRef<str>) -> Result<CoreBPE, LoadError> {
109        let name = name.as_ref();
110        Self::from_name(name)
111            .ok_or_else(|| LoadError::UnknownEncodingName(name.to_string()))?
112            .load()
113            .await
114    }
115
116    pub fn name(&self) -> &'static str {
117        match self {
118            Self::O200kBase => "o200k_base",
119            Self::O200kHarmony => "o200k_harmony",
120            Self::Cl100kBase => "cl100k_base",
121        }
122    }
123
124    #[cfg(not(target_arch = "wasm32"))]
125    pub fn load(&self) -> Result<CoreBPE, LoadError> {
126        #[cfg(not(target_arch = "wasm32"))]
127        let (vocab_file_path, check_hash) =
128            if let Ok(base_dir) = std::env::var(TIKTOKEN_ENCODINGS_BASE_VAR) {
129                (PathBuf::from(base_dir).join(self.vocab_file_name()), true)
130            } else {
131                let url = self.public_vocab_file_url();
132                (
133                    download_or_find_cached_file(&url, Some(self.expected_hash()))
134                        .map_err(LoadError::DownloadOrLoadVocabFile)?,
135                    false,
136                )
137            };
138
139        match self {
140            Self::O200kHarmony => {
141                let mut specials: Vec<(String, Rank)> = self
142                    .special_tokens()
143                    .iter()
144                    .map(|(s, r)| ((*s).to_string(), *r))
145                    .collect();
146                specials.extend((200014..=201088).map(|id| (format!("<|reserved_{id}|>"), id)));
147                #[cfg(not(target_arch = "wasm32"))]
148                {
149                    load_encoding_from_file(
150                        vocab_file_path,
151                        check_hash.then(|| self.expected_hash()),
152                        specials,
153                        &self.pattern(),
154                    )
155                }
156                #[cfg(target_arch = "wasm32")]
157                {
158                    load_encoding_from_bytes(&vocab_bytes, None, specials, &self.pattern())
159                }
160            }
161            Self::O200kBase => {
162                let mut specials: Vec<(String, Rank)> = self
163                    .special_tokens()
164                    .iter()
165                    .map(|(s, r)| ((*s).to_string(), *r))
166                    .collect();
167                specials.extend((199998..=201088).map(|id| (format!("<|reserved_{id}|>"), id)));
168                #[cfg(not(target_arch = "wasm32"))]
169                {
170                    load_encoding_from_file(
171                        vocab_file_path,
172                        check_hash.then(|| self.expected_hash()),
173                        specials,
174                        &self.pattern(),
175                    )
176                }
177                #[cfg(target_arch = "wasm32")]
178                {
179                    load_encoding_from_bytes(&vocab_bytes, None, specials, &self.pattern())
180                }
181            }
182            _ => {
183                #[cfg(not(target_arch = "wasm32"))]
184                {
185                    load_encoding_from_file(
186                        vocab_file_path,
187                        check_hash.then(|| self.expected_hash()),
188                        self.special_tokens().iter().cloned(),
189                        &self.pattern(),
190                    )
191                }
192                #[cfg(target_arch = "wasm32")]
193                {
194                    load_encoding_from_bytes(
195                        &vocab_bytes,
196                        None,
197                        self.special_tokens().iter().cloned(),
198                        &self.pattern(),
199                    )
200                }
201            }
202        }
203    }
204
205    #[cfg(target_arch = "wasm32")]
206    pub async fn load(&self) -> Result<CoreBPE, LoadError> {
207        let url = self.public_vocab_file_url();
208        let vocab_bytes = download_or_find_cached_file_bytes(&url, Some(self.expected_hash()))
209            .await
210            .map_err(LoadError::DownloadOrLoadVocabFile)?;
211
212        match self {
213            Self::O200kHarmony => {
214                let mut specials: Vec<(String, Rank)> = self
215                    .special_tokens()
216                    .iter()
217                    .map(|(s, r)| ((*s).to_string(), *r))
218                    .collect();
219                specials.extend((200014..=201088).map(|id| (format!("<|reserved_{id}|>"), id)));
220                load_encoding_from_bytes(&vocab_bytes, None, specials, &self.pattern())
221            }
222            Self::O200kBase => {
223                let mut specials: Vec<(String, Rank)> = self
224                    .special_tokens()
225                    .iter()
226                    .map(|(s, r)| ((*s).to_string(), *r))
227                    .collect();
228                specials.extend((199998..=201088).map(|id| (format!("<|reserved_{id}|>"), id)));
229                load_encoding_from_bytes(&vocab_bytes, None, specials, &self.pattern())
230            }
231            _ => load_encoding_from_bytes(
232                &vocab_bytes,
233                None,
234                self.special_tokens().iter().cloned(),
235                &self.pattern(),
236            ),
237        }
238    }
239
240    fn public_vocab_file_url(&self) -> String {
241        let base = tiktoken_base_url();
242        match self {
243            Self::O200kBase => format!("{base}o200k_base.tiktoken"),
244            Self::O200kHarmony => format!("{base}o200k_base.tiktoken"),
245            Self::Cl100kBase => format!("{base}cl100k_base.tiktoken"),
246        }
247    }
248
249    fn vocab_file_name(&self) -> &'static str {
250        match self {
251            Self::O200kBase => "o200k_base.tiktoken",
252            Self::O200kHarmony => "o200k_base.tiktoken",
253            Self::Cl100kBase => "cl100k_base.tiktoken",
254        }
255    }
256
257    fn expected_hash(&self) -> &'static str {
258        match self {
259            Self::O200kBase => "446a9538cb6c348e3516120d7c08b09f57c36495e2acfffe59a5bf8b0cfb1a2d",
260            Self::O200kHarmony => {
261                "446a9538cb6c348e3516120d7c08b09f57c36495e2acfffe59a5bf8b0cfb1a2d"
262            }
263            Self::Cl100kBase => "223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7",
264        }
265    }
266
267    fn special_tokens(&self) -> &'static [(&'static str, Rank)] {
268        match self {
269            Self::O200kBase => &[],
270            Self::O200kHarmony => &[
271                ("<|startoftext|>", 199998),
272                ("<|endoftext|>", 199999),
273                ("<|reserved_200000|>", 200000),
274                ("<|reserved_200001|>", 200001),
275                ("<|return|>", 200002),
276                ("<|constrain|>", 200003),
277                ("<|reserved_200004|>", 200004),
278                ("<|channel|>", 200005),
279                ("<|start|>", 200006),
280                ("<|end|>", 200007),
281                ("<|message|>", 200008),
282                ("<|reserved_200009|>", 200009),
283                ("<|reserved_200010|>", 200010),
284                ("<|reserved_200011|>", 200011),
285                ("<|call|>", 200012),
286                ("<|reserved_200013|>", 200013),
287            ],
288            Self::Cl100kBase => &[
289                ("<|endoftext|>", 100257),
290                ("<|fim_prefix|>", 100258),
291                ("<|fim_middle|>", 100259),
292                ("<|fim_suffix|>", 100260),
293                ("<|endofprompt|>", 100276),
294            ],
295        }
296    }
297
298    fn pattern(&self) -> String {
299        match self {
300            Self::O200kBase => {
301                [
302                    "[^\\r\\n\\p{L}\\p{N}]?[\\p{Lu}\\p{Lt}\\p{Lm}\\p{Lo}\\p{M}]*[\\p{Ll}\\p{Lm}\\p{Lo}\\p{M}]+(?i:'s|'t|'re|'ve|'m|'ll|'d)?",
303                    "[^\\r\\n\\p{L}\\p{N}]?[\\p{Lu}\\p{Lt}\\p{Lm}\\p{Lo}\\p{M}]+[\\p{Ll}\\p{Lm}\\p{Lo}\\p{M}]*(?i:'s|'t|'re|'ve|'m|'ll|'d)?",
304                    "\\p{N}{1,3}",
305                    " ?[^\\s\\p{L}\\p{N}]+[\\r\\n/]*",
306                    "\\s*[\\r\\n]+",
307                    "\\s+(?!\\S)",
308                    "\\s+",
309                ].join("|")
310            }
311            Self::O200kHarmony => {
312                [
313                    "[^\\r\\n\\p{L}\\p{N}]?[\\p{Lu}\\p{Lt}\\p{Lm}\\p{Lo}\\p{M}]*[\\p{Ll}\\p{Lm}\\p{Lo}\\p{M}]+(?i:'s|'t|'re|'ve|'m|'ll|'d)?",
314                    "[^\\r\\n\\p{L}\\p{N}]?[\\p{Lu}\\p{Lt}\\p{Lm}\\p{Lo}\\p{M}]+[\\p{Ll}\\p{Lm}\\p{Lo}\\p{M}]*(?i:'s|'t|'re|'ve|'m|'ll|'d)?",
315                    "\\p{N}{1,3}",
316                    " ?[^\\s\\p{L}\\p{N}]+[\\r\\n/]*",
317                    "\\s*[\\r\\n]+",
318                    "\\s+(?!\\S)",
319                    "\\s+",
320                ].join("|")
321            }
322            Self::Cl100kBase => {
323                "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?\\p{L}+|\\p{N}{1,3}| ?[^\\s\\p{L}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+".to_string()
324            }
325        }
326    }
327}
328
329fn load_tiktoken_vocab<R>(
330    mut reader: R,
331    expected_hash: Option<&str>,
332) -> std::result::Result<HashMap<Vec<u8>, Rank>, std::io::Error>
333where
334    R: std::io::BufRead,
335{
336    let mut hasher = expected_hash.map(|_| Sha256::new());
337    let mut bpe_ranks = HashMap::new();
338    // using readline here so that the line returned includes the newline bytes for the hasher
339    let mut lin_no = 0;
340    let mut line_buffer = String::new();
341    while reader.read_line(&mut line_buffer)? > 0 {
342        lin_no += 1;
343        if let Some(hasher) = hasher.as_mut() {
344            hasher.update(line_buffer.as_bytes());
345        }
346        let line = line_buffer.trim_end();
347        let (token, rank) = line.split_once(' ').ok_or_else(|| {
348            std::io::Error::new(
349                std::io::ErrorKind::InvalidData,
350                format!("expected token and rank, could not split on ' ' at line {lin_no}"),
351            )
352        })?;
353        let bytes = BASE64_STANDARD.decode(token).map_err(|e| {
354            std::io::Error::new(
355                std::io::ErrorKind::InvalidData,
356                format!("failed to decode base64 token at line {lin_no}: {e}",),
357            )
358        })?;
359        let rank = rank.parse().map_err(|e| {
360            std::io::Error::new(
361                std::io::ErrorKind::InvalidData,
362                format!("failed to parse rank at line {lin_no}: {e}"),
363            )
364        })?;
365        bpe_ranks.insert(bytes, rank);
366        line_buffer.clear();
367    }
368    if let Some(hasher) = hasher {
369        let expected_hash = expected_hash.unwrap();
370        let computed_hash = format!("{:x}", hasher.finalize());
371        if computed_hash != expected_hash {
372            return Err(std::io::Error::new(
373                std::io::ErrorKind::InvalidData,
374                format!("hash mismatch: computed={computed_hash}, expected={expected_hash}"),
375            ));
376        }
377    }
378    Ok(bpe_ranks)
379}
380
381pub fn load_tiktoken_vocab_file<P>(
382    path: P,
383    expected_hash: Option<&str>,
384) -> std::result::Result<HashMap<Vec<u8>, Rank>, std::io::Error>
385where
386    P: AsRef<Path>,
387{
388    let file = std::fs::File::open(path)?;
389    let reader = std::io::BufReader::new(file);
390    load_tiktoken_vocab(reader, expected_hash)
391}
392
393pub fn load_encoding_from_file<P, S, TS>(
394    file_path: P,
395    expected_hash: Option<&str>,
396    special_tokens: S,
397    pattern: &str,
398) -> Result<CoreBPE, LoadError>
399where
400    P: AsRef<Path>,
401    S: IntoIterator<Item = (TS, Rank)>,
402    TS: Into<String>,
403{
404    let encoder = load_tiktoken_vocab_file(file_path, expected_hash)
405        .map_err(LoadError::InvalidTiktokenVocabFile)?;
406    CoreBPE::new(
407        encoder,
408        special_tokens.into_iter().map(|(k, v)| (k.into(), v)),
409        pattern,
410    )
411    .map_err(LoadError::CoreBPECreationFailed)
412}
413
414/// This returns the path to a file containing the data at `url`. If the file is
415/// cached, it is used. Otherwise, the file is downloaded and cached.
416#[cfg(not(target_arch = "wasm32"))]
417fn download_or_find_cached_file(
418    url: &str,
419    expected_hash: Option<&str>,
420) -> Result<PathBuf, RemoteVocabFileError> {
421    let cache_dir = resolve_cache_dir()?;
422    let cache_path = resolve_cache_path(&cache_dir, url);
423    if cache_path.exists() {
424        if verify_file_hash(&cache_path, expected_hash)? {
425            return Ok(cache_path);
426        }
427        let _ = std::fs::remove_file(&cache_path);
428    }
429    let hash = load_remote_file(url, &cache_path)?;
430    if let Some(expected_hash) = expected_hash {
431        if hash != expected_hash {
432            let _ = std::fs::remove_file(&cache_path);
433            return Err(RemoteVocabFileError::HashMismatch {
434                file_url: url.to_string(),
435                expected_hash: expected_hash.to_string(),
436                computed_hash: hash,
437            });
438        }
439    }
440    Ok(cache_path)
441}
442
443#[cfg(target_arch = "wasm32")]
444async fn download_or_find_cached_file_bytes(
445    url: &str,
446    expected_hash: Option<&str>,
447) -> Result<Vec<u8>, RemoteVocabFileError> {
448    let bytes = load_remote_file_bytes(url).await?;
449    if let Some(expected_hash) = expected_hash {
450        let computed_hash = format!("{:x}", Sha256::digest(&bytes));
451        if computed_hash != expected_hash {
452            return Err(RemoteVocabFileError::HashMismatch {
453                file_url: url.to_string(),
454                expected_hash: expected_hash.to_string(),
455                computed_hash,
456            });
457        }
458    }
459    Ok(bytes)
460}
461
462fn resolve_cache_dir() -> Result<PathBuf, RemoteVocabFileError> {
463    // we use a different env var and a different default dir name to avoid
464    // conflicts with the python tiktoken package, while sharing a cache dir
465    // with the python tiktoken package is a desirable future goal, it is not
466    // a priority and we should optimize for avoiding breaking tiktoken installs
467    // on the same system until we can validate the correctness wrt the python
468    // implementation and write tests to avoid regressions
469    let cache_dir_override = std::env::var("TIKTOKEN_RS_CACHE_DIR").ok();
470    if let Some(cache_dir_override) = cache_dir_override {
471        Ok(PathBuf::from(cache_dir_override))
472    } else {
473        let cache_dir = std::env::temp_dir().join("tiktoken-rs-cache");
474        std::fs::create_dir_all(&cache_dir).map_err(|e| {
475            RemoteVocabFileError::IOError(format!("creating cache dir {cache_dir:?}"), e)
476        })?;
477        Ok(cache_dir)
478    }
479}
480
481fn resolve_cache_path(cache_dir: &Path, url: &str) -> PathBuf {
482    let mut hasher = Sha1::new();
483    hasher.update(url.as_bytes());
484    let cache_key = format!("{:x}", hasher.finalize());
485    cache_dir.join(cache_key)
486}
487
488fn verify_file_hash(
489    file_path: &Path,
490    expected_hash: Option<&str>,
491) -> Result<bool, RemoteVocabFileError> {
492    let Some(expected_hash) = expected_hash else {
493        return Ok(true);
494    };
495    let file = File::open(file_path)
496        .map_err(|e| RemoteVocabFileError::IOError(format!("opening file {file_path:?}"), e))?;
497    let mut reader = BufReader::new(file);
498    let mut hasher = Sha256::new();
499    std::io::copy(&mut reader, &mut hasher).map_err(|e| {
500        RemoteVocabFileError::IOError(format!("copying file {file_path:?} contents to hasher"), e)
501    })?;
502    let computed_hash = format!("{:x}", hasher.finalize());
503    Ok(computed_hash == expected_hash)
504}
505
506/// Loads a remote file to `destination` and returns the computed hash of the
507/// file contents.
508#[cfg(not(target_arch = "wasm32"))]
509fn load_remote_file(url: &str, destination: &Path) -> Result<String, RemoteVocabFileError> {
510    let client = reqwest::blocking::Client::new();
511    let mut response = client
512        .get(url)
513        .send()
514        .and_then(|r| r.error_for_status())
515        .map_err(|e| RemoteVocabFileError::FailedToDownloadOrLoadVocabFile(Box::new(e)))?;
516
517    let file = File::create(destination)
518        .map_err(|e| RemoteVocabFileError::IOError(format!("creating file {destination:?}"), e))?;
519    let mut dest = BufWriter::new(file);
520    let mut hasher = Sha256::new();
521    let mut buffer = [0u8; 8192];
522    loop {
523        let bytes_read = response.read(&mut buffer).map_err(|e| {
524            RemoteVocabFileError::IOError(format!("reading from response {url}"), e)
525        })?;
526        if bytes_read == 0 {
527            break;
528        }
529        dest.write_all(&buffer[..bytes_read]).map_err(|e| {
530            RemoteVocabFileError::IOError(format!("writing to file {destination:?}"), e)
531        })?;
532        hasher.update(&buffer[..bytes_read]);
533    }
534    Ok(format!("{:x}", hasher.finalize()))
535}
536
537#[cfg(target_arch = "wasm32")]
538fn load_remote_file(_url: &str, _destination: &Path) -> Result<String, RemoteVocabFileError> {
539    Err(RemoteVocabFileError::FailedToDownloadOrLoadVocabFile(
540        Box::new(std::io::Error::new(
541            std::io::ErrorKind::Other,
542            "Downloading files is not supported in wasm32",
543        )),
544    ))
545}
546
547#[cfg(target_arch = "wasm32")]
548async fn load_remote_file_bytes(url: &str) -> Result<Vec<u8>, RemoteVocabFileError> {
549    use reqwest::Client;
550
551    let client = Client::new();
552    let response = client
553        .get(url)
554        .send()
555        .await
556        .and_then(|r| r.error_for_status())
557        .map_err(|e| RemoteVocabFileError::FailedToDownloadOrLoadVocabFile(Box::new(e)))?;
558    let bytes = response
559        .bytes()
560        .await
561        .map_err(|e| RemoteVocabFileError::FailedToDownloadOrLoadVocabFile(Box::new(e)))?;
562    Ok(bytes.to_vec())
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    #[test]
570    fn test_load_encodings() {
571        for encoding in Encoding::all() {
572            let _ = encoding.load().unwrap();
573        }
574    }
575}