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 "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}