weft-core 0.1.1

OpenAI-compatible AI agent runtime with WASM capability plugin system
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
    pub core: CoreConfig,
    #[serde(default)]
    pub providers: Vec<ProviderConfig>,
    #[serde(default)]
    pub routing: RoutingConfig,
    #[serde(default)]
    pub key_strategy: KeyStrategyConfig,
    #[serde(default)]
    pub fallback: FallbackConfig,
    #[serde(default)]
    pub virtual_keys: Vec<VirtualKeyConfig>,
    #[serde(default)]
    pub services: Vec<ServiceConfig>,
    #[serde(default)]
    pub packages: Vec<PackageConfig>,
    #[serde(default)]
    pub registry: RegistryConfig,
    #[serde(default)]
    pub package_aliases: HashMap<String, String>,
    /// 搜索/Web 工具的 API key(透传给 js-extension-runtime 等服务的环境变量)。
    #[serde(default)]
    pub web_search: WebSearchConfig,
    /// 多 agent 编排的角色→模型映射(orchestrator-worker 分层)。
    #[serde(default)]
    pub team: TeamConfig,
}

/// `[team]` 配置段:多 agent 编排的角色→模型映射。
/// 角色(planner/implementer/reviewer/integrator)可各自指定 provider/model;
/// 留空则继承 core 的 `routing.default_model`。core 启动时把 role_routing
/// 序列化写入 KV(key=`team:role_routing`),team-runtime 读取后按角色注入。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TeamConfig {
    /// 角色 id → 模型路由。键如 "planner"/"implementer"/"reviewer"/"integrator"。
    #[serde(default, alias = "roleRouting")]
    pub role_routing: HashMap<String, RoleModel>,
}

/// 单个角色的模型路由:provider 与 model 均可选,缺省继承默认。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RoleModel {
    #[serde(default)]
    pub provider: Option<String>,
    #[serde(default)]
    pub model: Option<String>,
}

/// `[web_search]` 配置段:搜索后端 API key。
/// 启动时这些 key 会被注入进程环境(EXA_API_KEY 等),
/// js-extension-runtime 子进程继承后即可使用。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WebSearchConfig {
    #[serde(default)]
    pub exa_api_key: Option<String>,
    #[serde(default)]
    pub tavily_api_key: Option<String>,
    #[serde(default)]
    pub brave_api_key: Option<String>,
    /// 默认搜索 provider(exa/tavily/brave/duckduckgo/auto)。
    #[serde(default)]
    pub provider: Option<String>,
}

