Skip to main content

weft_core/config/
mod.rs

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    /// 搜索/Web 工具的 API key(透传给 js-extension-runtime 等服务的环境变量)。
26    #[serde(default)]
27    pub web_search: WebSearchConfig,
28    /// 多 agent 编排的角色→模型映射(orchestrator-worker 分层)。
29    #[serde(default)]
30    pub team: TeamConfig,
31}
32
33/// `[team]` 配置段:多 agent 编排的角色→模型映射。
34/// 角色(planner/implementer/reviewer/integrator)可各自指定 provider/model;
35/// 留空则继承 core 的 `routing.default_model`。core 启动时把 role_routing
36/// 序列化写入 KV(key=`team:role_routing`),team-runtime 读取后按角色注入。
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38pub struct TeamConfig {
39    /// 角色 id → 模型路由。键如 "planner"/"implementer"/"reviewer"/"integrator"。
40    #[serde(default, alias = "roleRouting")]
41    pub role_routing: HashMap<String, RoleModel>,
42}
43
44/// 单个角色的模型路由:provider 与 model 均可选,缺省继承默认。
45#[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/// `[web_search]` 配置段:搜索后端 API key。
54/// 启动时这些 key 会被注入进程环境(EXA_API_KEY 等),
55/// js-extension-runtime 子进程继承后即可使用。
56#[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    /// 默认搜索 provider(exa/tavily/brave/duckduckgo/auto)。
65    #[serde(default)]
66    pub provider: Option<String>,
67}
68
69impl WebSearchConfig {
70    /// 把已配置的 key 注入当前进程环境,供后续 spawn 的 service 子进程继承。
71    /// 仅在环境中尚未设置时写入(已有的环境变量优先,便于临时覆盖)。
72    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    /// 图像生成专用 provider(对应某个 [[providers]].name)。
328    /// 出图 capability(image.generate) 调用时,Core 从该 provider 取
329    /// base_url+key 注入请求,使 image-gen WASM 无需环境变量即可出图。
330    #[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    /// Path to the package directory (e.g. "packages/installed/weft-claw")
422    #[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;