1use crate::error::{CodeError, Result};
14use crate::llm::LlmConfig;
15use crate::memory::MemoryConfig;
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26#[serde(rename_all = "camelCase")]
27pub struct ModelCost {
28 #[serde(default)]
30 pub input: f64,
31 #[serde(default)]
33 pub output: f64,
34 #[serde(default)]
36 pub cache_read: f64,
37 #[serde(default)]
39 pub cache_write: f64,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, Default)]
44pub struct ModelLimit {
45 #[serde(default)]
47 pub context: u32,
48 #[serde(default)]
50 pub output: u32,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55pub struct ModelModalities {
56 #[serde(default)]
58 pub input: Vec<String>,
59 #[serde(default)]
61 pub output: Vec<String>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct ModelConfig {
68 pub id: String,
70 #[serde(default)]
72 pub name: String,
73 #[serde(default)]
75 pub family: String,
76 #[serde(default)]
78 pub api_key: Option<String>,
79 #[serde(default)]
81 pub base_url: Option<String>,
82 #[serde(default)]
84 pub headers: HashMap<String, String>,
85 #[serde(default)]
87 pub session_id_header: Option<String>,
88 #[serde(default)]
90 pub attachment: bool,
91 #[serde(default)]
93 pub reasoning: bool,
94 #[serde(default = "default_true")]
96 pub tool_call: bool,
97 #[serde(default = "default_true")]
99 pub temperature: bool,
100 #[serde(default)]
102 pub release_date: Option<String>,
103 #[serde(default)]
105 pub modalities: ModelModalities,
106 #[serde(default)]
108 pub cost: ModelCost,
109 #[serde(default)]
111 pub limit: ModelLimit,
112}
113
114fn default_true() -> bool {
115 true
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(rename_all = "camelCase")]
121pub struct ProviderConfig {
122 pub name: String,
124 #[serde(default)]
126 pub api_key: Option<String>,
127 #[serde(default)]
129 pub base_url: Option<String>,
130 #[serde(default)]
132 pub headers: HashMap<String, String>,
133 #[serde(default)]
135 pub session_id_header: Option<String>,
136 #[serde(default)]
138 pub models: Vec<ModelConfig>,
139}
140
141fn apply_model_caps(
147 mut config: LlmConfig,
148 model: &ModelConfig,
149 thinking_budget: Option<usize>,
150) -> LlmConfig {
151 if model.reasoning {
153 if let Some(budget) = thinking_budget {
154 config = config.with_thinking_budget(budget);
155 }
156 }
157
158 if model.limit.output > 0 {
160 config = config.with_max_tokens(model.limit.output as usize);
161 }
162
163 if !model.temperature {
166 config.disable_temperature = true;
167 }
168
169 config
170}
171
172impl ProviderConfig {
173 pub fn find_model(&self, model_id: &str) -> Option<&ModelConfig> {
175 self.models.iter().find(|m| m.id == model_id)
176 }
177
178 pub fn get_api_key<'a>(&'a self, model: &'a ModelConfig) -> Option<&'a str> {
180 model.api_key.as_deref().or(self.api_key.as_deref())
181 }
182
183 pub fn get_base_url<'a>(&'a self, model: &'a ModelConfig) -> Option<&'a str> {
185 model.base_url.as_deref().or(self.base_url.as_deref())
186 }
187
188 pub fn get_headers(&self, model: &ModelConfig) -> HashMap<String, String> {
190 let mut headers = self.headers.clone();
191 headers.extend(model.headers.clone());
192 headers
193 }
194
195 pub fn get_session_id_header<'a>(&'a self, model: &'a ModelConfig) -> Option<&'a str> {
197 model
198 .session_id_header
199 .as_deref()
200 .or(self.session_id_header.as_deref())
201 }
202}
203
204#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
210#[serde(rename_all = "lowercase")]
211pub enum StorageBackend {
212 Memory,
214 #[default]
216 File,
217 Custom,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, Default)]
230#[serde(rename_all = "camelCase")]
231pub struct CodeConfig {
232 #[serde(default, alias = "default_model")]
234 pub default_model: Option<String>,
235
236 #[serde(default)]
238 pub providers: Vec<ProviderConfig>,
239
240 #[serde(default)]
242 pub storage_backend: StorageBackend,
243
244 #[serde(skip_serializing_if = "Option::is_none")]
246 pub sessions_dir: Option<PathBuf>,
247
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub storage_url: Option<String>,
251
252 #[serde(default, alias = "skill_dirs")]
254 pub skill_dirs: Vec<PathBuf>,
255
256 #[serde(default, alias = "agent_dirs")]
258 pub agent_dirs: Vec<PathBuf>,
259
260 #[serde(default, alias = "max_tool_rounds")]
262 pub max_tool_rounds: Option<usize>,
263
264 #[serde(default, alias = "thinking_budget")]
266 pub thinking_budget: Option<usize>,
267
268 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub memory: Option<MemoryConfig>,
271
272 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub queue: Option<crate::queue::SessionQueueConfig>,
275
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub search: Option<SearchConfig>,
279
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub document_parser: Option<DocumentParserConfig>,
283
284 #[serde(default, alias = "mcp_servers")]
286 pub mcp_servers: Vec<crate::mcp::McpServerConfig>,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
291#[serde(rename_all = "camelCase")]
292pub struct SearchConfig {
293 #[serde(default = "default_search_timeout")]
295 pub timeout: u64,
296
297 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub health: Option<SearchHealthConfig>,
300
301 #[serde(default, rename = "engine")]
303 pub engines: std::collections::HashMap<String, SearchEngineConfig>,
304
305 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub headless: Option<HeadlessConfig>,
309}
310
311#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
313#[serde(rename_all = "lowercase")]
314pub enum BrowserBackend {
315 #[default]
317 Chrome,
318 Lightpanda,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
325#[serde(rename_all = "camelCase")]
326pub struct HeadlessConfig {
327 #[serde(default)]
329 pub backend: BrowserBackend,
330
331 #[serde(default = "default_headless_max_tabs")]
333 pub max_tabs: usize,
334
335 #[serde(
337 default,
338 alias = "chromePath",
339 alias = "lightpandaPath",
340 alias = "obscuraPath",
341 alias = "playwrightPath",
342 skip_serializing_if = "Option::is_none"
343 )]
344 pub browser_path: Option<String>,
345
346 #[serde(default, skip_serializing_if = "Vec::is_empty")]
348 pub launch_args: Vec<String>,
349
350 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub proxy_url: Option<String>,
353}
354
355impl BrowserBackend {
356 pub fn is_lightpanda(self) -> bool {
357 matches!(self, Self::Lightpanda)
358 }
359}
360
361impl Default for HeadlessConfig {
362 fn default() -> Self {
363 Self {
364 backend: BrowserBackend::Chrome,
365 max_tabs: 4,
366 browser_path: None,
367 launch_args: Vec::new(),
368 proxy_url: None,
369 }
370 }
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize)]
375#[serde(rename_all = "camelCase")]
376pub struct DocumentParserConfig {
377 #[serde(default = "default_enabled")]
379 pub enabled: bool,
380
381 #[serde(default = "default_document_parser_max_file_size_mb")]
383 pub max_file_size_mb: u64,
384
385 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub ocr: Option<DocumentOcrConfig>,
392
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub cache: Option<DocumentCacheConfig>,
396}
397
398impl Default for DocumentParserConfig {
399 fn default() -> Self {
400 Self {
401 enabled: true,
402 max_file_size_mb: default_document_parser_max_file_size_mb(),
403 ocr: None,
404 cache: Some(DocumentCacheConfig::default()),
405 }
406 }
407}
408
409impl DocumentParserConfig {
410 pub fn normalized(&self) -> Self {
411 Self {
412 enabled: self.enabled,
413 max_file_size_mb: self.max_file_size_mb.clamp(1, 1024),
414 ocr: self.ocr.as_ref().map(DocumentOcrConfig::normalized),
415 cache: self.cache.as_ref().map(DocumentCacheConfig::normalized),
416 }
417 }
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize)]
421#[serde(rename_all = "camelCase")]
422pub struct DocumentCacheConfig {
423 #[serde(default = "default_enabled")]
424 pub enabled: bool,
425
426 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub directory: Option<PathBuf>,
428}
429
430impl Default for DocumentCacheConfig {
431 fn default() -> Self {
432 Self {
433 enabled: true,
434 directory: None,
435 }
436 }
437}
438
439impl DocumentCacheConfig {
440 pub fn normalized(&self) -> Self {
441 Self {
442 enabled: self.enabled,
443 directory: self.directory.clone(),
444 }
445 }
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize)]
450#[serde(rename_all = "camelCase")]
451pub struct DocumentOcrConfig {
452 #[serde(default = "default_enabled")]
454 pub enabled: bool,
455
456 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub model: Option<String>,
459
460 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub prompt: Option<String>,
463
464 #[serde(default = "default_document_ocr_max_images")]
466 pub max_images: usize,
467
468 #[serde(default = "default_document_ocr_dpi")]
470 pub dpi: u32,
471
472 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub provider: Option<String>,
477
478 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub base_url: Option<String>,
481
482 #[serde(default, skip_serializing_if = "Option::is_none")]
484 pub api_key: Option<String>,
485}
486
487impl Default for DocumentOcrConfig {
488 fn default() -> Self {
489 Self {
490 enabled: false,
491 model: None,
492 prompt: None,
493 max_images: default_document_ocr_max_images(),
494 dpi: default_document_ocr_dpi(),
495 provider: None,
496 base_url: None,
497 api_key: None,
498 }
499 }
500}
501
502fn acl_attr<'a>(block: &'a a3s_acl::Block, keys: &[&str]) -> Option<&'a a3s_acl::Value> {
503 keys.iter().find_map(|key| block.attributes.get(*key))
504}
505
506fn acl_string(value: &a3s_acl::Value) -> Option<String> {
507 match value {
508 a3s_acl::Value::String(s) => Some(s.clone()),
509 a3s_acl::Value::Call(name, args) if name == "env" => {
510 let var_name = args.first().and_then(acl_string)?;
511 std::env::var(var_name).ok()
512 }
513 _ => None,
514 }
515}
516
517fn acl_string_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<String> {
518 acl_attr(block, keys).and_then(acl_string)
519}
520
521fn acl_label_or_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<String> {
522 block
523 .labels
524 .first()
525 .cloned()
526 .or_else(|| acl_string_attr(block, keys))
527}
528
529fn acl_bool_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<bool> {
530 match acl_attr(block, keys) {
531 Some(a3s_acl::Value::Bool(value)) => Some(*value),
532 _ => None,
533 }
534}
535
536fn acl_usize_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<usize> {
537 match acl_attr(block, keys) {
538 Some(a3s_acl::Value::Number(value)) if *value >= 0.0 => Some(*value as usize),
539 _ => None,
540 }
541}
542
543fn acl_path_list_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<Vec<PathBuf>> {
544 let value = acl_attr(block, keys)?;
545 match value {
546 a3s_acl::Value::List(items) => Some(
547 items
548 .iter()
549 .filter_map(acl_string)
550 .map(PathBuf::from)
551 .collect(),
552 ),
553 _ => acl_string(value).map(|s| vec![PathBuf::from(s)]),
554 }
555}
556
557impl DocumentOcrConfig {
558 pub fn normalized(&self) -> Self {
559 Self {
560 enabled: self.enabled,
561 model: self.model.clone(),
562 prompt: self.prompt.clone(),
563 max_images: self.max_images.clamp(1, 64),
564 dpi: self.dpi.clamp(72, 600),
565 provider: self.provider.clone(),
566 base_url: self.base_url.clone(),
567 api_key: self.api_key.clone(),
568 }
569 }
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize)]
574#[serde(rename_all = "camelCase")]
575pub struct SearchHealthConfig {
576 #[serde(default = "default_max_failures")]
578 pub max_failures: u32,
579
580 #[serde(default = "default_suspend_seconds")]
582 pub suspend_seconds: u64,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize)]
587#[serde(rename_all = "camelCase")]
588pub struct SearchEngineConfig {
589 #[serde(default = "default_enabled")]
591 pub enabled: bool,
592
593 #[serde(default = "default_weight")]
595 pub weight: f64,
596
597 #[serde(skip_serializing_if = "Option::is_none")]
599 pub timeout: Option<u64>,
600}
601
602fn default_search_timeout() -> u64 {
603 10
604}
605
606fn default_headless_max_tabs() -> usize {
607 4
608}
609
610fn default_max_failures() -> u32 {
611 3
612}
613
614fn default_suspend_seconds() -> u64 {
615 60
616}
617
618fn default_enabled() -> bool {
619 true
620}
621
622fn default_weight() -> f64 {
623 1.0
624}
625
626fn default_document_parser_max_file_size_mb() -> u64 {
627 50
628}
629
630fn default_document_ocr_max_images() -> usize {
631 8
632}
633
634fn default_document_ocr_dpi() -> u32 {
635 144
636}
637
638impl CodeConfig {
639 pub fn new() -> Self {
641 Self::default()
642 }
643
644 pub fn from_file(path: &Path) -> Result<Self> {
649 let content = std::fs::read_to_string(path).map_err(|e| {
650 CodeError::Config(format!(
651 "Failed to read config file {}: {}",
652 path.display(),
653 e
654 ))
655 })?;
656
657 Self::from_acl(&content).map_err(|e| {
658 CodeError::Config(format!(
659 "Failed to parse ACL config {}: {}",
660 path.display(),
661 e
662 ))
663 })
664 }
665
666 pub fn from_acl(content: &str) -> Result<Self> {
671 use a3s_acl::parse_acl;
672
673 let doc = parse_acl(content)
674 .map_err(|e| CodeError::Config(format!("Failed to parse ACL: {}", e)))?;
675
676 let mut config = Self::default();
677
678 for block in doc.blocks {
679 match block.name.as_str() {
680 "default_model" => {
681 if let Some(default_model) = acl_label_or_attr(&block, &["default_model"]) {
683 config.default_model = Some(default_model);
684 }
685 }
686 "storage_backend" => {
687 if let Some(backend) = acl_string_attr(&block, &["storage_backend"]) {
688 config.storage_backend = match backend.to_ascii_lowercase().as_str() {
689 "memory" => StorageBackend::Memory,
690 "custom" => StorageBackend::Custom,
691 _ => StorageBackend::File,
692 };
693 }
694 }
695 "sessions_dir" => {
696 if let Some(path) = acl_string_attr(&block, &["sessions_dir"]) {
697 config.sessions_dir = Some(PathBuf::from(path));
698 }
699 }
700 "storage_url" => {
701 if let Some(storage_url) = acl_string_attr(&block, &["storage_url"]) {
702 config.storage_url = Some(storage_url);
703 }
704 }
705 "skill_dirs" | "skills" => {
706 if let Some(paths) = acl_path_list_attr(&block, &["skill_dirs", "skills"]) {
707 config.skill_dirs = paths;
708 }
709 }
710 "agent_dirs" => {
711 if let Some(paths) = acl_path_list_attr(&block, &["agent_dirs"]) {
712 config.agent_dirs = paths;
713 }
714 }
715 "max_tool_rounds" => {
716 if let Some(max_tool_rounds) = acl_usize_attr(&block, &["max_tool_rounds"]) {
717 config.max_tool_rounds = Some(max_tool_rounds);
718 }
719 }
720 "thinking_budget" => {
721 if let Some(thinking_budget) = acl_usize_attr(&block, &["thinking_budget"]) {
722 config.thinking_budget = Some(thinking_budget);
723 }
724 }
725 "providers" => {
726 let provider_name = block.labels.first().cloned().ok_or_else(|| {
727 CodeError::Config(
728 "providers block requires a label (e.g., providers \"openai\" { ... })"
729 .into(),
730 )
731 })?;
732
733 let mut provider = ProviderConfig {
734 name: provider_name.clone(),
735 api_key: None,
736 base_url: None,
737 headers: HashMap::new(),
738 session_id_header: None,
739 models: Vec::new(),
740 };
741
742 for (key, value) in &block.attributes {
743 match key.as_str() {
744 "apiKey" | "api_key" => {
745 if let Some(api_key) = acl_string(value) {
746 provider.api_key = Some(api_key);
747 }
748 }
749 "baseUrl" | "base_url" => {
750 if let Some(base_url) = acl_string(value) {
751 provider.base_url = Some(base_url);
752 }
753 }
754 "sessionIdHeader" | "session_id_header" => {
755 if let Some(header) = acl_string(value) {
756 provider.session_id_header = Some(header);
757 }
758 }
759 _ => {}
760 }
761 }
762
763 for model_block in &block.blocks {
765 if model_block.name == "models" {
766 let model_name =
767 model_block.labels.first().cloned().ok_or_else(|| {
768 CodeError::Config(
769 "models block requires a label (e.g., models \"gpt-4\" { ... })"
770 .into(),
771 )
772 })?;
773
774 let mut model = ModelConfig {
775 id: model_name.clone(),
776 name: model_name.clone(),
777 family: String::new(),
778 api_key: None,
779 base_url: None,
780 headers: HashMap::new(),
781 session_id_header: None,
782 attachment: false,
783 reasoning: false,
784 tool_call: true,
785 temperature: true,
786 release_date: None,
787 modalities: ModelModalities::default(),
788 cost: ModelCost::default(),
789 limit: ModelLimit::default(),
790 };
791
792 for (key, value) in &model_block.attributes {
793 match key.as_str() {
794 "name" => {
795 if let Some(s) = acl_string(value) {
796 model.name = s;
797 }
798 }
799 "family" => {
800 if let Some(s) = acl_string(value) {
801 model.family = s;
802 }
803 }
804 "apiKey" | "api_key" => {
805 if let Some(api_key) = acl_string(value) {
806 model.api_key = Some(api_key);
807 }
808 }
809 "baseUrl" | "base_url" => {
810 if let Some(base_url) = acl_string(value) {
811 model.base_url = Some(base_url);
812 }
813 }
814 "sessionIdHeader" | "session_id_header" => {
815 if let Some(header) = acl_string(value) {
816 model.session_id_header = Some(header);
817 }
818 }
819 "attachment" => {
820 model.attachment =
821 acl_bool_attr(model_block, &["attachment"])
822 .unwrap_or(model.attachment);
823 }
824 "reasoning" => {
825 model.reasoning =
826 acl_bool_attr(model_block, &["reasoning"])
827 .unwrap_or(model.reasoning);
828 }
829 "toolCall" | "tool_call" => {
830 model.tool_call =
831 acl_bool_attr(model_block, &["toolCall", "tool_call"])
832 .unwrap_or(model.tool_call);
833 }
834 "temperature" => {
835 model.temperature =
836 acl_bool_attr(model_block, &["temperature"])
837 .unwrap_or(model.temperature);
838 }
839 "releaseDate" | "release_date" => {
840 if let Some(release_date) = acl_string(value) {
841 model.release_date = Some(release_date);
842 }
843 }
844 _ => {}
845 }
846 }
847
848 provider.models.push(model);
849 }
850 }
851
852 config.providers.push(provider);
853 }
854 _ => {
855 }
858 }
859 }
860
861 Ok(config)
862 }
863
864 pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> {
866 self.providers.iter().find(|p| p.name == name)
867 }
868
869 pub fn default_provider_config(&self) -> Option<&ProviderConfig> {
871 let default = self.default_model.as_ref()?;
872 let (provider_name, _) = default.split_once('/')?;
873 self.find_provider(provider_name)
874 }
875
876 pub fn default_model_config(&self) -> Option<(&ProviderConfig, &ModelConfig)> {
878 let default = self.default_model.as_ref()?;
879 let (provider_name, model_id) = default.split_once('/')?;
880 let provider = self.find_provider(provider_name)?;
881 let model = provider.find_model(model_id)?;
882 Some((provider, model))
883 }
884
885 pub fn default_llm_config(&self) -> Option<LlmConfig> {
889 let (provider, model) = self.default_model_config()?;
890 let api_key = provider.get_api_key(model)?;
891 let base_url = provider.get_base_url(model);
892 let headers = provider.get_headers(model);
893 let session_id_header = provider.get_session_id_header(model);
894
895 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
896 if let Some(url) = base_url {
897 config = config.with_base_url(url);
898 }
899 if !headers.is_empty() {
900 config = config.with_headers(headers);
901 }
902 if let Some(header_name) = session_id_header {
903 config = config.with_session_id_header(header_name);
904 }
905 config = apply_model_caps(config, model, self.thinking_budget);
906 Some(config)
907 }
908
909 pub fn llm_config(&self, provider_name: &str, model_id: &str) -> Option<LlmConfig> {
913 let provider = self.find_provider(provider_name)?;
914 let model = provider.find_model(model_id)?;
915 let api_key = provider.get_api_key(model)?;
916 let base_url = provider.get_base_url(model);
917 let headers = provider.get_headers(model);
918 let session_id_header = provider.get_session_id_header(model);
919
920 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
921 if let Some(url) = base_url {
922 config = config.with_base_url(url);
923 }
924 if !headers.is_empty() {
925 config = config.with_headers(headers);
926 }
927 if let Some(header_name) = session_id_header {
928 config = config.with_session_id_header(header_name);
929 }
930 config = apply_model_caps(config, model, self.thinking_budget);
931 Some(config)
932 }
933
934 pub fn list_models(&self) -> Vec<(&ProviderConfig, &ModelConfig)> {
936 self.providers
937 .iter()
938 .flat_map(|p| p.models.iter().map(move |m| (p, m)))
939 .collect()
940 }
941
942 pub fn add_skill_dir(mut self, dir: impl Into<PathBuf>) -> Self {
944 self.skill_dirs.push(dir.into());
945 self
946 }
947
948 pub fn add_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
950 self.agent_dirs.push(dir.into());
951 self
952 }
953
954 pub fn has_directories(&self) -> bool {
956 !self.skill_dirs.is_empty() || !self.agent_dirs.is_empty()
957 }
958
959 pub fn has_providers(&self) -> bool {
961 !self.providers.is_empty()
962 }
963}
964
965#[cfg(test)]
970mod tests {
971 use super::*;
972
973 #[test]
974 fn test_config_default() {
975 let config = CodeConfig::default();
976 assert!(config.skill_dirs.is_empty());
977 assert!(config.agent_dirs.is_empty());
978 assert!(config.providers.is_empty());
979 assert!(config.default_model.is_none());
980 assert_eq!(config.storage_backend, StorageBackend::File);
981 assert!(config.sessions_dir.is_none());
982 }
983
984 #[test]
985 fn test_storage_backend_default() {
986 let backend = StorageBackend::default();
987 assert_eq!(backend, StorageBackend::File);
988 }
989
990 #[test]
991 fn test_storage_backend_serde() {
992 let memory = StorageBackend::Memory;
994 let json = serde_json::to_string(&memory).unwrap();
995 assert_eq!(json, "\"memory\"");
996
997 let file = StorageBackend::File;
998 let json = serde_json::to_string(&file).unwrap();
999 assert_eq!(json, "\"file\"");
1000
1001 let memory: StorageBackend = serde_json::from_str("\"memory\"").unwrap();
1003 assert_eq!(memory, StorageBackend::Memory);
1004
1005 let file: StorageBackend = serde_json::from_str("\"file\"").unwrap();
1006 assert_eq!(file, StorageBackend::File);
1007 }
1008
1009 #[test]
1010 fn test_config_with_storage_backend() {
1011 let temp_dir = tempfile::tempdir().unwrap();
1012 let config_path = temp_dir.path().join("config.acl");
1013
1014 std::fs::write(
1015 &config_path,
1016 r#"
1017 storage_backend = "memory"
1018 sessions_dir = "/tmp/sessions"
1019 "#,
1020 )
1021 .unwrap();
1022
1023 let config = CodeConfig::from_file(&config_path).unwrap();
1024 assert_eq!(config.storage_backend, StorageBackend::Memory);
1025 assert_eq!(config.sessions_dir, Some(PathBuf::from("/tmp/sessions")));
1026 }
1027
1028 #[test]
1029 fn test_config_rejects_unlabeled_provider_blocks() {
1030 std::env::set_var("A3S_CODE_TEST_API_KEY", "sk-test");
1031 let err = CodeConfig::from_acl(
1032 r#"
1033 default_model = "openai/gpt-4.1"
1034 max_tool_rounds = 12
1035 skill_dirs = ["./skills"]
1036
1037 providers {
1038 name = "openai"
1039 api_key = env("A3S_CODE_TEST_API_KEY")
1040 base_url = "https://api.openai.com/v1"
1041
1042 models "gpt-4.1" {
1043 name = "GPT 4.1"
1044 reasoning = true
1045 tool_call = false
1046 }
1047 }
1048 "#,
1049 )
1050 .unwrap_err();
1051
1052 assert!(err.to_string().contains("providers block requires a label"));
1053 }
1054
1055 #[test]
1056 fn test_config_supports_acl_style_provider_labels() {
1057 let config = CodeConfig::from_acl(
1058 r#"
1059 default_model = "openai/gpt-4.1"
1060
1061 providers "openai" {
1062 apiKey = "sk-test"
1063 baseUrl = "https://api.openai.com/v1"
1064
1065 models "gpt-4.1" {
1066 name = "GPT 4.1"
1067 toolCall = true
1068 }
1069 }
1070 "#,
1071 )
1072 .unwrap();
1073
1074 assert_eq!(config.default_model.as_deref(), Some("openai/gpt-4.1"));
1075 assert_eq!(config.providers[0].name, "openai");
1076 assert_eq!(config.providers[0].api_key.as_deref(), Some("sk-test"));
1077 assert_eq!(config.providers[0].models[0].id, "gpt-4.1");
1078 assert_eq!(config.providers[0].models[0].name, "GPT 4.1");
1079 assert!(config.providers[0].models[0].tool_call);
1080 }
1081
1082 #[test]
1083 fn test_config_builder() {
1084 let config = CodeConfig::new()
1085 .add_skill_dir("/tmp/skills")
1086 .add_agent_dir("/tmp/agents");
1087
1088 assert_eq!(config.skill_dirs.len(), 1);
1089 assert_eq!(config.agent_dirs.len(), 1);
1090 }
1091
1092 #[test]
1093 fn test_find_provider() {
1094 let config = CodeConfig {
1095 providers: vec![
1096 ProviderConfig {
1097 name: "anthropic".to_string(),
1098 api_key: Some("key1".to_string()),
1099 base_url: None,
1100 headers: HashMap::new(),
1101 session_id_header: None,
1102 models: vec![],
1103 },
1104 ProviderConfig {
1105 name: "openai".to_string(),
1106 api_key: Some("key2".to_string()),
1107 base_url: None,
1108 headers: HashMap::new(),
1109 session_id_header: None,
1110 models: vec![],
1111 },
1112 ],
1113 ..Default::default()
1114 };
1115
1116 assert!(config.find_provider("anthropic").is_some());
1117 assert!(config.find_provider("openai").is_some());
1118 assert!(config.find_provider("unknown").is_none());
1119 }
1120
1121 #[test]
1122 fn test_default_llm_config() {
1123 let config = CodeConfig {
1124 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1125 providers: vec![ProviderConfig {
1126 name: "anthropic".to_string(),
1127 api_key: Some("test-api-key".to_string()),
1128 base_url: Some("https://api.anthropic.com".to_string()),
1129 headers: HashMap::new(),
1130 session_id_header: None,
1131 models: vec![ModelConfig {
1132 id: "claude-sonnet-4".to_string(),
1133 name: "Claude Sonnet 4".to_string(),
1134 family: "claude-sonnet".to_string(),
1135 api_key: None,
1136 base_url: None,
1137 headers: HashMap::new(),
1138 session_id_header: None,
1139 attachment: false,
1140 reasoning: false,
1141 tool_call: true,
1142 temperature: true,
1143 release_date: None,
1144 modalities: ModelModalities::default(),
1145 cost: ModelCost::default(),
1146 limit: ModelLimit::default(),
1147 }],
1148 }],
1149 ..Default::default()
1150 };
1151
1152 let llm_config = config.default_llm_config().unwrap();
1153 assert_eq!(llm_config.provider, "anthropic");
1154 assert_eq!(llm_config.model, "claude-sonnet-4");
1155 assert_eq!(llm_config.api_key.expose(), "test-api-key");
1156 assert_eq!(
1157 llm_config.base_url,
1158 Some("https://api.anthropic.com".to_string())
1159 );
1160 }
1161
1162 #[test]
1163 fn test_model_api_key_override() {
1164 let provider = ProviderConfig {
1165 name: "openai".to_string(),
1166 api_key: Some("provider-key".to_string()),
1167 base_url: Some("https://api.openai.com".to_string()),
1168 headers: HashMap::new(),
1169 session_id_header: None,
1170 models: vec![
1171 ModelConfig {
1172 id: "gpt-4".to_string(),
1173 name: "GPT-4".to_string(),
1174 family: "gpt".to_string(),
1175 api_key: None, base_url: None,
1177 headers: HashMap::new(),
1178 session_id_header: None,
1179 attachment: false,
1180 reasoning: false,
1181 tool_call: true,
1182 temperature: true,
1183 release_date: None,
1184 modalities: ModelModalities::default(),
1185 cost: ModelCost::default(),
1186 limit: ModelLimit::default(),
1187 },
1188 ModelConfig {
1189 id: "custom-model".to_string(),
1190 name: "Custom Model".to_string(),
1191 family: "custom".to_string(),
1192 api_key: Some("model-specific-key".to_string()), base_url: Some("https://custom.api.com".to_string()), headers: HashMap::new(),
1195 session_id_header: 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 };
1207
1208 let model1 = provider.find_model("gpt-4").unwrap();
1210 assert_eq!(provider.get_api_key(model1), Some("provider-key"));
1211 assert_eq!(
1212 provider.get_base_url(model1),
1213 Some("https://api.openai.com")
1214 );
1215
1216 let model2 = provider.find_model("custom-model").unwrap();
1218 assert_eq!(provider.get_api_key(model2), Some("model-specific-key"));
1219 assert_eq!(
1220 provider.get_base_url(model2),
1221 Some("https://custom.api.com")
1222 );
1223 }
1224
1225 #[test]
1226 fn test_list_models() {
1227 let config = CodeConfig {
1228 providers: vec![
1229 ProviderConfig {
1230 name: "anthropic".to_string(),
1231 api_key: None,
1232 base_url: None,
1233 headers: HashMap::new(),
1234 session_id_header: None,
1235 models: vec![
1236 ModelConfig {
1237 id: "claude-1".to_string(),
1238 name: "Claude 1".to_string(),
1239 family: "claude".to_string(),
1240 api_key: None,
1241 base_url: None,
1242 headers: HashMap::new(),
1243 session_id_header: None,
1244 attachment: false,
1245 reasoning: false,
1246 tool_call: true,
1247 temperature: true,
1248 release_date: None,
1249 modalities: ModelModalities::default(),
1250 cost: ModelCost::default(),
1251 limit: ModelLimit::default(),
1252 },
1253 ModelConfig {
1254 id: "claude-2".to_string(),
1255 name: "Claude 2".to_string(),
1256 family: "claude".to_string(),
1257 api_key: None,
1258 base_url: None,
1259 headers: HashMap::new(),
1260 session_id_header: None,
1261 attachment: false,
1262 reasoning: false,
1263 tool_call: true,
1264 temperature: true,
1265 release_date: None,
1266 modalities: ModelModalities::default(),
1267 cost: ModelCost::default(),
1268 limit: ModelLimit::default(),
1269 },
1270 ],
1271 },
1272 ProviderConfig {
1273 name: "openai".to_string(),
1274 api_key: None,
1275 base_url: None,
1276 headers: HashMap::new(),
1277 session_id_header: None,
1278 models: vec![ModelConfig {
1279 id: "gpt-4".to_string(),
1280 name: "GPT-4".to_string(),
1281 family: "gpt".to_string(),
1282 api_key: None,
1283 base_url: None,
1284 headers: HashMap::new(),
1285 session_id_header: None,
1286 attachment: false,
1287 reasoning: false,
1288 tool_call: true,
1289 temperature: true,
1290 release_date: None,
1291 modalities: ModelModalities::default(),
1292 cost: ModelCost::default(),
1293 limit: ModelLimit::default(),
1294 }],
1295 },
1296 ],
1297 ..Default::default()
1298 };
1299
1300 let models = config.list_models();
1301 assert_eq!(models.len(), 3);
1302 }
1303
1304 #[test]
1305 fn test_config_from_file_not_found() {
1306 let result = CodeConfig::from_file(Path::new("/nonexistent/config.json"));
1307 assert!(result.is_err());
1308 }
1309
1310 #[test]
1311 fn test_config_has_directories() {
1312 let empty = CodeConfig::default();
1313 assert!(!empty.has_directories());
1314
1315 let with_skills = CodeConfig::new().add_skill_dir("/tmp/skills");
1316 assert!(with_skills.has_directories());
1317
1318 let with_agents = CodeConfig::new().add_agent_dir("/tmp/agents");
1319 assert!(with_agents.has_directories());
1320 }
1321
1322 #[test]
1323 fn test_config_has_providers() {
1324 let empty = CodeConfig::default();
1325 assert!(!empty.has_providers());
1326
1327 let with_providers = CodeConfig {
1328 providers: vec![ProviderConfig {
1329 name: "test".to_string(),
1330 api_key: None,
1331 base_url: None,
1332 headers: HashMap::new(),
1333 session_id_header: None,
1334 models: vec![],
1335 }],
1336 ..Default::default()
1337 };
1338 assert!(with_providers.has_providers());
1339 }
1340
1341 #[test]
1342 fn test_storage_backend_equality() {
1343 assert_eq!(StorageBackend::Memory, StorageBackend::Memory);
1344 assert_eq!(StorageBackend::File, StorageBackend::File);
1345 assert_ne!(StorageBackend::Memory, StorageBackend::File);
1346 }
1347
1348 #[test]
1349 fn test_storage_backend_serde_custom() {
1350 let custom = StorageBackend::Custom;
1351 let json = serde_json::to_string(&custom).unwrap();
1353 assert_eq!(json, "\"custom\"");
1354
1355 let parsed: StorageBackend = serde_json::from_str("\"custom\"").unwrap();
1357 assert_eq!(parsed, StorageBackend::Custom);
1358 }
1359
1360 #[test]
1361 fn test_model_cost_default() {
1362 let cost = ModelCost::default();
1363 assert_eq!(cost.input, 0.0);
1364 assert_eq!(cost.output, 0.0);
1365 assert_eq!(cost.cache_read, 0.0);
1366 assert_eq!(cost.cache_write, 0.0);
1367 }
1368
1369 #[test]
1370 fn test_model_cost_serialization() {
1371 let cost = ModelCost {
1372 input: 3.0,
1373 output: 15.0,
1374 cache_read: 0.3,
1375 cache_write: 3.75,
1376 };
1377 let json = serde_json::to_string(&cost).unwrap();
1378 assert!(json.contains("\"input\":3"));
1379 assert!(json.contains("\"output\":15"));
1380 }
1381
1382 #[test]
1383 fn test_model_cost_deserialization_missing_fields() {
1384 let json = r#"{"input":3.0}"#;
1385 let cost: ModelCost = serde_json::from_str(json).unwrap();
1386 assert_eq!(cost.input, 3.0);
1387 assert_eq!(cost.output, 0.0);
1388 assert_eq!(cost.cache_read, 0.0);
1389 assert_eq!(cost.cache_write, 0.0);
1390 }
1391
1392 #[test]
1393 fn test_model_limit_default() {
1394 let limit = ModelLimit::default();
1395 assert_eq!(limit.context, 0);
1396 assert_eq!(limit.output, 0);
1397 }
1398
1399 #[test]
1400 fn test_model_limit_serialization() {
1401 let limit = ModelLimit {
1402 context: 200000,
1403 output: 8192,
1404 };
1405 let json = serde_json::to_string(&limit).unwrap();
1406 assert!(json.contains("\"context\":200000"));
1407 assert!(json.contains("\"output\":8192"));
1408 }
1409
1410 #[test]
1411 fn test_model_limit_deserialization_missing_fields() {
1412 let json = r#"{"context":100000}"#;
1413 let limit: ModelLimit = serde_json::from_str(json).unwrap();
1414 assert_eq!(limit.context, 100000);
1415 assert_eq!(limit.output, 0);
1416 }
1417
1418 #[test]
1419 fn test_model_modalities_default() {
1420 let modalities = ModelModalities::default();
1421 assert!(modalities.input.is_empty());
1422 assert!(modalities.output.is_empty());
1423 }
1424
1425 #[test]
1426 fn test_model_modalities_serialization() {
1427 let modalities = ModelModalities {
1428 input: vec!["text".to_string(), "image".to_string()],
1429 output: vec!["text".to_string()],
1430 };
1431 let json = serde_json::to_string(&modalities).unwrap();
1432 assert!(json.contains("\"input\""));
1433 assert!(json.contains("\"text\""));
1434 }
1435
1436 #[test]
1437 fn test_model_modalities_deserialization_missing_fields() {
1438 let json = r#"{"input":["text"]}"#;
1439 let modalities: ModelModalities = serde_json::from_str(json).unwrap();
1440 assert_eq!(modalities.input.len(), 1);
1441 assert!(modalities.output.is_empty());
1442 }
1443
1444 #[test]
1445 fn test_model_config_serialization() {
1446 let config = ModelConfig {
1447 id: "gpt-4o".to_string(),
1448 name: "GPT-4o".to_string(),
1449 family: "gpt-4".to_string(),
1450 api_key: Some("sk-test".to_string()),
1451 base_url: None,
1452 headers: HashMap::new(),
1453 session_id_header: None,
1454 attachment: true,
1455 reasoning: false,
1456 tool_call: true,
1457 temperature: true,
1458 release_date: Some("2024-05-13".to_string()),
1459 modalities: ModelModalities::default(),
1460 cost: ModelCost::default(),
1461 limit: ModelLimit::default(),
1462 };
1463 let json = serde_json::to_string(&config).unwrap();
1464 assert!(json.contains("\"id\":\"gpt-4o\""));
1465 assert!(json.contains("\"attachment\":true"));
1466 }
1467
1468 #[test]
1469 fn test_model_config_deserialization_with_defaults() {
1470 let json = r#"{"id":"test-model"}"#;
1471 let config: ModelConfig = serde_json::from_str(json).unwrap();
1472 assert_eq!(config.id, "test-model");
1473 assert_eq!(config.name, "");
1474 assert_eq!(config.family, "");
1475 assert!(config.api_key.is_none());
1476 assert!(!config.attachment);
1477 assert!(config.tool_call);
1478 assert!(config.temperature);
1479 }
1480
1481 #[test]
1482 fn test_model_config_all_optional_fields() {
1483 let json = r#"{
1484 "id": "claude-sonnet-4",
1485 "name": "Claude Sonnet 4",
1486 "family": "claude-sonnet",
1487 "apiKey": "sk-test",
1488 "baseUrl": "https://api.anthropic.com",
1489 "attachment": true,
1490 "reasoning": true,
1491 "toolCall": false,
1492 "temperature": false,
1493 "releaseDate": "2025-05-14"
1494 }"#;
1495 let config: ModelConfig = serde_json::from_str(json).unwrap();
1496 assert_eq!(config.id, "claude-sonnet-4");
1497 assert_eq!(config.name, "Claude Sonnet 4");
1498 assert_eq!(config.api_key, Some("sk-test".to_string()));
1499 assert_eq!(
1500 config.base_url,
1501 Some("https://api.anthropic.com".to_string())
1502 );
1503 assert!(config.attachment);
1504 assert!(config.reasoning);
1505 assert!(!config.tool_call);
1506 assert!(!config.temperature);
1507 }
1508
1509 #[test]
1510 fn test_provider_config_serialization() {
1511 let provider = ProviderConfig {
1512 name: "anthropic".to_string(),
1513 api_key: Some("sk-test".to_string()),
1514 base_url: Some("https://api.anthropic.com".to_string()),
1515 headers: HashMap::new(),
1516 session_id_header: None,
1517 models: vec![],
1518 };
1519 let json = serde_json::to_string(&provider).unwrap();
1520 assert!(json.contains("\"name\":\"anthropic\""));
1521 assert!(json.contains("\"apiKey\":\"sk-test\""));
1522 }
1523
1524 #[test]
1525 fn test_provider_config_deserialization_missing_optional() {
1526 let json = r#"{"name":"openai"}"#;
1527 let provider: ProviderConfig = serde_json::from_str(json).unwrap();
1528 assert_eq!(provider.name, "openai");
1529 assert!(provider.api_key.is_none());
1530 assert!(provider.base_url.is_none());
1531 assert!(provider.models.is_empty());
1532 }
1533
1534 #[test]
1535 fn test_provider_config_find_model() {
1536 let provider = ProviderConfig {
1537 name: "anthropic".to_string(),
1538 api_key: None,
1539 base_url: None,
1540 headers: HashMap::new(),
1541 session_id_header: None,
1542 models: vec![ModelConfig {
1543 id: "claude-sonnet-4".to_string(),
1544 name: "Claude Sonnet 4".to_string(),
1545 family: "claude-sonnet".to_string(),
1546 api_key: None,
1547 base_url: None,
1548 headers: HashMap::new(),
1549 session_id_header: None,
1550 attachment: false,
1551 reasoning: false,
1552 tool_call: true,
1553 temperature: true,
1554 release_date: None,
1555 modalities: ModelModalities::default(),
1556 cost: ModelCost::default(),
1557 limit: ModelLimit::default(),
1558 }],
1559 };
1560
1561 let found = provider.find_model("claude-sonnet-4");
1562 assert!(found.is_some());
1563 assert_eq!(found.unwrap().id, "claude-sonnet-4");
1564
1565 let not_found = provider.find_model("gpt-4o");
1566 assert!(not_found.is_none());
1567 }
1568
1569 #[test]
1570 fn test_provider_config_get_api_key() {
1571 let provider = ProviderConfig {
1572 name: "anthropic".to_string(),
1573 api_key: Some("provider-key".to_string()),
1574 base_url: None,
1575 headers: HashMap::new(),
1576 session_id_header: None,
1577 models: vec![],
1578 };
1579
1580 let model_with_key = ModelConfig {
1581 id: "test".to_string(),
1582 name: "".to_string(),
1583 family: "".to_string(),
1584 api_key: Some("model-key".to_string()),
1585 base_url: None,
1586 headers: HashMap::new(),
1587 session_id_header: None,
1588 attachment: false,
1589 reasoning: false,
1590 tool_call: true,
1591 temperature: true,
1592 release_date: None,
1593 modalities: ModelModalities::default(),
1594 cost: ModelCost::default(),
1595 limit: ModelLimit::default(),
1596 };
1597
1598 let model_without_key = ModelConfig {
1599 id: "test2".to_string(),
1600 name: "".to_string(),
1601 family: "".to_string(),
1602 api_key: None,
1603 base_url: None,
1604 headers: HashMap::new(),
1605 session_id_header: None,
1606 attachment: false,
1607 reasoning: false,
1608 tool_call: true,
1609 temperature: true,
1610 release_date: None,
1611 modalities: ModelModalities::default(),
1612 cost: ModelCost::default(),
1613 limit: ModelLimit::default(),
1614 };
1615
1616 assert_eq!(provider.get_api_key(&model_with_key), Some("model-key"));
1617 assert_eq!(
1618 provider.get_api_key(&model_without_key),
1619 Some("provider-key")
1620 );
1621 }
1622
1623 #[test]
1624 fn test_provider_config_get_headers_and_session_id_header() {
1625 let mut provider_headers = HashMap::new();
1626 provider_headers.insert("X-Provider".to_string(), "provider".to_string());
1627 provider_headers.insert("X-Shared".to_string(), "provider".to_string());
1628
1629 let mut model_headers = HashMap::new();
1630 model_headers.insert("X-Model".to_string(), "model".to_string());
1631 model_headers.insert("X-Shared".to_string(), "model".to_string());
1632
1633 let provider = ProviderConfig {
1634 name: "openai".to_string(),
1635 api_key: Some("provider-key".to_string()),
1636 base_url: None,
1637 headers: provider_headers,
1638 session_id_header: Some("X-Session-Id".to_string()),
1639 models: vec![],
1640 };
1641
1642 let model = ModelConfig {
1643 id: "gpt-4o".to_string(),
1644 name: "".to_string(),
1645 family: "".to_string(),
1646 api_key: None,
1647 base_url: None,
1648 headers: model_headers,
1649 session_id_header: Some("X-Model-Session".to_string()),
1650 attachment: false,
1651 reasoning: false,
1652 tool_call: true,
1653 temperature: true,
1654 release_date: None,
1655 modalities: ModelModalities::default(),
1656 cost: ModelCost::default(),
1657 limit: ModelLimit::default(),
1658 };
1659
1660 let headers = provider.get_headers(&model);
1661 assert_eq!(headers.get("X-Provider"), Some(&"provider".to_string()));
1662 assert_eq!(headers.get("X-Model"), Some(&"model".to_string()));
1663 assert_eq!(headers.get("X-Shared"), Some(&"model".to_string()));
1664 assert_eq!(
1665 provider.get_session_id_header(&model),
1666 Some("X-Model-Session")
1667 );
1668 }
1669
1670 #[test]
1671 fn test_llm_config_includes_headers_and_runtime_session_header() {
1672 let mut provider_headers = HashMap::new();
1673 provider_headers.insert("X-Provider".to_string(), "provider".to_string());
1674
1675 let config = CodeConfig {
1676 default_model: Some("openai/gpt-4o".to_string()),
1677 providers: vec![ProviderConfig {
1678 name: "openai".to_string(),
1679 api_key: Some("sk-test".to_string()),
1680 base_url: Some("https://api.example.com".to_string()),
1681 headers: provider_headers,
1682 session_id_header: Some("X-Session-Id".to_string()),
1683 models: vec![ModelConfig {
1684 id: "gpt-4o".to_string(),
1685 name: "".to_string(),
1686 family: "".to_string(),
1687 api_key: None,
1688 base_url: None,
1689 headers: HashMap::new(),
1690 session_id_header: None,
1691 attachment: false,
1692 reasoning: false,
1693 tool_call: true,
1694 temperature: true,
1695 release_date: None,
1696 modalities: ModelModalities::default(),
1697 cost: ModelCost::default(),
1698 limit: ModelLimit::default(),
1699 }],
1700 }],
1701 ..Default::default()
1702 };
1703
1704 let llm_config = config.default_llm_config().unwrap();
1705 assert_eq!(
1706 llm_config.headers.get("X-Provider"),
1707 Some(&"provider".to_string())
1708 );
1709 assert_eq!(
1710 llm_config.session_id_header.as_deref(),
1711 Some("X-Session-Id")
1712 );
1713 }
1714
1715 #[test]
1716 fn test_code_config_default_provider_config() {
1717 let config = CodeConfig {
1718 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1719 providers: vec![ProviderConfig {
1720 name: "anthropic".to_string(),
1721 api_key: Some("sk-test".to_string()),
1722 base_url: None,
1723 headers: HashMap::new(),
1724 session_id_header: None,
1725 models: vec![],
1726 }],
1727 ..Default::default()
1728 };
1729
1730 let provider = config.default_provider_config();
1731 assert!(provider.is_some());
1732 assert_eq!(provider.unwrap().name, "anthropic");
1733 }
1734
1735 #[test]
1736 fn test_code_config_default_model_config() {
1737 let config = CodeConfig {
1738 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1739 providers: vec![ProviderConfig {
1740 name: "anthropic".to_string(),
1741 api_key: Some("sk-test".to_string()),
1742 base_url: None,
1743 headers: HashMap::new(),
1744 session_id_header: None,
1745 models: vec![ModelConfig {
1746 id: "claude-sonnet-4".to_string(),
1747 name: "Claude Sonnet 4".to_string(),
1748 family: "claude-sonnet".to_string(),
1749 api_key: None,
1750 base_url: None,
1751 headers: HashMap::new(),
1752 session_id_header: None,
1753 attachment: false,
1754 reasoning: false,
1755 tool_call: true,
1756 temperature: true,
1757 release_date: None,
1758 modalities: ModelModalities::default(),
1759 cost: ModelCost::default(),
1760 limit: ModelLimit::default(),
1761 }],
1762 }],
1763 ..Default::default()
1764 };
1765
1766 let result = config.default_model_config();
1767 assert!(result.is_some());
1768 let (provider, model) = result.unwrap();
1769 assert_eq!(provider.name, "anthropic");
1770 assert_eq!(model.id, "claude-sonnet-4");
1771 }
1772
1773 #[test]
1774 fn test_code_config_default_llm_config() {
1775 let config = CodeConfig {
1776 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1777 providers: vec![ProviderConfig {
1778 name: "anthropic".to_string(),
1779 api_key: Some("sk-test".to_string()),
1780 base_url: Some("https://api.anthropic.com".to_string()),
1781 headers: HashMap::new(),
1782 session_id_header: None,
1783 models: vec![ModelConfig {
1784 id: "claude-sonnet-4".to_string(),
1785 name: "Claude Sonnet 4".to_string(),
1786 family: "claude-sonnet".to_string(),
1787 api_key: None,
1788 base_url: None,
1789 headers: HashMap::new(),
1790 session_id_header: None,
1791 attachment: false,
1792 reasoning: false,
1793 tool_call: true,
1794 temperature: true,
1795 release_date: None,
1796 modalities: ModelModalities::default(),
1797 cost: ModelCost::default(),
1798 limit: ModelLimit::default(),
1799 }],
1800 }],
1801 ..Default::default()
1802 };
1803
1804 let llm_config = config.default_llm_config();
1805 assert!(llm_config.is_some());
1806 }
1807
1808 #[test]
1809 fn test_code_config_list_models() {
1810 let config = CodeConfig {
1811 providers: vec![
1812 ProviderConfig {
1813 name: "anthropic".to_string(),
1814 api_key: None,
1815 base_url: None,
1816 headers: HashMap::new(),
1817 session_id_header: None,
1818 models: vec![ModelConfig {
1819 id: "claude-sonnet-4".to_string(),
1820 name: "".to_string(),
1821 family: "".to_string(),
1822 api_key: None,
1823 base_url: None,
1824 headers: HashMap::new(),
1825 session_id_header: None,
1826 attachment: false,
1827 reasoning: false,
1828 tool_call: true,
1829 temperature: true,
1830 release_date: None,
1831 modalities: ModelModalities::default(),
1832 cost: ModelCost::default(),
1833 limit: ModelLimit::default(),
1834 }],
1835 },
1836 ProviderConfig {
1837 name: "openai".to_string(),
1838 api_key: None,
1839 base_url: None,
1840 headers: HashMap::new(),
1841 session_id_header: None,
1842 models: vec![ModelConfig {
1843 id: "gpt-4o".to_string(),
1844 name: "".to_string(),
1845 family: "".to_string(),
1846 api_key: None,
1847 base_url: None,
1848 headers: HashMap::new(),
1849 session_id_header: None,
1850 attachment: false,
1851 reasoning: false,
1852 tool_call: true,
1853 temperature: true,
1854 release_date: None,
1855 modalities: ModelModalities::default(),
1856 cost: ModelCost::default(),
1857 limit: ModelLimit::default(),
1858 }],
1859 },
1860 ],
1861 ..Default::default()
1862 };
1863
1864 let models = config.list_models();
1865 assert_eq!(models.len(), 2);
1866 }
1867
1868 #[test]
1869 fn test_llm_config_specific_provider_model() {
1870 let model: ModelConfig = serde_json::from_value(serde_json::json!({
1871 "id": "claude-3",
1872 "name": "Claude 3"
1873 }))
1874 .unwrap();
1875
1876 let config = CodeConfig {
1877 providers: vec![ProviderConfig {
1878 name: "anthropic".to_string(),
1879 api_key: Some("sk-test".to_string()),
1880 base_url: None,
1881 headers: HashMap::new(),
1882 session_id_header: None,
1883 models: vec![model],
1884 }],
1885 ..Default::default()
1886 };
1887
1888 let llm = config.llm_config("anthropic", "claude-3");
1889 assert!(llm.is_some());
1890 let llm = llm.unwrap();
1891 assert_eq!(llm.provider, "anthropic");
1892 assert_eq!(llm.model, "claude-3");
1893 }
1894
1895 #[test]
1896 fn test_llm_config_missing_provider() {
1897 let config = CodeConfig::default();
1898 assert!(config.llm_config("nonexistent", "model").is_none());
1899 }
1900
1901 #[test]
1902 fn test_llm_config_missing_model() {
1903 let config = CodeConfig {
1904 providers: vec![ProviderConfig {
1905 name: "anthropic".to_string(),
1906 api_key: Some("sk-test".to_string()),
1907 base_url: None,
1908 headers: HashMap::new(),
1909 session_id_header: None,
1910 models: vec![],
1911 }],
1912 ..Default::default()
1913 };
1914 assert!(config.llm_config("anthropic", "nonexistent").is_none());
1915 }
1916
1917 #[test]
1918 fn test_document_parser_config_normalizes_nested_ocr_values() {
1919 let config = DocumentParserConfig {
1920 enabled: true,
1921 max_file_size_mb: 0,
1922 cache: Some(DocumentCacheConfig {
1923 enabled: true,
1924 directory: Some(PathBuf::from("/tmp/cache")),
1925 }),
1926 ocr: Some(DocumentOcrConfig {
1927 enabled: true,
1928 model: Some("openai/gpt-4.1-mini".to_string()),
1929 prompt: None,
1930 max_images: 0,
1931 dpi: 10,
1932 provider: None,
1933 base_url: None,
1934 api_key: None,
1935 }),
1936 }
1937 .normalized();
1938
1939 assert_eq!(config.max_file_size_mb, 1);
1940 let cache = config.cache.unwrap();
1941 assert!(cache.enabled);
1942 assert_eq!(cache.directory, Some(PathBuf::from("/tmp/cache")));
1943 let ocr = config.ocr.unwrap();
1944 assert_eq!(ocr.max_images, 1);
1945 assert_eq!(ocr.dpi, 72);
1946 }
1947}