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 0
214}
215fn d_display_preview() -> usize {
216 3000
217}
218fn d_max_retries() -> u32 {
219 2
220}
221impl Default for Limits {
222 fn default() -> Self {
223 Limits {
224 tool_output_chars: d_tool_output(),
225 display_preview_chars: d_display_preview(),
226 max_retries: d_max_retries(),
227 }
228 }
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "lowercase")]
234pub enum DangerAction {
235 Log,
236 Reject,
237 Ask,
238}
239impl Default for DangerAction {
240 fn default() -> Self {
241 DangerAction::Log
242 }
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct SafetyConfig {
248 #[serde(default)]
249 pub dangerous_command_action: DangerAction,
250}
251impl Default for SafetyConfig {
252 fn default() -> Self {
253 SafetyConfig {
254 dangerous_command_action: DangerAction::Log,
255 }
256 }
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct ContextConfig {
262 #[serde(default = "d_max_ctx")]
263 pub max_context_tokens: usize,
264 #[serde(default = "d_trigger")]
265 pub compact_trigger_tokens: usize,
266 #[serde(default = "d_protect_first")]
267 pub protect_first_rounds: usize,
268 #[serde(default = "d_keep_last")]
269 pub keep_last_rounds: usize,
270 #[serde(default = "d_protect_user")]
271 pub protect_last_user_rounds: usize,
272}
273fn d_max_ctx() -> usize {
274 262144
275}
276fn d_trigger() -> usize {
278 256000
279}
280fn d_protect_first() -> usize {
281 1
282}
283fn d_keep_last() -> usize {
284 10
285}
286fn d_protect_user() -> usize {
287 2
288}
289impl Default for ContextConfig {
290 fn default() -> Self {
291 ContextConfig {
292 max_context_tokens: d_max_ctx(),
293 compact_trigger_tokens: d_trigger(),
294 protect_first_rounds: d_protect_first(),
295 keep_last_rounds: d_keep_last(),
296 protect_last_user_rounds: d_protect_user(),
297 }
298 }
299}
300
301#[derive(Debug, Clone, Default, Serialize, Deserialize)]
303pub struct SearchConfig {
304 #[serde(default)]
305 pub searxng_url: Option<String>,
306 #[serde(default)]
307 pub brave_api_key: Option<String>,
308 #[serde(default)]
309 pub google_cse_key: Option<String>,
310 #[serde(default)]
311 pub google_cse_cx: Option<String>,
312 #[serde(default)]
313 pub bing_api_key: Option<String>,
314 #[serde(default)]
315 pub serpapi_key: Option<String>,
316}
317
318impl SearchConfig {
319 pub fn apply_env(&mut self) {
320 if let Ok(u) = std::env::var("SEARCHXNG_URL").or_else(|_| std::env::var("SEARXNG_URL")) {
321 if !u.is_empty() {
322 self.searxng_url = Some(u);
323 }
324 }
325 if let Ok(k) = std::env::var("BRAVE_API_KEY") {
326 if !k.is_empty() {
327 self.brave_api_key = Some(k);
328 }
329 }
330 if let Ok(k) = std::env::var("SERPAPI_KEY") {
331 if !k.is_empty() {
332 self.serpapi_key = Some(k);
333 }
334 }
335 }
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(rename_all = "lowercase")]
341pub enum ShellBackendChoice {
342 Native,
344 Fastshell,
346 Auto,
348}
349
350impl Default for ShellBackendChoice {
351 fn default() -> Self {
352 ShellBackendChoice::Auto
353 }
354}
355
356#[derive(Debug, Clone, Default, Serialize, Deserialize)]
363pub struct SkillsConfig {
364 #[serde(default)]
366 pub user_dir: Option<String>,
367 #[serde(default)]
372 pub extra_builtins: Vec<String>,
373 #[serde(skip, default)]
384 pub vfs_skills_dir: Option<String>,
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct AgentConfig {
390 #[serde(default)]
391 pub model: ModelConfig,
392 #[serde(default)]
394 pub multimodal: Option<ModelConfig>,
395 #[serde(default = "d_max_iter")]
396 pub max_iterations: u32,
397 #[serde(default)]
398 pub plan_first: bool,
399 #[serde(default)]
402 pub shell_backend: ShellBackendChoice,
403 #[serde(default)]
404 pub timeouts: Timeouts,
405 #[serde(default)]
406 pub limits: Limits,
407 #[serde(default)]
408 pub safety: SafetyConfig,
409 #[serde(default)]
410 pub context: ContextConfig,
411 #[serde(default)]
412 pub search: SearchConfig,
413 #[serde(default)]
414 pub skills: SkillsConfig,
415}
416
417fn d_max_iter() -> u32 {
418 300
419}
420
421impl Default for AgentConfig {
422 fn default() -> Self {
423 AgentConfig {
424 model: ModelConfig::default(),
425 multimodal: None,
426 max_iterations: d_max_iter(),
427 plan_first: false,
428 shell_backend: ShellBackendChoice::Auto,
429 timeouts: Timeouts::default(),
430 limits: Limits::default(),
431 safety: SafetyConfig::default(),
432 context: ContextConfig::default(),
433 search: SearchConfig::default(),
434 skills: SkillsConfig::default(),
435 }
436 }
437}
438
439impl AgentConfig {
440 pub fn apply_env(&mut self) {
442 self.model.apply_env();
443 self.search.apply_env();
445 if let Some(mm) = self.multimodal.as_mut() {
446 mm.apply_env();
447 }
448 if let Ok(b) = std::env::var("AACODE_SHELL_BACKEND") {
450 self.shell_backend = match b.trim().to_lowercase().as_str() {
451 "native" => ShellBackendChoice::Native,
452 "fastshell" => ShellBackendChoice::Fastshell,
453 _ => ShellBackendChoice::Auto,
454 };
455 }
456 if let Ok(d) = std::env::var("AACODE_SKILLS_DIR") {
460 let d = d.trim();
461 if !d.is_empty() {
462 self.skills.user_dir = Some(d.to_string());
463 }
464 }
465 }
466
467 pub fn validate(&self) -> Vec<String> {
469 let mut errs = Vec::new();
471 let has_key = self
472 .model
473 .api_key
474 .as_ref()
475 .map(|k| !k.trim().is_empty())
476 .unwrap_or(false);
477 if !has_key {
478 errs.push(
479 "LLM API key not configured (set model.api_key or env LLM_API_KEY)".to_string(),
480 );
481 }
482 errs
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 #[test]
489 fn task_json_context_trigger_parses() {
490 let v: serde_json::Value = serde_json::json!({
493 "model": {"name": "m", "api_key": "k"},
494 "context": {"compact_trigger_tokens": 123456}
495 });
496 let cfg: super::AgentConfig = serde_json::from_value(v).unwrap();
497 assert_eq!(cfg.context.compact_trigger_tokens, 123456);
498 assert_eq!(
500 super::ContextConfig::default().compact_trigger_tokens,
501 256000
502 );
503 }
504
505 #[test]
506 fn task_json_skills_user_dir_parses() {
507 let v: serde_json::Value = serde_json::json!({
510 "model": {"name": "m", "api_key": "k"},
511 "skills": {"user_dir": "/data/data/app/files/sandbox/skills"}
512 });
513 let cfg: super::AgentConfig = serde_json::from_value(v).unwrap();
514 assert_eq!(
515 cfg.skills.user_dir.as_deref(),
516 Some("/data/data/app/files/sandbox/skills")
517 );
518 let v2: serde_json::Value = serde_json::json!({"model": {"name": "m", "api_key": "k"}});
519 let cfg2: super::AgentConfig = serde_json::from_value(v2).unwrap();
520 assert!(cfg2.skills.user_dir.is_none());
521 }
522
523 #[test]
524 fn env_skills_dir_applies() {
525 std::env::set_var("AACODE_SKILLS_DIR", "/tmp/aacode_env_skills");
526 let mut cfg = super::AgentConfig::default();
527 cfg.apply_env();
528 std::env::remove_var("AACODE_SKILLS_DIR");
529 assert_eq!(
530 cfg.skills.user_dir.as_deref(),
531 Some("/tmp/aacode_env_skills")
532 );
533 }
534
535 use super::*;
536
537 #[test]
538 fn base_url_inference() {
539 let mut m = ModelConfig {
540 name: "deepseek-chat".into(),
541 gateway: Gateway::Openai,
542 ..Default::default()
543 };
544 assert_eq!(m.resolved_base_url(), "https://api.deepseek.com/v1");
545
546 m.name = "kimi-k2".into();
547 assert_eq!(m.resolved_base_url(), "https://api.moonshot.cn/v1");
548
549 m.name = "MiniMax-M2".into();
550 m.gateway = Gateway::Anthropic;
551 assert_eq!(m.resolved_base_url(), "https://api.minimax.chat/anthropic");
552
553 m.name = "claude-3".into();
554 m.gateway = Gateway::Anthropic;
555 assert_eq!(m.resolved_base_url(), "https://api.anthropic.com");
556 }
557
558 #[test]
559 fn explicit_base_url_wins() {
560 let m = ModelConfig {
561 name: "deepseek-chat".into(),
562 base_url: Some("http://localhost:9999/v1".into()),
563 ..Default::default()
564 };
565 assert_eq!(m.resolved_base_url(), "http://localhost:9999/v1");
566 }
567
568 #[test]
569 fn config_json_roundtrip() {
570 let cfg = AgentConfig::default();
571 let s = serde_json::to_string(&cfg).unwrap();
572 let back: AgentConfig = serde_json::from_str(&s).unwrap();
573 assert_eq!(back.max_iterations, 300);
574 assert_eq!(back.model.name, "deepseek-chat");
575 }
576
577 #[test]
578 fn partial_json_uses_defaults() {
579 let cfg: AgentConfig =
580 serde_json::from_str(r#"{"model":{"name":"gpt-4","api_key":"x"}}"#).unwrap();
581 assert_eq!(cfg.model.name, "gpt-4");
582 assert_eq!(cfg.max_iterations, 300);
583 assert_eq!(cfg.model.temperature, 0.1);
584 }
585
586 #[test]
587 fn validate_requires_key() {
588 let cfg = AgentConfig::default();
589 assert!(!cfg.validate().is_empty());
590 let cfg2: AgentConfig = serde_json::from_str(r#"{"model":{"api_key":"sk-x"}}"#).unwrap();
591 assert!(cfg2.validate().is_empty());
592 }
593}