1use crate::error::{CodeError, Result};
13use crate::llm::LlmConfig;
14use serde::{Deserialize, Serialize};
15use serde_json::Value as JsonValue;
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone, Serialize, Deserialize, Default)]
24#[serde(rename_all = "camelCase")]
25pub struct ModelCost {
26 #[serde(default)]
28 pub input: f64,
29 #[serde(default)]
31 pub output: f64,
32 #[serde(default)]
34 pub cache_read: f64,
35 #[serde(default)]
37 pub cache_write: f64,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42pub struct ModelLimit {
43 #[serde(default)]
45 pub context: u32,
46 #[serde(default)]
48 pub output: u32,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, Default)]
53pub struct ModelModalities {
54 #[serde(default)]
56 pub input: Vec<String>,
57 #[serde(default)]
59 pub output: Vec<String>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct ModelConfig {
66 pub id: String,
68 #[serde(default)]
70 pub name: String,
71 #[serde(default)]
73 pub family: String,
74 #[serde(default)]
76 pub api_key: Option<String>,
77 #[serde(default)]
79 pub base_url: Option<String>,
80 #[serde(default)]
82 pub attachment: bool,
83 #[serde(default)]
85 pub reasoning: bool,
86 #[serde(default = "default_true")]
88 pub tool_call: bool,
89 #[serde(default = "default_true")]
91 pub temperature: bool,
92 #[serde(default)]
94 pub release_date: Option<String>,
95 #[serde(default)]
97 pub modalities: ModelModalities,
98 #[serde(default)]
100 pub cost: ModelCost,
101 #[serde(default)]
103 pub limit: ModelLimit,
104}
105
106fn default_true() -> bool {
107 true
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct ProviderConfig {
114 pub name: String,
116 #[serde(default)]
118 pub api_key: Option<String>,
119 #[serde(default)]
121 pub base_url: Option<String>,
122 #[serde(default)]
124 pub models: Vec<ModelConfig>,
125}
126
127impl ProviderConfig {
128 pub fn find_model(&self, model_id: &str) -> Option<&ModelConfig> {
130 self.models.iter().find(|m| m.id == model_id)
131 }
132
133 pub fn get_api_key<'a>(&'a self, model: &'a ModelConfig) -> Option<&'a str> {
135 model.api_key.as_deref().or(self.api_key.as_deref())
136 }
137
138 pub fn get_base_url<'a>(&'a self, model: &'a ModelConfig) -> Option<&'a str> {
140 model.base_url.as_deref().or(self.base_url.as_deref())
141 }
142}
143
144#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
150#[serde(rename_all = "lowercase")]
151pub enum StorageBackend {
152 Memory,
154 #[default]
156 File,
157 Custom,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, Default)]
170#[serde(rename_all = "camelCase")]
171pub struct CodeConfig {
172 #[serde(default, alias = "default_model")]
174 pub default_model: Option<String>,
175
176 #[serde(default)]
178 pub providers: Vec<ProviderConfig>,
179
180 #[serde(default)]
182 pub storage_backend: StorageBackend,
183
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub sessions_dir: Option<PathBuf>,
187
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub storage_url: Option<String>,
191
192 #[serde(default, alias = "skill_dirs")]
194 pub skill_dirs: Vec<PathBuf>,
195
196 #[serde(default, alias = "agent_dirs")]
198 pub agent_dirs: Vec<PathBuf>,
199
200 #[serde(default, alias = "max_tool_rounds")]
202 pub max_tool_rounds: Option<usize>,
203
204 #[serde(default, alias = "thinking_budget")]
206 pub thinking_budget: Option<usize>,
207
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub memory: Option<crate::memory::MemoryConfig>,
211
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub queue: Option<crate::queue::SessionQueueConfig>,
215
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub search: Option<SearchConfig>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct SearchConfig {
225 #[serde(default = "default_search_timeout")]
227 pub timeout: u64,
228
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub health: Option<SearchHealthConfig>,
232
233 #[serde(default, rename = "engine")]
235 pub engines: std::collections::HashMap<String, SearchEngineConfig>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase")]
241pub struct SearchHealthConfig {
242 #[serde(default = "default_max_failures")]
244 pub max_failures: u32,
245
246 #[serde(default = "default_suspend_seconds")]
248 pub suspend_seconds: u64,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct SearchEngineConfig {
255 #[serde(default = "default_enabled")]
257 pub enabled: bool,
258
259 #[serde(default = "default_weight")]
261 pub weight: f64,
262
263 #[serde(skip_serializing_if = "Option::is_none")]
265 pub timeout: Option<u64>,
266}
267
268fn default_search_timeout() -> u64 {
269 10
270}
271
272fn default_max_failures() -> u32 {
273 3
274}
275
276fn default_suspend_seconds() -> u64 {
277 60
278}
279
280fn default_enabled() -> bool {
281 true
282}
283
284fn default_weight() -> f64 {
285 1.0
286}
287
288impl CodeConfig {
289 pub fn new() -> Self {
291 Self::default()
292 }
293
294 pub fn from_file(path: &Path) -> Result<Self> {
298 let content = std::fs::read_to_string(path).map_err(|e| {
299 CodeError::Config(format!(
300 "Failed to read config file {}: {}",
301 path.display(),
302 e
303 ))
304 })?;
305
306 Self::from_hcl(&content).map_err(|e| {
307 CodeError::Config(format!(
308 "Failed to parse HCL config {}: {}",
309 path.display(),
310 e
311 ))
312 })
313 }
314
315 pub fn from_hcl(content: &str) -> Result<Self> {
321 let body: hcl::Body = hcl::from_str(content)
322 .map_err(|e| CodeError::Config(format!("Failed to parse HCL: {}", e)))?;
323 let json_value = hcl_body_to_json(&body);
324 serde_json::from_value(json_value)
325 .map_err(|e| CodeError::Config(format!("Failed to deserialize HCL config: {}", e)))
326 }
327
328 pub fn save_to_file(&self, path: &Path) -> Result<()> {
332 if let Some(parent) = path.parent() {
333 std::fs::create_dir_all(parent).map_err(|e| {
334 CodeError::Config(format!(
335 "Failed to create config directory {}: {}",
336 parent.display(),
337 e
338 ))
339 })?;
340 }
341
342 let content = serde_json::to_string_pretty(self)
343 .map_err(|e| CodeError::Config(format!("Failed to serialize config: {}", e)))?;
344
345 std::fs::write(path, content).map_err(|e| {
346 CodeError::Config(format!(
347 "Failed to write config file {}: {}",
348 path.display(),
349 e
350 ))
351 })?;
352
353 Ok(())
354 }
355
356 pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> {
358 self.providers.iter().find(|p| p.name == name)
359 }
360
361 pub fn default_provider_config(&self) -> Option<&ProviderConfig> {
363 let default = self.default_model.as_ref()?;
364 let (provider_name, _) = default.split_once('/')?;
365 self.find_provider(provider_name)
366 }
367
368 pub fn default_model_config(&self) -> Option<(&ProviderConfig, &ModelConfig)> {
370 let default = self.default_model.as_ref()?;
371 let (provider_name, model_id) = default.split_once('/')?;
372 let provider = self.find_provider(provider_name)?;
373 let model = provider.find_model(model_id)?;
374 Some((provider, model))
375 }
376
377 pub fn default_llm_config(&self) -> Option<LlmConfig> {
381 let (provider, model) = self.default_model_config()?;
382 let api_key = provider.get_api_key(model)?;
383 let base_url = provider.get_base_url(model);
384
385 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
386 if let Some(url) = base_url {
387 config = config.with_base_url(url);
388 }
389 Some(config)
390 }
391
392 pub fn llm_config(&self, provider_name: &str, model_id: &str) -> Option<LlmConfig> {
396 let provider = self.find_provider(provider_name)?;
397 let model = provider.find_model(model_id)?;
398 let api_key = provider.get_api_key(model)?;
399 let base_url = provider.get_base_url(model);
400
401 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
402 if let Some(url) = base_url {
403 config = config.with_base_url(url);
404 }
405 Some(config)
406 }
407
408 pub fn list_models(&self) -> Vec<(&ProviderConfig, &ModelConfig)> {
410 self.providers
411 .iter()
412 .flat_map(|p| p.models.iter().map(move |m| (p, m)))
413 .collect()
414 }
415
416 pub fn add_skill_dir(mut self, dir: impl Into<PathBuf>) -> Self {
418 self.skill_dirs.push(dir.into());
419 self
420 }
421
422 pub fn add_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
424 self.agent_dirs.push(dir.into());
425 self
426 }
427
428 pub fn has_directories(&self) -> bool {
430 !self.skill_dirs.is_empty() || !self.agent_dirs.is_empty()
431 }
432
433 pub fn has_providers(&self) -> bool {
435 !self.providers.is_empty()
436 }
437}
438
439const HCL_ARRAY_BLOCKS: &[&str] = &["providers", "models"];
445
446fn hcl_body_to_json(body: &hcl::Body) -> JsonValue {
448 let mut map = serde_json::Map::new();
449
450 for attr in body.attributes() {
452 let key = snake_to_camel(attr.key.as_str());
453 let value = hcl_expr_to_json(attr.expr());
454 map.insert(key, value);
455 }
456
457 for block in body.blocks() {
459 let key = snake_to_camel(block.identifier.as_str());
460 let block_value = hcl_body_to_json(block.body());
461
462 if HCL_ARRAY_BLOCKS.contains(&block.identifier.as_str()) {
463 let arr = map
465 .entry(key)
466 .or_insert_with(|| JsonValue::Array(Vec::new()));
467 if let JsonValue::Array(ref mut vec) = arr {
468 vec.push(block_value);
469 }
470 } else {
471 map.insert(key, block_value);
472 }
473 }
474
475 JsonValue::Object(map)
476}
477
478fn snake_to_camel(s: &str) -> String {
480 let mut result = String::with_capacity(s.len());
481 let mut capitalize_next = false;
482 for ch in s.chars() {
483 if ch == '_' {
484 capitalize_next = true;
485 } else if capitalize_next {
486 result.extend(ch.to_uppercase());
487 capitalize_next = false;
488 } else {
489 result.push(ch);
490 }
491 }
492 result
493}
494
495fn hcl_expr_to_json(expr: &hcl::Expression) -> JsonValue {
497 match expr {
498 hcl::Expression::String(s) => JsonValue::String(s.clone()),
499 hcl::Expression::Number(n) => {
500 if let Some(i) = n.as_i64() {
501 JsonValue::Number(i.into())
502 } else if let Some(f) = n.as_f64() {
503 serde_json::Number::from_f64(f)
504 .map(JsonValue::Number)
505 .unwrap_or(JsonValue::Null)
506 } else {
507 JsonValue::Null
508 }
509 }
510 hcl::Expression::Bool(b) => JsonValue::Bool(*b),
511 hcl::Expression::Null => JsonValue::Null,
512 hcl::Expression::Array(arr) => JsonValue::Array(arr.iter().map(hcl_expr_to_json).collect()),
513 hcl::Expression::Object(obj) => {
514 let map: serde_json::Map<String, JsonValue> = obj
515 .iter()
516 .map(|(k, v)| {
517 let key = match k {
518 hcl::ObjectKey::Identifier(id) => snake_to_camel(id.as_str()),
519 hcl::ObjectKey::Expression(expr) => {
520 if let hcl::Expression::String(s) = expr {
521 snake_to_camel(s)
522 } else {
523 format!("{:?}", expr)
524 }
525 }
526 _ => format!("{:?}", k),
527 };
528 (key, hcl_expr_to_json(v))
529 })
530 .collect();
531 JsonValue::Object(map)
532 }
533 _ => JsonValue::String(format!("{:?}", expr)),
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
542 fn test_config_default() {
543 let config = CodeConfig::default();
544 assert!(config.skill_dirs.is_empty());
545 assert!(config.agent_dirs.is_empty());
546 assert!(config.providers.is_empty());
547 assert!(config.default_model.is_none());
548 assert_eq!(config.storage_backend, StorageBackend::File);
549 assert!(config.sessions_dir.is_none());
550 }
551
552 #[test]
553 fn test_storage_backend_default() {
554 let backend = StorageBackend::default();
555 assert_eq!(backend, StorageBackend::File);
556 }
557
558 #[test]
559 fn test_storage_backend_serde() {
560 let memory = StorageBackend::Memory;
562 let json = serde_json::to_string(&memory).unwrap();
563 assert_eq!(json, "\"memory\"");
564
565 let file = StorageBackend::File;
566 let json = serde_json::to_string(&file).unwrap();
567 assert_eq!(json, "\"file\"");
568
569 let memory: StorageBackend = serde_json::from_str("\"memory\"").unwrap();
571 assert_eq!(memory, StorageBackend::Memory);
572
573 let file: StorageBackend = serde_json::from_str("\"file\"").unwrap();
574 assert_eq!(file, StorageBackend::File);
575 }
576
577 #[test]
578 fn test_config_with_storage_backend() {
579 let temp_dir = tempfile::tempdir().unwrap();
580 let config_path = temp_dir.path().join("config.hcl");
581
582 std::fs::write(
583 &config_path,
584 r#"
585 storage_backend = "memory"
586 sessions_dir = "/tmp/sessions"
587 "#,
588 )
589 .unwrap();
590
591 let config = CodeConfig::from_file(&config_path).unwrap();
592 assert_eq!(config.storage_backend, StorageBackend::Memory);
593 assert_eq!(config.sessions_dir, Some(PathBuf::from("/tmp/sessions")));
594 }
595
596 #[test]
597 fn test_config_builder() {
598 let config = CodeConfig::new()
599 .add_skill_dir("/tmp/skills")
600 .add_agent_dir("/tmp/agents");
601
602 assert_eq!(config.skill_dirs.len(), 1);
603 assert_eq!(config.agent_dirs.len(), 1);
604 }
605
606 #[test]
607 fn test_find_provider() {
608 let config = CodeConfig {
609 providers: vec![
610 ProviderConfig {
611 name: "anthropic".to_string(),
612 api_key: Some("key1".to_string()),
613 base_url: None,
614 models: vec![],
615 },
616 ProviderConfig {
617 name: "openai".to_string(),
618 api_key: Some("key2".to_string()),
619 base_url: None,
620 models: vec![],
621 },
622 ],
623 ..Default::default()
624 };
625
626 assert!(config.find_provider("anthropic").is_some());
627 assert!(config.find_provider("openai").is_some());
628 assert!(config.find_provider("unknown").is_none());
629 }
630
631 #[test]
632 fn test_default_llm_config() {
633 let config = CodeConfig {
634 default_model: Some("anthropic/claude-sonnet-4".to_string()),
635 providers: vec![ProviderConfig {
636 name: "anthropic".to_string(),
637 api_key: Some("test-api-key".to_string()),
638 base_url: Some("https://api.anthropic.com".to_string()),
639 models: vec![ModelConfig {
640 id: "claude-sonnet-4".to_string(),
641 name: "Claude Sonnet 4".to_string(),
642 family: "claude-sonnet".to_string(),
643 api_key: None,
644 base_url: None,
645 attachment: false,
646 reasoning: false,
647 tool_call: true,
648 temperature: true,
649 release_date: None,
650 modalities: ModelModalities::default(),
651 cost: ModelCost::default(),
652 limit: ModelLimit::default(),
653 }],
654 }],
655 ..Default::default()
656 };
657
658 let llm_config = config.default_llm_config().unwrap();
659 assert_eq!(llm_config.provider, "anthropic");
660 assert_eq!(llm_config.model, "claude-sonnet-4");
661 assert_eq!(llm_config.api_key.expose(), "test-api-key");
662 assert_eq!(
663 llm_config.base_url,
664 Some("https://api.anthropic.com".to_string())
665 );
666 }
667
668 #[test]
669 fn test_model_api_key_override() {
670 let provider = ProviderConfig {
671 name: "openai".to_string(),
672 api_key: Some("provider-key".to_string()),
673 base_url: Some("https://api.openai.com".to_string()),
674 models: vec![
675 ModelConfig {
676 id: "gpt-4".to_string(),
677 name: "GPT-4".to_string(),
678 family: "gpt".to_string(),
679 api_key: None, base_url: None,
681 attachment: false,
682 reasoning: false,
683 tool_call: true,
684 temperature: true,
685 release_date: None,
686 modalities: ModelModalities::default(),
687 cost: ModelCost::default(),
688 limit: ModelLimit::default(),
689 },
690 ModelConfig {
691 id: "custom-model".to_string(),
692 name: "Custom Model".to_string(),
693 family: "custom".to_string(),
694 api_key: Some("model-specific-key".to_string()), base_url: Some("https://custom.api.com".to_string()), attachment: false,
697 reasoning: false,
698 tool_call: true,
699 temperature: true,
700 release_date: None,
701 modalities: ModelModalities::default(),
702 cost: ModelCost::default(),
703 limit: ModelLimit::default(),
704 },
705 ],
706 };
707
708 let model1 = provider.find_model("gpt-4").unwrap();
710 assert_eq!(provider.get_api_key(model1), Some("provider-key"));
711 assert_eq!(
712 provider.get_base_url(model1),
713 Some("https://api.openai.com")
714 );
715
716 let model2 = provider.find_model("custom-model").unwrap();
718 assert_eq!(provider.get_api_key(model2), Some("model-specific-key"));
719 assert_eq!(
720 provider.get_base_url(model2),
721 Some("https://custom.api.com")
722 );
723 }
724
725 #[test]
726 fn test_list_models() {
727 let config = CodeConfig {
728 providers: vec![
729 ProviderConfig {
730 name: "anthropic".to_string(),
731 api_key: None,
732 base_url: None,
733 models: vec![
734 ModelConfig {
735 id: "claude-1".to_string(),
736 name: "Claude 1".to_string(),
737 family: "claude".to_string(),
738 api_key: None,
739 base_url: None,
740 attachment: false,
741 reasoning: false,
742 tool_call: true,
743 temperature: true,
744 release_date: None,
745 modalities: ModelModalities::default(),
746 cost: ModelCost::default(),
747 limit: ModelLimit::default(),
748 },
749 ModelConfig {
750 id: "claude-2".to_string(),
751 name: "Claude 2".to_string(),
752 family: "claude".to_string(),
753 api_key: None,
754 base_url: None,
755 attachment: false,
756 reasoning: false,
757 tool_call: true,
758 temperature: true,
759 release_date: None,
760 modalities: ModelModalities::default(),
761 cost: ModelCost::default(),
762 limit: ModelLimit::default(),
763 },
764 ],
765 },
766 ProviderConfig {
767 name: "openai".to_string(),
768 api_key: None,
769 base_url: None,
770 models: vec![ModelConfig {
771 id: "gpt-4".to_string(),
772 name: "GPT-4".to_string(),
773 family: "gpt".to_string(),
774 api_key: None,
775 base_url: None,
776 attachment: false,
777 reasoning: false,
778 tool_call: true,
779 temperature: true,
780 release_date: None,
781 modalities: ModelModalities::default(),
782 cost: ModelCost::default(),
783 limit: ModelLimit::default(),
784 }],
785 },
786 ],
787 ..Default::default()
788 };
789
790 let models = config.list_models();
791 assert_eq!(models.len(), 3);
792 }
793
794 #[test]
795 #[test]
796 fn test_config_from_file_not_found() {
797 let result = CodeConfig::from_file(Path::new("/nonexistent/config.json"));
798 assert!(result.is_err());
799 }
800
801 #[test]
802 fn test_config_has_directories() {
803 let empty = CodeConfig::default();
804 assert!(!empty.has_directories());
805
806 let with_skills = CodeConfig::new().add_skill_dir("/tmp/skills");
807 assert!(with_skills.has_directories());
808
809 let with_agents = CodeConfig::new().add_agent_dir("/tmp/agents");
810 assert!(with_agents.has_directories());
811 }
812
813 #[test]
814 fn test_config_has_providers() {
815 let empty = CodeConfig::default();
816 assert!(!empty.has_providers());
817
818 let with_providers = CodeConfig {
819 providers: vec![ProviderConfig {
820 name: "test".to_string(),
821 api_key: None,
822 base_url: None,
823 models: vec![],
824 }],
825 ..Default::default()
826 };
827 assert!(with_providers.has_providers());
828 }
829
830 #[test]
831 fn test_storage_backend_equality() {
832 assert_eq!(StorageBackend::Memory, StorageBackend::Memory);
833 assert_eq!(StorageBackend::File, StorageBackend::File);
834 assert_ne!(StorageBackend::Memory, StorageBackend::File);
835 }
836
837 #[test]
838 fn test_storage_backend_serde_custom() {
839 let custom = StorageBackend::Custom;
840 let json = serde_json::to_string(&custom).unwrap();
842 assert_eq!(json, "\"custom\"");
843
844 let parsed: StorageBackend = serde_json::from_str("\"custom\"").unwrap();
846 assert_eq!(parsed, StorageBackend::Custom);
847 }
848
849 #[test]
850 fn test_model_cost_default() {
851 let cost = ModelCost::default();
852 assert_eq!(cost.input, 0.0);
853 assert_eq!(cost.output, 0.0);
854 assert_eq!(cost.cache_read, 0.0);
855 assert_eq!(cost.cache_write, 0.0);
856 }
857
858 #[test]
859 fn test_model_cost_serialization() {
860 let cost = ModelCost {
861 input: 3.0,
862 output: 15.0,
863 cache_read: 0.3,
864 cache_write: 3.75,
865 };
866 let json = serde_json::to_string(&cost).unwrap();
867 assert!(json.contains("\"input\":3"));
868 assert!(json.contains("\"output\":15"));
869 }
870
871 #[test]
872 fn test_model_cost_deserialization_missing_fields() {
873 let json = r#"{"input":3.0}"#;
874 let cost: ModelCost = serde_json::from_str(json).unwrap();
875 assert_eq!(cost.input, 3.0);
876 assert_eq!(cost.output, 0.0);
877 assert_eq!(cost.cache_read, 0.0);
878 assert_eq!(cost.cache_write, 0.0);
879 }
880
881 #[test]
882 fn test_model_limit_default() {
883 let limit = ModelLimit::default();
884 assert_eq!(limit.context, 0);
885 assert_eq!(limit.output, 0);
886 }
887
888 #[test]
889 fn test_model_limit_serialization() {
890 let limit = ModelLimit {
891 context: 200000,
892 output: 8192,
893 };
894 let json = serde_json::to_string(&limit).unwrap();
895 assert!(json.contains("\"context\":200000"));
896 assert!(json.contains("\"output\":8192"));
897 }
898
899 #[test]
900 fn test_model_limit_deserialization_missing_fields() {
901 let json = r#"{"context":100000}"#;
902 let limit: ModelLimit = serde_json::from_str(json).unwrap();
903 assert_eq!(limit.context, 100000);
904 assert_eq!(limit.output, 0);
905 }
906
907 #[test]
908 fn test_model_modalities_default() {
909 let modalities = ModelModalities::default();
910 assert!(modalities.input.is_empty());
911 assert!(modalities.output.is_empty());
912 }
913
914 #[test]
915 fn test_model_modalities_serialization() {
916 let modalities = ModelModalities {
917 input: vec!["text".to_string(), "image".to_string()],
918 output: vec!["text".to_string()],
919 };
920 let json = serde_json::to_string(&modalities).unwrap();
921 assert!(json.contains("\"input\""));
922 assert!(json.contains("\"text\""));
923 }
924
925 #[test]
926 fn test_model_modalities_deserialization_missing_fields() {
927 let json = r#"{"input":["text"]}"#;
928 let modalities: ModelModalities = serde_json::from_str(json).unwrap();
929 assert_eq!(modalities.input.len(), 1);
930 assert!(modalities.output.is_empty());
931 }
932
933 #[test]
934 fn test_model_config_serialization() {
935 let config = ModelConfig {
936 id: "gpt-4o".to_string(),
937 name: "GPT-4o".to_string(),
938 family: "gpt-4".to_string(),
939 api_key: Some("sk-test".to_string()),
940 base_url: None,
941 attachment: true,
942 reasoning: false,
943 tool_call: true,
944 temperature: true,
945 release_date: Some("2024-05-13".to_string()),
946 modalities: ModelModalities::default(),
947 cost: ModelCost::default(),
948 limit: ModelLimit::default(),
949 };
950 let json = serde_json::to_string(&config).unwrap();
951 assert!(json.contains("\"id\":\"gpt-4o\""));
952 assert!(json.contains("\"attachment\":true"));
953 }
954
955 #[test]
956 fn test_model_config_deserialization_with_defaults() {
957 let json = r#"{"id":"test-model"}"#;
958 let config: ModelConfig = serde_json::from_str(json).unwrap();
959 assert_eq!(config.id, "test-model");
960 assert_eq!(config.name, "");
961 assert_eq!(config.family, "");
962 assert!(config.api_key.is_none());
963 assert!(!config.attachment);
964 assert!(config.tool_call);
965 assert!(config.temperature);
966 }
967
968 #[test]
969 fn test_model_config_all_optional_fields() {
970 let json = r#"{
971 "id": "claude-sonnet-4",
972 "name": "Claude Sonnet 4",
973 "family": "claude-sonnet",
974 "apiKey": "sk-test",
975 "baseUrl": "https://api.anthropic.com",
976 "attachment": true,
977 "reasoning": true,
978 "toolCall": false,
979 "temperature": false,
980 "releaseDate": "2025-05-14"
981 }"#;
982 let config: ModelConfig = serde_json::from_str(json).unwrap();
983 assert_eq!(config.id, "claude-sonnet-4");
984 assert_eq!(config.name, "Claude Sonnet 4");
985 assert_eq!(config.api_key, Some("sk-test".to_string()));
986 assert_eq!(
987 config.base_url,
988 Some("https://api.anthropic.com".to_string())
989 );
990 assert!(config.attachment);
991 assert!(config.reasoning);
992 assert!(!config.tool_call);
993 assert!(!config.temperature);
994 }
995
996 #[test]
997 fn test_provider_config_serialization() {
998 let provider = ProviderConfig {
999 name: "anthropic".to_string(),
1000 api_key: Some("sk-test".to_string()),
1001 base_url: Some("https://api.anthropic.com".to_string()),
1002 models: vec![],
1003 };
1004 let json = serde_json::to_string(&provider).unwrap();
1005 assert!(json.contains("\"name\":\"anthropic\""));
1006 assert!(json.contains("\"apiKey\":\"sk-test\""));
1007 }
1008
1009 #[test]
1010 fn test_provider_config_deserialization_missing_optional() {
1011 let json = r#"{"name":"openai"}"#;
1012 let provider: ProviderConfig = serde_json::from_str(json).unwrap();
1013 assert_eq!(provider.name, "openai");
1014 assert!(provider.api_key.is_none());
1015 assert!(provider.base_url.is_none());
1016 assert!(provider.models.is_empty());
1017 }
1018
1019 #[test]
1020 fn test_provider_config_find_model() {
1021 let provider = ProviderConfig {
1022 name: "anthropic".to_string(),
1023 api_key: None,
1024 base_url: None,
1025 models: vec![ModelConfig {
1026 id: "claude-sonnet-4".to_string(),
1027 name: "Claude Sonnet 4".to_string(),
1028 family: "claude-sonnet".to_string(),
1029 api_key: None,
1030 base_url: None,
1031 attachment: false,
1032 reasoning: false,
1033 tool_call: true,
1034 temperature: true,
1035 release_date: None,
1036 modalities: ModelModalities::default(),
1037 cost: ModelCost::default(),
1038 limit: ModelLimit::default(),
1039 }],
1040 };
1041
1042 let found = provider.find_model("claude-sonnet-4");
1043 assert!(found.is_some());
1044 assert_eq!(found.unwrap().id, "claude-sonnet-4");
1045
1046 let not_found = provider.find_model("gpt-4o");
1047 assert!(not_found.is_none());
1048 }
1049
1050 #[test]
1051 fn test_provider_config_get_api_key() {
1052 let provider = ProviderConfig {
1053 name: "anthropic".to_string(),
1054 api_key: Some("provider-key".to_string()),
1055 base_url: None,
1056 models: vec![],
1057 };
1058
1059 let model_with_key = ModelConfig {
1060 id: "test".to_string(),
1061 name: "".to_string(),
1062 family: "".to_string(),
1063 api_key: Some("model-key".to_string()),
1064 base_url: None,
1065 attachment: false,
1066 reasoning: false,
1067 tool_call: true,
1068 temperature: true,
1069 release_date: None,
1070 modalities: ModelModalities::default(),
1071 cost: ModelCost::default(),
1072 limit: ModelLimit::default(),
1073 };
1074
1075 let model_without_key = ModelConfig {
1076 id: "test2".to_string(),
1077 name: "".to_string(),
1078 family: "".to_string(),
1079 api_key: None,
1080 base_url: None,
1081 attachment: false,
1082 reasoning: false,
1083 tool_call: true,
1084 temperature: true,
1085 release_date: None,
1086 modalities: ModelModalities::default(),
1087 cost: ModelCost::default(),
1088 limit: ModelLimit::default(),
1089 };
1090
1091 assert_eq!(provider.get_api_key(&model_with_key), Some("model-key"));
1092 assert_eq!(
1093 provider.get_api_key(&model_without_key),
1094 Some("provider-key")
1095 );
1096 }
1097
1098 #[test]
1099 #[test]
1100 fn test_code_config_default_provider_config() {
1101 let config = CodeConfig {
1102 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1103 providers: vec![ProviderConfig {
1104 name: "anthropic".to_string(),
1105 api_key: Some("sk-test".to_string()),
1106 base_url: None,
1107 models: vec![],
1108 }],
1109 ..Default::default()
1110 };
1111
1112 let provider = config.default_provider_config();
1113 assert!(provider.is_some());
1114 assert_eq!(provider.unwrap().name, "anthropic");
1115 }
1116
1117 #[test]
1118 fn test_code_config_default_model_config() {
1119 let config = CodeConfig {
1120 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1121 providers: vec![ProviderConfig {
1122 name: "anthropic".to_string(),
1123 api_key: Some("sk-test".to_string()),
1124 base_url: None,
1125 models: vec![ModelConfig {
1126 id: "claude-sonnet-4".to_string(),
1127 name: "Claude Sonnet 4".to_string(),
1128 family: "claude-sonnet".to_string(),
1129 api_key: None,
1130 base_url: None,
1131 attachment: false,
1132 reasoning: false,
1133 tool_call: true,
1134 temperature: true,
1135 release_date: None,
1136 modalities: ModelModalities::default(),
1137 cost: ModelCost::default(),
1138 limit: ModelLimit::default(),
1139 }],
1140 }],
1141 ..Default::default()
1142 };
1143
1144 let result = config.default_model_config();
1145 assert!(result.is_some());
1146 let (provider, model) = result.unwrap();
1147 assert_eq!(provider.name, "anthropic");
1148 assert_eq!(model.id, "claude-sonnet-4");
1149 }
1150
1151 #[test]
1152 fn test_code_config_default_llm_config() {
1153 let config = CodeConfig {
1154 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1155 providers: vec![ProviderConfig {
1156 name: "anthropic".to_string(),
1157 api_key: Some("sk-test".to_string()),
1158 base_url: Some("https://api.anthropic.com".to_string()),
1159 models: vec![ModelConfig {
1160 id: "claude-sonnet-4".to_string(),
1161 name: "Claude Sonnet 4".to_string(),
1162 family: "claude-sonnet".to_string(),
1163 api_key: None,
1164 base_url: None,
1165 attachment: false,
1166 reasoning: false,
1167 tool_call: true,
1168 temperature: true,
1169 release_date: None,
1170 modalities: ModelModalities::default(),
1171 cost: ModelCost::default(),
1172 limit: ModelLimit::default(),
1173 }],
1174 }],
1175 ..Default::default()
1176 };
1177
1178 let llm_config = config.default_llm_config();
1179 assert!(llm_config.is_some());
1180 }
1181
1182 #[test]
1183 fn test_code_config_list_models() {
1184 let config = CodeConfig {
1185 providers: vec![
1186 ProviderConfig {
1187 name: "anthropic".to_string(),
1188 api_key: None,
1189 base_url: None,
1190 models: vec![ModelConfig {
1191 id: "claude-sonnet-4".to_string(),
1192 name: "".to_string(),
1193 family: "".to_string(),
1194 api_key: None,
1195 base_url: None,
1196 attachment: false,
1197 reasoning: false,
1198 tool_call: true,
1199 temperature: true,
1200 release_date: None,
1201 modalities: ModelModalities::default(),
1202 cost: ModelCost::default(),
1203 limit: ModelLimit::default(),
1204 }],
1205 },
1206 ProviderConfig {
1207 name: "openai".to_string(),
1208 api_key: None,
1209 base_url: None,
1210 models: vec![ModelConfig {
1211 id: "gpt-4o".to_string(),
1212 name: "".to_string(),
1213 family: "".to_string(),
1214 api_key: None,
1215 base_url: None,
1216 attachment: false,
1217 reasoning: false,
1218 tool_call: true,
1219 temperature: true,
1220 release_date: None,
1221 modalities: ModelModalities::default(),
1222 cost: ModelCost::default(),
1223 limit: ModelLimit::default(),
1224 }],
1225 },
1226 ],
1227 ..Default::default()
1228 };
1229
1230 let models = config.list_models();
1231 assert_eq!(models.len(), 2);
1232 }
1233
1234 #[test]
1235 fn test_llm_config_specific_provider_model() {
1236 let model: ModelConfig = serde_json::from_value(serde_json::json!({
1237 "id": "claude-3",
1238 "name": "Claude 3"
1239 }))
1240 .unwrap();
1241
1242 let config = CodeConfig {
1243 providers: vec![ProviderConfig {
1244 name: "anthropic".to_string(),
1245 api_key: Some("sk-test".to_string()),
1246 base_url: None,
1247 models: vec![model],
1248 }],
1249 ..Default::default()
1250 };
1251
1252 let llm = config.llm_config("anthropic", "claude-3");
1253 assert!(llm.is_some());
1254 let llm = llm.unwrap();
1255 assert_eq!(llm.provider, "anthropic");
1256 assert_eq!(llm.model, "claude-3");
1257 }
1258
1259 #[test]
1260 fn test_llm_config_missing_provider() {
1261 let config = CodeConfig::default();
1262 assert!(config.llm_config("nonexistent", "model").is_none());
1263 }
1264
1265 #[test]
1266 fn test_llm_config_missing_model() {
1267 let config = CodeConfig {
1268 providers: vec![ProviderConfig {
1269 name: "anthropic".to_string(),
1270 api_key: Some("sk-test".to_string()),
1271 base_url: None,
1272 models: vec![],
1273 }],
1274 ..Default::default()
1275 };
1276 assert!(config.llm_config("anthropic", "nonexistent").is_none());
1277 }
1278
1279 #[test]
1280 fn test_from_hcl_string() {
1281 let hcl = r#"
1282 default_model = "anthropic/claude-sonnet-4"
1283
1284 providers {
1285 name = "anthropic"
1286 api_key = "test-key"
1287
1288 models {
1289 id = "claude-sonnet-4"
1290 name = "Claude Sonnet 4"
1291 }
1292 }
1293 "#;
1294
1295 let config = CodeConfig::from_hcl(hcl).unwrap();
1296 assert_eq!(
1297 config.default_model,
1298 Some("anthropic/claude-sonnet-4".to_string())
1299 );
1300 assert_eq!(config.providers.len(), 1);
1301 assert_eq!(config.providers[0].name, "anthropic");
1302 assert_eq!(config.providers[0].models.len(), 1);
1303 assert_eq!(config.providers[0].models[0].id, "claude-sonnet-4");
1304 }
1305
1306 #[test]
1307 fn test_from_hcl_multi_provider() {
1308 let hcl = r#"
1309 default_model = "anthropic/claude-sonnet-4"
1310
1311 providers {
1312 name = "anthropic"
1313 api_key = "sk-ant-test"
1314
1315 models {
1316 id = "claude-sonnet-4"
1317 name = "Claude Sonnet 4"
1318 }
1319
1320 models {
1321 id = "claude-opus-4"
1322 name = "Claude Opus 4"
1323 reasoning = true
1324 }
1325 }
1326
1327 providers {
1328 name = "openai"
1329 api_key = "sk-test"
1330
1331 models {
1332 id = "gpt-4o"
1333 name = "GPT-4o"
1334 }
1335 }
1336 "#;
1337
1338 let config = CodeConfig::from_hcl(hcl).unwrap();
1339 assert_eq!(config.providers.len(), 2);
1340 assert_eq!(config.providers[0].models.len(), 2);
1341 assert_eq!(config.providers[1].models.len(), 1);
1342 assert_eq!(config.providers[1].name, "openai");
1343 }
1344
1345 #[test]
1346 fn test_snake_to_camel() {
1347 assert_eq!(snake_to_camel("default_model"), "defaultModel");
1348 assert_eq!(snake_to_camel("api_key"), "apiKey");
1349 assert_eq!(snake_to_camel("base_url"), "baseUrl");
1350 assert_eq!(snake_to_camel("name"), "name");
1351 assert_eq!(snake_to_camel("tool_call"), "toolCall");
1352 }
1353
1354 #[test]
1355 fn test_from_file_auto_detect_hcl() {
1356 let temp_dir = tempfile::tempdir().unwrap();
1357 let config_path = temp_dir.path().join("config.hcl");
1358
1359 std::fs::write(
1360 &config_path,
1361 r#"
1362 default_model = "anthropic/claude-sonnet-4"
1363
1364 providers {
1365 name = "anthropic"
1366 api_key = "test-key"
1367
1368 models {
1369 id = "claude-sonnet-4"
1370 }
1371 }
1372 "#,
1373 )
1374 .unwrap();
1375
1376 let config = CodeConfig::from_file(&config_path).unwrap();
1377 assert_eq!(
1378 config.default_model,
1379 Some("anthropic/claude-sonnet-4".to_string())
1380 );
1381 }
1382
1383 #[test]
1384 fn test_from_hcl_with_queue_config() {
1385 let hcl = r#"
1386 default_model = "anthropic/claude-sonnet-4"
1387
1388 providers {
1389 name = "anthropic"
1390 api_key = "test-key"
1391 }
1392
1393 queue {
1394 query_max_concurrency = 20
1395 execute_max_concurrency = 5
1396 enable_metrics = true
1397 enable_dlq = true
1398 }
1399 "#;
1400
1401 let config = CodeConfig::from_hcl(hcl).unwrap();
1402 assert!(config.queue.is_some());
1403 let queue = config.queue.unwrap();
1404 assert_eq!(queue.query_max_concurrency, 20);
1405 assert_eq!(queue.execute_max_concurrency, 5);
1406 assert!(queue.enable_metrics);
1407 assert!(queue.enable_dlq);
1408 }
1409
1410 #[test]
1411 fn test_from_hcl_with_search_config() {
1412 let hcl = r#"
1413 default_model = "anthropic/claude-sonnet-4"
1414
1415 providers {
1416 name = "anthropic"
1417 api_key = "test-key"
1418 }
1419
1420 search {
1421 timeout = 30
1422
1423 health {
1424 max_failures = 5
1425 suspend_seconds = 120
1426 }
1427
1428 engine {
1429 google {
1430 enabled = true
1431 weight = 1.5
1432 }
1433 bing {
1434 enabled = true
1435 weight = 1.0
1436 timeout = 15
1437 }
1438 }
1439 }
1440 "#;
1441
1442 let config = CodeConfig::from_hcl(hcl).unwrap();
1443 assert!(config.search.is_some());
1444 let search = config.search.unwrap();
1445 assert_eq!(search.timeout, 30);
1446 assert!(search.health.is_some());
1447 let health = search.health.unwrap();
1448 assert_eq!(health.max_failures, 5);
1449 assert_eq!(health.suspend_seconds, 120);
1450 assert_eq!(search.engines.len(), 2);
1451 assert!(search.engines.contains_key("google"));
1452 assert!(search.engines.contains_key("bing"));
1453 let google = &search.engines["google"];
1454 assert!(google.enabled);
1455 assert_eq!(google.weight, 1.5);
1456 let bing = &search.engines["bing"];
1457 assert_eq!(bing.timeout, Some(15));
1458 }
1459
1460 #[test]
1461 fn test_from_hcl_with_queue_and_search() {
1462 let hcl = r#"
1463 default_model = "anthropic/claude-sonnet-4"
1464
1465 providers {
1466 name = "anthropic"
1467 api_key = "test-key"
1468 }
1469
1470 queue {
1471 query_max_concurrency = 10
1472 enable_metrics = true
1473 }
1474
1475 search {
1476 timeout = 20
1477 engine {
1478 duckduckgo {
1479 enabled = true
1480 }
1481 }
1482 }
1483 "#;
1484
1485 let config = CodeConfig::from_hcl(hcl).unwrap();
1486 assert!(config.queue.is_some());
1487 assert!(config.search.is_some());
1488 assert_eq!(config.queue.unwrap().query_max_concurrency, 10);
1489 assert_eq!(config.search.unwrap().timeout, 20);
1490 }
1491
1492 #[test]
1493 fn test_from_hcl_with_advanced_queue_config() {
1494 let hcl = r#"
1495 default_model = "anthropic/claude-sonnet-4"
1496
1497 providers {
1498 name = "anthropic"
1499 api_key = "test-key"
1500 }
1501
1502 queue {
1503 query_max_concurrency = 20
1504 enable_metrics = true
1505
1506 retry_policy {
1507 strategy = "exponential"
1508 max_retries = 5
1509 initial_delay_ms = 200
1510 }
1511
1512 rate_limit {
1513 limit_type = "per_second"
1514 max_operations = 100
1515 }
1516
1517 priority_boost {
1518 strategy = "standard"
1519 deadline_ms = 300000
1520 }
1521
1522 pressure_threshold = 50
1523 }
1524 "#;
1525
1526 let config = CodeConfig::from_hcl(hcl).unwrap();
1527 assert!(config.queue.is_some());
1528 let queue = config.queue.unwrap();
1529
1530 assert_eq!(queue.query_max_concurrency, 20);
1531 assert!(queue.enable_metrics);
1532
1533 assert!(queue.retry_policy.is_some());
1535 let retry = queue.retry_policy.unwrap();
1536 assert_eq!(retry.strategy, "exponential");
1537 assert_eq!(retry.max_retries, 5);
1538 assert_eq!(retry.initial_delay_ms, 200);
1539
1540 assert!(queue.rate_limit.is_some());
1542 let rate = queue.rate_limit.unwrap();
1543 assert_eq!(rate.limit_type, "per_second");
1544 assert_eq!(rate.max_operations, Some(100));
1545
1546 assert!(queue.priority_boost.is_some());
1548 let boost = queue.priority_boost.unwrap();
1549 assert_eq!(boost.strategy, "standard");
1550 assert_eq!(boost.deadline_ms, Some(300000));
1551
1552 assert_eq!(queue.pressure_threshold, Some(50));
1554 }
1555}