vtcode_config/core/
custom_provider.rs1use serde::{Deserialize, Serialize};
2
3#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
9#[derive(Debug, Clone, Deserialize, Serialize)]
10pub struct CustomProviderConfig {
11 pub name: String,
14
15 pub display_name: String,
18
19 pub base_url: String,
22
23 #[serde(default)]
26 pub api_key_env: String,
27
28 #[serde(default)]
30 pub model: String,
31}
32
33impl CustomProviderConfig {
34 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 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 "litellm",
102 "openresponses",
103 ];
104 let lower = self.name.to_lowercase();
105 if reserved.contains(&lower.as_str()) {
106 return Err(format!(
107 "custom_providers[{}]: name collides with built-in provider",
108 self.name
109 ));
110 }
111
112 Ok(())
113 }
114}
115
116fn is_valid_provider_name(name: &str) -> bool {
117 let bytes = name.as_bytes();
118 let Some(first) = bytes.first() else {
119 return false;
120 };
121 let Some(last) = bytes.last() else {
122 return false;
123 };
124
125 let is_valid_char = |ch: u8| matches!(ch, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_');
126 let is_alphanumeric = |ch: u8| matches!(ch, b'a'..=b'z' | b'0'..=b'9');
127
128 is_alphanumeric(*first) && is_alphanumeric(*last) && bytes.iter().copied().all(is_valid_char)
129}
130
131#[cfg(test)]
132mod tests {
133 use super::CustomProviderConfig;
134
135 #[test]
136 fn validate_accepts_lowercase_provider_name() {
137 let config = CustomProviderConfig {
138 name: "mycorp".to_string(),
139 display_name: "MyCorp".to_string(),
140 base_url: "https://llm.example/v1".to_string(),
141 api_key_env: String::new(),
142 model: "gpt-4o-mini".to_string(),
143 };
144
145 assert!(config.validate().is_ok());
146 assert_eq!(config.resolved_api_key_env(), "MYCORP_API_KEY");
147 }
148
149 #[test]
150 fn validate_rejects_invalid_provider_name() {
151 let config = CustomProviderConfig {
152 name: "My Corp".to_string(),
153 display_name: "My Corp".to_string(),
154 base_url: "https://llm.example/v1".to_string(),
155 api_key_env: String::new(),
156 model: "gpt-4o-mini".to_string(),
157 };
158
159 let err = config.validate().expect_err("invalid name should fail");
160 assert!(err.contains("must use lowercase letters, digits, hyphens, or underscores"));
161 }
162}