Skip to main content

magi_code/config/
custom_provider_config.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, 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
15pub(crate) const MAX_CUSTOM_PROVIDER_REQUEST_HEADERS: usize = 32;
16
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
18#[serde(tag = "source", rename_all = "snake_case")]
19pub enum CustomProviderHeaderValue {
20    ConversationId,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
24pub struct CustomProviderConfig {
25    pub label: String,
26    pub base_url: String,
27    #[serde(
28        default,
29        deserialize_with = "deserialize_optional_env_var",
30        skip_serializing_if = "Option::is_none"
31    )]
32    pub api_key_env_var: Option<String>,
33    #[serde(
34        default,
35        deserialize_with = "deserialize_optional_models_dev_provider",
36        skip_serializing_if = "Option::is_none"
37    )]
38    pub models_dev_provider: Option<String>,
39    #[serde(
40        default,
41        deserialize_with = "deserialize_optional_fast_mode",
42        skip_serializing_if = "Option::is_none"
43    )]
44    pub fast_mode: Option<CustomProviderFastMode>,
45    #[serde(default, skip_serializing_if = "is_false")]
46    pub use_responses_endpoint: bool,
47    #[serde(default, skip_serializing_if = "is_false")]
48    pub supports_text_verbosity: bool,
49    #[serde(default, skip_serializing_if = "is_gpt_like")]
50    pub reasoning_protocol: CustomReasoningProtocol,
51    #[serde(
52        default,
53        deserialize_with = "deserialize_extra_models",
54        skip_serializing_if = "Vec::is_empty"
55    )]
56    pub extra_models: Vec<String>,
57    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
58    pub request_headers: BTreeMap<String, CustomProviderHeaderValue>,
59}
60
61pub(super) fn validate_custom_provider_settings(settings: &Settings) -> anyhow::Result<()> {
62    for (id, custom) in &settings.custom_providers {
63        validate_custom_provider_id(id).map_err(|error| {
64            anyhow::anyhow!("custom provider '{id}' has invalid provider id: {error}")
65        })?;
66        validate_custom_provider_label(&custom.label).map_err(|error| {
67            anyhow::anyhow!("custom provider '{id}' has invalid label: {error}")
68        })?;
69        normalize_custom_provider_base_url(&custom.base_url).map_err(|error| {
70            anyhow::anyhow!("custom provider '{id}' has invalid base_url: {error}")
71        })?;
72        if let Some(env_var) = &custom.api_key_env_var {
73            validate_env_var_name(env_var).map_err(|error| {
74                anyhow::anyhow!("custom provider '{id}' has invalid api_key_env_var: {error}")
75            })?;
76        }
77        if let Some(fast_mode) = &custom.fast_mode {
78            validate_custom_provider_fast_mode(fast_mode).map_err(|error| {
79                anyhow::anyhow!("custom provider '{id}' has invalid fast_mode: {error}")
80            })?;
81        }
82        if let Some(models_dev_provider) = &custom.models_dev_provider {
83            validate_models_dev_provider_namespace(models_dev_provider).map_err(|error| {
84                anyhow::anyhow!("custom provider '{id}' has invalid models_dev_provider: {error}")
85            })?;
86        }
87        normalized_extra_models(&custom.extra_models).map_err(|error| {
88            anyhow::anyhow!("custom provider '{id}' has invalid extra_models: {error}")
89        })?;
90        validate_custom_provider_request_headers(id, &custom.request_headers)?;
91    }
92    Ok(())
93}
94
95fn validate_custom_provider_request_headers(
96    provider_id: &str,
97    headers: &BTreeMap<String, CustomProviderHeaderValue>,
98) -> anyhow::Result<()> {
99    if headers.len() > MAX_CUSTOM_PROVIDER_REQUEST_HEADERS {
100        anyhow::bail!(
101            "custom provider '{provider_id}' request_headers must contain at most {MAX_CUSTOM_PROVIDER_REQUEST_HEADERS} entries"
102        );
103    }
104    let mut normalized = BTreeSet::new();
105    for name in headers.keys() {
106        reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
107            anyhow::anyhow!(
108                "custom provider '{provider_id}' has invalid request header name '{name}'"
109            )
110        })?;
111        let lower = name.to_ascii_lowercase();
112        if !normalized.insert(lower.clone()) {
113            anyhow::bail!(
114                "custom provider '{provider_id}' has duplicate request header name '{name}' (names are case-insensitive)"
115            );
116        }
117        if matches!(
118            lower.as_str(),
119            "accept"
120                | "authorization"
121                | "content-length"
122                | "content-type"
123                | "host"
124                | "proxy-authorization"
125                | "transfer-encoding"
126                | "user-agent"
127        ) {
128            anyhow::bail!(
129                "custom provider '{provider_id}' request_headers must not override transport-owned header '{name}'"
130            );
131        }
132    }
133    Ok(())
134}
135
136fn validate_custom_provider_label(label: &str) -> anyhow::Result<()> {
137    let label = label.trim();
138    if label.is_empty() || label.len() > 100 {
139        anyhow::bail!("custom provider label must be non-empty and at most 100 characters");
140    }
141    if looks_like_secret_label(label) {
142        anyhow::bail!("custom provider label must not look like a secret value");
143    }
144    Ok(())
145}
146
147fn looks_like_secret_label(value: &str) -> bool {
148    let value = value.trim();
149    value.starts_with("sk-")
150        || value.starts_with("Bearer ")
151        || value.contains('=')
152        || (value.len() >= 48
153            && value
154                .chars()
155                .filter(|ch| ch.is_ascii_alphanumeric())
156                .count()
157                >= 40)
158}
159
160pub(crate) fn validate_custom_provider_id(id: &str) -> anyhow::Result<String> {
161    let id = id.trim();
162    if matches!(
163        id,
164        crate::providers::OPENAI_CODEX_PROVIDER
165            | crate::providers::ANTHROPIC_PROVIDER
166            | "claude-code"
167            | "openai"
168    ) {
169        anyhow::bail!("custom provider id '{id}' is reserved");
170    }
171    if id.len() > 63
172        || id.is_empty()
173        || !id.as_bytes()[0].is_ascii_lowercase()
174        || id.ends_with('-')
175        || !id
176            .chars()
177            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
178    {
179        anyhow::bail!(
180            "custom provider id must match ^[a-z][a-z0-9-]{{0,62}}$ with no trailing hyphen"
181        );
182    }
183    Ok(id.to_string())
184}
185
186pub(crate) fn looks_like_secret_value(value: &str) -> bool {
187    let value = value.trim();
188    value.starts_with("sk-")
189        || value.starts_with("Bearer ")
190        || value.contains('=')
191        || value.chars().any(char::is_whitespace)
192        || (value.len() >= 48
193            && value
194                .chars()
195                .filter(|ch| ch.is_ascii_alphanumeric())
196                .count()
197                >= 40)
198}
199
200pub(crate) fn validate_env_var_name(name: &str) -> anyhow::Result<String> {
201    let name = name.trim();
202    if looks_like_secret_value(name) {
203        anyhow::bail!(
204            "API key environment variable name looks like a secret value; enter a variable name such as CUSTOM_PROVIDER_API_KEY"
205        );
206    }
207    if name.is_empty()
208        || !(name.as_bytes()[0].is_ascii_uppercase() || name.as_bytes()[0] == b'_')
209        || !name
210            .chars()
211            .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
212    {
213        anyhow::bail!("API key environment variable name must match ^[A-Z_][A-Z0-9_]*$");
214    }
215    Ok(name.to_string())
216}
217
218pub(crate) fn validate_optional_env_var_name(name: &str) -> anyhow::Result<Option<String>> {
219    if name.trim().is_empty() {
220        return Ok(None);
221    }
222    validate_env_var_name(name).map(Some)
223}
224
225pub(crate) fn normalized_extra_models(extra_models: &[String]) -> anyhow::Result<Vec<String>> {
226    if extra_models.len() > 64 {
227        anyhow::bail!("extra_models must contain at most 64 model ids");
228    }
229    let mut seen = BTreeSet::new();
230    let mut normalized = Vec::new();
231    for model in extra_models {
232        let model = model.trim();
233        if model.is_empty() {
234            anyhow::bail!("extra_models entries must be non-empty");
235        }
236        if model.len() > 200 {
237            anyhow::bail!("extra_models entries must be at most 200 bytes");
238        }
239        if model
240            .chars()
241            .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
242        {
243            anyhow::bail!(
244                "extra_models entries must not contain ASCII control characters or whitespace"
245            );
246        }
247        if looks_like_secret_value(model) {
248            anyhow::bail!("extra_models entries must not look like secret values");
249        }
250        if seen.insert(model.to_string()) {
251            normalized.push(model.to_string());
252        }
253    }
254    Ok(normalized)
255}
256
257pub(crate) fn validate_models_dev_provider_namespace(namespace: &str) -> anyhow::Result<String> {
258    let namespace = namespace.trim();
259    if looks_like_secret_value(namespace) {
260        anyhow::bail!(
261            "models.dev provider namespace looks like a secret value; enter a namespace such as openai"
262        );
263    }
264    if namespace.len() > 63
265        || namespace.is_empty()
266        || !namespace.as_bytes()[0].is_ascii_lowercase()
267        || namespace.ends_with('-')
268        || !namespace
269            .chars()
270            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
271    {
272        anyhow::bail!(
273            "models.dev provider namespace must match ^[a-z][a-z0-9-]{{0,62}}$ with no trailing hyphen"
274        );
275    }
276    Ok(namespace.to_string())
277}
278
279pub(crate) fn derive_custom_provider_id(label: &str) -> anyhow::Result<String> {
280    let mut id = String::new();
281    let mut last_was_separator = false;
282    for ch in label.trim().chars() {
283        if ch.is_ascii_alphanumeric() {
284            id.push(ch.to_ascii_lowercase());
285            last_was_separator = false;
286        } else if !last_was_separator && !id.is_empty() {
287            id.push('-');
288            last_was_separator = true;
289        }
290    }
291    while id.ends_with('-') {
292        id.pop();
293    }
294    validate_custom_provider_id(&id)
295        .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"))
296}
297
298pub(crate) fn normalize_custom_provider_base_url(input: &str) -> anyhow::Result<String> {
299    let value = input.trim().trim_end_matches('/');
300    let parsed = reqwest::Url::parse(value)
301        .map_err(|_| anyhow::anyhow!("custom provider base URL must be a valid URL"))?;
302    if !parsed.username().is_empty() || parsed.password().is_some() {
303        anyhow::bail!("custom provider base URL must not include URL credentials or userinfo");
304    }
305    if parsed.query().is_some() || parsed.fragment().is_some() {
306        anyhow::bail!("custom provider base URL must not include query parameters or fragments");
307    }
308    let path = parsed.path().trim_end_matches('/');
309    if path.ends_with("/responses")
310        || path.ends_with("/models")
311        || path.ends_with("/completions")
312        || path.ends_with("/chat/completions")
313    {
314        anyhow::bail!("custom provider base URL must be an API root, not an endpoint URL");
315    }
316    match parsed.scheme() {
317        "https" | "http" => Ok(value.to_string()),
318        _ => anyhow::bail!("custom provider base URL must use http:// or https://"),
319    }
320}
321
322pub(crate) fn make_custom_provider_config(
323    label: &str,
324    base_url: &str,
325    api_key_env_var: &str,
326) -> anyhow::Result<CustomProviderConfig> {
327    let label = label.trim();
328    validate_custom_provider_label(label)?;
329    Ok(CustomProviderConfig {
330        label: label.to_string(),
331        base_url: normalize_custom_provider_base_url(base_url)?,
332        api_key_env_var: validate_optional_env_var_name(api_key_env_var)?,
333        models_dev_provider: None,
334        fast_mode: None,
335        use_responses_endpoint: false,
336        supports_text_verbosity: false,
337        reasoning_protocol: CustomReasoningProtocol::default(),
338        extra_models: Vec::new(),
339        request_headers: BTreeMap::new(),
340    })
341}
342
343fn validate_custom_provider_fast_mode(fast_mode: &CustomProviderFastMode) -> anyhow::Result<()> {
344    validate_fast_service_tier(&fast_mode.service_tier)?;
345    validate_fast_models(&fast_mode.models)?;
346    Ok(())
347}
348
349fn validate_fast_service_tier(service_tier: &str) -> anyhow::Result<String> {
350    let service_tier = service_tier.trim();
351    if service_tier.is_empty()
352        || service_tier.len() > 64
353        || !service_tier.bytes().all(|byte| {
354            byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-' || byte == b'.'
355        })
356    {
357        anyhow::bail!(
358            "fast_mode.service_tier must be a non-empty ASCII identifier of at most 64 characters"
359        );
360    }
361    if looks_like_secret_value(service_tier) {
362        anyhow::bail!("fast_mode.service_tier must not look like a secret value");
363    }
364    Ok(service_tier.to_string())
365}
366
367fn validate_fast_models(models: &[String]) -> anyhow::Result<()> {
368    if models.is_empty() || models.len() > 64 {
369        anyhow::bail!("fast_mode.models must contain 1 to 64 model ids");
370    }
371    if models.iter().any(|model| model == "*") && models.len() != 1 {
372        anyhow::bail!("fast_mode.models wildcard must be the sole member");
373    }
374    let mut seen = BTreeSet::new();
375    for model in models {
376        if model.is_empty()
377            || model.chars().count() > 200
378            || model
379                .chars()
380                .any(|ch| ch.is_whitespace() || ch.is_control())
381        {
382            anyhow::bail!(
383                "fast_mode.models entries must be non-empty model ids of at most 200 characters without whitespace or control characters"
384            );
385        }
386        if model != "*" && looks_like_secret_value(model) {
387            anyhow::bail!("fast_mode.models entries must not look like secret values");
388        }
389        if !seen.insert(model.as_str()) {
390            anyhow::bail!("fast_mode.models must not contain duplicate model ids");
391        }
392    }
393    Ok(())
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
397pub struct CustomProviderFastMode {
398    pub service_tier: String,
399    pub models: Vec<String>,
400}
401
402#[derive(JsonSchema)]
403#[allow(dead_code)]
404struct CustomProviderFastModeSchema {
405    #[schemars(regex(pattern = r"^\s*[A-Za-z0-9_.-]{1,64}\s*$"))]
406    service_tier: String,
407    #[schemars(
408        length(min = 1, max = 64),
409        inner(regex(pattern = r"^\S{1,200}$")),
410        transform = add_fast_models_schema_constraints
411    )]
412    models: Vec<String>,
413}
414
415fn add_fast_models_schema_constraints(schema: &mut schemars::Schema) {
416    let object = schema.ensure_object();
417    object.insert("uniqueItems".to_string(), serde_json::json!(true));
418    object.insert(
419        "oneOf".to_string(),
420        serde_json::json!([
421            {
422                "contains": {"pattern": r"^\*$"},
423                "maxItems": 1
424            },
425            {
426                "not": {"contains": {"pattern": r"^\*$"}}
427            }
428        ]),
429    );
430}
431
432impl JsonSchema for CustomProviderFastMode {
433    fn schema_name() -> std::borrow::Cow<'static, str> {
434        "CustomProviderFastMode".into()
435    }
436
437    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
438        CustomProviderFastModeSchema::json_schema(generator)
439    }
440}
441
442#[cfg(test)]
443impl CustomProviderFastMode {
444    pub(crate) fn supports_model(&self, model: &str) -> bool {
445        self.models
446            .iter()
447            .any(|candidate| candidate == "*" || candidate == model)
448    }
449}
450fn is_gpt_like(value: &CustomReasoningProtocol) -> bool {
451    *value == CustomReasoningProtocol::GptLike
452}
453
454fn is_false(value: &bool) -> bool {
455    !*value
456}
457
458fn deserialize_optional_env_var<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
459where
460    D: serde::Deserializer<'de>,
461{
462    let value = Option::<String>::deserialize(deserializer)?;
463    Ok(value.and_then(|value| {
464        let trimmed = value.trim();
465        if trimmed.is_empty() {
466            None
467        } else {
468            Some(trimmed.to_string())
469        }
470    }))
471}
472
473fn deserialize_optional_fast_mode<'de, D>(
474    deserializer: D,
475) -> Result<Option<CustomProviderFastMode>, D::Error>
476where
477    D: serde::Deserializer<'de>,
478{
479    let value = Option::<CustomProviderFastMode>::deserialize(deserializer)?;
480    Ok(value.map(|mut fast| {
481        fast.service_tier = fast.service_tier.trim().to_string();
482        fast
483    }))
484}
485
486fn deserialize_optional_models_dev_provider<'de, D>(
487    deserializer: D,
488) -> Result<Option<String>, D::Error>
489where
490    D: serde::Deserializer<'de>,
491{
492    let value = Option::<String>::deserialize(deserializer)?;
493    Ok(value.and_then(|value| {
494        let trimmed = value.trim();
495        if trimmed.is_empty() {
496            None
497        } else {
498            Some(trimmed.to_string())
499        }
500    }))
501}
502
503fn deserialize_extra_models<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
504where
505    D: serde::Deserializer<'de>,
506{
507    let values = Vec::<String>::deserialize(deserializer)?;
508    Ok(values
509        .into_iter()
510        .map(|value| value.trim().to_string())
511        .collect())
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    fn settings_with_fast_models(models: Vec<String>) -> Settings {
519        Settings {
520            custom_providers: [(
521                "provider".to_string(),
522                CustomProviderConfig {
523                    label: "Provider".to_string(),
524                    base_url: "https://example.test".to_string(),
525                    api_key_env_var: None,
526                    models_dev_provider: None,
527                    fast_mode: Some(CustomProviderFastMode {
528                        service_tier: "priority".to_string(),
529                        models,
530                    }),
531                    use_responses_endpoint: false,
532                    supports_text_verbosity: false,
533                    reasoning_protocol: CustomReasoningProtocol::default(),
534                    extra_models: Vec::new(),
535                    request_headers: BTreeMap::new(),
536                },
537            )]
538            .into(),
539            ..Settings::default()
540        }
541    }
542
543    #[test]
544    fn fast_mode_trims_service_tier_but_preserves_exact_model_ids() {
545        let config: CustomProviderConfig = serde_json::from_value(serde_json::json!({
546            "label": "Provider", "base_url": "https://example.test",
547            "fast_mode": {"service_tier": " priority ", "models": [" model-a "]}
548        }))
549        .unwrap();
550        let fast_mode = config.fast_mode.as_ref().unwrap();
551        assert_eq!(fast_mode.service_tier, "priority");
552        assert_eq!(fast_mode.models, vec![" model-a ".to_string()]);
553        assert!(!fast_mode.supports_model("model-a"));
554        assert!(fast_mode.supports_model(" model-a "));
555        assert!(
556            validate_custom_provider_settings(&settings_with_fast_models(fast_mode.models.clone()))
557                .is_err()
558        );
559    }
560
561    #[test]
562    fn request_headers_parse_conversation_source_and_reject_owned_or_duplicate_names() {
563        let config: CustomProviderConfig = serde_json::from_value(serde_json::json!({
564            "label": "Provider",
565            "base_url": "https://example.test",
566            "request_headers": {
567                "x-opencode-session": {"source": "conversation_id"}
568            }
569        }))
570        .unwrap();
571        assert_eq!(
572            config.request_headers.get("x-opencode-session"),
573            Some(&CustomProviderHeaderValue::ConversationId)
574        );
575
576        for headers in [
577            BTreeMap::from([(
578                "Authorization".to_string(),
579                CustomProviderHeaderValue::ConversationId,
580            )]),
581            BTreeMap::from([
582                (
583                    "X-Session".to_string(),
584                    CustomProviderHeaderValue::ConversationId,
585                ),
586                (
587                    "x-session".to_string(),
588                    CustomProviderHeaderValue::ConversationId,
589                ),
590            ]),
591        ] {
592            assert!(validate_custom_provider_request_headers("provider", &headers).is_err());
593        }
594    }
595
596    #[test]
597    fn fast_mode_runtime_validation_uses_unicode_character_limits_and_rejects_whitespace() {
598        for models in [
599            vec![" model".to_string()],
600            vec!["model ".to_string()],
601            vec!["model id".to_string()],
602            vec!["model\u{2003}id".to_string()],
603            vec!["model\u{0000}id".to_string()],
604        ] {
605            assert!(validate_custom_provider_settings(&settings_with_fast_models(models)).is_err());
606        }
607        assert!(
608            validate_custom_provider_settings(&settings_with_fast_models(vec!["😀".repeat(200),]))
609                .is_ok()
610        );
611        assert!(
612            validate_custom_provider_settings(&settings_with_fast_models(vec!["😀".repeat(201),]))
613                .is_err()
614        );
615    }
616
617    #[test]
618    fn fast_mode_rejects_duplicate_models_and_non_sole_wildcard() {
619        for models in [
620            vec!["model".to_string(), "model".to_string()],
621            vec!["*".to_string(), "model".to_string()],
622        ] {
623            assert!(validate_custom_provider_settings(&settings_with_fast_models(models)).is_err());
624        }
625    }
626}