use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const REGISTRY_URL: &str = "https://models.dev/api.json";
const TIMEOUT: Duration = Duration::from_secs(20);
const MAX_AGE: u64 = 7 * 24 * 60 * 60;
const FILE_NAME: &str = "model-quirks.toml";
pub const PATH_VAR: &str = "DREP_QUIRKS_PATH";
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Quirks {
pub temperature: Option<f32>,
pub max_tokens: Option<u32>,
pub max_tokens_from_registry: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelFacts {
#[serde(default = "yes")]
pub temperature: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_limit: Option<u32>,
}
impl ModelFacts {
fn narrow(&mut self, other: Self) {
self.temperature = self.temperature && other.temperature;
self.output_limit = match (self.output_limit, other.output_limit) {
(Some(mine), Some(theirs)) => Some(mine.min(theirs)),
(mine, theirs) => mine.or(theirs),
};
}
}
fn yes() -> bool {
true
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Registry {
fetched_at: u64,
#[serde(default)]
providers: BTreeMap<String, BTreeMap<String, ModelFacts>>,
}
#[derive(Debug, Error)]
pub enum QuirksError {
#[error("could not reach the model registry: {0}")]
Transport(String),
#[error("the model registry could not be read: {0}")]
Malformed(String),
#[error("could not write the model registry cache to {0}: {1}")]
Cache(PathBuf, String),
}
pub trait QuirksSource {
#[allow(async_fn_in_trait)]
async fn registry(&self) -> Result<Registry, QuirksError>;
}
pub trait Fetch {
#[allow(async_fn_in_trait)]
async fn document(&self) -> Result<String, QuirksError>;
}
#[cfg(test)]
impl<F: Fetch> Fetch for &F {
async fn document(&self) -> Result<String, QuirksError> {
(*self).document().await
}
}
#[derive(Debug, Clone)]
pub struct Http {
url: String,
max_bytes: u64,
}
impl Http {
pub fn new(url: &str) -> Self {
Self {
url: url.to_string(),
max_bytes: MAX_DOCUMENT_BYTES,
}
}
pub fn with_max_bytes(mut self, max_bytes: u64) -> Self {
self.max_bytes = max_bytes;
self
}
}
impl Fetch for Http {
async fn document(&self) -> Result<String, QuirksError> {
let client = crate::http::client(TIMEOUT).map_err(QuirksError::Transport)?;
let response = client
.get(&self.url)
.send()
.await
.map_err(|err| QuirksError::Transport(err.to_string()))?;
if !response.status().is_success() {
return Err(QuirksError::Transport(format!(
"HTTP {}",
response.status().as_u16()
)));
}
crate::http::read_bounded(response, self.max_bytes)
.await
.map_err(|err| match err {
crate::http::ReadError::Transport(msg) => QuirksError::Transport(msg),
crate::http::ReadError::Malformed(msg) => QuirksError::Malformed(msg),
})
}
}
pub struct Cached<F> {
path: Option<PathBuf>,
fetcher: F,
now: u64,
}
impl Cached<Http> {
pub fn new(path: Option<PathBuf>) -> Self {
Self {
path,
fetcher: Http::new(REGISTRY_URL),
now: unix_now(),
}
}
}
#[cfg(test)]
impl<F: Fetch> Cached<F> {
pub(crate) fn at(path: Option<PathBuf>, fetcher: F, now: u64) -> Self {
Self { path, fetcher, now }
}
}
impl<F: Fetch> QuirksSource for Cached<F> {
async fn registry(&self) -> Result<Registry, QuirksError> {
let cached = self.path.as_deref().and_then(Registry::load);
if let Some(registry) = &cached
&& !registry.is_stale(self.now)
{
return Ok(registry.clone());
}
let fetched = self
.fetcher
.document()
.await
.and_then(|body| Registry::distil(&body, self.now));
match fetched {
Ok(registry) => {
if let Some(path) = &self.path {
let _ = registry.save(path);
}
Ok(registry)
}
Err(err) => cached.ok_or(err),
}
}
}
impl Registry {
pub fn facts(&self, endpoint: &str, model: &str) -> Option<&ModelFacts> {
self.providers
.get(&crate::auth::normalise(endpoint))?
.get(model)
}
pub fn is_stale(&self, now: u64) -> bool {
now.saturating_sub(self.fetched_at) > MAX_AGE
}
pub fn load(path: &Path) -> Option<Self> {
toml::from_str(&std::fs::read_to_string(path).ok()?).ok()
}
pub fn save(&self, path: &Path) -> Result<(), QuirksError> {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
crate::auth::ensure_dir_private(parent)
.map_err(|err| QuirksError::Cache(parent.to_path_buf(), err.to_string()))?;
}
let body = toml::to_string(self)
.map_err(|err| QuirksError::Cache(path.to_path_buf(), err.to_string()))?;
let temporary = path.with_extension("toml.tmp");
std::fs::write(&temporary, body)
.map_err(|err| QuirksError::Cache(temporary.clone(), err.to_string()))?;
std::fs::rename(&temporary, path).map_err(|err| {
let _ = std::fs::remove_file(&temporary);
QuirksError::Cache(path.to_path_buf(), err.to_string())
})
}
pub fn distil(body: &str, fetched_at: u64) -> Result<Self, QuirksError> {
let raw: BTreeMap<String, RawProvider> = serde_json::from_str(body)
.map_err(|err| QuirksError::Malformed(crate::text::excerpt(&err.to_string(), 120)))?;
let mut providers: BTreeMap<String, BTreeMap<String, ModelFacts>> = BTreeMap::new();
for provider in raw.into_values() {
let Some(api) = provider.api.filter(|api| !api.trim().is_empty()) else {
continue;
};
let entry = providers.entry(crate::auth::normalise(&api)).or_default();
for (id, model) in provider.models {
let facts = ModelFacts {
temperature: model.temperature,
output_limit: model.limit.and_then(|limit| limit.output),
};
entry
.entry(id)
.and_modify(|held| held.narrow(facts))
.or_insert(facts);
}
}
if providers.is_empty() {
return Err(QuirksError::Malformed(
"the document named no provider with an endpoint".to_string(),
));
}
Ok(Self {
fetched_at,
providers,
})
}
}
pub fn resolve(
registry: Option<&Registry>,
defaults: Quirks,
endpoint: &str,
model: &str,
) -> Quirks {
let Some(facts) = registry.and_then(|registry| registry.facts(endpoint, model)) else {
return defaults;
};
Quirks {
temperature: if facts.temperature {
defaults.temperature
} else {
None
},
max_tokens: defaults
.max_tokens
.map(|fallback| facts.output_limit.unwrap_or(fallback).min(fallback)),
max_tokens_from_registry: defaults
.max_tokens
.is_some_and(|fallback| facts.output_limit.is_some_and(|limit| limit <= fallback)),
}
}
const MAX_DOCUMENT_BYTES: u64 = 32 * 1024 * 1024;
pub fn default_path() -> Option<PathBuf> {
path_from(std::env::var_os(PATH_VAR))
}
pub fn path_from(overridden: Option<std::ffi::OsString>) -> Option<PathBuf> {
if let Some(path) = overridden {
return Some(PathBuf::from(path));
}
directories::ProjectDirs::from("dev", "slb350", "drep")
.map(|dirs| dirs.config_dir().join(FILE_NAME))
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_secs())
.unwrap_or(0)
}
#[derive(Debug, Deserialize)]
struct RawProvider {
#[serde(default)]
api: Option<String>,
#[serde(default)]
models: BTreeMap<String, RawModel>,
}
#[derive(Debug, Deserialize)]
struct RawModel {
#[serde(default = "yes")]
temperature: bool,
#[serde(default)]
limit: Option<RawLimit>,
}
#[derive(Debug, Deserialize)]
struct RawLimit {
#[serde(default)]
output: Option<u32>,
}
#[cfg(test)]
mod tests;