use std::default::Default;
use std::fmt::{self, Display};
use std::str::FromStr;
use std::sync::OnceLock;
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use tiktoken_rs::CoreBPE;
use tiktoken_rs::model::get_context_size;
use crate::profile;
static TOKENIZER: OnceLock<CoreBPE> = OnceLock::new();
const MODEL_GPT4_1: &str = "gpt-4.1";
const MODEL_GPT4_1_MINI: &str = "gpt-4.1-mini";
const MODEL_GPT4_1_NANO: &str = "gpt-4.1-nano";
const MODEL_GPT4_5: &str = "gpt-4.5";
#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Default)]
pub enum Model {
GPT41,
#[default]
GPT41Mini,
GPT41Nano,
GPT45,
Other(String)
}
impl Model {
pub fn count_tokens(&self, text: &str) -> Result<usize> {
profile!("Count tokens");
if text.is_empty() {
return Ok(0);
}
let tokenizer = TOKENIZER.get_or_init(|| get_tokenizer(self.as_ref()));
let tokens = tokenizer.encode_ordinary(text);
Ok(tokens.len())
}
pub fn context_size(&self) -> usize {
profile!("Get context size");
get_context_size(self.as_ref()).unwrap_or(4096)
}
pub(crate) fn truncate(&self, text: &str, max_tokens: usize) -> Result<String> {
profile!("Truncate text");
self.walk_truncate(text, max_tokens, usize::MAX)
}
pub(crate) fn walk_truncate(&self, text: &str, max_tokens: usize, _within: usize) -> Result<String> {
profile!("Walk truncate");
log::debug!("max_tokens: {max_tokens}");
if max_tokens == 0 || text.is_empty() {
return Ok(String::new());
}
let tokenizer = TOKENIZER.get_or_init(|| get_tokenizer(self.as_ref()));
let tokens = tokenizer.encode_ordinary(text);
if tokens.len() <= max_tokens {
return Ok(text.to_string());
}
let mut end = max_tokens;
loop {
match tokenizer.decode(&tokens[..end]) {
Ok(decoded) => return Ok(decoded),
Err(_) if end > 0 => end -= 1,
Err(e) => return Err(e)
}
}
}
}
impl AsRef<str> for Model {
fn as_ref(&self) -> &str {
match self {
Model::GPT41 => MODEL_GPT4_1,
Model::GPT41Mini => MODEL_GPT4_1_MINI,
Model::GPT41Nano => MODEL_GPT4_1_NANO,
Model::GPT45 => MODEL_GPT4_5,
Model::Other(name) => name.as_str()
}
}
}
impl From<&Model> for String {
fn from(model: &Model) -> Self {
model.as_ref().to_string()
}
}
impl Model {
pub fn as_str(&self) -> &str {
self.as_ref()
}
}
impl FromStr for Model {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
let trimmed = s.trim();
let normalized = trimmed.to_lowercase();
match normalized.as_str() {
"" => bail!("Model name cannot be empty"),
"gpt-4.1" => Ok(Model::GPT41),
"gpt-4.1-mini" => Ok(Model::GPT41Mini),
"gpt-4.1-nano" => Ok(Model::GPT41Nano),
"gpt-4.5" => Ok(Model::GPT45),
"gpt-4" | "gpt-4o" => {
log::warn!(
"Model '{}' is deprecated. Mapping to 'gpt-4.1'. \
Please update your configuration with: git ai config set model gpt-4.1",
s
);
Ok(Model::GPT41)
}
"gpt-4o-mini" | "gpt-3.5-turbo" => {
log::warn!(
"Model '{}' is deprecated. Mapping to 'gpt-4.1-mini'. \
Please update your configuration with: git ai config set model gpt-4.1-mini",
s
);
Ok(Model::GPT41Mini)
}
_ => Ok(Model::Other(trimmed.to_string()))
}
}
}
impl Display for Model {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_ref())
}
}
impl From<&str> for Model {
fn from(s: &str) -> Self {
s.parse().unwrap_or_else(|e| {
log::error!("Failed to parse model '{}': {}. Falling back to default model 'gpt-4.1'.", s, e);
Model::default()
})
}
}
impl From<String> for Model {
fn from(s: String) -> Self {
s.as_str().into()
}
}
pub fn is_known_or_deprecated(name: &str) -> bool {
matches!(
name.trim().to_lowercase().as_str(),
"gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" | "gpt-4.5" | "gpt-4" | "gpt-4o" | "gpt-4o-mini" | "gpt-3.5-turbo"
)
}
fn get_tokenizer(_model_str: &str) -> CoreBPE {
tiktoken_rs::cl100k_base().expect("Failed to create tokenizer")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_truncate_large_text_is_exact_and_utf8_safe() {
let model = Model::GPT41;
let text = "The quick brown fox café 世界 🚀 jumps over the lazy dog. ".repeat(500);
let max_tokens = 100;
let truncated = model.truncate(&text, max_tokens).unwrap();
let recount = model.count_tokens(&truncated).unwrap();
assert!(recount <= max_tokens, "re-encoded token count {recount} exceeds max {max_tokens}");
assert!(truncated.len() < text.len(), "expected truncation to shorten the text");
assert!(!truncated.is_empty(), "truncation of large text should not be empty");
}
#[test]
fn test_truncate_passthrough_when_within_limit() {
let model = Model::GPT41;
let text = "small bit of text";
let truncated = model.truncate(text, 1000).unwrap();
assert_eq!(truncated, text);
}
#[test]
fn test_truncate_zero_tokens() {
let model = Model::GPT41;
let truncated = model.truncate("anything at all here", 0).unwrap();
assert_eq!(truncated, "");
}
#[test]
fn test_truncate_multibyte_small_budget() {
let model = Model::GPT41;
let text = "日本語のテキストをトークン化してから切り詰めます".repeat(20);
let truncated = model.truncate(&text, 5).unwrap();
let recount = model.count_tokens(&truncated).unwrap();
assert!(recount <= 5, "re-encoded token count {recount} exceeds 5");
assert!(truncated.is_char_boundary(truncated.len()));
}
}