1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct AppConfig {
6 pub core: CoreConfig,
7 #[serde(default)]
8 pub providers: Vec<ProviderConfig>,
9 #[serde(default)]
10 pub routing: RoutingConfig,
11 #[serde(default)]
12 pub key_strategy: KeyStrategyConfig,
13 #[serde(default)]
14 pub fallback: FallbackConfig,
15 #[serde(default)]
16 pub virtual_keys: Vec<VirtualKeyConfig>,
17 #[serde(default)]
18 pub services: Vec<ServiceConfig>,
19 #[serde(default)]
20 pub packages: Vec<PackageConfig>,
21 #[serde(default)]
22 pub registry: RegistryConfig,
23 #[serde(default)]
24 pub package_aliases: HashMap<String, String>,
25 #[serde(default)]
27 pub web_search: WebSearchConfig,
28 #[serde(default)]
30 pub team: TeamConfig,
31}
32
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38pub struct TeamConfig {
39 #[serde(default, alias = "roleRouting")]
41 pub role_routing: HashMap<String, RoleModel>,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct RoleModel {
47 #[serde(default)]
48 pub provider: Option<String>,
49 #[serde(default)]
50 pub model: Option<String>,
51}
52
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57pub struct WebSearchConfig {
58 #[serde(default)]
59 pub exa_api_key: Option<String>,
60 #[serde(default)]
61 pub tavily_api_key: Option<String>,
62 #[serde(default)]
63 pub brave_api_key: Option<String>,
64 #[serde(default)]
66 pub provider: Option<String>,
67}
68
69impl WebSearchConfig {
70 pub fn apply_to_env(&self) {
73 fn set_if_absent(key: &str, value: &Option<String>) {
74 if let Some(v) = value {
75 let v = v.trim();
76 if !v.is_empty() && std::env::var(key).is_err() {
77 std::env::set_var(key, v);
78 }
79 }
80 }
81 set_if_absent("EXA_API_KEY", &self.exa_api_key);
82 set_if_absent("TAVILY_API_KEY", &self.tavily_api_key);
83 set_if_absent("BRAVE_API_KEY", &self.brave_api_key);
84 set_if_absent("WEFT_WEB_SEARCH_PROVIDER", &self.provider);
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct CoreConfig {
90 #[serde(default = "default_host")]
91 pub host: String,
92 #[serde(default = "default_port")]
93 pub port: u16,
94 #[serde(default = "default_log_level")]
95 pub log_level: String,
96 #[serde(default = "default_data_dir")]
97 pub data_dir: String,
98}
99
100fn default_host() -> String {
101 "127.0.0.1".into()
102}
103fn default_port() -> u16 {
104 17830
105}
106fn default_log_level() -> String {
107 "info".into()
108}
109fn default_data_dir() -> String {
110 "./data".into()
111}
112
113impl Default for CoreConfig {
114 fn default() -> Self {
115 Self {
116 host: default_host(),
117 port: default_port(),
118 log_level: default_log_level(),
119 data_dir: default_data_dir(),
120 }
121 }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct ProviderConfig {
126 pub name: String,
127 pub base_url: String,
128 #[serde(default = "default_format")]
129 pub format: String,
130 #[serde(default)]
131 pub api: ProviderApi,
132 #[serde(default)]
133 pub keys: Vec<ApiKeyConfig>,
134 #[serde(default)]
135 pub models: Vec<String>,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "snake_case")]
140pub enum ProviderApi {
141 ChatCompletions,
142 Responses,
143}
144
145impl Default for ProviderApi {
146 fn default() -> Self {
147 Self::ChatCompletions
148 }
149}
150
151impl ProviderApi {
152 pub fn as_str(self) -> &'static str {
153 match self {
154 Self::ChatCompletions => "chat_completions",
155 Self::Responses => "responses",
156 }
157 }
158}
159
160fn default_format() -> String {
161 "openai".into()
162}
163
164pub fn validate_config(config: &AppConfig) -> anyhow::Result<()> {
165 for provider in &config.providers {
166 validate_provider(provider)?;
167 }
168 Ok(())
169}
170
171pub fn validate_provider(provider: &ProviderConfig) -> anyhow::Result<()> {
172 match provider.format.as_str() {
173 "openai" | "anthropic" => {}
174 other => anyhow::bail!(
175 "Invalid provider format '{}'. Must be 'openai' or 'anthropic'",
176 other
177 ),
178 }
179
180 if provider.format == "anthropic" && provider.api != ProviderApi::ChatCompletions {
181 anyhow::bail!(
182 "Provider '{}' uses format 'anthropic' but api '{}'; only chat_completions is supported for anthropic providers",
183 provider.name,
184 provider.api.as_str()
185 );
186 }
187
188 for model in &provider.models {
189 if requires_openai_responses(model) && provider.api != ProviderApi::Responses {
190 anyhow::bail!(
191 "Model '{}' on provider '{}' requires OpenAI Responses API. Set api = 'responses' for this provider.",
192 model,
193 provider.name
194 );
195 }
196 }
197
198 Ok(())
199}
200
201pub fn requires_openai_responses(model: &str) -> bool {
202 let model = model.to_ascii_lowercase();
203 model.starts_with("gpt-5")
204 || model.starts_with("gpt-4o")
205 || model.starts_with("o1")
206 || model.starts_with("o3")
207 || model.starts_with("o4")
208 || model.contains("codex")
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 fn provider(name: &str, base_url: &str, models: Vec<&str>) -> ProviderConfig {
216 ProviderConfig {
217 name: name.into(),
218 base_url: base_url.into(),
219 format: "openai".into(),
220 api: ProviderApi::ChatCompletions,
221 keys: vec![ApiKeyConfig {
222 value: "sk-test".into(),
223 label: None,
224 enabled: true,
225 }],
226 models: models.into_iter().map(String::from).collect(),
227 }
228 }
229
230 #[test]
231 fn legacy_deepseek_config_loads_with_chat_completions_default() {
232 let config: AppConfig = toml::from_str(
233 r#"
234 [core]
235 host = "127.0.0.1"
236 port = 17830
237
238 [routing]
239 default_provider = "deepseek"
240 default_model = "deepseek-chat"
241
242 [[providers]]
243 name = "deepseek"
244 base_url = "https://api.deepseek.com"
245 format = "openai"
246 models = ["deepseek-chat", "deepseek-reasoner"]
247
248 [[providers.keys]]
249 value = "sk-deepseek"
250 "#,
251 )
252 .unwrap();
253
254 assert_eq!(config.providers[0].api, ProviderApi::ChatCompletions);
255 validate_config(&config).unwrap();
256 }
257
258 #[test]
259 fn deepseek_models_do_not_require_openai_responses() {
260 let config = AppConfig {
261 core: CoreConfig::default(),
262 providers: vec![provider(
263 "deepseek",
264 "https://api.deepseek.com",
265 vec!["deepseek-chat", "deepseek-reasoner"],
266 )],
267 routing: RoutingConfig::default(),
268 key_strategy: KeyStrategyConfig::default(),
269 fallback: FallbackConfig::default(),
270 virtual_keys: vec![],
271 services: vec![],
272 packages: vec![],
273 registry: RegistryConfig::default(),
274 package_aliases: HashMap::new(),
275 web_search: Default::default(),
276 team: Default::default(),
277 };
278
279 validate_config(&config).unwrap();
280 }
281
282 #[test]
283 fn openai_responses_only_models_still_require_responses_api() {
284 let config = AppConfig {
285 core: CoreConfig::default(),
286 providers: vec![provider(
287 "openai",
288 "https://api.openai.com",
289 vec!["gpt-5", "gpt-4o", "o3-mini", "codex-mini-latest"],
290 )],
291 routing: RoutingConfig::default(),
292 key_strategy: KeyStrategyConfig::default(),
293 fallback: FallbackConfig::default(),
294 virtual_keys: vec![],
295 services: vec![],
296 packages: vec![],
297 registry: RegistryConfig::default(),
298 package_aliases: HashMap::new(),
299 web_search: Default::default(),
300 team: Default::default(),
301 };
302
303 let err = validate_config(&config).unwrap_err().to_string();
304 assert!(err.contains("requires OpenAI Responses API"));
305 }
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct ApiKeyConfig {
310 pub value: String,
311 #[serde(default)]
312 pub label: Option<String>,
313 #[serde(default = "default_true")]
314 pub enabled: bool,
315}
316
317fn default_true() -> bool {
318 true
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize, Default)]
322pub struct RoutingConfig {
323 #[serde(default)]
324 pub default_provider: Option<String>,
325 #[serde(default)]
326 pub default_model: Option<String>,
327 #[serde(default)]
331 pub image_provider: Option<String>,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct KeyStrategyConfig {
336 #[serde(default = "default_key_mode")]
337 pub mode: String,
338}
339
340fn default_key_mode() -> String {
341 "failover".into()
342}
343
344impl Default for KeyStrategyConfig {
345 fn default() -> Self {
346 Self {
347 mode: default_key_mode(),
348 }
349 }
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct FallbackConfig {
354 #[serde(default = "default_retry_count")]
355 pub retry_count: u32,
356 #[serde(default = "default_true")]
357 pub switch_key: bool,
358 #[serde(default = "default_true")]
359 pub switch_provider: bool,
360 #[serde(default)]
361 pub priority: Vec<String>,
362}
363
364fn default_retry_count() -> u32 {
365 2
366}
367
368impl Default for FallbackConfig {
369 fn default() -> Self {
370 Self {
371 retry_count: default_retry_count(),
372 switch_key: true,
373 switch_provider: true,
374 priority: vec![],
375 }
376 }
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct VirtualKeyConfig {
381 pub key: String,
382 #[serde(default)]
383 pub label: Option<String>,
384 #[serde(default)]
385 pub provider: Option<String>,
386 #[serde(default)]
387 pub model: Option<String>,
388 #[serde(default)]
389 pub max_rpm: Option<u32>,
390 #[serde(default)]
391 pub max_budget: Option<f64>,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct ServiceConfig {
396 pub name: String,
397 pub command: String,
398 #[serde(default)]
399 pub args: Vec<String>,
400 #[serde(default)]
401 pub workdir: Option<String>,
402 #[serde(default)]
403 pub env: HashMap<String, String>,
404 #[serde(default)]
405 pub health_url: Option<String>,
406 #[serde(default = "default_health_interval")]
407 pub health_interval: u64,
408 #[serde(default)]
409 pub auto_start: bool,
410 #[serde(default)]
411 pub restart_on_crash: bool,
412}
413
414fn default_health_interval() -> u64 {
415 10
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct PackageConfig {
420 pub name: String,
421 #[serde(default)]
423 pub path: String,
424 #[serde(default = "default_true")]
425 pub enabled: bool,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct RegistryConfig {
430 #[serde(default = "default_gitea_url")]
431 pub gitea_url: String,
432 #[serde(default)]
433 pub gitea_token: Option<String>,
434 #[serde(default)]
435 pub package_source_url: Option<String>,
436 #[serde(default)]
437 pub app_source_url: Option<String>,
438}
439
440fn default_gitea_url() -> String {
441 "https://gitea.alhz.org".into()
442}
443
444impl Default for RegistryConfig {
445 fn default() -> Self {
446 Self {
447 gitea_url: default_gitea_url(),
448 gitea_token: None,
449 package_source_url: None,
450 app_source_url: None,
451 }
452 }
453}
454
455pub mod store;