Skip to main content

magi_code/config/
custom_provider_config.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeSet;
4
5use super::Settings;
6
7#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
8#[serde(rename_all = "kebab-case")]
9pub enum CustomReasoningProtocol {
10    #[default]
11    GptLike,
12    AnthropicLike,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
16pub struct CustomProviderConfig {
17    pub label: String,
18    pub base_url: String,
19    #[serde(
20        default,
21        deserialize_with = "deserialize_optional_env_var",
22        skip_serializing_if = "Option::is_none"
23    )]
24    pub api_key_env_var: Option<String>,
25    #[serde(
26        default,
27        deserialize_with = "deserialize_optional_models_dev_provider",
28        skip_serializing_if = "Option::is_none"
29    )]
30    pub models_dev_provider: Option<String>,
31    #[serde(default, skip_serializing_if = "is_false")]
32    pub use_responses_endpoint: bool,
33    #[serde(default, skip_serializing_if = "is_false")]
34    pub supports_text_verbosity: bool,
35    #[serde(default, skip_serializing_if = "is_gpt_like")]
36    pub reasoning_protocol: CustomReasoningProtocol,
37    #[serde(
38        default,
39        deserialize_with = "deserialize_extra_models",
40        skip_serializing_if = "Vec::is_empty"
41    )]
42    pub extra_models: Vec<String>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct CustomProviderRegistration {
47    pub id: String,
48    pub config: CustomProviderConfig,
49}
50
51pub(super) fn validate_custom_provider_settings(settings: &Settings) -> anyhow::Result<()> {
52    for (id, custom) in &settings.custom_providers {
53        validate_custom_provider_id(id).map_err(|error| {
54            anyhow::anyhow!("custom provider '{id}' has invalid provider id: {error}")
55        })?;
56        validate_custom_provider_label(&custom.label).map_err(|error| {
57            anyhow::anyhow!("custom provider '{id}' has invalid label: {error}")
58        })?;
59        normalize_custom_provider_base_url(&custom.base_url).map_err(|error| {
60            anyhow::anyhow!("custom provider '{id}' has invalid base_url: {error}")
61        })?;
62        if let Some(env_var) = &custom.api_key_env_var {
63            validate_env_var_name(env_var).map_err(|error| {
64                anyhow::anyhow!("custom provider '{id}' has invalid api_key_env_var: {error}")
65            })?;
66        }
67        if let Some(models_dev_provider) = &custom.models_dev_provider {
68            validate_models_dev_provider_namespace(models_dev_provider).map_err(|error| {
69                anyhow::anyhow!("custom provider '{id}' has invalid models_dev_provider: {error}")
70            })?;
71        }
72        normalized_extra_models(&custom.extra_models).map_err(|error| {
73            anyhow::anyhow!("custom provider '{id}' has invalid extra_models: {error}")
74        })?;
75    }
76    Ok(())
77}
78
79fn validate_custom_provider_label(label: &str) -> anyhow::Result<()> {
80    let label = label.trim();
81    if label.is_empty() || label.len() > 100 {
82        anyhow::bail!("custom provider label must be non-empty and at most 100 characters");
83    }
84    if looks_like_secret_label(label) {
85        anyhow::bail!("custom provider label must not look like a secret value");
86    }
87    Ok(())
88}
89
90fn looks_like_secret_label(value: &str) -> bool {
91    let value = value.trim();
92    value.starts_with("sk-")
93        || value.starts_with("Bearer ")
94        || value.contains('=')
95        || (value.len() >= 48
96            && value
97                .chars()
98                .filter(|ch| ch.is_ascii_alphanumeric())
99                .count()
100                >= 40)
101}
102
103pub fn validate_custom_provider_id(id: &str) -> anyhow::Result<String> {
104    let id = id.trim();
105    if matches!(
106        id,
107        crate::providers::OPENAI_CODEX_PROVIDER
108            | crate::providers::ANTHROPIC_PROVIDER
109            | crate::providers::CLAUDE_CODE_PROVIDER
110            | "openai"
111    ) {
112        anyhow::bail!("custom provider id '{id}' is reserved");
113    }
114    if id.len() > 63
115        || id.is_empty()
116        || !id.as_bytes()[0].is_ascii_lowercase()
117        || id.ends_with('-')
118        || !id
119            .chars()
120            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
121    {
122        anyhow::bail!(
123            "custom provider id must match ^[a-z][a-z0-9-]{{0,62}}$ with no trailing hyphen"
124        );
125    }
126    Ok(id.to_string())
127}
128
129pub fn looks_like_secret_value(value: &str) -> bool {
130    let value = value.trim();
131    value.starts_with("sk-")
132        || value.starts_with("Bearer ")
133        || value.contains('=')
134        || value.chars().any(char::is_whitespace)
135        || (value.len() >= 48
136            && value
137                .chars()
138                .filter(|ch| ch.is_ascii_alphanumeric())
139                .count()
140                >= 40)
141}
142
143pub fn validate_env_var_name(name: &str) -> anyhow::Result<String> {
144    let name = name.trim();
145    if looks_like_secret_value(name) {
146        anyhow::bail!(
147            "API key environment variable name looks like a secret value; enter a variable name such as CUSTOM_PROVIDER_API_KEY"
148        );
149    }
150    if name.is_empty()
151        || !(name.as_bytes()[0].is_ascii_uppercase() || name.as_bytes()[0] == b'_')
152        || !name
153            .chars()
154            .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
155    {
156        anyhow::bail!("API key environment variable name must match ^[A-Z_][A-Z0-9_]*$");
157    }
158    Ok(name.to_string())
159}
160
161pub fn validate_optional_env_var_name(name: &str) -> anyhow::Result<Option<String>> {
162    if name.trim().is_empty() {
163        return Ok(None);
164    }
165    validate_env_var_name(name).map(Some)
166}
167
168pub(crate) fn normalized_extra_models(extra_models: &[String]) -> anyhow::Result<Vec<String>> {
169    if extra_models.len() > 64 {
170        anyhow::bail!("extra_models must contain at most 64 model ids");
171    }
172    let mut seen = BTreeSet::new();
173    let mut normalized = Vec::new();
174    for model in extra_models {
175        let model = model.trim();
176        if model.is_empty() {
177            anyhow::bail!("extra_models entries must be non-empty");
178        }
179        if model.len() > 200 {
180            anyhow::bail!("extra_models entries must be at most 200 bytes");
181        }
182        if model
183            .chars()
184            .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
185        {
186            anyhow::bail!(
187                "extra_models entries must not contain ASCII control characters or whitespace"
188            );
189        }
190        if looks_like_secret_value(model) {
191            anyhow::bail!("extra_models entries must not look like secret values");
192        }
193        if seen.insert(model.to_string()) {
194            normalized.push(model.to_string());
195        }
196    }
197    Ok(normalized)
198}
199
200pub fn validate_models_dev_provider_namespace(namespace: &str) -> anyhow::Result<String> {
201    let namespace = namespace.trim();
202    if looks_like_secret_value(namespace) {
203        anyhow::bail!(
204            "models.dev provider namespace looks like a secret value; enter a namespace such as openai"
205        );
206    }
207    if namespace.len() > 63
208        || namespace.is_empty()
209        || !namespace.as_bytes()[0].is_ascii_lowercase()
210        || namespace.ends_with('-')
211        || !namespace
212            .chars()
213            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
214    {
215        anyhow::bail!(
216            "models.dev provider namespace must match ^[a-z][a-z0-9-]{{0,62}}$ with no trailing hyphen"
217        );
218    }
219    Ok(namespace.to_string())
220}
221
222pub fn derive_custom_provider_id(label: &str) -> anyhow::Result<String> {
223    let mut id = String::new();
224    let mut last_was_separator = false;
225    for ch in label.trim().chars() {
226        if ch.is_ascii_alphanumeric() {
227            id.push(ch.to_ascii_lowercase());
228            last_was_separator = false;
229        } else if !last_was_separator && !id.is_empty() {
230            id.push('-');
231            last_was_separator = true;
232        }
233    }
234    while id.ends_with('-') {
235        id.pop();
236    }
237    validate_custom_provider_id(&id)
238        .map_err(|_| anyhow::anyhow!("custom provider label must derive a provider id matching ^[a-z][a-z0-9-]{{0,62}}$ and must not be reserved"))
239}
240
241pub fn normalize_custom_provider_base_url(input: &str) -> anyhow::Result<String> {
242    let value = input.trim().trim_end_matches('/');
243    let parsed = reqwest::Url::parse(value)
244        .map_err(|_| anyhow::anyhow!("custom provider base URL must be a valid URL"))?;
245    if !parsed.username().is_empty() || parsed.password().is_some() {
246        anyhow::bail!("custom provider base URL must not include URL credentials or userinfo");
247    }
248    if parsed.query().is_some() || parsed.fragment().is_some() {
249        anyhow::bail!("custom provider base URL must not include query parameters or fragments");
250    }
251    let path = parsed.path().trim_end_matches('/');
252    if path.ends_with("/responses")
253        || path.ends_with("/models")
254        || path.ends_with("/completions")
255        || path.ends_with("/chat/completions")
256    {
257        anyhow::bail!("custom provider base URL must be an API root, not an endpoint URL");
258    }
259    match parsed.scheme() {
260        "https" | "http" => Ok(value.to_string()),
261        _ => anyhow::bail!("custom provider base URL must use http:// or https://"),
262    }
263}
264
265pub fn make_custom_provider_config(
266    label: &str,
267    base_url: &str,
268    api_key_env_var: &str,
269) -> anyhow::Result<CustomProviderConfig> {
270    let label = label.trim();
271    validate_custom_provider_label(label)?;
272    Ok(CustomProviderConfig {
273        label: label.to_string(),
274        base_url: normalize_custom_provider_base_url(base_url)?,
275        api_key_env_var: validate_optional_env_var_name(api_key_env_var)?,
276        models_dev_provider: None,
277        use_responses_endpoint: false,
278        supports_text_verbosity: false,
279        reasoning_protocol: CustomReasoningProtocol::default(),
280        extra_models: Vec::new(),
281    })
282}
283
284fn is_gpt_like(value: &CustomReasoningProtocol) -> bool {
285    *value == CustomReasoningProtocol::GptLike
286}
287
288fn is_false(value: &bool) -> bool {
289    !*value
290}
291
292fn deserialize_optional_env_var<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
293where
294    D: serde::Deserializer<'de>,
295{
296    let value = Option::<String>::deserialize(deserializer)?;
297    Ok(value.and_then(|value| {
298        let trimmed = value.trim();
299        if trimmed.is_empty() {
300            None
301        } else {
302            Some(trimmed.to_string())
303        }
304    }))
305}
306
307fn deserialize_optional_models_dev_provider<'de, D>(
308    deserializer: D,
309) -> Result<Option<String>, D::Error>
310where
311    D: serde::Deserializer<'de>,
312{
313    let value = Option::<String>::deserialize(deserializer)?;
314    Ok(value.and_then(|value| {
315        let trimmed = value.trim();
316        if trimmed.is_empty() {
317            None
318        } else {
319            Some(trimmed.to_string())
320        }
321    }))
322}
323
324fn deserialize_extra_models<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
325where
326    D: serde::Deserializer<'de>,
327{
328    let values = Vec::<String>::deserialize(deserializer)?;
329    Ok(values
330        .into_iter()
331        .map(|value| value.trim().to_string())
332        .collect())
333}