Skip to main content

oino_models/
registration.rs

1//! Provider registrations (extension-api §4.4) and config-only provider overrides
2//!.
3
4use indexmap::IndexMap;
5use oino_ai::{InputLimits, InputModality, JsonObject, JsonValue, Model, ModelCost};
6use oino_ai::{PromptCache, ThinkingLevelMap};
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use crate::models_json::{SamplingByLevel, sampling_by_level};
10
11/// How a registration sends the API key (`authHeader`).
12///
13/// A boolean and `"bearer"` are both accepted spellings. Any
14/// other header name (e.g. Anthropic's `"x-api-key"`) is informational: the provider
15/// component places the key itself, so it maps to [`AuthHeaderMode::None`].
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub enum AuthHeaderMode {
18    /// The provider component places the key itself.
19    #[default]
20    None,
21    /// `Authorization: Bearer <key>`.
22    Bearer,
23}
24
25impl Serialize for AuthHeaderMode {
26    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
27        match self {
28            Self::None => serializer.serialize_str("none"),
29            Self::Bearer => serializer.serialize_str("bearer"),
30        }
31    }
32}
33
34impl<'de> Deserialize<'de> for AuthHeaderMode {
35    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
36        #[derive(Deserialize)]
37        #[serde(untagged)]
38        enum Raw {
39            Flag(bool),
40            Name(String),
41        }
42        match Raw::deserialize(deserializer)? {
43            Raw::Flag(true) => Ok(Self::Bearer),
44            Raw::Name(name) if name.eq_ignore_ascii_case("bearer") => Ok(Self::Bearer),
45            Raw::Flag(false) | Raw::Name(_) => Ok(Self::None),
46        }
47    }
48}
49
50/// A model in a registration or override.
51///
52/// Most fields default to the models.json values when
53/// they are missing (`name = id`, `input = ["text"]`, zero cost, 128 000 / 16 384 tokens).
54#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct ProviderModelConfig {
57    /// Model id.
58    pub id: String,
59    /// Display name (default: `id`).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub name: Option<String>,
62    /// API (default: the provider's).
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub api: Option<String>,
65    /// Base URL (default: the provider's).
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub base_url: Option<String>,
68    /// Thinking support.
69    #[serde(default)]
70    pub reasoning: bool,
71    /// Provider value per thinking level.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub thinking_level_map: Option<ThinkingLevelMap>,
74    /// Input modalities.
75    #[serde(default = "default_input")]
76    pub input: Vec<InputModality>,
77    /// Request/image limits.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub input_limits: Option<InputLimits>,
80    /// Pricing.
81    #[serde(default)]
82    pub cost: ModelCost,
83    /// Prompt cache lifetimes.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub prompt_cache: Option<PromptCache>,
86    /// Context window.
87    #[serde(default = "default_context_window")]
88    pub context_window: u64,
89    /// Maximum output tokens.
90    #[serde(default = "default_max_tokens")]
91    pub max_tokens: u64,
92    /// Sampling defaults.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub sampling_params: Option<JsonObject>,
95    /// Sampling per thinking level.
96    #[serde(
97        default,
98        deserialize_with = "sampling_by_level",
99        skip_serializing_if = "Option::is_none"
100    )]
101    pub sampling_params_by_thinking_level: Option<SamplingByLevel>,
102    /// Model headers. On a registration they stay on the model; on an override they are
103    /// request-time headers.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub headers: Option<IndexMap<String, String>>,
106    /// Compatibility flags.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub compat: Option<JsonValue>,
109}
110
111fn default_input() -> Vec<InputModality> {
112    vec![InputModality::Text]
113}
114
115/// models.json default context window.
116pub(crate) const DEFAULT_CONTEXT_WINDOW: u64 = 128_000;
117/// models.json default output limit.
118pub(crate) const DEFAULT_MAX_TOKENS: u64 = 16_384;
119
120fn default_context_window() -> u64 {
121    DEFAULT_CONTEXT_WINDOW
122}
123
124fn default_max_tokens() -> u64 {
125    DEFAULT_MAX_TOKENS
126}
127
128impl ProviderModelConfig {
129    /// Builds the catalog model with resolved `provider`, `api` and `baseUrl`.
130    #[must_use]
131    pub fn into_model(self, provider: &str, api: String, base_url: String) -> Model {
132        Model {
133            name: self.name.unwrap_or_else(|| self.id.clone()),
134            id: self.id,
135            api,
136            provider: provider.to_owned(),
137            base_url,
138            input: self.input,
139            input_limits: self.input_limits,
140            cost: self.cost,
141            headers: self.headers,
142            kind: None,
143            reasoning: self.reasoning,
144            thinking_level_map: self.thinking_level_map,
145            prompt_cache: self.prompt_cache,
146            context_window: self.context_window,
147            max_tokens: self.max_tokens,
148            sampling_params: self.sampling_params,
149            sampling_params_by_thinking_level: self.sampling_params_by_thinking_level,
150            compat: self.compat,
151        }
152    }
153}
154
155/// Model list of a registration: `"dynamic"` (host calls the component's `list-models`) or a
156/// static list.
157#[derive(Clone, Debug, Default, PartialEq)]
158pub enum ProviderModels {
159    /// Models come from `list-models` ([`crate::ModelRegistry::set_provider_models`]).
160    #[default]
161    Dynamic,
162    /// Static models.
163    Static(Vec<ProviderModelConfig>),
164}
165
166impl Serialize for ProviderModels {
167    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
168        match self {
169            Self::Dynamic => serializer.serialize_str("dynamic"),
170            Self::Static(models) => models.serialize(serializer),
171        }
172    }
173}
174
175impl<'de> Deserialize<'de> for ProviderModels {
176    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
177        #[derive(Deserialize)]
178        #[serde(untagged)]
179        enum Raw {
180            Name(String),
181            List(Vec<ProviderModelConfig>),
182        }
183        match Raw::deserialize(deserializer)? {
184            Raw::Name(name) if name == "dynamic" => Ok(Self::Dynamic),
185            Raw::Name(name) => Err(serde::de::Error::custom(format!(
186                "models must be \"dynamic\" or an array, got \"{name}\""
187            ))),
188            Raw::List(models) => Ok(Self::Static(models)),
189        }
190    }
191}
192
193/// A provider component's registration (extension-api §4.4).
194#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct ProviderRegistration {
197    /// Provider id (`openrouter`).
198    pub id: String,
199    /// Display name.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub name: Option<String>,
202    /// API the component implements for this provider.
203    pub api: String,
204    /// Default base URL of the provider's models.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub base_url: Option<String>,
207    /// Environment variables holding an API key, in lookup order.
208    #[serde(default)]
209    pub auth_env: Vec<String>,
210    /// Informational for the component (see crate docs: core only injects
211    /// `Authorization` for models.json / override `authHeader: true`).
212    #[serde(default)]
213    pub auth_header: AuthHeaderMode,
214    /// The component supports OAuth login/refresh.
215    #[serde(default)]
216    pub oauth: bool,
217    /// Provider headers used by the component.
218    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
219    pub headers: IndexMap<String, String>,
220    /// Provider compatibility flags applied to every model.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub compat: Option<JsonValue>,
223    /// Model list.
224    #[serde(default)]
225    pub models: ProviderModels,
226}
227
228impl ProviderRegistration {
229    /// Display name (`name`, else `id`).
230    #[must_use]
231    pub fn display_name(&self) -> &str {
232        self.name.as_deref().unwrap_or(&self.id)
233    }
234
235    /// Whether the provider offers API-key auth: every registration does unless it is
236    /// OAuth-only (`oauth: true` without `authEnv`).
237    #[must_use]
238    pub fn has_api_key_auth(&self) -> bool {
239        !(self.oauth && self.auth_env.is_empty())
240    }
241}
242
243/// Config-only overrides an extension sets for an
244/// existing provider id. Re-registration merges defined fields over the previous override.
245#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct ProviderConfigOverride {
248    /// Display name.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub name: Option<String>,
251    /// Base URL for every model (or the default for `models`).
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub base_url: Option<String>,
254    /// API key config value (`literal | $ENV | !command`).
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub api_key: Option<String>,
257    /// Default API for `models`.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub api: Option<String>,
260    /// Request headers (values resolve like `apiKey`).
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub headers: Option<IndexMap<String, String>>,
263    /// Send the resolved key as `Authorization: Bearer`.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub auth_header: Option<bool>,
266    /// Replaces the provider's model list.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub models: Option<Vec<ProviderModelConfig>>,
269}
270
271impl ProviderConfigOverride {
272    /// Re-registration merge: defined values of `next` replace those of `self`.
273    #[must_use]
274    pub fn merged_with(self, next: Self) -> Self {
275        Self {
276            name: next.name.or(self.name),
277            base_url: next.base_url.or(self.base_url),
278            api_key: next.api_key.or(self.api_key),
279            api: next.api.or(self.api),
280            headers: next.headers.or(self.headers),
281            auth_header: next.auth_header.or(self.auth_header),
282            models: next.models.or(self.models),
283        }
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use serde_json::json;
291
292    #[test]
293    fn parses_extension_api_shape() {
294        let registration: ProviderRegistration = serde_json::from_value(json!({
295            "id": "openrouter", "name": "OpenRouter", "api": "openai-completions",
296            "baseUrl": "https://openrouter.ai/api/v1", "authEnv": ["OPENROUTER_API_KEY"],
297            "authHeader": "bearer", "oauth": false, "headers": {}, "compat": {},
298            "models": "dynamic"
299        }))
300        .unwrap();
301        assert_eq!(registration.models, ProviderModels::Dynamic);
302        assert_eq!(registration.auth_header, AuthHeaderMode::Bearer);
303        assert!(registration.has_api_key_auth());
304
305        let registration: ProviderRegistration = serde_json::from_value(json!({
306            "id": "anthropic", "api": "anthropic-messages", "authHeader": "x-api-key",
307            "models": "dynamic"
308        }))
309        .unwrap();
310        assert_eq!(registration.auth_header, AuthHeaderMode::None);
311
312        let registration: ProviderRegistration = serde_json::from_value(json!({
313            "id": "x", "api": "a", "oauth": true, "authHeader": true,
314            "models": [{"id": "m"}]
315        }))
316        .unwrap();
317        assert!(!registration.has_api_key_auth());
318        let ProviderModels::Static(models) = registration.models else {
319            panic!("static")
320        };
321        assert_eq!(models[0].context_window, 128_000);
322        assert_eq!(models[0].input, vec![InputModality::Text]);
323    }
324
325    #[test]
326    fn override_merge_keeps_undefined() {
327        let first = ProviderConfigOverride {
328            base_url: Some("a".into()),
329            api_key: Some("k".into()),
330            ..Default::default()
331        };
332        let merged = first.merged_with(ProviderConfigOverride {
333            base_url: Some("b".into()),
334            ..Default::default()
335        });
336        assert_eq!(merged.base_url.as_deref(), Some("b"));
337        assert_eq!(merged.api_key.as_deref(), Some("k"));
338    }
339}