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