| 1 | //! Persisted, operator-declared input to the existing catalog. These records |
| 2 | //! describe one exact route; they never create credentials or model aliases. |
| 3 | |
| 4 | use std::collections::{BTreeMap, BTreeSet}; |
| 5 | |
| 6 | use serde::{Deserialize, Deserializer, Serialize}; |
| 7 | |
| 8 | use super::{CatalogOffering, CatalogSource, base_url_fingerprint}; |
| 9 | use crate::models_dev::{ModelsDevCost, ModelsDevLimit, ModelsDevModalities}; |
| 10 | |
| 11 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 12 | pub struct ConfiguredModel { |
| 13 | pub provider: String, |
| 14 | pub base_url: String, |
| 15 | /// Exact, case-sensitive wire identity. The label is never sent instead. |
| 16 | pub id: String, |
| 17 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 18 | pub display_name: Option<String>, |
| 19 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 20 | #[serde(deserialize_with = "deserialize_limit")] |
| 21 | pub limit: Option<ModelsDevLimit>, |
| 22 | /// USD per million tokens, using the same shape as the catalog. |
| 23 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 24 | #[serde(deserialize_with = "deserialize_cost")] |
| 25 | pub cost: Option<ModelsDevCost>, |
| 26 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 27 | #[serde(deserialize_with = "deserialize_modalities")] |
| 28 | pub modalities: Option<ModelsDevModalities>, |
| 29 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 30 | pub attachment: Option<bool>, |
| 31 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 32 | pub reasoning: Option<bool>, |
| 33 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 34 | pub tool_call: Option<bool>, |
| 35 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 36 | pub structured_output: Option<bool>, |
| 37 | /// Keep future metadata intact through typed config saves. |
| 38 | #[serde(flatten)] |
| 39 | pub extras: BTreeMap<String, toml::Value>, |
| 40 | } |
| 41 | |
| 42 | impl ConfiguredModel { |
| 43 | pub fn matches_route(&self, provider: &str, base_url: &str) -> bool { |
| 44 | !base_url.contains(['@', '?', '#']) |
| 45 | && self.provider == provider |
| 46 | && base_url_fingerprint(&self.base_url) == base_url_fingerprint(base_url) |
| 47 | } |
| 48 | |
| 49 | /// Unknown fields stay unknown; no sibling, alias, or provider fact is |
| 50 | /// inherited. Source is assigned here and cannot be supplied by the file. |
| 51 | pub fn to_catalog_offering(&self) -> CatalogOffering { |
| 52 | CatalogOffering { |
| 53 | provider: self.provider.clone(), |
| 54 | wire_model_id: self.id.clone(), |
| 55 | endpoint_key: "chat".into(), |
| 56 | limit: self.limit.clone(), |
| 57 | cost: self.cost.clone(), |
| 58 | modalities: self.modalities.clone(), |
| 59 | attachment: self.attachment, |
| 60 | reasoning: self.reasoning, |
| 61 | tool_call: self.tool_call, |
| 62 | structured_output: self.structured_output, |
| 63 | source: CatalogSource::ConfigOverride, |
| 64 | ..CatalogOffering::default() |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | pub fn validate_configured_models(models: &[ConfiguredModel]) -> anyhow::Result<()> { |
| 70 | let mut identities = BTreeSet::new(); |
| 71 | for (index, model) in models.iter().enumerate() { |
| 72 | // Error messages name fields, never echo untrusted values or URLs. |
| 73 | let invalid = |field| anyhow::anyhow!("custom_models[{index}].{field} is invalid"); |
| 74 | for (field, value) in [("provider", &model.provider), ("id", &model.id)] { |
| 75 | if value.is_empty() |
| 76 | || value.len() > 256 |
| 77 | || value.chars().any(char::is_whitespace) |
| 78 | || value.chars().any(char::is_control) |
| 79 | { |
| 80 | return Err(invalid(field)); |
| 81 | } |
| 82 | } |
| 83 | if model.id.eq_ignore_ascii_case("auto") { |
| 84 | return Err(invalid("id")); |
| 85 | } |
| 86 | let Some((scheme, rest)) = model.base_url.split_once("://") else { |
| 87 | return Err(invalid("base_url")); |
| 88 | }; |
| 89 | if !matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https") |
| 90 | || rest.split('/').next().is_none_or(str::is_empty) |
| 91 | || model.base_url.contains(['@', '?', '#']) |
| 92 | || model |
| 93 | .base_url |
| 94 | .chars() |
| 95 | .any(|c| c.is_whitespace() || c.is_control()) |
| 96 | { |
| 97 | return Err(invalid("base_url")); |
| 98 | } |
| 99 | if model |
| 100 | .display_name |
| 101 | .as_ref() |
| 102 | .is_some_and(|name| name.trim().is_empty() || name.chars().any(char::is_control)) |
| 103 | { |
| 104 | return Err(invalid("display_name")); |
| 105 | } |
| 106 | if let Some(limit) = &model.limit |
| 107 | && ([limit.context, limit.input, limit.output] |
| 108 | .into_iter() |
| 109 | .flatten() |
| 110 | .any(|value| value == 0 || value > u64::from(u32::MAX)) |
| 111 | || limit.context.is_some_and(|context| { |
| 112 | limit.input.is_some_and(|input| input > context) |
| 113 | || limit.output.is_some_and(|output| output > context) |
| 114 | })) |
| 115 | { |
| 116 | return Err(invalid("limit")); |
| 117 | } |
| 118 | if model |
| 119 | .cost |
| 120 | .as_ref() |
| 121 | .is_some_and(|cost| !crate::pricing::catalog_cost_is_valid(cost)) |
| 122 | { |
| 123 | return Err(invalid("cost")); |
| 124 | } |
| 125 | if model.extras.keys().any(|key| { |
| 126 | matches!( |
| 127 | key.as_str(), |
| 128 | "source" |
| 129 | | "canonical_model" |
| 130 | | "aliases" |
| 131 | | "api_key" |
| 132 | | "auth" |
| 133 | | "headers" |
| 134 | | "endpoint_key" |
| 135 | | "default_for_provider" |
| 136 | ) |
| 137 | }) { |
| 138 | return Err(invalid("metadata authority")); |
| 139 | } |
| 140 | if !identities.insert(( |
| 141 | model.provider.clone(), |
| 142 | base_url_fingerprint(&model.base_url), |
| 143 | model.id.clone(), |
| 144 | )) { |
| 145 | return Err(invalid("duplicate route")); |
| 146 | } |
| 147 | } |
| 148 | Ok(()) |
| 149 | } |
| 150 | |
| 151 | pub fn deserialize_configured_models<'de, D>( |
| 152 | deserializer: D, |
| 153 | ) -> Result<Option<Vec<ConfiguredModel>>, D::Error> |
| 154 | where |
| 155 | D: Deserializer<'de>, |
| 156 | { |
| 157 | let models = Option::<Vec<ConfiguredModel>>::deserialize(deserializer)?; |
| 158 | validate_configured_models(models.as_deref().unwrap_or_default()) |
| 159 | .map_err(serde::de::Error::custom)?; |
| 160 | Ok(models) |
| 161 | } |
| 162 | |
| 163 | // Nested units and limits have precise meanings. Reject unrecognized keys |
| 164 | // instead of silently dropping a currency, tier, or other pricing condition. |
| 165 | fn deserialize_known<'de, D: Deserializer<'de>, T: serde::de::DeserializeOwned>( |
| 166 | d: D, |
| 167 | fields: &[&str], |
| 168 | ) -> Result<Option<T>, D::Error> { |
| 169 | let value = Option::<toml::Value>::deserialize(d)?; |
| 170 | value |
| 171 | .map(|value| { |
| 172 | if value |
| 173 | .as_table() |
| 174 | .is_none_or(|table| table.keys().any(|key| !fields.contains(&key.as_str()))) |
| 175 | { |
| 176 | return Err(serde::de::Error::custom( |
| 177 | "unsupported nested custom model metadata field", |
| 178 | )); |
| 179 | } |
| 180 | value |
| 181 | .try_into() |
| 182 | .map_err(|_| serde::de::Error::custom("invalid custom model metadata")) |
| 183 | }) |
| 184 | .transpose() |
| 185 | } |
| 186 | fn deserialize_limit<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ModelsDevLimit>, D::Error> { |
| 187 | deserialize_known(d, &["context", "input", "output"]) |
| 188 | } |
| 189 | fn deserialize_cost<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ModelsDevCost>, D::Error> { |
| 190 | deserialize_known(d, &["input", "output", "cache_read", "cache_write"]) |
| 191 | } |
| 192 | fn deserialize_modalities<'de, D: Deserializer<'de>>( |
| 193 | d: D, |
| 194 | ) -> Result<Option<ModelsDevModalities>, D::Error> { |
| 195 | deserialize_known(d, &["input", "output"]) |
| 196 | } |
| 197 |