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")]
204 pub tool_output_chars: usize,
205 #[serde(default = "d_tool_output_lines")]
207 pub tool_output_lines: usize,
208 #[serde(default = "d_display_preview")]
209 pub display_preview_chars: usize,
210 #[serde(default = "d_max_retries")]
211 pub max_retries: u32,
212}
213fn d_tool_output() -> usize {
214 0
218}
219fn d_tool_output_lines() -> usize {
220 500
221}
222fn d_display_preview() -> usize {
223 3000
224}
225fn d_max_retries() -> u32 {
226 2
227}
228impl Default for Limits {
229 fn default() -> Self {
230 Limits {
231 tool_output_chars: d_tool_output(),
232 tool_output_lines: d_tool_output_lines(),
233 display_preview_chars: d_display_preview(),
234 max_retries: d_max_retries(),
235 }
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(rename_all = "lowercase")]
242pub enum DangerAction {
243 Log,
244 Reject,
245 Ask,
246}
247impl Default for DangerAction {
248 fn default() -> Self {
249 DangerAction::Log
250 }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "lowercase")]
256pub enum UnknownAction {
257 Allow,
259 Ask,
261 Reject,
263}
264impl Default for UnknownAction {
265 fn default() -> Self {
266 UnknownAction::Allow
267 }
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(rename_all = "snake_case")]
277pub enum PermissionMode {
278 FullAccess,
279 ReadOnly,
280}
281impl Default for PermissionMode {
282 fn default() -> Self {
283 PermissionMode::FullAccess
284 }
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct SafetyConfig {
290 #[serde(default)]
291 pub dangerous_command_action: DangerAction,
292 #[serde(default)]
294 pub unknown_command_action: UnknownAction,
295 #[serde(default)]
297 pub permission_mode: PermissionMode,
298}
299impl Default for SafetyConfig {
300 fn default() -> Self {
301 SafetyConfig {
302 dangerous_command_action: DangerAction::Log,
303 unknown_command_action: UnknownAction::default(),
304 permission_mode: PermissionMode::default(),
305 }
306 }
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct ContextConfig {
312 #[serde(default = "d_max_ctx")]
313 pub max_context_tokens: usize,
314 #[serde(default = "d_trigger")]
315 pub compact_trigger_tokens: usize,
316 #[serde(default = "d_protect_first")]
317 pub protect_first_rounds: usize,
318 #[serde(default = "d_keep_last")]
319 pub keep_last_rounds: usize,
320 #[serde(default = "d_protect_user")]
321 pub protect_last_user_rounds: usize,
322 #[serde(default = "d_retrieval_trigger")]
325 pub retrieval_trigger_tokens: usize,
326 #[serde(default = "d_retrieval_keep")]
328 pub retrieval_keep_tokens: usize,
329 #[serde(default = "d_retrieval_recent")]
331 pub retrieval_keep_recent_user_messages: usize,
332 #[serde(default = "d_retrieval_rounds")]
334 pub retrieval_keep_min_rounds: usize,
335}
336fn d_max_ctx() -> usize {
337 262144
338}
339fn d_retrieval_trigger() -> usize {
340 100000
341}
342fn d_retrieval_keep() -> usize {
343 20000
344}
345fn d_retrieval_recent() -> usize {
346 2
347}
348fn d_retrieval_rounds() -> usize {
349 4
350}
351fn d_trigger() -> usize {
353 256000
354}
355fn d_protect_first() -> usize {
356 1
357}
358fn d_keep_last() -> usize {
359 10
360}
361fn d_protect_user() -> usize {
362 2
363}
364impl Default for ContextConfig {
365 fn default() -> Self {
366 ContextConfig {
367 max_context_tokens: d_max_ctx(),
368 compact_trigger_tokens: d_trigger(),
369 protect_first_rounds: d_protect_first(),
370 keep_last_rounds: d_keep_last(),
371 protect_last_user_rounds: d_protect_user(),
372 retrieval_trigger_tokens: d_retrieval_trigger(),
373 retrieval_keep_tokens: d_retrieval_keep(),
374 retrieval_keep_recent_user_messages: d_retrieval_recent(),
375 retrieval_keep_min_rounds: d_retrieval_rounds(),
376 }
377 }
378}
379
380#[derive(Debug, Clone, Default, Serialize, Deserialize)]
382pub struct SearchConfig {
383 #[serde(default)]
384 pub searxng_url: Option<String>,
385 #[serde(default)]
386 pub brave_api_key: Option<String>,
387 #[serde(default)]
388 pub google_cse_key: Option<String>,
389 #[serde(default)]
390 pub google_cse_cx: Option<String>,
391 #[serde(default)]
392 pub bing_api_key: Option<String>,
393 #[serde(default)]
394 pub serpapi_key: Option<String>,
395}
396
397impl SearchConfig {
398 pub fn apply_env(&mut self) {
399 if let Ok(u) = std::env::var("SEARCHXNG_URL").or_else(|_| std::env::var("SEARXNG_URL")) {
400 if !u.is_empty() {
401 self.searxng_url = Some(u);
402 }
403 }
404 if let Ok(k) = std::env::var("BRAVE_API_KEY") {
405 if !k.is_empty() {
406 self.brave_api_key = Some(k);
407 }
408 }
409 if let Ok(k) = std::env::var("SERPAPI_KEY") {
410 if !k.is_empty() {
411 self.serpapi_key = Some(k);
412 }
413 }
414 }
415}
416
417#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(rename_all = "lowercase")]
420pub enum ShellBackendChoice {
421 Native,
423 Fastshell,
425 Auto,
427}
428
429impl Default for ShellBackendChoice {
430 fn default() -> Self {
431 ShellBackendChoice::Auto
432 }
433}
434
435#[derive(Debug, Clone, Default, Serialize, Deserialize)]
442pub struct SkillsConfig {
443 #[serde(default)]
445 pub user_dir: Option<String>,
446 #[serde(default)]
451 pub extra_builtins: Vec<String>,
452 #[serde(skip, default)]
463 pub vfs_skills_dir: Option<String>,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct AgentConfig {
469 #[serde(default)]
470 pub model: ModelConfig,
471 #[serde(default)]
473 pub multimodal: Option<ModelConfig>,
474 #[serde(default = "d_max_iter")]
475 pub max_iterations: u32,
476 #[serde(default)]
477 pub plan_first: bool,
478 #[serde(default)]
481 pub shell_backend: ShellBackendChoice,
482 #[serde(default)]
483 pub timeouts: Timeouts,
484 #[serde(default)]
485 pub limits: Limits,
486 #[serde(default)]
487 pub safety: SafetyConfig,
488 #[serde(default)]
489 pub context: ContextConfig,
490 #[serde(default)]
491 pub search: SearchConfig,
492 #[serde(default)]
493 pub skills: SkillsConfig,
494}
495
496fn d_max_iter() -> u32 {
497 300
498}
499
500impl Default for AgentConfig {
501 fn default() -> Self {
502 AgentConfig {
503 model: ModelConfig::default(),
504 multimodal: None,
505 max_iterations: d_max_iter(),
506 plan_first: false,
507 shell_backend: ShellBackendChoice::Auto,
508 timeouts: Timeouts::default(),
509 limits: Limits::default(),
510 safety: SafetyConfig::default(),
511 context: ContextConfig::default(),
512 search: SearchConfig::default(),
513 skills: SkillsConfig::default(),
514 }
515 }
516}
517
518impl AgentConfig {
519 pub fn apply_env(&mut self) {
521 self.model.apply_env();
522 self.search.apply_env();
524 if let Some(mm) = self.multimodal.as_mut() {
525 mm.apply_env();
526 }
527 if let Ok(b) = std::env::var("AACODE_SHELL_BACKEND") {
529 self.shell_backend = match b.trim().to_lowercase().as_str() {
530 "native" => ShellBackendChoice::Native,
531 "fastshell" => ShellBackendChoice::Fastshell,
532 _ => ShellBackendChoice::Auto,
533 };
534 }
535 if let Ok(d) = std::env::var("AACODE_SKILLS_DIR") {
539 let d = d.trim();
540 if !d.is_empty() {
541 self.skills.user_dir = Some(d.to_string());
542 }
543 }
544 }
545
546 pub fn validate(&self) -> Vec<String> {
548 let mut errs = Vec::new();
550 let has_key = self
551 .model
552 .api_key
553 .as_ref()
554 .map(|k| !k.trim().is_empty())
555 .unwrap_or(false);
556 if !has_key {
557 errs.push(
558 "LLM API key not configured (set model.api_key or env LLM_API_KEY)".to_string(),
559 );
560 }
561 errs
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 #[test]
568 fn task_json_context_trigger_parses() {
569 let v: serde_json::Value = serde_json::json!({
572 "model": {"name": "m", "api_key": "k"},
573 "context": {"compact_trigger_tokens": 123456}
574 });
575 let cfg: super::AgentConfig = serde_json::from_value(v).unwrap();
576 assert_eq!(cfg.context.compact_trigger_tokens, 123456);
577 assert_eq!(
579 super::ContextConfig::default().compact_trigger_tokens,
580 256000
581 );
582 }
583
584 #[test]
585 fn task_json_skills_user_dir_parses() {
586 let v: serde_json::Value = serde_json::json!({
589 "model": {"name": "m", "api_key": "k"},
590 "skills": {"user_dir": "/data/data/app/files/sandbox/skills"}
591 });
592 let cfg: super::AgentConfig = serde_json::from_value(v).unwrap();
593 assert_eq!(
594 cfg.skills.user_dir.as_deref(),
595 Some("/data/data/app/files/sandbox/skills")
596 );
597 let v2: serde_json::Value = serde_json::json!({"model": {"name": "m", "api_key": "k"}});
598 let cfg2: super::AgentConfig = serde_json::from_value(v2).unwrap();
599 assert!(cfg2.skills.user_dir.is_none());
600 }
601
602 #[test]
603 fn env_skills_dir_applies() {
604 std::env::set_var("AACODE_SKILLS_DIR", "/tmp/aacode_env_skills");
605 let mut cfg = super::AgentConfig::default();
606 cfg.apply_env();
607 std::env::remove_var("AACODE_SKILLS_DIR");
608 assert_eq!(
609 cfg.skills.user_dir.as_deref(),
610 Some("/tmp/aacode_env_skills")
611 );
612 }
613
614 use super::*;
615
616 #[test]
617 fn base_url_inference() {
618 let mut m = ModelConfig {
619 name: "deepseek-chat".into(),
620 gateway: Gateway::Openai,
621 ..Default::default()
622 };
623 assert_eq!(m.resolved_base_url(), "https://api.deepseek.com/v1");
624
625 m.name = "kimi-k2".into();
626 assert_eq!(m.resolved_base_url(), "https://api.moonshot.cn/v1");
627
628 m.name = "MiniMax-M2".into();
629 m.gateway = Gateway::Anthropic;
630 assert_eq!(m.resolved_base_url(), "https://api.minimax.chat/anthropic");
631
632 m.name = "claude-3".into();
633 m.gateway = Gateway::Anthropic;
634 assert_eq!(m.resolved_base_url(), "https://api.anthropic.com");
635 }
636
637 #[test]
638 fn explicit_base_url_wins() {
639 let m = ModelConfig {
640 name: "deepseek-chat".into(),
641 base_url: Some("http://localhost:9999/v1".into()),
642 ..Default::default()
643 };
644 assert_eq!(m.resolved_base_url(), "http://localhost:9999/v1");
645 }
646
647 #[test]
648 fn config_json_roundtrip() {
649 let cfg = AgentConfig::default();
650 let s = serde_json::to_string(&cfg).unwrap();
651 let back: AgentConfig = serde_json::from_str(&s).unwrap();
652 assert_eq!(back.max_iterations, 300);
653 assert_eq!(back.model.name, "deepseek-chat");
654 }
655
656 #[test]
657 fn partial_json_uses_defaults() {
658 let cfg: AgentConfig =
659 serde_json::from_str(r#"{"model":{"name":"gpt-4","api_key":"x"}}"#).unwrap();
660 assert_eq!(cfg.model.name, "gpt-4");
661 assert_eq!(cfg.max_iterations, 300);
662 assert_eq!(cfg.model.temperature, 0.1);
663 }
664
665 #[test]
666 fn validate_requires_key() {
667 let cfg = AgentConfig::default();
668 assert!(!cfg.validate().is_empty());
669 let cfg2: AgentConfig = serde_json::from_str(r#"{"model":{"api_key":"sk-x"}}"#).unwrap();
670 assert!(cfg2.validate().is_empty());
671 }
672}