Skip to main content

vtcode_config/core/
provider_override.rs

1use serde::{Deserialize, Serialize};
2
3/// Configuration for overriding a built-in provider's model list.
4///
5/// Allows users to extend built-in providers (e.g., `opencode-zen`,
6/// `opencode-go`) with additional custom models, and optionally override
7/// the provider's base URL or API key environment variable.
8///
9/// # Example
10///
11/// ```toml
12/// [providers.opencode-zen]
13/// models = [
14///     "opencode/gpt-5.4",
15///     "opencode/gpt-5.4-mini",
16///     "my-custom-model",
17/// ]
18/// base_url = "https://custom-endpoint.example.com"
19/// api_key_env = "MY_CUSTOM_KEY"
20/// ```
21#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
22#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
23pub struct ProviderOverrideConfig {
24    /// Additional model identifiers to offer for this built-in provider.
25    ///
26    /// These models are appended to the provider's hardcoded model list
27    /// and appear in the `/model` picker alongside built-in entries.
28    #[serde(default)]
29    pub models: Vec<String>,
30
31    /// Optional base URL override for the provider endpoint.
32    ///
33    /// When set, custom models from this override are routed to the
34    /// specified endpoint instead of the provider's default. Values from
35    /// repository-controlled workspace/project layers are rejected.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub base_url: Option<String>,
38
39    /// Optional environment variable name for the API key.
40    ///
41    /// When set, overrides the provider's default API key environment
42    /// variable for models from this override. Values from
43    /// repository-controlled workspace/project layers are rejected.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub api_key_env: Option<String>,
46}
47
48impl ProviderOverrideConfig {
49    /// Validate that all model entries are non-empty after trimming and
50    /// that there are no duplicate model entries.
51    pub(crate) fn validate(&self, provider_name: &str) -> Result<(), String> {
52        let mut seen = std::collections::HashSet::new();
53        for model in &self.models {
54            let trimmed = model.trim();
55            if trimmed.is_empty() {
56                return Err(format!("providers[{provider_name}]: `models` entries must not be empty"));
57            }
58            if !seen.insert(trimmed.to_lowercase()) {
59                return Err(format!("providers[{provider_name}]: duplicate model `{trimmed}`"));
60            }
61        }
62        if let Some(base_url) = &self.base_url
63            && base_url.trim().is_empty()
64        {
65            return Err(format!("providers[{provider_name}]: `base_url` must not be empty"));
66        }
67        if let Some(api_key_env) = &self.api_key_env
68            && api_key_env.trim().is_empty()
69        {
70            return Err(format!("providers[{provider_name}]: `api_key_env` must not be empty"));
71        }
72        if let Some(api_key_env) = &self.api_key_env
73            && let Err(err) = crate::auth::CredentialIdentity::new(provider_name, api_key_env)
74        {
75            return Err(format!("providers[{provider_name}]: invalid `api_key_env`: {err}"));
76        }
77        Ok(())
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::ProviderOverrideConfig;
84
85    #[test]
86    fn default_config_is_empty() {
87        let config = ProviderOverrideConfig::default();
88        assert!(config.models.is_empty());
89        assert!(config.base_url.is_none());
90        assert!(config.api_key_env.is_none());
91    }
92
93    #[test]
94    fn validate_accepts_valid_config() {
95        let config = ProviderOverrideConfig {
96            models: vec!["model-a".to_string(), "model-b".to_string()],
97            base_url: Some("https://example.com".to_string()),
98            api_key_env: Some("MY_KEY".to_string()),
99        };
100        assert!(config.validate("test-provider").is_ok());
101    }
102
103    #[test]
104    fn validate_rejects_empty_model_entry() {
105        let config = ProviderOverrideConfig {
106            models: vec!["model-a".to_string(), "   ".to_string()],
107            base_url: None,
108            api_key_env: None,
109        };
110        let err = config.validate("test-provider").expect_err("blank model should fail");
111        assert!(err.contains("`models` entries must not be empty"));
112    }
113
114    #[test]
115    fn validate_rejects_empty_base_url() {
116        let config = ProviderOverrideConfig {
117            models: vec!["model-a".to_string()],
118            base_url: Some("   ".to_string()),
119            api_key_env: None,
120        };
121        let err = config.validate("test-provider").expect_err("blank base_url should fail");
122        assert!(err.contains("`base_url` must not be empty"));
123    }
124
125    #[test]
126    fn validate_rejects_empty_api_key_env() {
127        let config = ProviderOverrideConfig {
128            models: vec!["model-a".to_string()],
129            base_url: None,
130            api_key_env: Some("   ".to_string()),
131        };
132        let err = config.validate("test-provider").expect_err("blank api_key_env should fail");
133        assert!(err.contains("`api_key_env` must not be empty"));
134    }
135
136    #[test]
137    fn validate_rejects_duplicate_models() {
138        let config = ProviderOverrideConfig {
139            models: vec!["model-a".to_string(), "model-b".to_string(), "model-a".to_string()],
140            base_url: None,
141            api_key_env: None,
142        };
143        let err = config.validate("test-provider").expect_err("duplicate model should fail");
144        assert!(err.contains("duplicate model"));
145    }
146
147    #[test]
148    fn validate_rejects_duplicate_models_case_insensitive() {
149        let config = ProviderOverrideConfig {
150            models: vec!["Model-A".to_string(), "model-a".to_string()],
151            base_url: None,
152            api_key_env: None,
153        };
154        let err = config
155            .validate("test-provider")
156            .expect_err("case-insensitive duplicate should fail");
157        assert!(err.contains("duplicate model"));
158    }
159}