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