1use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum Gateway {
18 Openai,
19 Anthropic,
20}
21
22impl Default for Gateway {
23 fn default() -> Self {
24 Gateway::Openai
25 }
26}
27
28impl Gateway {
29 pub fn as_str(&self) -> &'static str {
30 match self {
31 Gateway::Openai => "openai",
32 Gateway::Anthropic => "anthropic",
33 }
34 }
35
36 pub fn parse(s: &str) -> Gateway {
37 match s.trim().to_lowercase().as_str() {
38 "anthropic" => Gateway::Anthropic,
39 _ => Gateway::Openai,
40 }
41 }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ModelConfig {
47 #[serde(default = "default_model_name")]
48 pub name: String,
49 #[serde(default)]
50 pub api_key: Option<String>,
51 #[serde(default)]
52 pub base_url: Option<String>,
53 #[serde(default)]
54 pub gateway: Gateway,
55 #[serde(default = "default_temperature")]
56 pub temperature: f32,
57 #[serde(default = "default_max_tokens")]
58 pub max_tokens: u32,
59 #[serde(default)]
60 pub multimodal: bool,
61 #[serde(default)]
63 pub request_timeout_secs: Option<u64>,
64}
65
66fn default_model_name() -> String {
67 "deepseek-chat".to_string()
68}
69fn default_temperature() -> f32 {
70 0.1
71}
72fn default_max_tokens() -> u32 {
73 8192
74}
75
76impl Default for ModelConfig {
77 fn default() -> Self {
78 ModelConfig {
79 name: default_model_name(),
80 api_key: None,
81 base_url: None,
82 gateway: Gateway::Openai,
83 temperature: default_temperature(),
84 max_tokens: default_max_tokens(),
85 multimodal: false,
86 request_timeout_secs: None,
87 }
88 }
89}
90
91impl ModelConfig {
92 pub fn resolved_base_url(&self) -> String {
95 if let Some(url) = &self.base_url {
96 if !url.trim().is_empty() {
97 return url.clone();
98 }
99 }
100 let m = self.name.to_lowercase();
101 let is_kimi = m.contains("kimi") || m.contains("moonshot");
102 let is_minimax = m.contains("minimax");
103 let is_deepseek = m.contains("deepseek");
104 let is_claude = m.contains("claude");
105
106 match self.gateway {
107 Gateway::Anthropic => {
108 if is_minimax {
109 "https://api.minimax.chat/anthropic".into()
110 } else if is_deepseek {
111 "https://api.deepseek.com/anthropic".into()
112 } else if is_kimi {
113 "https://api.moonshot.cn/anthropic".into()
114 } else {
115 "https://api.anthropic.com".into()
116 }
117 }
118 Gateway::Openai => {
119 if is_minimax {
120 "https://api.minimax.chat/v1".into()
121 } else if is_deepseek {
122 "https://api.deepseek.com/v1".into()
123 } else if is_kimi {
124 "https://api.moonshot.cn/v1".into()
125 } else if is_claude {
126 "https://api.openai.com/v1".into()
127 } else {
128 "https://api.openai.com/v1".into()
129 }
130 }
131 }
132 }
133
134 pub fn apply_env(&mut self) {
136 if let Ok(k) = std::env::var("LLM_API_KEY").or_else(|_| std::env::var("OPENAI_API_KEY")) {
137 if !k.is_empty() {
138 self.api_key = Some(k);
139 }
140 }
141 if let Ok(u) = std::env::var("LLM_API_URL").or_else(|_| std::env::var("OPENAI_BASE_URL")) {
142 if !u.is_empty() {
143 self.base_url = Some(u);
144 }
145 }
146 if let Ok(n) = std::env::var("LLM_MODEL_NAME") {
147 if !n.is_empty() {
148 self.name = n;
149 }
150 }
151 if let Ok(g) = std::env::var("LLM_GATEWAY") {
152 if !g.is_empty() {
153 self.gateway = Gateway::parse(&g);
154 }
155 }
156 if let Ok(mm) = std::env::var("LLM_MULTIMODAL") {
157 let l = mm.to_lowercase();
158 self.multimodal = matches!(l.as_str(), "true" | "1" | "yes" | "on");
159 }
160 }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct Timeouts {
166 #[serde(default = "d_shell")]
167 pub shell_command: u64,
168 #[serde(default = "d_shell_idle")]
169 pub shell_idle: u64,
170 #[serde(default = "d_web")]
171 pub web_request: u64,
172 #[serde(default = "d_model")]
173 pub model_request: u64,
174}
175fn d_shell() -> u64 {
176 120
177}
178fn d_shell_idle() -> u64 {
179 30
180}
181fn d_web() -> u64 {
182 15
183}
184fn d_model() -> u64 {
185 300
186}
187impl Default for Timeouts {
188 fn default() -> Self {
189 Timeouts {
190 shell_command: d_shell(),
191 shell_idle: d_shell_idle(),
192 web_request: d_web(),
193 model_request: d_model(),
194 }
195 }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct Limits {
201 #[serde(default = "d_tool_output")]
202 pub tool_output_chars: usize,
203 #[serde(default = "d_display_preview")]
204 pub display_preview_chars: usize,
205 #[serde(default = "d_max_retries")]
206 pub max_retries: u32,
207}
208fn d_tool_output() -> usize {
209 24000
210}
211fn d_display_preview() -> usize {
212 3000
213}
214fn d_max_retries() -> u32 {
215 2
216}
217impl Default for Limits {
218 fn default() -> Self {
219 Limits {
220 tool_output_chars: d_tool_output(),
221 display_preview_chars: d_display_preview(),
222 max_retries: d_max_retries(),
223 }
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(rename_all = "lowercase")]
230pub enum DangerAction {
231 Log,
232 Reject,
233 Ask,
234}
235impl Default for DangerAction {
236 fn default() -> Self {
237 DangerAction::Log
238 }
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct SafetyConfig {
244 #[serde(default)]
245 pub dangerous_command_action: DangerAction,
246}
247impl Default for SafetyConfig {
248 fn default() -> Self {
249 SafetyConfig {
250 dangerous_command_action: DangerAction::Log,
251 }
252 }
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct ContextConfig {
258 #[serde(default = "d_max_ctx")]
259 pub max_context_tokens: usize,
260 #[serde(default = "d_trigger")]
261 pub compact_trigger_tokens: usize,
262 #[serde(default = "d_protect_first")]
263 pub protect_first_rounds: usize,
264 #[serde(default = "d_keep_last")]
265 pub keep_last_rounds: usize,
266 #[serde(default = "d_protect_user")]
267 pub protect_last_user_rounds: usize,
268}
269fn d_max_ctx() -> usize {
270 262144
271}
272fn d_trigger() -> usize {
274 256000
275}
276fn d_protect_first() -> usize {
277 1
278}
279fn d_keep_last() -> usize {
280 10
281}
282fn d_protect_user() -> usize {
283 2
284}
285impl Default for ContextConfig {
286 fn default() -> Self {
287 ContextConfig {
288 max_context_tokens: d_max_ctx(),
289 compact_trigger_tokens: d_trigger(),
290 protect_first_rounds: d_protect_first(),
291 keep_last_rounds: d_keep_last(),
292 protect_last_user_rounds: d_protect_user(),
293 }
294 }
295}
296
297#[derive(Debug, Clone, Default, Serialize, Deserialize)]
299pub struct SearchConfig {
300 #[serde(default)]
301 pub searxng_url: Option<String>,
302 #[serde(default)]
303 pub brave_api_key: Option<String>,
304 #[serde(default)]
305 pub google_cse_key: Option<String>,
306 #[serde(default)]
307 pub google_cse_cx: Option<String>,
308 #[serde(default)]
309 pub bing_api_key: Option<String>,
310 #[serde(default)]
311 pub serpapi_key: Option<String>,
312}
313
314impl SearchConfig {
315 pub fn apply_env(&mut self) {
316 if let Ok(u) = std::env::var("SEARCHXNG_URL").or_else(|_| std::env::var("SEARXNG_URL")) {
317 if !u.is_empty() {
318 self.searxng_url = Some(u);
319 }
320 }
321 if let Ok(k) = std::env::var("BRAVE_API_KEY") {
322 if !k.is_empty() {
323 self.brave_api_key = Some(k);
324 }
325 }
326 if let Ok(k) = std::env::var("SERPAPI_KEY") {
327 if !k.is_empty() {
328 self.serpapi_key = Some(k);
329 }
330 }
331 }
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(rename_all = "lowercase")]
337pub enum ShellBackendChoice {
338 Native,
340 Fastshell,
342 Auto,
344}
345
346impl Default for ShellBackendChoice {
347 fn default() -> Self {
348 ShellBackendChoice::Auto
349 }
350}
351
352#[derive(Debug, Clone, Default, Serialize, Deserialize)]
359pub struct SkillsConfig {
360 #[serde(default)]
362 pub user_dir: Option<String>,
363 #[serde(default)]
368 pub extra_builtins: Vec<String>,
369 #[serde(skip, default)]
380 pub vfs_skills_dir: Option<String>,
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct AgentConfig {
386 #[serde(default)]
387 pub model: ModelConfig,
388 #[serde(default)]
390 pub multimodal: Option<ModelConfig>,
391 #[serde(default = "d_max_iter")]
392 pub max_iterations: u32,
393 #[serde(default)]
394 pub plan_first: bool,
395 #[serde(default)]
398 pub shell_backend: ShellBackendChoice,
399 #[serde(default)]
400 pub timeouts: Timeouts,
401 #[serde(default)]
402 pub limits: Limits,
403 #[serde(default)]
404 pub safety: SafetyConfig,
405 #[serde(default)]
406 pub context: ContextConfig,
407 #[serde(default)]
408 pub search: SearchConfig,
409 #[serde(default)]
410 pub skills: SkillsConfig,
411}
412
413fn d_max_iter() -> u32 {
414 300
415}
416
417impl Default for AgentConfig {
418 fn default() -> Self {
419 AgentConfig {
420 model: ModelConfig::default(),
421 multimodal: None,
422 max_iterations: d_max_iter(),
423 plan_first: false,
424 shell_backend: ShellBackendChoice::Auto,
425 timeouts: Timeouts::default(),
426 limits: Limits::default(),
427 safety: SafetyConfig::default(),
428 context: ContextConfig::default(),
429 search: SearchConfig::default(),
430 skills: SkillsConfig::default(),
431 }
432 }
433}
434
435impl AgentConfig {
436 pub fn apply_env(&mut self) {
438 self.model.apply_env();
439 self.search.apply_env();
441 if let Some(mm) = self.multimodal.as_mut() {
442 mm.apply_env();
443 }
444 if let Ok(b) = std::env::var("AACODE_SHELL_BACKEND") {
446 self.shell_backend = match b.trim().to_lowercase().as_str() {
447 "native" => ShellBackendChoice::Native,
448 "fastshell" => ShellBackendChoice::Fastshell,
449 _ => ShellBackendChoice::Auto,
450 };
451 }
452 if let Ok(d) = std::env::var("AACODE_SKILLS_DIR") {
456 let d = d.trim();
457 if !d.is_empty() {
458 self.skills.user_dir = Some(d.to_string());
459 }
460 }
461 }
462
463 pub fn validate(&self) -> Vec<String> {
465 let mut errs = Vec::new();
467 let has_key = self
468 .model
469 .api_key
470 .as_ref()
471 .map(|k| !k.trim().is_empty())
472 .unwrap_or(false);
473 if !has_key {
474 errs.push(
475 "LLM API key not configured (set model.api_key or env LLM_API_KEY)".to_string(),
476 );
477 }
478 errs
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 #[test]
485 fn task_json_context_trigger_parses() {
486 let v: serde_json::Value = serde_json::json!({
489 "model": {"name": "m", "api_key": "k"},
490 "context": {"compact_trigger_tokens": 123456}
491 });
492 let cfg: super::AgentConfig = serde_json::from_value(v).unwrap();
493 assert_eq!(cfg.context.compact_trigger_tokens, 123456);
494 assert_eq!(super::ContextConfig::default().compact_trigger_tokens, 256000);
496 }
497
498 #[test]
499 fn task_json_skills_user_dir_parses() {
500 let v: serde_json::Value = serde_json::json!({
503 "model": {"name": "m", "api_key": "k"},
504 "skills": {"user_dir": "/data/data/app/files/sandbox/skills"}
505 });
506 let cfg: super::AgentConfig = serde_json::from_value(v).unwrap();
507 assert_eq!(
508 cfg.skills.user_dir.as_deref(),
509 Some("/data/data/app/files/sandbox/skills")
510 );
511 let v2: serde_json::Value = serde_json::json!({"model": {"name": "m", "api_key": "k"}});
512 let cfg2: super::AgentConfig = serde_json::from_value(v2).unwrap();
513 assert!(cfg2.skills.user_dir.is_none());
514 }
515
516 #[test]
517 fn env_skills_dir_applies() {
518 std::env::set_var("AACODE_SKILLS_DIR", "/tmp/aacode_env_skills");
519 let mut cfg = super::AgentConfig::default();
520 cfg.apply_env();
521 std::env::remove_var("AACODE_SKILLS_DIR");
522 assert_eq!(cfg.skills.user_dir.as_deref(), Some("/tmp/aacode_env_skills"));
523 }
524
525 use super::*;
526
527 #[test]
528 fn base_url_inference() {
529 let mut m = ModelConfig {
530 name: "deepseek-chat".into(),
531 gateway: Gateway::Openai,
532 ..Default::default()
533 };
534 assert_eq!(m.resolved_base_url(), "https://api.deepseek.com/v1");
535
536 m.name = "kimi-k2".into();
537 assert_eq!(m.resolved_base_url(), "https://api.moonshot.cn/v1");
538
539 m.name = "MiniMax-M2".into();
540 m.gateway = Gateway::Anthropic;
541 assert_eq!(m.resolved_base_url(), "https://api.minimax.chat/anthropic");
542
543 m.name = "claude-3".into();
544 m.gateway = Gateway::Anthropic;
545 assert_eq!(m.resolved_base_url(), "https://api.anthropic.com");
546 }
547
548 #[test]
549 fn explicit_base_url_wins() {
550 let m = ModelConfig {
551 name: "deepseek-chat".into(),
552 base_url: Some("http://localhost:9999/v1".into()),
553 ..Default::default()
554 };
555 assert_eq!(m.resolved_base_url(), "http://localhost:9999/v1");
556 }
557
558 #[test]
559 fn config_json_roundtrip() {
560 let cfg = AgentConfig::default();
561 let s = serde_json::to_string(&cfg).unwrap();
562 let back: AgentConfig = serde_json::from_str(&s).unwrap();
563 assert_eq!(back.max_iterations, 300);
564 assert_eq!(back.model.name, "deepseek-chat");
565 }
566
567 #[test]
568 fn partial_json_uses_defaults() {
569 let cfg: AgentConfig =
570 serde_json::from_str(r#"{"model":{"name":"gpt-4","api_key":"x"}}"#).unwrap();
571 assert_eq!(cfg.model.name, "gpt-4");
572 assert_eq!(cfg.max_iterations, 300);
573 assert_eq!(cfg.model.temperature, 0.1);
574 }
575
576 #[test]
577 fn validate_requires_key() {
578 let cfg = AgentConfig::default();
579 assert!(!cfg.validate().is_empty());
580 let cfg2: AgentConfig =
581 serde_json::from_str(r#"{"model":{"api_key":"sk-x"}}"#).unwrap();
582 assert!(cfg2.validate().is_empty());
583 }
584}