use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use oino_ai::{InputLimits, InputModality, JsonObject, JsonValue, ModelCost, ModelCostTier};
use oino_ai::{PromptCache, ThinkingLevel, ThinkingLevelMap};
use serde::{Deserialize, Deserializer};
use crate::json_text::{strip_bom, strip_json_comments};
use crate::schema::{MODELS_CONFIG, validate};
pub type SamplingByLevel = IndexMap<ThinkingLevel, JsonObject>;
pub(crate) fn sampling_by_level<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<SamplingByLevel>, D::Error> {
let raw = Option::<IndexMap<String, JsonValue>>::deserialize(deserializer)?;
Ok(raw.map(|raw| {
raw.into_iter()
.filter_map(|(key, value)| {
let level = key.parse::<ThinkingLevel>().ok()?;
match value {
JsonValue::Object(object) => Some((level, object)),
_ => None,
}
})
.collect()
}))
}
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelsJsonModel {
pub id: String,
pub name: Option<String>,
pub api: Option<String>,
pub base_url: Option<String>,
pub reasoning: Option<bool>,
pub thinking_level_map: Option<ThinkingLevelMap>,
pub input: Option<Vec<InputModality>>,
pub input_limits: Option<InputLimits>,
pub cost: Option<ModelCost>,
pub prompt_cache: Option<PromptCache>,
pub context_window: Option<f64>,
pub max_tokens: Option<f64>,
pub sampling_params: Option<JsonObject>,
#[serde(default, deserialize_with = "sampling_by_level")]
pub sampling_params_by_thinking_level: Option<SamplingByLevel>,
pub headers: Option<IndexMap<String, String>>,
pub compat: Option<JsonValue>,
}
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PartialCost {
pub input: Option<f64>,
pub output: Option<f64>,
pub cache_read: Option<f64>,
pub cache_write: Option<f64>,
pub tiers: Option<Vec<ModelCostTier>>,
}
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelsJsonModelOverride {
pub name: Option<String>,
pub reasoning: Option<bool>,
pub thinking_level_map: Option<ThinkingLevelMap>,
pub input: Option<Vec<InputModality>>,
pub input_limits: Option<InputLimits>,
pub cost: Option<PartialCost>,
pub prompt_cache: Option<PromptCache>,
pub context_window: Option<f64>,
pub max_tokens: Option<f64>,
pub sampling_params: Option<JsonObject>,
#[serde(default, deserialize_with = "sampling_by_level")]
pub sampling_params_by_thinking_level: Option<SamplingByLevel>,
pub headers: Option<IndexMap<String, String>>,
pub compat: Option<JsonValue>,
}
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelsJsonProvider {
pub name: Option<String>,
pub base_url: Option<String>,
pub api_key: Option<String>,
pub api: Option<String>,
pub oauth: Option<String>,
pub headers: Option<IndexMap<String, String>>,
pub compat: Option<JsonValue>,
pub auth_header: Option<bool>,
pub models: Option<Vec<ModelsJsonModel>>,
pub model_overrides: Option<IndexMap<String, ModelsJsonModelOverride>>,
}
#[derive(Deserialize)]
struct RawConfig {
providers: IndexMap<String, ModelsJsonProvider>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ModelsJson {
providers: IndexMap<String, ModelsJsonProvider>,
error: Option<String>,
}
impl ModelsJson {
#[must_use]
pub fn empty() -> Self {
Self::default()
}
#[must_use]
pub fn load(path: Option<&Path>) -> Self {
let Some(path) = path else {
return Self::empty();
};
match std::fs::read_to_string(path) {
Ok(content) => Self::parse_at(&content, path),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Self::empty(),
Err(error) => Self::failed(format!(
"Failed to load models.json: {error}\n\nFile: {}",
path.display()
)),
}
}
#[must_use]
pub fn parse_at(content: &str, path: &Path) -> Self {
let file = PathBuf::from(path);
let parsed: JsonValue = match serde_json::from_str(&strip_json_comments(strip_bom(content)))
{
Ok(value) => value,
Err(error) => {
return Self::failed(format!(
"Failed to parse models.json: {error}\n\nFile: {}",
file.display()
));
}
};
let errors = validate(&MODELS_CONFIG, &parsed);
if !errors.is_empty() {
let lines: Vec<String> = errors
.iter()
.map(|error| format!(" - {}: {}", error.path, error.message))
.collect();
return Self::failed(format!(
"Invalid models.json schema:\n{}\n\nFile: {}",
lines.join("\n"),
file.display()
));
}
match serde_json::from_value::<RawConfig>(parsed) {
Ok(raw) => Self {
providers: raw.providers,
error: None,
},
Err(error) => Self::failed(format!(
"Invalid models.json schema:\n - root: {error}\n\nFile: {}",
file.display()
)),
}
}
fn failed(error: String) -> Self {
Self {
providers: IndexMap::new(),
error: Some(error),
}
}
#[must_use]
pub fn provider(&self, id: &str) -> Option<&ModelsJsonProvider> {
self.providers.get(id)
}
pub fn provider_ids(&self) -> impl Iterator<Item = &str> {
self.providers.keys().map(String::as_str)
}
#[must_use]
pub fn error(&self) -> Option<&str> {
self.error.as_deref()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(text: &str) -> ModelsJson {
ModelsJson::parse_at(text, Path::new("/x/models.json"))
}
#[test]
fn parses_lenient_json() {
let config = parse("{ // c\n \"providers\": {\"a\": {\"baseUrl\": \"u\",}},}");
assert_eq!(config.error(), None);
assert_eq!(
config.provider("a").and_then(|p| p.base_url.as_deref()),
Some("u")
);
}
#[test]
fn schema_error_text() {
let config = parse(r#"{"providers":{"a":{"baseUrl":5}}}"#);
assert_eq!(
config.error(),
Some(
"Invalid models.json schema:\n - providers.a.baseUrl: must be string\n\nFile: /x/models.json"
)
);
assert_eq!(config.provider_ids().count(), 0);
}
#[test]
fn missing_file_is_empty() {
let config = ModelsJson::load(Some(Path::new("/definitely/missing/models.json")));
assert_eq!(config, ModelsJson::empty());
}
}