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