1use std::path::{Path, PathBuf};
7
8use indexmap::IndexMap;
9use oino_ai::{InputLimits, InputModality, JsonObject, JsonValue, ModelCost, ModelCostTier};
10use oino_ai::{PromptCache, ThinkingLevel, ThinkingLevelMap};
11use serde::{Deserialize, Deserializer};
12
13use crate::json_text::{strip_bom, strip_json_comments};
14use crate::schema::{MODELS_CONFIG, validate};
15
16pub type SamplingByLevel = IndexMap<ThinkingLevel, JsonObject>;
18
19pub(crate) fn sampling_by_level<'de, D: Deserializer<'de>>(
21 deserializer: D,
22) -> Result<Option<SamplingByLevel>, D::Error> {
23 let raw = Option::<IndexMap<String, JsonValue>>::deserialize(deserializer)?;
24 Ok(raw.map(|raw| {
25 raw.into_iter()
26 .filter_map(|(key, value)| {
27 let level = key.parse::<ThinkingLevel>().ok()?;
28 match value {
29 JsonValue::Object(object) => Some((level, object)),
30 _ => None,
31 }
32 })
33 .collect()
34 }))
35}
36
37#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct ModelsJsonModel {
41 pub id: String,
43 pub name: Option<String>,
45 pub api: Option<String>,
47 pub base_url: Option<String>,
49 pub reasoning: Option<bool>,
51 pub thinking_level_map: Option<ThinkingLevelMap>,
53 pub input: Option<Vec<InputModality>>,
55 pub input_limits: Option<InputLimits>,
57 pub cost: Option<ModelCost>,
59 pub prompt_cache: Option<PromptCache>,
61 pub context_window: Option<f64>,
63 pub max_tokens: Option<f64>,
65 pub sampling_params: Option<JsonObject>,
67 #[serde(default, deserialize_with = "sampling_by_level")]
69 pub sampling_params_by_thinking_level: Option<SamplingByLevel>,
70 pub headers: Option<IndexMap<String, String>>,
72 pub compat: Option<JsonValue>,
74}
75
76#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct PartialCost {
80 pub input: Option<f64>,
82 pub output: Option<f64>,
84 pub cache_read: Option<f64>,
86 pub cache_write: Option<f64>,
88 pub tiers: Option<Vec<ModelCostTier>>,
90}
91
92#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct ModelsJsonModelOverride {
97 pub name: Option<String>,
99 pub reasoning: Option<bool>,
101 pub thinking_level_map: Option<ThinkingLevelMap>,
103 pub input: Option<Vec<InputModality>>,
105 pub input_limits: Option<InputLimits>,
107 pub cost: Option<PartialCost>,
109 pub prompt_cache: Option<PromptCache>,
111 pub context_window: Option<f64>,
113 pub max_tokens: Option<f64>,
115 pub sampling_params: Option<JsonObject>,
117 #[serde(default, deserialize_with = "sampling_by_level")]
119 pub sampling_params_by_thinking_level: Option<SamplingByLevel>,
120 pub headers: Option<IndexMap<String, String>>,
122 pub compat: Option<JsonValue>,
124}
125
126#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct ModelsJsonProvider {
130 pub name: Option<String>,
132 pub base_url: Option<String>,
134 pub api_key: Option<String>,
136 pub api: Option<String>,
138 pub oauth: Option<String>,
140 pub headers: Option<IndexMap<String, String>>,
142 pub compat: Option<JsonValue>,
144 pub auth_header: Option<bool>,
146 pub models: Option<Vec<ModelsJsonModel>>,
148 pub model_overrides: Option<IndexMap<String, ModelsJsonModelOverride>>,
150}
151
152#[derive(Deserialize)]
153struct RawConfig {
154 providers: IndexMap<String, ModelsJsonProvider>,
155}
156
157#[derive(Clone, Debug, Default, PartialEq)]
159pub struct ModelsJson {
160 providers: IndexMap<String, ModelsJsonProvider>,
161 error: Option<String>,
162}
163
164impl ModelsJson {
165 #[must_use]
167 pub fn empty() -> Self {
168 Self::default()
169 }
170
171 #[must_use]
174 pub fn load(path: Option<&Path>) -> Self {
175 let Some(path) = path else {
176 return Self::empty();
177 };
178 match std::fs::read_to_string(path) {
179 Ok(content) => Self::parse_at(&content, path),
180 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Self::empty(),
181 Err(error) => Self::failed(format!(
182 "Failed to load models.json: {error}\n\nFile: {}",
183 path.display()
184 )),
185 }
186 }
187
188 #[must_use]
190 pub fn parse_at(content: &str, path: &Path) -> Self {
191 let file = PathBuf::from(path);
192 let parsed: JsonValue = match serde_json::from_str(&strip_json_comments(strip_bom(content)))
193 {
194 Ok(value) => value,
195 Err(error) => {
196 return Self::failed(format!(
197 "Failed to parse models.json: {error}\n\nFile: {}",
198 file.display()
199 ));
200 }
201 };
202 let errors = validate(&MODELS_CONFIG, &parsed);
203 if !errors.is_empty() {
204 let lines: Vec<String> = errors
205 .iter()
206 .map(|error| format!(" - {}: {}", error.path, error.message))
207 .collect();
208 return Self::failed(format!(
209 "Invalid models.json schema:\n{}\n\nFile: {}",
210 lines.join("\n"),
211 file.display()
212 ));
213 }
214 match serde_json::from_value::<RawConfig>(parsed) {
215 Ok(raw) => Self {
216 providers: raw.providers,
217 error: None,
218 },
219 Err(error) => Self::failed(format!(
221 "Invalid models.json schema:\n - root: {error}\n\nFile: {}",
222 file.display()
223 )),
224 }
225 }
226
227 fn failed(error: String) -> Self {
228 Self {
229 providers: IndexMap::new(),
230 error: Some(error),
231 }
232 }
233
234 #[must_use]
236 pub fn provider(&self, id: &str) -> Option<&ModelsJsonProvider> {
237 self.providers.get(id)
238 }
239
240 pub fn provider_ids(&self) -> impl Iterator<Item = &str> {
242 self.providers.keys().map(String::as_str)
243 }
244
245 #[must_use]
247 pub fn error(&self) -> Option<&str> {
248 self.error.as_deref()
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn parse(text: &str) -> ModelsJson {
257 ModelsJson::parse_at(text, Path::new("/x/models.json"))
258 }
259
260 #[test]
261 fn parses_lenient_json() {
262 let config = parse("{ // c\n \"providers\": {\"a\": {\"baseUrl\": \"u\",}},}");
263 assert_eq!(config.error(), None);
264 assert_eq!(
265 config.provider("a").and_then(|p| p.base_url.as_deref()),
266 Some("u")
267 );
268 }
269
270 #[test]
271 fn schema_error_text() {
272 let config = parse(r#"{"providers":{"a":{"baseUrl":5}}}"#);
273 assert_eq!(
274 config.error(),
275 Some(
276 "Invalid models.json schema:\n - providers.a.baseUrl: must be string\n\nFile: /x/models.json"
277 )
278 );
279 assert_eq!(config.provider_ids().count(), 0);
280 }
281
282 #[test]
283 fn missing_file_is_empty() {
284 let config = ModelsJson::load(Some(Path::new("/definitely/missing/models.json")));
285 assert_eq!(config, ModelsJson::empty());
286 }
287}