1use crate::error::{CodeError, Result};
13use crate::llm::LlmConfig;
14use crate::memory::MemoryConfig;
15use serde::{Deserialize, Serialize};
16use serde_json::Value as JsonValue;
17use std::path::{Path, PathBuf};
18
19#[derive(Debug, Clone, Serialize, Deserialize, Default)]
25#[serde(rename_all = "camelCase")]
26pub struct ModelCost {
27 #[serde(default)]
29 pub input: f64,
30 #[serde(default)]
32 pub output: f64,
33 #[serde(default)]
35 pub cache_read: f64,
36 #[serde(default)]
38 pub cache_write: f64,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43pub struct ModelLimit {
44 #[serde(default)]
46 pub context: u32,
47 #[serde(default)]
49 pub output: u32,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, Default)]
54pub struct ModelModalities {
55 #[serde(default)]
57 pub input: Vec<String>,
58 #[serde(default)]
60 pub output: Vec<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct ModelConfig {
67 pub id: String,
69 #[serde(default)]
71 pub name: String,
72 #[serde(default)]
74 pub family: String,
75 #[serde(default)]
77 pub api_key: Option<String>,
78 #[serde(default)]
80 pub base_url: Option<String>,
81 #[serde(default)]
83 pub attachment: bool,
84 #[serde(default)]
86 pub reasoning: bool,
87 #[serde(default = "default_true")]
89 pub tool_call: bool,
90 #[serde(default = "default_true")]
92 pub temperature: bool,
93 #[serde(default)]
95 pub release_date: Option<String>,
96 #[serde(default)]
98 pub modalities: ModelModalities,
99 #[serde(default)]
101 pub cost: ModelCost,
102 #[serde(default)]
104 pub limit: ModelLimit,
105}
106
107fn default_true() -> bool {
108 true
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct ProviderConfig {
115 pub name: String,
117 #[serde(default)]
119 pub api_key: Option<String>,
120 #[serde(default)]
122 pub base_url: Option<String>,
123 #[serde(default)]
125 pub models: Vec<ModelConfig>,
126}
127
128impl ProviderConfig {
129 pub fn find_model(&self, model_id: &str) -> Option<&ModelConfig> {
131 self.models.iter().find(|m| m.id == model_id)
132 }
133
134 pub fn get_api_key<'a>(&'a self, model: &'a ModelConfig) -> Option<&'a str> {
136 model.api_key.as_deref().or(self.api_key.as_deref())
137 }
138
139 pub fn get_base_url<'a>(&'a self, model: &'a ModelConfig) -> Option<&'a str> {
141 model.base_url.as_deref().or(self.base_url.as_deref())
142 }
143}
144
145#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
151#[serde(rename_all = "lowercase")]
152pub enum StorageBackend {
153 Memory,
155 #[default]
157 File,
158 Custom,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, Default)]
171#[serde(rename_all = "camelCase")]
172pub struct CodeConfig {
173 #[serde(default, alias = "default_model")]
175 pub default_model: Option<String>,
176
177 #[serde(default)]
179 pub providers: Vec<ProviderConfig>,
180
181 #[serde(default)]
183 pub storage_backend: StorageBackend,
184
185 #[serde(skip_serializing_if = "Option::is_none")]
187 pub sessions_dir: Option<PathBuf>,
188
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub storage_url: Option<String>,
192
193 #[serde(default, alias = "skill_dirs")]
195 pub skill_dirs: Vec<PathBuf>,
196
197 #[serde(default, alias = "agent_dirs")]
199 pub agent_dirs: Vec<PathBuf>,
200
201 #[serde(default, alias = "max_tool_rounds")]
203 pub max_tool_rounds: Option<usize>,
204
205 #[serde(default, alias = "thinking_budget")]
207 pub thinking_budget: Option<usize>,
208
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub memory: Option<MemoryConfig>,
212
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub queue: Option<crate::queue::SessionQueueConfig>,
216
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub search: Option<SearchConfig>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(rename_all = "camelCase")]
225pub struct SearchConfig {
226 #[serde(default = "default_search_timeout")]
228 pub timeout: u64,
229
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub health: Option<SearchHealthConfig>,
233
234 #[serde(default, rename = "engine")]
236 pub engines: std::collections::HashMap<String, SearchEngineConfig>,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
241#[serde(rename_all = "camelCase")]
242pub struct SearchHealthConfig {
243 #[serde(default = "default_max_failures")]
245 pub max_failures: u32,
246
247 #[serde(default = "default_suspend_seconds")]
249 pub suspend_seconds: u64,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase")]
255pub struct SearchEngineConfig {
256 #[serde(default = "default_enabled")]
258 pub enabled: bool,
259
260 #[serde(default = "default_weight")]
262 pub weight: f64,
263
264 #[serde(skip_serializing_if = "Option::is_none")]
266 pub timeout: Option<u64>,
267}
268
269fn default_search_timeout() -> u64 {
270 10
271}
272
273fn default_max_failures() -> u32 {
274 3
275}
276
277fn default_suspend_seconds() -> u64 {
278 60
279}
280
281fn default_enabled() -> bool {
282 true
283}
284
285fn default_weight() -> f64 {
286 1.0
287}
288
289impl CodeConfig {
290 pub fn new() -> Self {
292 Self::default()
293 }
294
295 pub fn from_file(path: &Path) -> Result<Self> {
299 let content = std::fs::read_to_string(path).map_err(|e| {
300 CodeError::Config(format!(
301 "Failed to read config file {}: {}",
302 path.display(),
303 e
304 ))
305 })?;
306
307 Self::from_hcl(&content).map_err(|e| {
308 CodeError::Config(format!(
309 "Failed to parse HCL config {}: {}",
310 path.display(),
311 e
312 ))
313 })
314 }
315
316 pub fn from_hcl(content: &str) -> Result<Self> {
322 let body: hcl::Body = hcl::from_str(content)
323 .map_err(|e| CodeError::Config(format!("Failed to parse HCL: {}", e)))?;
324 let json_value = hcl_body_to_json(&body);
325 serde_json::from_value(json_value)
326 .map_err(|e| CodeError::Config(format!("Failed to deserialize HCL config: {}", e)))
327 }
328
329 pub fn save_to_file(&self, path: &Path) -> Result<()> {
333 if let Some(parent) = path.parent() {
334 std::fs::create_dir_all(parent).map_err(|e| {
335 CodeError::Config(format!(
336 "Failed to create config directory {}: {}",
337 parent.display(),
338 e
339 ))
340 })?;
341 }
342
343 let content = serde_json::to_string_pretty(self)
344 .map_err(|e| CodeError::Config(format!("Failed to serialize config: {}", e)))?;
345
346 std::fs::write(path, content).map_err(|e| {
347 CodeError::Config(format!(
348 "Failed to write config file {}: {}",
349 path.display(),
350 e
351 ))
352 })?;
353
354 Ok(())
355 }
356
357 pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> {
359 self.providers.iter().find(|p| p.name == name)
360 }
361
362 pub fn default_provider_config(&self) -> Option<&ProviderConfig> {
364 let default = self.default_model.as_ref()?;
365 let (provider_name, _) = default.split_once('/')?;
366 self.find_provider(provider_name)
367 }
368
369 pub fn default_model_config(&self) -> Option<(&ProviderConfig, &ModelConfig)> {
371 let default = self.default_model.as_ref()?;
372 let (provider_name, model_id) = default.split_once('/')?;
373 let provider = self.find_provider(provider_name)?;
374 let model = provider.find_model(model_id)?;
375 Some((provider, model))
376 }
377
378 pub fn default_llm_config(&self) -> Option<LlmConfig> {
382 let (provider, model) = self.default_model_config()?;
383 let api_key = provider.get_api_key(model)?;
384 let base_url = provider.get_base_url(model);
385
386 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
387 if let Some(url) = base_url {
388 config = config.with_base_url(url);
389 }
390 Some(config)
391 }
392
393 pub fn llm_config(&self, provider_name: &str, model_id: &str) -> Option<LlmConfig> {
397 let provider = self.find_provider(provider_name)?;
398 let model = provider.find_model(model_id)?;
399 let api_key = provider.get_api_key(model)?;
400 let base_url = provider.get_base_url(model);
401
402 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
403 if let Some(url) = base_url {
404 config = config.with_base_url(url);
405 }
406 Some(config)
407 }
408
409 pub fn list_models(&self) -> Vec<(&ProviderConfig, &ModelConfig)> {
411 self.providers
412 .iter()
413 .flat_map(|p| p.models.iter().map(move |m| (p, m)))
414 .collect()
415 }
416
417 pub fn add_skill_dir(mut self, dir: impl Into<PathBuf>) -> Self {
419 self.skill_dirs.push(dir.into());
420 self
421 }
422
423 pub fn add_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
425 self.agent_dirs.push(dir.into());
426 self
427 }
428
429 pub fn has_directories(&self) -> bool {
431 !self.skill_dirs.is_empty() || !self.agent_dirs.is_empty()
432 }
433
434 pub fn has_providers(&self) -> bool {
436 !self.providers.is_empty()
437 }
438}
439
440const HCL_ARRAY_BLOCKS: &[&str] = &["providers", "models"];
446
447fn hcl_body_to_json(body: &hcl::Body) -> JsonValue {
449 let mut map = serde_json::Map::new();
450
451 for attr in body.attributes() {
453 let key = snake_to_camel(attr.key.as_str());
454 let value = hcl_expr_to_json(attr.expr());
455 map.insert(key, value);
456 }
457
458 for block in body.blocks() {
460 let key = snake_to_camel(block.identifier.as_str());
461 let block_value = hcl_body_to_json(block.body());
462
463 if HCL_ARRAY_BLOCKS.contains(&block.identifier.as_str()) {
464 let arr = map
466 .entry(key)
467 .or_insert_with(|| JsonValue::Array(Vec::new()));
468 if let JsonValue::Array(ref mut vec) = arr {
469 vec.push(block_value);
470 }
471 } else {
472 map.insert(key, block_value);
473 }
474 }
475
476 JsonValue::Object(map)
477}
478
479fn snake_to_camel(s: &str) -> String {
481 let mut result = String::with_capacity(s.len());
482 let mut capitalize_next = false;
483 for ch in s.chars() {
484 if ch == '_' {
485 capitalize_next = true;
486 } else if capitalize_next {
487 result.extend(ch.to_uppercase());
488 capitalize_next = false;
489 } else {
490 result.push(ch);
491 }
492 }
493 result
494}
495
496fn hcl_expr_to_json(expr: &hcl::Expression) -> JsonValue {
498 match expr {
499 hcl::Expression::String(s) => JsonValue::String(s.clone()),
500 hcl::Expression::Number(n) => {
501 if let Some(i) = n.as_i64() {
502 JsonValue::Number(i.into())
503 } else if let Some(f) = n.as_f64() {
504 serde_json::Number::from_f64(f)
505 .map(JsonValue::Number)
506 .unwrap_or(JsonValue::Null)
507 } else {
508 JsonValue::Null
509 }
510 }
511 hcl::Expression::Bool(b) => JsonValue::Bool(*b),
512 hcl::Expression::Null => JsonValue::Null,
513 hcl::Expression::Array(arr) => JsonValue::Array(arr.iter().map(hcl_expr_to_json).collect()),
514 hcl::Expression::Object(obj) => {
515 let map: serde_json::Map<String, JsonValue> = obj
516 .iter()
517 .map(|(k, v)| {
518 let key = match k {
519 hcl::ObjectKey::Identifier(id) => snake_to_camel(id.as_str()),
520 hcl::ObjectKey::Expression(expr) => {
521 if let hcl::Expression::String(s) = expr {
522 snake_to_camel(s)
523 } else {
524 format!("{:?}", expr)
525 }
526 }
527 _ => format!("{:?}", k),
528 };
529 (key, hcl_expr_to_json(v))
530 })
531 .collect();
532 JsonValue::Object(map)
533 }
534 _ => JsonValue::String(format!("{:?}", expr)),
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 #[test]
543 fn test_config_default() {
544 let config = CodeConfig::default();
545 assert!(config.skill_dirs.is_empty());
546 assert!(config.agent_dirs.is_empty());
547 assert!(config.providers.is_empty());
548 assert!(config.default_model.is_none());
549 assert_eq!(config.storage_backend, StorageBackend::File);
550 assert!(config.sessions_dir.is_none());
551 }
552
553 #[test]
554 fn test_storage_backend_default() {
555 let backend = StorageBackend::default();
556 assert_eq!(backend, StorageBackend::File);
557 }
558
559 #[test]
560 fn test_storage_backend_serde() {
561 let memory = StorageBackend::Memory;
563 let json = serde_json::to_string(&memory).unwrap();
564 assert_eq!(json, "\"memory\"");
565
566 let file = StorageBackend::File;
567 let json = serde_json::to_string(&file).unwrap();
568 assert_eq!(json, "\"file\"");
569
570 let memory: StorageBackend = serde_json::from_str("\"memory\"").unwrap();
572 assert_eq!(memory, StorageBackend::Memory);
573
574 let file: StorageBackend = serde_json::from_str("\"file\"").unwrap();
575 assert_eq!(file, StorageBackend::File);
576 }
577
578 #[test]
579 fn test_config_with_storage_backend() {
580 let temp_dir = tempfile::tempdir().unwrap();
581 let config_path = temp_dir.path().join("config.hcl");
582
583 std::fs::write(
584 &config_path,
585 r#"
586 storage_backend = "memory"
587 sessions_dir = "/tmp/sessions"
588 "#,
589 )
590 .unwrap();
591
592 let config = CodeConfig::from_file(&config_path).unwrap();
593 assert_eq!(config.storage_backend, StorageBackend::Memory);
594 assert_eq!(config.sessions_dir, Some(PathBuf::from("/tmp/sessions")));
595 }
596
597 #[test]
598 fn test_config_builder() {
599 let config = CodeConfig::new()
600 .add_skill_dir("/tmp/skills")
601 .add_agent_dir("/tmp/agents");
602
603 assert_eq!(config.skill_dirs.len(), 1);
604 assert_eq!(config.agent_dirs.len(), 1);
605 }
606
607 #[test]
608 fn test_find_provider() {
609 let config = CodeConfig {
610 providers: vec![
611 ProviderConfig {
612 name: "anthropic".to_string(),
613 api_key: Some("key1".to_string()),
614 base_url: None,
615 models: vec![],
616 },
617 ProviderConfig {
618 name: "openai".to_string(),
619 api_key: Some("key2".to_string()),
620 base_url: None,
621 models: vec![],
622 },
623 ],
624 ..Default::default()
625 };
626
627 assert!(config.find_provider("anthropic").is_some());
628 assert!(config.find_provider("openai").is_some());
629 assert!(config.find_provider("unknown").is_none());
630 }
631
632 #[test]
633 fn test_default_llm_config() {
634 let config = CodeConfig {
635 default_model: Some("anthropic/claude-sonnet-4".to_string()),
636 providers: vec![ProviderConfig {
637 name: "anthropic".to_string(),
638 api_key: Some("test-api-key".to_string()),
639 base_url: Some("https://api.anthropic.com".to_string()),
640 models: vec![ModelConfig {
641 id: "claude-sonnet-4".to_string(),
642 name: "Claude Sonnet 4".to_string(),
643 family: "claude-sonnet".to_string(),
644 api_key: None,
645 base_url: None,
646 attachment: false,
647 reasoning: false,
648 tool_call: true,
649 temperature: true,
650 release_date: None,
651 modalities: ModelModalities::default(),
652 cost: ModelCost::default(),
653 limit: ModelLimit::default(),
654 }],
655 }],
656 ..Default::default()
657 };
658
659 let llm_config = config.default_llm_config().unwrap();
660 assert_eq!(llm_config.provider, "anthropic");
661 assert_eq!(llm_config.model, "claude-sonnet-4");
662 assert_eq!(llm_config.api_key.expose(), "test-api-key");
663 assert_eq!(
664 llm_config.base_url,
665 Some("https://api.anthropic.com".to_string())
666 );
667 }
668
669 #[test]
670 fn test_model_api_key_override() {
671 let provider = ProviderConfig {
672 name: "openai".to_string(),
673 api_key: Some("provider-key".to_string()),
674 base_url: Some("https://api.openai.com".to_string()),
675 models: vec![
676 ModelConfig {
677 id: "gpt-4".to_string(),
678 name: "GPT-4".to_string(),
679 family: "gpt".to_string(),
680 api_key: None, base_url: None,
682 attachment: false,
683 reasoning: false,
684 tool_call: true,
685 temperature: true,
686 release_date: None,
687 modalities: ModelModalities::default(),
688 cost: ModelCost::default(),
689 limit: ModelLimit::default(),
690 },
691 ModelConfig {
692 id: "custom-model".to_string(),
693 name: "Custom Model".to_string(),
694 family: "custom".to_string(),
695 api_key: Some("model-specific-key".to_string()), base_url: Some("https://custom.api.com".to_string()), attachment: false,
698 reasoning: false,
699 tool_call: true,
700 temperature: true,
701 release_date: None,
702 modalities: ModelModalities::default(),
703 cost: ModelCost::default(),
704 limit: ModelLimit::default(),
705 },
706 ],
707 };
708
709 let model1 = provider.find_model("gpt-4").unwrap();
711 assert_eq!(provider.get_api_key(model1), Some("provider-key"));
712 assert_eq!(
713 provider.get_base_url(model1),
714 Some("https://api.openai.com")
715 );
716
717 let model2 = provider.find_model("custom-model").unwrap();
719 assert_eq!(provider.get_api_key(model2), Some("model-specific-key"));
720 assert_eq!(
721 provider.get_base_url(model2),
722 Some("https://custom.api.com")
723 );
724 }
725
726 #[test]
727 fn test_list_models() {
728 let config = CodeConfig {
729 providers: vec![
730 ProviderConfig {
731 name: "anthropic".to_string(),
732 api_key: None,
733 base_url: None,
734 models: vec![
735 ModelConfig {
736 id: "claude-1".to_string(),
737 name: "Claude 1".to_string(),
738 family: "claude".to_string(),
739 api_key: None,
740 base_url: None,
741 attachment: false,
742 reasoning: false,
743 tool_call: true,
744 temperature: true,
745 release_date: None,
746 modalities: ModelModalities::default(),
747 cost: ModelCost::default(),
748 limit: ModelLimit::default(),
749 },
750 ModelConfig {
751 id: "claude-2".to_string(),
752 name: "Claude 2".to_string(),
753 family: "claude".to_string(),
754 api_key: None,
755 base_url: None,
756 attachment: false,
757 reasoning: false,
758 tool_call: true,
759 temperature: true,
760 release_date: None,
761 modalities: ModelModalities::default(),
762 cost: ModelCost::default(),
763 limit: ModelLimit::default(),
764 },
765 ],
766 },
767 ProviderConfig {
768 name: "openai".to_string(),
769 api_key: None,
770 base_url: None,
771 models: vec![ModelConfig {
772 id: "gpt-4".to_string(),
773 name: "GPT-4".to_string(),
774 family: "gpt".to_string(),
775 api_key: None,
776 base_url: None,
777 attachment: false,
778 reasoning: false,
779 tool_call: true,
780 temperature: true,
781 release_date: None,
782 modalities: ModelModalities::default(),
783 cost: ModelCost::default(),
784 limit: ModelLimit::default(),
785 }],
786 },
787 ],
788 ..Default::default()
789 };
790
791 let models = config.list_models();
792 assert_eq!(models.len(), 3);
793 }
794
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 fn test_code_config_default_provider_config() {
1100 let config = CodeConfig {
1101 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1102 providers: vec![ProviderConfig {
1103 name: "anthropic".to_string(),
1104 api_key: Some("sk-test".to_string()),
1105 base_url: None,
1106 models: vec![],
1107 }],
1108 ..Default::default()
1109 };
1110
1111 let provider = config.default_provider_config();
1112 assert!(provider.is_some());
1113 assert_eq!(provider.unwrap().name, "anthropic");
1114 }
1115
1116 #[test]
1117 fn test_code_config_default_model_config() {
1118 let config = CodeConfig {
1119 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1120 providers: vec![ProviderConfig {
1121 name: "anthropic".to_string(),
1122 api_key: Some("sk-test".to_string()),
1123 base_url: None,
1124 models: vec![ModelConfig {
1125 id: "claude-sonnet-4".to_string(),
1126 name: "Claude Sonnet 4".to_string(),
1127 family: "claude-sonnet".to_string(),
1128 api_key: None,
1129 base_url: None,
1130 attachment: false,
1131 reasoning: false,
1132 tool_call: true,
1133 temperature: true,
1134 release_date: None,
1135 modalities: ModelModalities::default(),
1136 cost: ModelCost::default(),
1137 limit: ModelLimit::default(),
1138 }],
1139 }],
1140 ..Default::default()
1141 };
1142
1143 let result = config.default_model_config();
1144 assert!(result.is_some());
1145 let (provider, model) = result.unwrap();
1146 assert_eq!(provider.name, "anthropic");
1147 assert_eq!(model.id, "claude-sonnet-4");
1148 }
1149
1150 #[test]
1151 fn test_code_config_default_llm_config() {
1152 let config = CodeConfig {
1153 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1154 providers: vec![ProviderConfig {
1155 name: "anthropic".to_string(),
1156 api_key: Some("sk-test".to_string()),
1157 base_url: Some("https://api.anthropic.com".to_string()),
1158 models: vec![ModelConfig {
1159 id: "claude-sonnet-4".to_string(),
1160 name: "Claude Sonnet 4".to_string(),
1161 family: "claude-sonnet".to_string(),
1162 api_key: None,
1163 base_url: None,
1164 attachment: false,
1165 reasoning: false,
1166 tool_call: true,
1167 temperature: true,
1168 release_date: None,
1169 modalities: ModelModalities::default(),
1170 cost: ModelCost::default(),
1171 limit: ModelLimit::default(),
1172 }],
1173 }],
1174 ..Default::default()
1175 };
1176
1177 let llm_config = config.default_llm_config();
1178 assert!(llm_config.is_some());
1179 }
1180
1181 #[test]
1182 fn test_code_config_list_models() {
1183 let config = CodeConfig {
1184 providers: vec![
1185 ProviderConfig {
1186 name: "anthropic".to_string(),
1187 api_key: None,
1188 base_url: None,
1189 models: vec![ModelConfig {
1190 id: "claude-sonnet-4".to_string(),
1191 name: "".to_string(),
1192 family: "".to_string(),
1193 api_key: None,
1194 base_url: None,
1195 attachment: false,
1196 reasoning: false,
1197 tool_call: true,
1198 temperature: true,
1199 release_date: None,
1200 modalities: ModelModalities::default(),
1201 cost: ModelCost::default(),
1202 limit: ModelLimit::default(),
1203 }],
1204 },
1205 ProviderConfig {
1206 name: "openai".to_string(),
1207 api_key: None,
1208 base_url: None,
1209 models: vec![ModelConfig {
1210 id: "gpt-4o".to_string(),
1211 name: "".to_string(),
1212 family: "".to_string(),
1213 api_key: None,
1214 base_url: None,
1215 attachment: false,
1216 reasoning: false,
1217 tool_call: true,
1218 temperature: true,
1219 release_date: None,
1220 modalities: ModelModalities::default(),
1221 cost: ModelCost::default(),
1222 limit: ModelLimit::default(),
1223 }],
1224 },
1225 ],
1226 ..Default::default()
1227 };
1228
1229 let models = config.list_models();
1230 assert_eq!(models.len(), 2);
1231 }
1232
1233 #[test]
1234 fn test_llm_config_specific_provider_model() {
1235 let model: ModelConfig = serde_json::from_value(serde_json::json!({
1236 "id": "claude-3",
1237 "name": "Claude 3"
1238 }))
1239 .unwrap();
1240
1241 let config = CodeConfig {
1242 providers: vec![ProviderConfig {
1243 name: "anthropic".to_string(),
1244 api_key: Some("sk-test".to_string()),
1245 base_url: None,
1246 models: vec![model],
1247 }],
1248 ..Default::default()
1249 };
1250
1251 let llm = config.llm_config("anthropic", "claude-3");
1252 assert!(llm.is_some());
1253 let llm = llm.unwrap();
1254 assert_eq!(llm.provider, "anthropic");
1255 assert_eq!(llm.model, "claude-3");
1256 }
1257
1258 #[test]
1259 fn test_llm_config_missing_provider() {
1260 let config = CodeConfig::default();
1261 assert!(config.llm_config("nonexistent", "model").is_none());
1262 }
1263
1264 #[test]
1265 fn test_llm_config_missing_model() {
1266 let config = CodeConfig {
1267 providers: vec![ProviderConfig {
1268 name: "anthropic".to_string(),
1269 api_key: Some("sk-test".to_string()),
1270 base_url: None,
1271 models: vec![],
1272 }],
1273 ..Default::default()
1274 };
1275 assert!(config.llm_config("anthropic", "nonexistent").is_none());
1276 }
1277
1278 #[test]
1279 fn test_from_hcl_string() {
1280 let hcl = r#"
1281 default_model = "anthropic/claude-sonnet-4"
1282
1283 providers {
1284 name = "anthropic"
1285 api_key = "test-key"
1286
1287 models {
1288 id = "claude-sonnet-4"
1289 name = "Claude Sonnet 4"
1290 }
1291 }
1292 "#;
1293
1294 let config = CodeConfig::from_hcl(hcl).unwrap();
1295 assert_eq!(
1296 config.default_model,
1297 Some("anthropic/claude-sonnet-4".to_string())
1298 );
1299 assert_eq!(config.providers.len(), 1);
1300 assert_eq!(config.providers[0].name, "anthropic");
1301 assert_eq!(config.providers[0].models.len(), 1);
1302 assert_eq!(config.providers[0].models[0].id, "claude-sonnet-4");
1303 }
1304
1305 #[test]
1306 fn test_from_hcl_multi_provider() {
1307 let hcl = r#"
1308 default_model = "anthropic/claude-sonnet-4"
1309
1310 providers {
1311 name = "anthropic"
1312 api_key = "sk-ant-test"
1313
1314 models {
1315 id = "claude-sonnet-4"
1316 name = "Claude Sonnet 4"
1317 }
1318
1319 models {
1320 id = "claude-opus-4"
1321 name = "Claude Opus 4"
1322 reasoning = true
1323 }
1324 }
1325
1326 providers {
1327 name = "openai"
1328 api_key = "sk-test"
1329
1330 models {
1331 id = "gpt-4o"
1332 name = "GPT-4o"
1333 }
1334 }
1335 "#;
1336
1337 let config = CodeConfig::from_hcl(hcl).unwrap();
1338 assert_eq!(config.providers.len(), 2);
1339 assert_eq!(config.providers[0].models.len(), 2);
1340 assert_eq!(config.providers[1].models.len(), 1);
1341 assert_eq!(config.providers[1].name, "openai");
1342 }
1343
1344 #[test]
1345 fn test_snake_to_camel() {
1346 assert_eq!(snake_to_camel("default_model"), "defaultModel");
1347 assert_eq!(snake_to_camel("api_key"), "apiKey");
1348 assert_eq!(snake_to_camel("base_url"), "baseUrl");
1349 assert_eq!(snake_to_camel("name"), "name");
1350 assert_eq!(snake_to_camel("tool_call"), "toolCall");
1351 }
1352
1353 #[test]
1354 fn test_from_file_auto_detect_hcl() {
1355 let temp_dir = tempfile::tempdir().unwrap();
1356 let config_path = temp_dir.path().join("config.hcl");
1357
1358 std::fs::write(
1359 &config_path,
1360 r#"
1361 default_model = "anthropic/claude-sonnet-4"
1362
1363 providers {
1364 name = "anthropic"
1365 api_key = "test-key"
1366
1367 models {
1368 id = "claude-sonnet-4"
1369 }
1370 }
1371 "#,
1372 )
1373 .unwrap();
1374
1375 let config = CodeConfig::from_file(&config_path).unwrap();
1376 assert_eq!(
1377 config.default_model,
1378 Some("anthropic/claude-sonnet-4".to_string())
1379 );
1380 }
1381
1382 #[test]
1383 fn test_from_hcl_with_queue_config() {
1384 let hcl = r#"
1385 default_model = "anthropic/claude-sonnet-4"
1386
1387 providers {
1388 name = "anthropic"
1389 api_key = "test-key"
1390 }
1391
1392 queue {
1393 query_max_concurrency = 20
1394 execute_max_concurrency = 5
1395 enable_metrics = true
1396 enable_dlq = true
1397 }
1398 "#;
1399
1400 let config = CodeConfig::from_hcl(hcl).unwrap();
1401 assert!(config.queue.is_some());
1402 let queue = config.queue.unwrap();
1403 assert_eq!(queue.query_max_concurrency, 20);
1404 assert_eq!(queue.execute_max_concurrency, 5);
1405 assert!(queue.enable_metrics);
1406 assert!(queue.enable_dlq);
1407 }
1408
1409 #[test]
1410 fn test_from_hcl_with_search_config() {
1411 let hcl = r#"
1412 default_model = "anthropic/claude-sonnet-4"
1413
1414 providers {
1415 name = "anthropic"
1416 api_key = "test-key"
1417 }
1418
1419 search {
1420 timeout = 30
1421
1422 health {
1423 max_failures = 5
1424 suspend_seconds = 120
1425 }
1426
1427 engine {
1428 google {
1429 enabled = true
1430 weight = 1.5
1431 }
1432 bing {
1433 enabled = true
1434 weight = 1.0
1435 timeout = 15
1436 }
1437 }
1438 }
1439 "#;
1440
1441 let config = CodeConfig::from_hcl(hcl).unwrap();
1442 assert!(config.search.is_some());
1443 let search = config.search.unwrap();
1444 assert_eq!(search.timeout, 30);
1445 assert!(search.health.is_some());
1446 let health = search.health.unwrap();
1447 assert_eq!(health.max_failures, 5);
1448 assert_eq!(health.suspend_seconds, 120);
1449 assert_eq!(search.engines.len(), 2);
1450 assert!(search.engines.contains_key("google"));
1451 assert!(search.engines.contains_key("bing"));
1452 let google = &search.engines["google"];
1453 assert!(google.enabled);
1454 assert_eq!(google.weight, 1.5);
1455 let bing = &search.engines["bing"];
1456 assert_eq!(bing.timeout, Some(15));
1457 }
1458
1459 #[test]
1460 fn test_from_hcl_with_queue_and_search() {
1461 let hcl = r#"
1462 default_model = "anthropic/claude-sonnet-4"
1463
1464 providers {
1465 name = "anthropic"
1466 api_key = "test-key"
1467 }
1468
1469 queue {
1470 query_max_concurrency = 10
1471 enable_metrics = true
1472 }
1473
1474 search {
1475 timeout = 20
1476 engine {
1477 duckduckgo {
1478 enabled = true
1479 }
1480 }
1481 }
1482 "#;
1483
1484 let config = CodeConfig::from_hcl(hcl).unwrap();
1485 assert!(config.queue.is_some());
1486 assert!(config.search.is_some());
1487 assert_eq!(config.queue.unwrap().query_max_concurrency, 10);
1488 assert_eq!(config.search.unwrap().timeout, 20);
1489 }
1490
1491 #[test]
1492 fn test_from_hcl_with_advanced_queue_config() {
1493 let hcl = r#"
1494 default_model = "anthropic/claude-sonnet-4"
1495
1496 providers {
1497 name = "anthropic"
1498 api_key = "test-key"
1499 }
1500
1501 queue {
1502 query_max_concurrency = 20
1503 enable_metrics = true
1504
1505 retry_policy {
1506 strategy = "exponential"
1507 max_retries = 5
1508 initial_delay_ms = 200
1509 }
1510
1511 rate_limit {
1512 limit_type = "per_second"
1513 max_operations = 100
1514 }
1515
1516 priority_boost {
1517 strategy = "standard"
1518 deadline_ms = 300000
1519 }
1520
1521 pressure_threshold = 50
1522 }
1523 "#;
1524
1525 let config = CodeConfig::from_hcl(hcl).unwrap();
1526 assert!(config.queue.is_some());
1527 let queue = config.queue.unwrap();
1528
1529 assert_eq!(queue.query_max_concurrency, 20);
1530 assert!(queue.enable_metrics);
1531
1532 assert!(queue.retry_policy.is_some());
1534 let retry = queue.retry_policy.unwrap();
1535 assert_eq!(retry.strategy, "exponential");
1536 assert_eq!(retry.max_retries, 5);
1537 assert_eq!(retry.initial_delay_ms, 200);
1538
1539 assert!(queue.rate_limit.is_some());
1541 let rate = queue.rate_limit.unwrap();
1542 assert_eq!(rate.limit_type, "per_second");
1543 assert_eq!(rate.max_operations, Some(100));
1544
1545 assert!(queue.priority_boost.is_some());
1547 let boost = queue.priority_boost.unwrap();
1548 assert_eq!(boost.strategy, "standard");
1549 assert_eq!(boost.deadline_ms, Some(300000));
1550
1551 assert_eq!(queue.pressure_threshold, Some(50));
1553 }
1554}