1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub enum AuthHeaderMode {
18 #[default]
20 None,
21 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct ProviderModelConfig {
57 pub id: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub name: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub api: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub base_url: Option<String>,
68 #[serde(default)]
70 pub reasoning: bool,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub thinking_level_map: Option<ThinkingLevelMap>,
74 #[serde(default = "default_input")]
76 pub input: Vec<InputModality>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub input_limits: Option<InputLimits>,
80 #[serde(default)]
82 pub cost: ModelCost,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub prompt_cache: Option<PromptCache>,
86 #[serde(default = "default_context_window")]
88 pub context_window: u64,
89 #[serde(default = "default_max_tokens")]
91 pub max_tokens: u64,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub sampling_params: Option<JsonObject>,
95 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub headers: Option<IndexMap<String, String>>,
106 #[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
115pub(crate) const DEFAULT_CONTEXT_WINDOW: u64 = 128_000;
117pub(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 #[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#[derive(Clone, Debug, Default, PartialEq)]
158pub enum ProviderModels {
159 #[default]
161 Dynamic,
162 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct ProviderRegistration {
197 pub id: String,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub name: Option<String>,
202 pub api: String,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub base_url: Option<String>,
207 #[serde(default)]
209 pub auth_env: Vec<String>,
210 #[serde(default)]
213 pub auth_header: AuthHeaderMode,
214 #[serde(default)]
216 pub oauth: bool,
217 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
219 pub headers: IndexMap<String, String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub compat: Option<JsonValue>,
223 #[serde(default)]
225 pub models: ProviderModels,
226}
227
228impl ProviderRegistration {
229 #[must_use]
231 pub fn display_name(&self) -> &str {
232 self.name.as_deref().unwrap_or(&self.id)
233 }
234
235 #[must_use]
238 pub fn has_api_key_auth(&self) -> bool {
239 !(self.oauth && self.auth_env.is_empty())
240 }
241}
242
243#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct ProviderConfigOverride {
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub name: Option<String>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub base_url: Option<String>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub api_key: Option<String>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub api: Option<String>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub headers: Option<IndexMap<String, String>>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub auth_header: Option<bool>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub models: Option<Vec<ProviderModelConfig>>,
269}
270
271impl ProviderConfigOverride {
272 #[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}