impl WebSearchConfig {
    /// 把已配置的 key 注入当前进程环境,供后续 spawn 的 service 子进程继承。
    /// 仅在环境中尚未设置时写入(已有的环境变量优先,便于临时覆盖)。
    pub fn apply_to_env(&self) {
        fn set_if_absent(key: &str, value: &Option<String>) {
            if let Some(v) = value {
                let v = v.trim();
                if !v.is_empty() && std::env::var(key).is_err() {
                    std::env::set_var(key, v);
                }
            }
        }
        set_if_absent("EXA_API_KEY", &self.exa_api_key);
        set_if_absent("TAVILY_API_KEY", &self.tavily_api_key);
        set_if_absent("BRAVE_API_KEY", &self.brave_api_key);
        set_if_absent("WEFT_WEB_SEARCH_PROVIDER", &self.provider);
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoreConfig {
    #[serde(default = "default_host")]
    pub host: String,
    #[serde(default = "default_port")]
    pub port: u16,
    #[serde(default = "default_log_level")]
    pub log_level: String,
    #[serde(default = "default_data_dir")]
    pub data_dir: String,
}

fn default_host() -> String {
    "127.0.0.1".into()
}
fn default_port() -> u16 {
    17830
}
fn default_log_level() -> String {
    "info".into()
}
fn default_data_dir() -> String {
    "./data".into()
}

impl Default for CoreConfig {
    fn default() -> Self {
        Self {
            host: default_host(),
            port: default_port(),
            log_level: default_log_level(),
            data_dir: default_data_dir(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
    pub name: String,
    pub base_url: String,
    #[serde(default = "default_format")]
    pub format: String,
    #[serde(default)]
    pub api: ProviderApi,
    #[serde(default)]
    pub keys: Vec<ApiKeyConfig>,
    #[serde(default)]
    pub models: Vec<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderApi {
    ChatCompletions,
    Responses,
}

impl Default for ProviderApi {
    fn default() -> Self {
        Self::ChatCompletions
    }
}

impl ProviderApi {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::ChatCompletions => "chat_completions",
            Self::Responses => "responses",
        }
    }
}

fn default_format() -> String {
    "openai".into()
}

pub fn validate_config(config: &AppConfig) -> anyhow::Result<()> {
    for provider in &config.providers {
        validate_provider(provider)?;
    }
    Ok(())
}

pub fn validate_provider(provider: &ProviderConfig) -> anyhow::Result<()> {
    match provider.format.as_str() {
        "openai" | "anthropic" => {}
        other => anyhow::bail!(
            "Invalid provider format '{}'. Must be 'openai' or 'anthropic'",
            other
        ),
    }

    if provider.format == "anthropic" && provider.api != ProviderApi::ChatCompletions {
        anyhow::bail!(
            "Provider '{}' uses format 'anthropic' but api '{}'; only chat_completions is supported for anthropic providers",
            provider.name,
            provider.api.as_str()
        );
    }

    for model in &provider.models {
        if requires_openai_responses(model) && provider.api != ProviderApi::Responses {
            anyhow::bail!(
                "Model '{}' on provider '{}' requires OpenAI Responses API. Set api = 'responses' for this provider.",
                model,
                provider.name
            );
        }
    }

    Ok(())
}

pub fn requires_openai_responses(model: &str) -> bool {
    let model = model.to_ascii_lowercase();
    model.starts_with("gpt-5")
        || model.starts_with("gpt-4o")
        || model.starts_with("o1")
        || model.starts_with("o3")
        || model.starts_with("o4")
        || model.contains("codex")
}

#[cfg(test)]
mod tests {
    use super::*;

    fn provider(name: &str, base_url: &str, models: Vec<&str>) -> ProviderConfig {
        ProviderConfig {
            name: name.into(),
            base_url: base_url.into(),
            format: "openai".into(),
            api: ProviderApi::ChatCompletions,
            keys: vec![ApiKeyConfig {
                value: "sk-test".into(),
                label: None,
                enabled: true,
            }],
            models: models.into_iter().map(String::from).collect(),
        }
    }

    #[test]
    fn legacy_deepseek_config_loads_with_chat_completions_default() {
        let config: AppConfig = toml::from_str(
            r#"
                [core]
                host = "127.0.0.1"
                port = 17830

                [routing]
                default_provider = "deepseek"
                default_model = "deepseek-chat"

                [[providers]]
                name = "deepseek"
                base_url = "https://api.deepseek.com"
                format = "openai"
                models = ["deepseek-chat", "deepseek-reasoner"]

                [[providers.keys]]
                value = "sk-deepseek"
            "#,
        )
        .unwrap();

        assert_eq!(config.providers[0].api, ProviderApi::ChatCompletions);
        validate_config(&config).unwrap();
    }

    #[test]
    fn deepseek_models_do_not_require_openai_responses() {
        let config = AppConfig {
            core: CoreConfig::default(),
            providers: vec![provider(
                "deepseek",
                "https://api.deepseek.com",
                vec!["deepseek-chat", "deepseek-reasoner"],
            )],
            routing: RoutingConfig::default(),
            key_strategy: KeyStrategyConfig::default(),
            fallback: FallbackConfig::default(),
            virtual_keys: vec![],
            services: vec![],
            packages: vec![],
            registry: RegistryConfig::default(),
            package_aliases: HashMap::new(),
            web_search: Default::default(),
            team: Default::default(),
        };

        validate_config(&config).unwrap();
    }

    #[test]
    fn openai_responses_only_models_still_require_responses_api() {
        let config = AppConfig {
            core: CoreConfig::default(),
            providers: vec![provider(
                "openai",
                "https://api.openai.com",
                vec!["gpt-5", "gpt-4o", "o3-mini", "codex-mini-latest"],
            )],
            routing: RoutingConfig::default(),
            key_strategy: KeyStrategyConfig::default(),
            fallback: FallbackConfig::default(),
            virtual_keys: vec![],
            services: vec![],
            packages: vec![],
            registry: RegistryConfig::default(),
            package_aliases: HashMap::new(),
            web_search: Default::default(),
            team: Default::default(),
        };

        let err = validate_config(&config).unwrap_err().to_string();
        assert!(err.contains("requires OpenAI Responses API"));
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiKeyConfig {
    pub value: String,
    #[serde(default)]
    pub label: Option<String>,
    #[serde(default = "default_true")]
    pub enabled: bool,
}

fn default_true() -> bool {
    true
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RoutingConfig {
    #[serde(default)]
    pub default_provider: Option<String>,
    #[serde(default)]
    pub default_model: Option<String>,
    /// 图像生成专用 provider(对应某个 [[providers]].name)。
    /// 出图 capability(image.generate) 调用时,Core 从该 provider 取
    /// base_url+key 注入请求,使 image-gen WASM 无需环境变量即可出图。
    #[serde(default)]
    pub image_provider: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyStrategyConfig {
    #[serde(default = "default_key_mode")]
    pub mode: String,
}

fn default_key_mode() -> String {
    "failover".into()
}

impl Default for KeyStrategyConfig {
    fn default() -> Self {
        Self {
            mode: default_key_mode(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FallbackConfig {
    #[serde(default = "default_retry_count")]
    pub retry_count: u32,
    #[serde(default = "default_true")]
    pub switch_key: bool,
    #[serde(default = "default_true")]
    pub switch_provider: bool,
    #[serde(default)]
    pub priority: Vec<String>,
}

fn default_retry_count() -> u32 {
    2
}

impl Default for FallbackConfig {
    fn default() -> Self {
        Self {
            retry_count: default_retry_count(),
            switch_key: true,
            switch_provider: true,
            priority: vec![],
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VirtualKeyConfig {
    pub key: String,
    #[serde(default)]
    pub label: Option<String>,
    #[serde(default)]
    pub provider: Option<String>,
    #[serde(default)]
    pub model: Option<String>,
    #[serde(default)]
    pub max_rpm: Option<u32>,
    #[serde(default)]
    pub max_budget: Option<f64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceConfig {
    pub name: String,
    pub command: String,
    #[serde(default)]
    pub args: Vec<String>,
    #[serde(default)]
    pub workdir: Option<String>,
    #[serde(default)]
    pub env: HashMap<String, String>,
    #[serde(default)]
    pub health_url: Option<String>,
    #[serde(default = "default_health_interval")]
    pub health_interval: u64,
    #[serde(default)]
    pub auto_start: bool,
    #[serde(default)]
    pub restart_on_crash: bool,
}

fn default_health_interval() -> u64 {
    10
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageConfig {
    pub name: String,
    /// Path to the package directory (e.g. "packages/installed/weft-claw")
    #[serde(default)]
    pub path: String,
    #[serde(default = "default_true")]
    pub enabled: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryConfig {
    #[serde(default = "default_gitea_url")]
    pub gitea_url: String,
    #[serde(default)]
    pub gitea_token: Option<String>,
    #[serde(default)]
    pub package_source_url: Option<String>,
    #[serde(default)]
    pub app_source_url: Option<String>,
}

fn default_gitea_url() -> String {
    "https://gitea.alhz.org".into()
}

impl Default for RegistryConfig {
    fn default() -> Self {
        Self {
            gitea_url: default_gitea_url(),
            gitea_token: None,
            package_source_url: None,
            app_source_url: None,
        }
    }
}

pub mod store;