Skip to main content

vtcode_config/core/
custom_provider.rs

1use serde::{Deserialize, Serialize};
2
3/// Configuration for a user-defined OpenAI-compatible provider endpoint.
4///
5/// Allows users to define multiple named custom endpoints (e.g., corporate
6/// proxies) with distinct display names, so they can toggle between them
7/// and clearly see which endpoint is active.
8#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
9#[derive(Debug, Clone, Deserialize, Serialize)]
10pub struct CustomProviderConfig {
11    /// Stable provider key used for routing and persistence (e.g., "mycorp").
12    /// Must be lowercase alphanumeric with optional hyphens/underscores.
13    pub name: String,
14
15    /// Human-friendly label shown in the TUI header, footer, and model picker
16    /// (e.g., "MyCorporateName").
17    pub display_name: String,
18
19    /// Base URL of the OpenAI-compatible API endpoint
20    /// (e.g., "https://llm.corp.example/v1").
21    pub base_url: String,
22
23    /// Environment variable name that holds the API key for this endpoint
24    /// (e.g., "MYCORP_API_KEY").
25    #[serde(default)]
26    pub api_key_env: String,
27
28    /// Default model to use with this endpoint (e.g., "gpt-4o-mini").
29    #[serde(default)]
30    pub model: String,
31}
32
33impl CustomProviderConfig {
34    /// Resolve the API key environment variable used for this provider.
35    ///
36    /// Falls back to a derived `NAME_API_KEY`-style variable when the config
37    /// does not set `api_key_env`.
38    pub fn resolved_api_key_env(&self) -> String {
39        if !self.api_key_env.trim().is_empty() {
40            return self.api_key_env.clone();
41        }
42
43        let mut key = String::new();
44        for ch in self.name.chars() {
45            if ch.is_ascii_alphanumeric() {
46                key.push(ch.to_ascii_uppercase());
47            } else if !key.ends_with('_') {
48                key.push('_');
49            }
50        }
51        if !key.ends_with("_API_KEY") {
52            if !key.ends_with('_') {
53                key.push('_');
54            }
55            key.push_str("API_KEY");
56        }
57        key
58    }
59
60    /// Validate that required fields are present and the name doesn't collide
61    /// with built-in provider keys.
62    pub fn validate(&self) -> Result<(), String> {
63        if self.name.trim().is_empty() {
64            return Err("custom_providers: `name` must not be empty".to_string());
65        }
66
67        if !is_valid_provider_name(&self.name) {
68            return Err(format!(
69                "custom_providers[{}]: `name` must use lowercase letters, digits, hyphens, or underscores",
70                self.name
71            ));
72        }
73
74        if self.display_name.trim().is_empty() {
75            return Err(format!(
76                "custom_providers[{}]: `display_name` must not be empty",
77                self.name
78            ));
79        }
80
81        if self.base_url.trim().is_empty() {
82            return Err(format!(
83                "custom_providers[{}]: `base_url` must not be empty",
84                self.name
85            ));
86        }
87
88        let reserved = [
89            "openai",
90            "anthropic",
91            "gemini",
92            "copilot",
93            "deepseek",
94            "openrouter",
95            "ollama",
96            "lmstudio",
97            "moonshot",
98            "zai",
99            "minimax",
100            "huggingface",
101            "openresponses",
102        ];
103        let lower = self.name.to_lowercase();
104        if reserved.contains(&lower.as_str()) {
105            return Err(format!(
106                "custom_providers[{}]: name collides with built-in provider",
107                self.name
108            ));
109        }
110
111        Ok(())
112    }
113}
114
115fn is_valid_provider_name(name: &str) -> bool {
116    let bytes = name.as_bytes();
117    let Some(first) = bytes.first() else {
118        return false;
119    };
120    let Some(last) = bytes.last() else {
121        return false;
122    };
123
124    let is_valid_char = |ch: u8| matches!(ch, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_');
125    let is_alphanumeric = |ch: u8| matches!(ch, b'a'..=b'z' | b'0'..=b'9');
126
127    is_alphanumeric(*first) && is_alphanumeric(*last) && bytes.iter().copied().all(is_valid_char)
128}
129
130#[cfg(test)]
131mod tests {
132    use super::CustomProviderConfig;
133
134    #[test]
135    fn validate_accepts_lowercase_provider_name() {
136        let config = CustomProviderConfig {
137            name: "mycorp".to_string(),
138            display_name: "MyCorp".to_string(),
139            base_url: "https://llm.example/v1".to_string(),
140            api_key_env: String::new(),
141            model: "gpt-4o-mini".to_string(),
142        };
143
144        assert!(config.validate().is_ok());
145        assert_eq!(config.resolved_api_key_env(), "MYCORP_API_KEY");
146    }
147
148    #[test]
149    fn validate_rejects_invalid_provider_name() {
150        let config = CustomProviderConfig {
151            name: "My Corp".to_string(),
152            display_name: "My Corp".to_string(),
153            base_url: "https://llm.example/v1".to_string(),
154            api_key_env: String::new(),
155            model: "gpt-4o-mini".to_string(),
156        };
157
158        let err = config.validate().expect_err("invalid name should fail");
159        assert!(err.contains("must use lowercase letters, digits, hyphens, or underscores"));
160    }
161}