use hf_hub::api::sync::ApiBuilder;
use hf_hub::{Repo, RepoType};
use serde::{Deserialize, Serialize};
use tokenizers::{FromPretrainedParameters, Tokenizer};
use super::TokenId;
const COMMON_LOCATIONS: &[EosTokenLocation] = &[
EosTokenLocation {
file: "generation_config.json",
location: EosTokenField::Id,
},
EosTokenLocation {
file: "tokenizer_config.json",
location: EosTokenField::Value,
},
EosTokenLocation {
file: "tokenizer_config.json",
location: EosTokenField::Object,
},
];
#[derive(Debug, Serialize, Deserialize)]
struct Id {
eos_token_id: u64,
}
#[derive(Debug, Serialize, Deserialize)]
struct Value {
eos_token: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct Object {
eos_token: Content,
}
#[derive(Debug, Serialize, Deserialize)]
struct Content {
content: String,
}
enum EosTokenField {
Id,
Value,
Object,
}
struct EosTokenLocation {
file: &'static str,
location: EosTokenField,
}
pub(crate) trait Locator {
fn locate_eos_token_id(
model: &str,
tokenizer: &Tokenizer,
parameters: &Option<FromPretrainedParameters>,
) -> Result<TokenId, Vec<String>>;
}
pub(crate) struct HFLocator;
impl Locator for HFLocator {
fn locate_eos_token_id(
model: &str,
tokenizer: &Tokenizer,
parameters: &Option<FromPretrainedParameters>,
) -> Result<TokenId, Vec<String>> {
let mut reasons = Vec::with_capacity(COMMON_LOCATIONS.len());
for location in COMMON_LOCATIONS {
match location.lookup(model, tokenizer, parameters) {
Ok(id) => return Ok(id),
Err(reason) => reasons.push(reason),
}
}
Err(reasons)
}
}
impl EosTokenLocation {
fn lookup(
&self,
model: &str,
tokenizer: &Tokenizer,
parameters: &Option<FromPretrainedParameters>,
) -> Result<TokenId, String> {
let file = self.file;
let file_path = Self::download_config(model, file, parameters)
.map_err(|e| format!("{file}: could not download from the hub: {e}"))?;
let reader = std::fs::File::open(&file_path)
.map_err(|e| format!("{file}: could not open the downloaded file: {e}"))?;
match self.location {
EosTokenField::Id => {
let config: Id = serde_json::from_reader(reader)
.map_err(|e| format!("{file}: eos_token_id field missing or malformed: {e}"))?;
u32::try_from(config.eos_token_id)
.map_err(|_| format!("{file}: eos_token_id does not fit a u32"))
}
EosTokenField::Value => {
let config: Value = serde_json::from_reader(reader)
.map_err(|e| format!("{file}: eos_token field missing or malformed: {e}"))?;
tokenizer.token_to_id(&config.eos_token).ok_or_else(|| {
format!(
"{file}: eos_token {:?} not found in the tokenizer vocabulary",
config.eos_token
)
})
}
EosTokenField::Object => {
let config: Object = serde_json::from_reader(reader).map_err(|e| {
format!("{file}: eos_token.content field missing or malformed: {e}")
})?;
tokenizer
.token_to_id(&config.eos_token.content)
.ok_or_else(|| {
format!(
"{file}: eos_token.content {:?} not found in the tokenizer vocabulary",
config.eos_token.content
)
})
}
}
}
fn download_config(
project: &str,
file: &str,
parameters: &Option<FromPretrainedParameters>,
) -> tokenizers::Result<std::path::PathBuf> {
let params = parameters.clone().unwrap_or_default();
Self::validate(project)?;
Self::validate(¶ms.revision)?;
let repo = Repo::with_revision(project.to_string(), RepoType::Model, params.revision);
let api = ApiBuilder::new()
.with_token(params.token)
.build()?
.repo(repo);
Ok(api.get(file)?)
}
fn validate(input: &str) -> tokenizers::Result<()> {
let valid_chars = ['-', '_', '.', '/'];
if !input
.chars()
.all(|c: char| c.is_alphanumeric() || valid_chars.contains(&c))
{
return Err(format!(
"Input {input} contains invalid characters, expected only alphanumeric or {}",
valid_chars
.iter()
.map(|x| format!("'{}'", x))
.collect::<Vec<_>>()
.join(", ")
)
.into());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn common_locations() {
for (model, expected_token_id, expected_token) in &[
("openai-community/gpt2", 50256, "<|endoftext|>"),
("microsoft/phi-2", 50256, "<|endoftext|>"),
("hf-internal-testing/llama-tokenizer", 2, "</s>"),
] {
let tokenizer = Tokenizer::from_pretrained(model, None).expect("Tokenizer failed");
let located = HFLocator::locate_eos_token_id(model, &tokenizer, &None)
.expect("Token id is not located");
assert_eq!(located, *expected_token_id);
assert_eq!(
tokenizer.id_to_token(located).expect("Token is not found"),
expected_token.to_string()
);
}
}
#[test]
fn bad_location() {
let bad_location = EosTokenLocation {
file: "tokenizer_config.json",
location: EosTokenField::Id,
};
let model = "microsoft/phi-2";
let tokenizer = Tokenizer::from_pretrained(model, None).expect("Tokenizer failed");
let reason = bad_location
.lookup(model, &tokenizer, &None)
.expect_err("field genuinely absent from this file");
assert!(reason.contains("tokenizer_config.json"), "{reason}");
let bad_file = EosTokenLocation {
file: "generation_config.json",
location: EosTokenField::Value,
};
let reason = bad_file
.lookup(model, &tokenizer, &None)
.expect_err("field genuinely absent from this file");
assert!(reason.contains("generation_config.json"), "{reason}");
}
#[test]
fn miss_reports_exactly_one_reason_per_location_no_repeated_lookups() {
let model = "hf-internal-testing/this-repo-does-not-exist-xyz123";
let tokenizer = Tokenizer::new(tokenizers::models::bpe::BPE::default());
let reasons = HFLocator::locate_eos_token_id(model, &tokenizer, &None)
.expect_err("a nonexistent repo cannot resolve any location");
assert_eq!(reasons.len(), COMMON_LOCATIONS.len());
}
#[test]
fn validate_config_input() {
let input = "bad_model_name*";
assert!(EosTokenLocation::validate(input).is_err());
}
}