use std::path::{Path, PathBuf};
use candle_core::{DType, Device, Tensor, D};
use candle_nn::VarBuilder;
use candle_transformers::models::llama::{Cache, Config, Llama, LlamaConfig, LlamaEosToks};
use tokenizers::Tokenizer;
pub use crate::providers::DEFAULT_LOCAL_MODEL;
const MODEL_FILES: [&str; 3] = ["config.json", "tokenizer.json", "model.safetensors"];
#[derive(Debug, thiserror::Error)]
pub enum LocalModelError {
#[error("downloading {file} for {repo}: {message}")]
Download {
repo: String,
file: String,
message: String,
},
#[error("model cache: {0}")]
Io(#[from] std::io::Error),
#[error("unsupported model {repo}: {message}")]
Unsupported {
repo: String,
message: String,
},
#[error("tokenizer: {0}")]
Tokenizer(String),
#[error("model: {0}")]
Candle(#[from] candle_core::Error),
}
#[derive(Debug, Clone, PartialEq)]
pub struct GeneratedToken {
pub text: String,
pub id: u32,
pub logprob: f32,
pub alternatives: Vec<(String, f32)>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Occlusion {
pub words: Vec<String>,
pub tokens: Vec<String>,
pub scores: Vec<Vec<f32>>,
}
impl Occlusion {
pub fn word_totals(&self) -> Vec<f32> {
(0..self.words.len())
.map(|w| self.scores.iter().map(|row| row[w]).sum())
.collect()
}
}
pub struct LocalModel {
repo: String,
model: Llama,
config: Config,
tokenizer: Tokenizer,
device: Device,
eos: Vec<u32>,
}
impl std::fmt::Debug for LocalModel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LocalModel").field("repo", &self.repo).finish_non_exhaustive()
}
}
impl LocalModel {
pub fn cache_dir() -> PathBuf {
if let Some(dir) = std::env::var_os("EOT_MODEL_DIR") {
return dir.into();
}
let base = if cfg!(windows) {
std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
} else if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Caches"))
} else {
std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
};
base.unwrap_or_else(std::env::temp_dir)
.join("every-other-token")
.join("models")
}
pub async fn load(repo: &str) -> Result<Self, LocalModelError> {
let dir = Self::cache_dir().join(repo.replace('/', "--"));
download_files(repo, &dir).await?;
let repo = repo.to_string();
let dir_clone = dir.clone();
tokio::task::spawn_blocking(move || Self::load_dir(&repo, &dir_clone))
.await
.map_err(|e| LocalModelError::Io(std::io::Error::other(e.to_string())))?
}
pub fn load_dir(repo: &str, dir: &Path) -> Result<Self, LocalModelError> {
let raw = std::fs::read(dir.join("config.json"))?;
let arch: serde_json::Value = serde_json::from_slice(&raw).map_err(|e| unsupported(repo, e))?;
let is_llama = arch["model_type"].as_str() == Some("llama")
|| arch["architectures"][0].as_str() == Some("LlamaForCausalLM");
if !is_llama {
return Err(LocalModelError::Unsupported {
repo: repo.to_string(),
message: format!(
"only Llama-architecture models are supported (this is '{}')",
arch["model_type"].as_str().unwrap_or("unknown")
),
});
}
let llama_config: LlamaConfig = serde_json::from_slice(&raw).map_err(|e| unsupported(repo, e))?;
let eos = match &llama_config.eos_token_id {
Some(LlamaEosToks::Single(id)) => vec![*id],
Some(LlamaEosToks::Multiple(ids)) => ids.clone(),
None => Vec::new(),
};
let config = llama_config.into_config(false);
let device = Device::Cpu;
let weights = std::fs::read(dir.join("model.safetensors"))?;
let vb = VarBuilder::from_buffered_safetensors(weights, DType::F32, &device)?;
let model = Llama::load(vb, &config)?;
let tokenizer = Tokenizer::from_file(dir.join("tokenizer.json"))
.map_err(|e| LocalModelError::Tokenizer(e.to_string()))?;
Ok(Self {
repo: repo.to_string(),
model,
config,
tokenizer,
device,
eos,
})
}
pub fn repo(&self) -> &str {
&self.repo
}
pub fn chat_prompt(&self, prompt: &str) -> String {
if self.tokenizer.token_to_id("<|im_start|>").is_some() {
format!("<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n")
} else {
prompt.to_string()
}
}
fn encode(&self, text: &str) -> Result<Vec<u32>, LocalModelError> {
self.tokenizer
.encode(text, false)
.map(|e| e.get_ids().to_vec())
.map_err(|e| LocalModelError::Tokenizer(e.to_string()))
}
fn decode(&self, ids: &[u32]) -> Result<String, LocalModelError> {
self.tokenizer
.decode(ids, true)
.map_err(|e| LocalModelError::Tokenizer(e.to_string()))
}
fn log_softmax(logits: &Tensor) -> Result<Vec<f32>, LocalModelError> {
let logits = logits.squeeze(0)?.to_dtype(DType::F32)?;
let lsm = candle_nn::ops::log_softmax(&logits, D::Minus1)?;
Ok(lsm.to_vec1::<f32>()?)
}
fn step(&self, ids: &[u32], pos: usize, cache: &mut Cache) -> Result<Vec<f32>, LocalModelError> {
let input = Tensor::new(ids, &self.device)?.unsqueeze(0)?;
let logits = self.model.forward(&input, pos, cache)?;
Self::log_softmax(&logits)
}
fn top_k(&self, logprobs: &[f32], k: usize) -> Result<Vec<(String, f32)>, LocalModelError> {
let mut idx: Vec<usize> = (0..logprobs.len()).collect();
let k = k.min(idx.len());
idx.select_nth_unstable_by(k.saturating_sub(1), |a, b| logprobs[*b].total_cmp(&logprobs[*a]));
idx.truncate(k);
idx.sort_by(|a, b| logprobs[*b].total_cmp(&logprobs[*a]));
idx.into_iter()
.map(|i| Ok((self.decode(&[i as u32])?, logprobs[i].exp())))
.collect()
}
pub fn generate(
&self,
prompt: &str,
max_tokens: usize,
temperature: f32,
seed: u64,
mut on_token: impl FnMut(&GeneratedToken),
) -> Result<Vec<GeneratedToken>, LocalModelError> {
use rand::{Rng, SeedableRng};
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let prompt_ids = self.encode(&self.chat_prompt(prompt))?;
let mut cache = Cache::new(true, DType::F32, &self.config, &self.device)?;
let mut logprobs = self.step(&prompt_ids, 0, &mut cache)?;
let mut generated: Vec<u32> = Vec::new();
let mut decoded = String::new();
let mut out = Vec::new();
for pos in (prompt_ids.len()..).take(max_tokens) {
let id = if temperature <= 0.0 {
argmax(&logprobs)
} else {
let weights: Vec<f64> = logprobs.iter().map(|lp| f64::from(lp / temperature).exp()).collect();
let total: f64 = weights.iter().sum();
let mut r = rng.gen::<f64>() * total;
let mut chosen = argmax(&logprobs);
for (i, w) in weights.iter().enumerate() {
r -= w;
if r <= 0.0 {
chosen = i as u32;
break;
}
}
chosen
};
if self.eos.contains(&id) {
break;
}
let alternatives = self.top_k(&logprobs, 5)?;
let logprob = logprobs[id as usize];
generated.push(id);
let full = self.decode(&generated)?;
let text = full.strip_prefix(decoded.as_str()).unwrap_or(&full).to_string();
decoded = full;
let token = GeneratedToken {
text,
id,
logprob,
alternatives,
};
on_token(&token);
out.push(token);
logprobs = self.step(&[id], pos, &mut cache)?;
}
Ok(out)
}
pub fn score(&self, prompt: &str, answer: &[u32]) -> Result<Vec<f32>, LocalModelError> {
let prompt_ids = self.encode(&self.chat_prompt(prompt))?;
let mut cache = Cache::new(true, DType::F32, &self.config, &self.device)?;
let mut logprobs = self.step(&prompt_ids, 0, &mut cache)?;
let mut out = Vec::with_capacity(answer.len());
for (pos, &id) in (prompt_ids.len()..).zip(answer) {
out.push(logprobs[id as usize]);
logprobs = self.step(&[id], pos, &mut cache)?;
}
Ok(out)
}
pub fn occlusion(&self, prompt: &str, answer: &[GeneratedToken]) -> Result<Occlusion, LocalModelError> {
let ids: Vec<u32> = answer.iter().map(|t| t.id).collect();
let words: Vec<String> = prompt.split_whitespace().map(str::to_string).collect();
let full = self.score(prompt, &ids)?;
let mut scores = vec![vec![0.0_f32; words.len()]; ids.len()];
for w in 0..words.len() {
let masked: Vec<&str> = words
.iter()
.enumerate()
.filter(|(i, _)| *i != w)
.map(|(_, s)| s.as_str())
.collect();
let masked_lp = self.score(&masked.join(" "), &ids)?;
for ((row, f), m) in scores.iter_mut().zip(&full).zip(&masked_lp) {
row[w] = f - m;
}
}
Ok(Occlusion {
words,
tokens: answer.iter().map(|t| t.text.clone()).collect(),
scores,
})
}
}
fn argmax(v: &[f32]) -> u32 {
v.iter()
.enumerate()
.max_by(|a, b| a.1.total_cmp(b.1))
.map(|(i, _)| i as u32)
.unwrap_or(0)
}
fn unsupported(repo: &str, e: impl std::fmt::Display) -> LocalModelError {
LocalModelError::Unsupported {
repo: repo.to_string(),
message: e.to_string(),
}
}
async fn download_files(repo: &str, dir: &Path) -> Result<(), LocalModelError> {
use futures_util::StreamExt;
use std::io::Write;
std::fs::create_dir_all(dir)?;
let client = reqwest::Client::new();
for file in MODEL_FILES {
let target = dir.join(file);
if target.exists() {
continue;
}
let err = |message: String| LocalModelError::Download {
repo: repo.to_string(),
file: file.to_string(),
message,
};
let url = format!("https://huggingface.co/{repo}/resolve/main/{file}");
let resp = client.get(&url).send().await.map_err(|e| err(e.to_string()))?;
if !resp.status().is_success() {
return Err(err(format!("HTTP {} from {url}", resp.status())));
}
let total = resp.content_length();
if file == "model.safetensors" {
eprintln!(
"Downloading {repo} ({} MB, once) to {}",
total.map(|b| b / 1_000_000).unwrap_or(0),
dir.display()
);
}
let partial = dir.join(format!("{file}.partial"));
let mut out = std::fs::File::create(&partial)?;
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
out.write_all(&chunk.map_err(|e| err(e.to_string()))?)?;
}
out.flush()?;
drop(out);
std::fs::rename(&partial, &target)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn argmax_picks_the_largest() {
assert_eq!(argmax(&[-3.0, -0.1, -2.0]), 1);
}
#[test]
fn word_totals_sum_each_column() {
let o = Occlusion {
words: vec!["a".into(), "b".into()],
tokens: vec!["x".into(), "y".into()],
scores: vec![vec![1.0, 0.5], vec![2.0, -0.5]],
};
assert_eq!(o.word_totals(), vec![3.0, 0.0]);
}
#[test]
fn cache_dir_respects_the_override() {
assert!(LocalModel::cache_dir().ends_with("models") || std::env::var_os("EOT_MODEL_DIR").is_some());
}
}