Skip to main content

oino_models/
models_json.rs

1//! One immutable, credential-blind load of `models.json`.
2//!
3//! Load problems never fail hard: they become a diagnostic string
4//! ([`ModelsJson::error`]) and the file contributes no providers.
5
6use 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
16/// `samplingParamsByThinkingLevel`: known levels only (unknown keys are ignored).
17pub type SamplingByLevel = IndexMap<ThinkingLevel, JsonObject>;
18
19/// Deserializes `samplingParamsByThinkingLevel`, ignoring unknown level keys.
20pub(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/// A `models[]` entry.
38#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct ModelsJsonModel {
41    /// Model id sent to the API.
42    pub id: String,
43    /// Display name (default: `id`).
44    pub name: Option<String>,
45    /// Overrides the provider `api`.
46    pub api: Option<String>,
47    /// Overrides the provider `baseUrl`.
48    pub base_url: Option<String>,
49    /// Thinking support (default `false`).
50    pub reasoning: Option<bool>,
51    /// Provider value per thinking level.
52    pub thinking_level_map: Option<ThinkingLevelMap>,
53    /// Input modalities (default `["text"]`).
54    pub input: Option<Vec<InputModality>>,
55    /// Request/image limits.
56    pub input_limits: Option<InputLimits>,
57    /// Pricing (default all zero).
58    pub cost: Option<ModelCost>,
59    /// Prompt cache lifetimes.
60    pub prompt_cache: Option<PromptCache>,
61    /// Context window (default 128 000).
62    pub context_window: Option<f64>,
63    /// Maximum output tokens (default 16 384).
64    pub max_tokens: Option<f64>,
65    /// Sampling defaults.
66    pub sampling_params: Option<JsonObject>,
67    /// Sampling per thinking level.
68    #[serde(default, deserialize_with = "sampling_by_level")]
69    pub sampling_params_by_thinking_level: Option<SamplingByLevel>,
70    /// Request headers (values resolve like `apiKey`).
71    pub headers: Option<IndexMap<String, String>>,
72    /// Compatibility flags (opaque to core).
73    pub compat: Option<JsonValue>,
74}
75
76/// Partial cost in a model override.
77#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct PartialCost {
80    /// Input price.
81    pub input: Option<f64>,
82    /// Output price.
83    pub output: Option<f64>,
84    /// Cache-read price.
85    pub cache_read: Option<f64>,
86    /// Cache-write price.
87    pub cache_write: Option<f64>,
88    /// Pricing tiers (replace the base tiers).
89    pub tiers: Option<Vec<ModelCostTier>>,
90}
91
92/// A `modelOverrides` entry: every field merges over the
93/// existing model.
94#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct ModelsJsonModelOverride {
97    /// Display name.
98    pub name: Option<String>,
99    /// Thinking support.
100    pub reasoning: Option<bool>,
101    /// Merged per level.
102    pub thinking_level_map: Option<ThinkingLevelMap>,
103    /// Replaces the modalities.
104    pub input: Option<Vec<InputModality>>,
105    /// Merged field-wise.
106    pub input_limits: Option<InputLimits>,
107    /// Merged field-wise.
108    pub cost: Option<PartialCost>,
109    /// Merged field-wise.
110    pub prompt_cache: Option<PromptCache>,
111    /// Context window.
112    pub context_window: Option<f64>,
113    /// Maximum output tokens.
114    pub max_tokens: Option<f64>,
115    /// Merged per key.
116    pub sampling_params: Option<JsonObject>,
117    /// Merged per level and key.
118    #[serde(default, deserialize_with = "sampling_by_level")]
119    pub sampling_params_by_thinking_level: Option<SamplingByLevel>,
120    /// Request-time headers for this model.
121    pub headers: Option<IndexMap<String, String>>,
122    /// Merged like provider compat.
123    pub compat: Option<JsonValue>,
124}
125
126/// A `providers.<id>` entry.
127#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct ModelsJsonProvider {
130    /// Display name.
131    pub name: Option<String>,
132    /// API base URL; also applies to the provider's registered models.
133    pub base_url: Option<String>,
134    /// `literal | $ENV | ${ENV} | !command`.
135    pub api_key: Option<String>,
136    /// API of the provider's models.
137    pub api: Option<String>,
138    /// The `"radius"` OAuth flow marker (validated, no flow in core).
139    pub oauth: Option<String>,
140    /// Request headers (values resolve like `apiKey`).
141    pub headers: Option<IndexMap<String, String>>,
142    /// Compatibility flags.
143    pub compat: Option<JsonValue>,
144    /// Send the resolved key as `Authorization: Bearer` (default `false`).
145    pub auth_header: Option<bool>,
146    /// Added or replaced models.
147    pub models: Option<Vec<ModelsJsonModel>>,
148    /// Metadata overrides keyed by model id.
149    pub model_overrides: Option<IndexMap<String, ModelsJsonModelOverride>>,
150}
151
152#[derive(Deserialize)]
153struct RawConfig {
154    providers: IndexMap<String, ModelsJsonProvider>,
155}
156
157/// One load of `models.json`.
158#[derive(Clone, Debug, Default, PartialEq)]
159pub struct ModelsJson {
160    providers: IndexMap<String, ModelsJsonProvider>,
161    error: Option<String>,
162}
163
164impl ModelsJson {
165    /// An empty configuration (no file).
166    #[must_use]
167    pub fn empty() -> Self {
168        Self::default()
169    }
170
171    /// A missing file is empty; read, parse and schema errors become
172    /// [`ModelsJson::error`] with the load texts.
173    #[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    /// Parses `content` as if read from `path` (used for diagnostics).
189    #[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            // Unreachable after validation in practice; still reported, never panics.
220            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    /// Provider entry by id.
235    #[must_use]
236    pub fn provider(&self, id: &str) -> Option<&ModelsJsonProvider> {
237        self.providers.get(id)
238    }
239
240    /// Provider ids in file order.
241    pub fn provider_ids(&self) -> impl Iterator<Item = &str> {
242        self.providers.keys().map(String::as_str)
243    }
244
245    /// The load diagnostic, if the file could not be used.
246    #[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}