返回 CodeWhale
configured.rs
根目录 / crates / config / src / catalog / configured.rs
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
197 lines RUST