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(
282 default,
283 alias = "agentic_search",
284 skip_serializing_if = "Option::is_none"
285 )]
286 pub agentic_search: Option<AgenticSearchConfig>,
287
288 #[serde(
290 default,
291 alias = "agentic_parse",
292 skip_serializing_if = "Option::is_none"
293 )]
294 pub agentic_parse: Option<AgenticParseConfig>,
295
296 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub document_parser: Option<DocumentParserConfig>,
299
300 #[serde(default, alias = "mcp_servers")]
302 pub mcp_servers: Vec<crate::mcp::McpServerConfig>,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct SearchConfig {
309 #[serde(default = "default_search_timeout")]
311 pub timeout: u64,
312
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub health: Option<SearchHealthConfig>,
316
317 #[serde(default, rename = "engine")]
319 pub engines: std::collections::HashMap<String, SearchEngineConfig>,
320
321 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub headless: Option<HeadlessConfig>,
325}
326
327#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(rename_all = "lowercase")]
330pub enum BrowserBackend {
331 #[default]
333 Chrome,
334 Lightpanda,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
341#[serde(rename_all = "camelCase")]
342pub struct HeadlessConfig {
343 #[serde(default)]
345 pub backend: BrowserBackend,
346
347 #[serde(default = "default_headless_max_tabs")]
349 pub max_tabs: usize,
350
351 #[serde(
353 default,
354 alias = "chromePath",
355 alias = "lightpandaPath",
356 alias = "obscuraPath",
357 alias = "playwrightPath",
358 skip_serializing_if = "Option::is_none"
359 )]
360 pub browser_path: Option<String>,
361
362 #[serde(default, skip_serializing_if = "Vec::is_empty")]
364 pub launch_args: Vec<String>,
365
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub proxy_url: Option<String>,
369}
370
371impl BrowserBackend {
372 pub fn is_lightpanda(self) -> bool {
373 matches!(self, Self::Lightpanda)
374 }
375}
376
377impl Default for HeadlessConfig {
378 fn default() -> Self {
379 Self {
380 backend: BrowserBackend::Chrome,
381 max_tabs: 4,
382 browser_path: None,
383 launch_args: Vec::new(),
384 proxy_url: None,
385 }
386 }
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
391#[serde(rename_all = "camelCase")]
392pub struct AgenticSearchConfig {
393 #[serde(default = "default_enabled")]
395 pub enabled: bool,
396
397 #[serde(default = "default_agentic_search_mode")]
399 pub default_mode: String,
400
401 #[serde(default = "default_agentic_search_max_results")]
403 pub max_results: usize,
404
405 #[serde(default = "default_agentic_search_context_lines")]
407 pub context_lines: usize,
408}
409
410impl Default for AgenticSearchConfig {
411 fn default() -> Self {
412 Self {
413 enabled: true,
414 default_mode: default_agentic_search_mode(),
415 max_results: default_agentic_search_max_results(),
416 context_lines: default_agentic_search_context_lines(),
417 }
418 }
419}
420
421impl AgenticSearchConfig {
422 pub fn normalized(&self) -> Self {
423 let default_mode = match self.default_mode.to_ascii_lowercase().as_str() {
424 "fast" => "fast".to_string(),
425 "deep" => "deep".to_string(),
426 "filename_only" | "filename" => "filename_only".to_string(),
427 _ => default_agentic_search_mode(),
428 };
429
430 Self {
431 enabled: self.enabled,
432 default_mode,
433 max_results: self.max_results.clamp(1, 100),
434 context_lines: self.context_lines.min(20),
435 }
436 }
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize)]
441#[serde(rename_all = "camelCase")]
442pub struct AgenticParseConfig {
443 #[serde(default = "default_enabled")]
445 pub enabled: bool,
446
447 #[serde(default = "default_agentic_parse_strategy")]
449 pub default_strategy: String,
450
451 #[serde(default = "default_agentic_parse_max_chars")]
453 pub max_chars: usize,
454}
455
456impl Default for AgenticParseConfig {
457 fn default() -> Self {
458 Self {
459 enabled: true,
460 default_strategy: default_agentic_parse_strategy(),
461 max_chars: default_agentic_parse_max_chars(),
462 }
463 }
464}
465
466impl AgenticParseConfig {
467 pub fn normalized(&self) -> Self {
468 let default_strategy = match self.default_strategy.to_ascii_lowercase().as_str() {
469 "auto" => "auto".to_string(),
470 "structured" => "structured".to_string(),
471 "narrative" => "narrative".to_string(),
472 "tabular" => "tabular".to_string(),
473 "code" => "code".to_string(),
474 _ => default_agentic_parse_strategy(),
475 };
476
477 Self {
478 enabled: self.enabled,
479 default_strategy,
480 max_chars: self.max_chars.clamp(500, 200_000),
481 }
482 }
483}
484
485#[derive(Debug, Clone, Serialize, Deserialize)]
487#[serde(rename_all = "camelCase")]
488pub struct DocumentParserConfig {
489 #[serde(default = "default_enabled")]
491 pub enabled: bool,
492
493 #[serde(default = "default_document_parser_max_file_size_mb")]
495 pub max_file_size_mb: u64,
496
497 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub ocr: Option<DocumentOcrConfig>,
504
505 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub cache: Option<DocumentCacheConfig>,
508}
509
510impl Default for DocumentParserConfig {
511 fn default() -> Self {
512 Self {
513 enabled: true,
514 max_file_size_mb: default_document_parser_max_file_size_mb(),
515 ocr: None,
516 cache: Some(DocumentCacheConfig::default()),
517 }
518 }
519}
520
521impl DocumentParserConfig {
522 pub fn normalized(&self) -> Self {
523 Self {
524 enabled: self.enabled,
525 max_file_size_mb: self.max_file_size_mb.clamp(1, 1024),
526 ocr: self.ocr.as_ref().map(DocumentOcrConfig::normalized),
527 cache: self.cache.as_ref().map(DocumentCacheConfig::normalized),
528 }
529 }
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize)]
533#[serde(rename_all = "camelCase")]
534pub struct DocumentCacheConfig {
535 #[serde(default = "default_enabled")]
536 pub enabled: bool,
537
538 #[serde(default, skip_serializing_if = "Option::is_none")]
539 pub directory: Option<PathBuf>,
540}
541
542impl Default for DocumentCacheConfig {
543 fn default() -> Self {
544 Self {
545 enabled: true,
546 directory: None,
547 }
548 }
549}
550
551impl DocumentCacheConfig {
552 pub fn normalized(&self) -> Self {
553 Self {
554 enabled: self.enabled,
555 directory: self.directory.clone(),
556 }
557 }
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize)]
562#[serde(rename_all = "camelCase")]
563pub struct DocumentOcrConfig {
564 #[serde(default = "default_enabled")]
566 pub enabled: bool,
567
568 #[serde(default, skip_serializing_if = "Option::is_none")]
570 pub model: Option<String>,
571
572 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub prompt: Option<String>,
575
576 #[serde(default = "default_document_ocr_max_images")]
578 pub max_images: usize,
579
580 #[serde(default = "default_document_ocr_dpi")]
582 pub dpi: u32,
583
584 #[serde(default, skip_serializing_if = "Option::is_none")]
588 pub provider: Option<String>,
589
590 #[serde(default, skip_serializing_if = "Option::is_none")]
592 pub base_url: Option<String>,
593
594 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub api_key: Option<String>,
597}
598
599impl Default for DocumentOcrConfig {
600 fn default() -> Self {
601 Self {
602 enabled: false,
603 model: None,
604 prompt: None,
605 max_images: default_document_ocr_max_images(),
606 dpi: default_document_ocr_dpi(),
607 provider: None,
608 base_url: None,
609 api_key: None,
610 }
611 }
612}
613
614fn acl_attr<'a>(block: &'a a3s_acl::Block, keys: &[&str]) -> Option<&'a a3s_acl::Value> {
615 keys.iter().find_map(|key| block.attributes.get(*key))
616}
617
618fn acl_string(value: &a3s_acl::Value) -> Option<String> {
619 match value {
620 a3s_acl::Value::String(s) => Some(s.clone()),
621 a3s_acl::Value::Call(name, args) if name == "env" => {
622 let var_name = args.first().and_then(acl_string)?;
623 std::env::var(var_name).ok()
624 }
625 _ => None,
626 }
627}
628
629fn acl_string_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<String> {
630 acl_attr(block, keys).and_then(acl_string)
631}
632
633fn acl_label_or_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<String> {
634 block
635 .labels
636 .first()
637 .cloned()
638 .or_else(|| acl_string_attr(block, keys))
639}
640
641fn acl_bool_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<bool> {
642 match acl_attr(block, keys) {
643 Some(a3s_acl::Value::Bool(value)) => Some(*value),
644 _ => None,
645 }
646}
647
648fn acl_usize_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<usize> {
649 match acl_attr(block, keys) {
650 Some(a3s_acl::Value::Number(value)) if *value >= 0.0 => Some(*value as usize),
651 _ => None,
652 }
653}
654
655fn acl_path_list_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<Vec<PathBuf>> {
656 let value = acl_attr(block, keys)?;
657 match value {
658 a3s_acl::Value::List(items) => Some(
659 items
660 .iter()
661 .filter_map(acl_string)
662 .map(PathBuf::from)
663 .collect(),
664 ),
665 _ => acl_string(value).map(|s| vec![PathBuf::from(s)]),
666 }
667}
668
669impl DocumentOcrConfig {
670 pub fn normalized(&self) -> Self {
671 Self {
672 enabled: self.enabled,
673 model: self.model.clone(),
674 prompt: self.prompt.clone(),
675 max_images: self.max_images.clamp(1, 64),
676 dpi: self.dpi.clamp(72, 600),
677 provider: self.provider.clone(),
678 base_url: self.base_url.clone(),
679 api_key: self.api_key.clone(),
680 }
681 }
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize)]
686#[serde(rename_all = "camelCase")]
687pub struct SearchHealthConfig {
688 #[serde(default = "default_max_failures")]
690 pub max_failures: u32,
691
692 #[serde(default = "default_suspend_seconds")]
694 pub suspend_seconds: u64,
695}
696
697#[derive(Debug, Clone, Serialize, Deserialize)]
699#[serde(rename_all = "camelCase")]
700pub struct SearchEngineConfig {
701 #[serde(default = "default_enabled")]
703 pub enabled: bool,
704
705 #[serde(default = "default_weight")]
707 pub weight: f64,
708
709 #[serde(skip_serializing_if = "Option::is_none")]
711 pub timeout: Option<u64>,
712}
713
714fn default_search_timeout() -> u64 {
715 10
716}
717
718fn default_headless_max_tabs() -> usize {
719 4
720}
721
722fn default_max_failures() -> u32 {
723 3
724}
725
726fn default_suspend_seconds() -> u64 {
727 60
728}
729
730fn default_enabled() -> bool {
731 true
732}
733
734fn default_weight() -> f64 {
735 1.0
736}
737
738fn default_agentic_search_mode() -> String {
739 "fast".to_string()
740}
741
742fn default_agentic_search_max_results() -> usize {
743 10
744}
745
746fn default_agentic_search_context_lines() -> usize {
747 2
748}
749
750fn default_agentic_parse_strategy() -> String {
751 "auto".to_string()
752}
753
754fn default_agentic_parse_max_chars() -> usize {
755 8000
756}
757
758fn default_document_parser_max_file_size_mb() -> u64 {
759 50
760}
761
762fn default_document_ocr_max_images() -> usize {
763 8
764}
765
766fn default_document_ocr_dpi() -> u32 {
767 144
768}
769
770impl CodeConfig {
771 pub fn new() -> Self {
773 Self::default()
774 }
775
776 pub fn from_file(path: &Path) -> Result<Self> {
781 let content = std::fs::read_to_string(path).map_err(|e| {
782 CodeError::Config(format!(
783 "Failed to read config file {}: {}",
784 path.display(),
785 e
786 ))
787 })?;
788
789 Self::from_acl(&content).map_err(|e| {
790 CodeError::Config(format!(
791 "Failed to parse ACL config {}: {}",
792 path.display(),
793 e
794 ))
795 })
796 }
797
798 pub fn from_acl(content: &str) -> Result<Self> {
803 use a3s_acl::parse_acl;
804
805 let doc = parse_acl(content)
806 .map_err(|e| CodeError::Config(format!("Failed to parse ACL: {}", e)))?;
807
808 let mut config = Self::default();
809
810 for block in doc.blocks {
811 match block.name.as_str() {
812 "default_model" => {
813 if let Some(default_model) = acl_label_or_attr(&block, &["default_model"]) {
815 config.default_model = Some(default_model);
816 }
817 }
818 "storage_backend" => {
819 if let Some(backend) = acl_string_attr(&block, &["storage_backend"]) {
820 config.storage_backend = match backend.to_ascii_lowercase().as_str() {
821 "memory" => StorageBackend::Memory,
822 "custom" => StorageBackend::Custom,
823 _ => StorageBackend::File,
824 };
825 }
826 }
827 "sessions_dir" => {
828 if let Some(path) = acl_string_attr(&block, &["sessions_dir"]) {
829 config.sessions_dir = Some(PathBuf::from(path));
830 }
831 }
832 "storage_url" => {
833 if let Some(storage_url) = acl_string_attr(&block, &["storage_url"]) {
834 config.storage_url = Some(storage_url);
835 }
836 }
837 "skill_dirs" | "skills" => {
838 if let Some(paths) = acl_path_list_attr(&block, &["skill_dirs", "skills"]) {
839 config.skill_dirs = paths;
840 }
841 }
842 "agent_dirs" => {
843 if let Some(paths) = acl_path_list_attr(&block, &["agent_dirs"]) {
844 config.agent_dirs = paths;
845 }
846 }
847 "max_tool_rounds" => {
848 if let Some(max_tool_rounds) = acl_usize_attr(&block, &["max_tool_rounds"]) {
849 config.max_tool_rounds = Some(max_tool_rounds);
850 }
851 }
852 "thinking_budget" => {
853 if let Some(thinking_budget) = acl_usize_attr(&block, &["thinking_budget"]) {
854 config.thinking_budget = Some(thinking_budget);
855 }
856 }
857 "providers" => {
858 let provider_name = block.labels.first().cloned().ok_or_else(|| {
859 CodeError::Config(
860 "providers block requires a label (e.g., providers \"openai\" { ... })"
861 .into(),
862 )
863 })?;
864
865 let mut provider = ProviderConfig {
866 name: provider_name.clone(),
867 api_key: None,
868 base_url: None,
869 headers: HashMap::new(),
870 session_id_header: None,
871 models: Vec::new(),
872 };
873
874 for (key, value) in &block.attributes {
875 match key.as_str() {
876 "apiKey" | "api_key" => {
877 if let Some(api_key) = acl_string(value) {
878 provider.api_key = Some(api_key);
879 }
880 }
881 "baseUrl" | "base_url" => {
882 if let Some(base_url) = acl_string(value) {
883 provider.base_url = Some(base_url);
884 }
885 }
886 "sessionIdHeader" | "session_id_header" => {
887 if let Some(header) = acl_string(value) {
888 provider.session_id_header = Some(header);
889 }
890 }
891 _ => {}
892 }
893 }
894
895 for model_block in &block.blocks {
897 if model_block.name == "models" {
898 let model_name =
899 model_block.labels.first().cloned().ok_or_else(|| {
900 CodeError::Config(
901 "models block requires a label (e.g., models \"gpt-4\" { ... })"
902 .into(),
903 )
904 })?;
905
906 let mut model = ModelConfig {
907 id: model_name.clone(),
908 name: model_name.clone(),
909 family: String::new(),
910 api_key: None,
911 base_url: None,
912 headers: HashMap::new(),
913 session_id_header: None,
914 attachment: false,
915 reasoning: false,
916 tool_call: true,
917 temperature: true,
918 release_date: None,
919 modalities: ModelModalities::default(),
920 cost: ModelCost::default(),
921 limit: ModelLimit::default(),
922 };
923
924 for (key, value) in &model_block.attributes {
925 match key.as_str() {
926 "name" => {
927 if let Some(s) = acl_string(value) {
928 model.name = s;
929 }
930 }
931 "family" => {
932 if let Some(s) = acl_string(value) {
933 model.family = s;
934 }
935 }
936 "apiKey" | "api_key" => {
937 if let Some(api_key) = acl_string(value) {
938 model.api_key = Some(api_key);
939 }
940 }
941 "baseUrl" | "base_url" => {
942 if let Some(base_url) = acl_string(value) {
943 model.base_url = Some(base_url);
944 }
945 }
946 "sessionIdHeader" | "session_id_header" => {
947 if let Some(header) = acl_string(value) {
948 model.session_id_header = Some(header);
949 }
950 }
951 "attachment" => {
952 model.attachment =
953 acl_bool_attr(model_block, &["attachment"])
954 .unwrap_or(model.attachment);
955 }
956 "reasoning" => {
957 model.reasoning =
958 acl_bool_attr(model_block, &["reasoning"])
959 .unwrap_or(model.reasoning);
960 }
961 "toolCall" | "tool_call" => {
962 model.tool_call =
963 acl_bool_attr(model_block, &["toolCall", "tool_call"])
964 .unwrap_or(model.tool_call);
965 }
966 "temperature" => {
967 model.temperature =
968 acl_bool_attr(model_block, &["temperature"])
969 .unwrap_or(model.temperature);
970 }
971 "releaseDate" | "release_date" => {
972 if let Some(release_date) = acl_string(value) {
973 model.release_date = Some(release_date);
974 }
975 }
976 _ => {}
977 }
978 }
979
980 provider.models.push(model);
981 }
982 }
983
984 config.providers.push(provider);
985 }
986 _ => {
987 }
990 }
991 }
992
993 Ok(config)
994 }
995
996 pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> {
998 self.providers.iter().find(|p| p.name == name)
999 }
1000
1001 pub fn default_provider_config(&self) -> Option<&ProviderConfig> {
1003 let default = self.default_model.as_ref()?;
1004 let (provider_name, _) = default.split_once('/')?;
1005 self.find_provider(provider_name)
1006 }
1007
1008 pub fn default_model_config(&self) -> Option<(&ProviderConfig, &ModelConfig)> {
1010 let default = self.default_model.as_ref()?;
1011 let (provider_name, model_id) = default.split_once('/')?;
1012 let provider = self.find_provider(provider_name)?;
1013 let model = provider.find_model(model_id)?;
1014 Some((provider, model))
1015 }
1016
1017 pub fn default_llm_config(&self) -> Option<LlmConfig> {
1021 let (provider, model) = self.default_model_config()?;
1022 let api_key = provider.get_api_key(model)?;
1023 let base_url = provider.get_base_url(model);
1024 let headers = provider.get_headers(model);
1025 let session_id_header = provider.get_session_id_header(model);
1026
1027 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
1028 if let Some(url) = base_url {
1029 config = config.with_base_url(url);
1030 }
1031 if !headers.is_empty() {
1032 config = config.with_headers(headers);
1033 }
1034 if let Some(header_name) = session_id_header {
1035 config = config.with_session_id_header(header_name);
1036 }
1037 config = apply_model_caps(config, model, self.thinking_budget);
1038 Some(config)
1039 }
1040
1041 pub fn llm_config(&self, provider_name: &str, model_id: &str) -> Option<LlmConfig> {
1045 let provider = self.find_provider(provider_name)?;
1046 let model = provider.find_model(model_id)?;
1047 let api_key = provider.get_api_key(model)?;
1048 let base_url = provider.get_base_url(model);
1049 let headers = provider.get_headers(model);
1050 let session_id_header = provider.get_session_id_header(model);
1051
1052 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
1053 if let Some(url) = base_url {
1054 config = config.with_base_url(url);
1055 }
1056 if !headers.is_empty() {
1057 config = config.with_headers(headers);
1058 }
1059 if let Some(header_name) = session_id_header {
1060 config = config.with_session_id_header(header_name);
1061 }
1062 config = apply_model_caps(config, model, self.thinking_budget);
1063 Some(config)
1064 }
1065
1066 pub fn list_models(&self) -> Vec<(&ProviderConfig, &ModelConfig)> {
1068 self.providers
1069 .iter()
1070 .flat_map(|p| p.models.iter().map(move |m| (p, m)))
1071 .collect()
1072 }
1073
1074 pub fn add_skill_dir(mut self, dir: impl Into<PathBuf>) -> Self {
1076 self.skill_dirs.push(dir.into());
1077 self
1078 }
1079
1080 pub fn add_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
1082 self.agent_dirs.push(dir.into());
1083 self
1084 }
1085
1086 pub fn has_directories(&self) -> bool {
1088 !self.skill_dirs.is_empty() || !self.agent_dirs.is_empty()
1089 }
1090
1091 pub fn has_providers(&self) -> bool {
1093 !self.providers.is_empty()
1094 }
1095}
1096
1097#[cfg(test)]
1102mod tests {
1103 use super::*;
1104
1105 #[test]
1106 fn test_config_default() {
1107 let config = CodeConfig::default();
1108 assert!(config.skill_dirs.is_empty());
1109 assert!(config.agent_dirs.is_empty());
1110 assert!(config.providers.is_empty());
1111 assert!(config.default_model.is_none());
1112 assert_eq!(config.storage_backend, StorageBackend::File);
1113 assert!(config.sessions_dir.is_none());
1114 }
1115
1116 #[test]
1117 fn test_storage_backend_default() {
1118 let backend = StorageBackend::default();
1119 assert_eq!(backend, StorageBackend::File);
1120 }
1121
1122 #[test]
1123 fn test_storage_backend_serde() {
1124 let memory = StorageBackend::Memory;
1126 let json = serde_json::to_string(&memory).unwrap();
1127 assert_eq!(json, "\"memory\"");
1128
1129 let file = StorageBackend::File;
1130 let json = serde_json::to_string(&file).unwrap();
1131 assert_eq!(json, "\"file\"");
1132
1133 let memory: StorageBackend = serde_json::from_str("\"memory\"").unwrap();
1135 assert_eq!(memory, StorageBackend::Memory);
1136
1137 let file: StorageBackend = serde_json::from_str("\"file\"").unwrap();
1138 assert_eq!(file, StorageBackend::File);
1139 }
1140
1141 #[test]
1142 fn test_config_with_storage_backend() {
1143 let temp_dir = tempfile::tempdir().unwrap();
1144 let config_path = temp_dir.path().join("config.acl");
1145
1146 std::fs::write(
1147 &config_path,
1148 r#"
1149 storage_backend = "memory"
1150 sessions_dir = "/tmp/sessions"
1151 "#,
1152 )
1153 .unwrap();
1154
1155 let config = CodeConfig::from_file(&config_path).unwrap();
1156 assert_eq!(config.storage_backend, StorageBackend::Memory);
1157 assert_eq!(config.sessions_dir, Some(PathBuf::from("/tmp/sessions")));
1158 }
1159
1160 #[test]
1161 fn test_config_rejects_unlabeled_provider_blocks() {
1162 std::env::set_var("A3S_CODE_TEST_API_KEY", "sk-test");
1163 let err = CodeConfig::from_acl(
1164 r#"
1165 default_model = "openai/gpt-4.1"
1166 max_tool_rounds = 12
1167 skill_dirs = ["./skills"]
1168
1169 providers {
1170 name = "openai"
1171 api_key = env("A3S_CODE_TEST_API_KEY")
1172 base_url = "https://api.openai.com/v1"
1173
1174 models "gpt-4.1" {
1175 name = "GPT 4.1"
1176 reasoning = true
1177 tool_call = false
1178 }
1179 }
1180 "#,
1181 )
1182 .unwrap_err();
1183
1184 assert!(err.to_string().contains("providers block requires a label"));
1185 }
1186
1187 #[test]
1188 fn test_config_supports_acl_style_provider_labels() {
1189 let config = CodeConfig::from_acl(
1190 r#"
1191 default_model = "openai/gpt-4.1"
1192
1193 providers "openai" {
1194 apiKey = "sk-test"
1195 baseUrl = "https://api.openai.com/v1"
1196
1197 models "gpt-4.1" {
1198 name = "GPT 4.1"
1199 toolCall = true
1200 }
1201 }
1202 "#,
1203 )
1204 .unwrap();
1205
1206 assert_eq!(config.default_model.as_deref(), Some("openai/gpt-4.1"));
1207 assert_eq!(config.providers[0].name, "openai");
1208 assert_eq!(config.providers[0].api_key.as_deref(), Some("sk-test"));
1209 assert_eq!(config.providers[0].models[0].id, "gpt-4.1");
1210 assert_eq!(config.providers[0].models[0].name, "GPT 4.1");
1211 assert!(config.providers[0].models[0].tool_call);
1212 }
1213
1214 #[test]
1215 fn test_config_builder() {
1216 let config = CodeConfig::new()
1217 .add_skill_dir("/tmp/skills")
1218 .add_agent_dir("/tmp/agents");
1219
1220 assert_eq!(config.skill_dirs.len(), 1);
1221 assert_eq!(config.agent_dirs.len(), 1);
1222 }
1223
1224 #[test]
1225 fn test_find_provider() {
1226 let config = CodeConfig {
1227 providers: vec![
1228 ProviderConfig {
1229 name: "anthropic".to_string(),
1230 api_key: Some("key1".to_string()),
1231 base_url: None,
1232 headers: HashMap::new(),
1233 session_id_header: None,
1234 models: vec![],
1235 },
1236 ProviderConfig {
1237 name: "openai".to_string(),
1238 api_key: Some("key2".to_string()),
1239 base_url: None,
1240 headers: HashMap::new(),
1241 session_id_header: None,
1242 models: vec![],
1243 },
1244 ],
1245 ..Default::default()
1246 };
1247
1248 assert!(config.find_provider("anthropic").is_some());
1249 assert!(config.find_provider("openai").is_some());
1250 assert!(config.find_provider("unknown").is_none());
1251 }
1252
1253 #[test]
1254 fn test_default_llm_config() {
1255 let config = CodeConfig {
1256 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1257 providers: vec![ProviderConfig {
1258 name: "anthropic".to_string(),
1259 api_key: Some("test-api-key".to_string()),
1260 base_url: Some("https://api.anthropic.com".to_string()),
1261 headers: HashMap::new(),
1262 session_id_header: None,
1263 models: vec![ModelConfig {
1264 id: "claude-sonnet-4".to_string(),
1265 name: "Claude Sonnet 4".to_string(),
1266 family: "claude-sonnet".to_string(),
1267 api_key: None,
1268 base_url: None,
1269 headers: HashMap::new(),
1270 session_id_header: None,
1271 attachment: false,
1272 reasoning: false,
1273 tool_call: true,
1274 temperature: true,
1275 release_date: None,
1276 modalities: ModelModalities::default(),
1277 cost: ModelCost::default(),
1278 limit: ModelLimit::default(),
1279 }],
1280 }],
1281 ..Default::default()
1282 };
1283
1284 let llm_config = config.default_llm_config().unwrap();
1285 assert_eq!(llm_config.provider, "anthropic");
1286 assert_eq!(llm_config.model, "claude-sonnet-4");
1287 assert_eq!(llm_config.api_key.expose(), "test-api-key");
1288 assert_eq!(
1289 llm_config.base_url,
1290 Some("https://api.anthropic.com".to_string())
1291 );
1292 }
1293
1294 #[test]
1295 fn test_model_api_key_override() {
1296 let provider = ProviderConfig {
1297 name: "openai".to_string(),
1298 api_key: Some("provider-key".to_string()),
1299 base_url: Some("https://api.openai.com".to_string()),
1300 headers: HashMap::new(),
1301 session_id_header: None,
1302 models: vec![
1303 ModelConfig {
1304 id: "gpt-4".to_string(),
1305 name: "GPT-4".to_string(),
1306 family: "gpt".to_string(),
1307 api_key: None, base_url: None,
1309 headers: HashMap::new(),
1310 session_id_header: None,
1311 attachment: false,
1312 reasoning: false,
1313 tool_call: true,
1314 temperature: true,
1315 release_date: None,
1316 modalities: ModelModalities::default(),
1317 cost: ModelCost::default(),
1318 limit: ModelLimit::default(),
1319 },
1320 ModelConfig {
1321 id: "custom-model".to_string(),
1322 name: "Custom Model".to_string(),
1323 family: "custom".to_string(),
1324 api_key: Some("model-specific-key".to_string()), base_url: Some("https://custom.api.com".to_string()), headers: HashMap::new(),
1327 session_id_header: None,
1328 attachment: false,
1329 reasoning: false,
1330 tool_call: true,
1331 temperature: true,
1332 release_date: None,
1333 modalities: ModelModalities::default(),
1334 cost: ModelCost::default(),
1335 limit: ModelLimit::default(),
1336 },
1337 ],
1338 };
1339
1340 let model1 = provider.find_model("gpt-4").unwrap();
1342 assert_eq!(provider.get_api_key(model1), Some("provider-key"));
1343 assert_eq!(
1344 provider.get_base_url(model1),
1345 Some("https://api.openai.com")
1346 );
1347
1348 let model2 = provider.find_model("custom-model").unwrap();
1350 assert_eq!(provider.get_api_key(model2), Some("model-specific-key"));
1351 assert_eq!(
1352 provider.get_base_url(model2),
1353 Some("https://custom.api.com")
1354 );
1355 }
1356
1357 #[test]
1358 fn test_list_models() {
1359 let config = CodeConfig {
1360 providers: vec![
1361 ProviderConfig {
1362 name: "anthropic".to_string(),
1363 api_key: None,
1364 base_url: None,
1365 headers: HashMap::new(),
1366 session_id_header: None,
1367 models: vec![
1368 ModelConfig {
1369 id: "claude-1".to_string(),
1370 name: "Claude 1".to_string(),
1371 family: "claude".to_string(),
1372 api_key: None,
1373 base_url: None,
1374 headers: HashMap::new(),
1375 session_id_header: None,
1376 attachment: false,
1377 reasoning: false,
1378 tool_call: true,
1379 temperature: true,
1380 release_date: None,
1381 modalities: ModelModalities::default(),
1382 cost: ModelCost::default(),
1383 limit: ModelLimit::default(),
1384 },
1385 ModelConfig {
1386 id: "claude-2".to_string(),
1387 name: "Claude 2".to_string(),
1388 family: "claude".to_string(),
1389 api_key: None,
1390 base_url: None,
1391 headers: HashMap::new(),
1392 session_id_header: None,
1393 attachment: false,
1394 reasoning: false,
1395 tool_call: true,
1396 temperature: true,
1397 release_date: None,
1398 modalities: ModelModalities::default(),
1399 cost: ModelCost::default(),
1400 limit: ModelLimit::default(),
1401 },
1402 ],
1403 },
1404 ProviderConfig {
1405 name: "openai".to_string(),
1406 api_key: None,
1407 base_url: None,
1408 headers: HashMap::new(),
1409 session_id_header: None,
1410 models: vec![ModelConfig {
1411 id: "gpt-4".to_string(),
1412 name: "GPT-4".to_string(),
1413 family: "gpt".to_string(),
1414 api_key: None,
1415 base_url: None,
1416 headers: HashMap::new(),
1417 session_id_header: None,
1418 attachment: false,
1419 reasoning: false,
1420 tool_call: true,
1421 temperature: true,
1422 release_date: None,
1423 modalities: ModelModalities::default(),
1424 cost: ModelCost::default(),
1425 limit: ModelLimit::default(),
1426 }],
1427 },
1428 ],
1429 ..Default::default()
1430 };
1431
1432 let models = config.list_models();
1433 assert_eq!(models.len(), 3);
1434 }
1435
1436 #[test]
1437 fn test_config_from_file_not_found() {
1438 let result = CodeConfig::from_file(Path::new("/nonexistent/config.json"));
1439 assert!(result.is_err());
1440 }
1441
1442 #[test]
1443 fn test_config_has_directories() {
1444 let empty = CodeConfig::default();
1445 assert!(!empty.has_directories());
1446
1447 let with_skills = CodeConfig::new().add_skill_dir("/tmp/skills");
1448 assert!(with_skills.has_directories());
1449
1450 let with_agents = CodeConfig::new().add_agent_dir("/tmp/agents");
1451 assert!(with_agents.has_directories());
1452 }
1453
1454 #[test]
1455 fn test_config_has_providers() {
1456 let empty = CodeConfig::default();
1457 assert!(!empty.has_providers());
1458
1459 let with_providers = CodeConfig {
1460 providers: vec![ProviderConfig {
1461 name: "test".to_string(),
1462 api_key: None,
1463 base_url: None,
1464 headers: HashMap::new(),
1465 session_id_header: None,
1466 models: vec![],
1467 }],
1468 ..Default::default()
1469 };
1470 assert!(with_providers.has_providers());
1471 }
1472
1473 #[test]
1474 fn test_storage_backend_equality() {
1475 assert_eq!(StorageBackend::Memory, StorageBackend::Memory);
1476 assert_eq!(StorageBackend::File, StorageBackend::File);
1477 assert_ne!(StorageBackend::Memory, StorageBackend::File);
1478 }
1479
1480 #[test]
1481 fn test_storage_backend_serde_custom() {
1482 let custom = StorageBackend::Custom;
1483 let json = serde_json::to_string(&custom).unwrap();
1485 assert_eq!(json, "\"custom\"");
1486
1487 let parsed: StorageBackend = serde_json::from_str("\"custom\"").unwrap();
1489 assert_eq!(parsed, StorageBackend::Custom);
1490 }
1491
1492 #[test]
1493 fn test_model_cost_default() {
1494 let cost = ModelCost::default();
1495 assert_eq!(cost.input, 0.0);
1496 assert_eq!(cost.output, 0.0);
1497 assert_eq!(cost.cache_read, 0.0);
1498 assert_eq!(cost.cache_write, 0.0);
1499 }
1500
1501 #[test]
1502 fn test_model_cost_serialization() {
1503 let cost = ModelCost {
1504 input: 3.0,
1505 output: 15.0,
1506 cache_read: 0.3,
1507 cache_write: 3.75,
1508 };
1509 let json = serde_json::to_string(&cost).unwrap();
1510 assert!(json.contains("\"input\":3"));
1511 assert!(json.contains("\"output\":15"));
1512 }
1513
1514 #[test]
1515 fn test_model_cost_deserialization_missing_fields() {
1516 let json = r#"{"input":3.0}"#;
1517 let cost: ModelCost = serde_json::from_str(json).unwrap();
1518 assert_eq!(cost.input, 3.0);
1519 assert_eq!(cost.output, 0.0);
1520 assert_eq!(cost.cache_read, 0.0);
1521 assert_eq!(cost.cache_write, 0.0);
1522 }
1523
1524 #[test]
1525 fn test_model_limit_default() {
1526 let limit = ModelLimit::default();
1527 assert_eq!(limit.context, 0);
1528 assert_eq!(limit.output, 0);
1529 }
1530
1531 #[test]
1532 fn test_model_limit_serialization() {
1533 let limit = ModelLimit {
1534 context: 200000,
1535 output: 8192,
1536 };
1537 let json = serde_json::to_string(&limit).unwrap();
1538 assert!(json.contains("\"context\":200000"));
1539 assert!(json.contains("\"output\":8192"));
1540 }
1541
1542 #[test]
1543 fn test_model_limit_deserialization_missing_fields() {
1544 let json = r#"{"context":100000}"#;
1545 let limit: ModelLimit = serde_json::from_str(json).unwrap();
1546 assert_eq!(limit.context, 100000);
1547 assert_eq!(limit.output, 0);
1548 }
1549
1550 #[test]
1551 fn test_model_modalities_default() {
1552 let modalities = ModelModalities::default();
1553 assert!(modalities.input.is_empty());
1554 assert!(modalities.output.is_empty());
1555 }
1556
1557 #[test]
1558 fn test_model_modalities_serialization() {
1559 let modalities = ModelModalities {
1560 input: vec!["text".to_string(), "image".to_string()],
1561 output: vec!["text".to_string()],
1562 };
1563 let json = serde_json::to_string(&modalities).unwrap();
1564 assert!(json.contains("\"input\""));
1565 assert!(json.contains("\"text\""));
1566 }
1567
1568 #[test]
1569 fn test_model_modalities_deserialization_missing_fields() {
1570 let json = r#"{"input":["text"]}"#;
1571 let modalities: ModelModalities = serde_json::from_str(json).unwrap();
1572 assert_eq!(modalities.input.len(), 1);
1573 assert!(modalities.output.is_empty());
1574 }
1575
1576 #[test]
1577 fn test_model_config_serialization() {
1578 let config = ModelConfig {
1579 id: "gpt-4o".to_string(),
1580 name: "GPT-4o".to_string(),
1581 family: "gpt-4".to_string(),
1582 api_key: Some("sk-test".to_string()),
1583 base_url: None,
1584 headers: HashMap::new(),
1585 session_id_header: None,
1586 attachment: true,
1587 reasoning: false,
1588 tool_call: true,
1589 temperature: true,
1590 release_date: Some("2024-05-13".to_string()),
1591 modalities: ModelModalities::default(),
1592 cost: ModelCost::default(),
1593 limit: ModelLimit::default(),
1594 };
1595 let json = serde_json::to_string(&config).unwrap();
1596 assert!(json.contains("\"id\":\"gpt-4o\""));
1597 assert!(json.contains("\"attachment\":true"));
1598 }
1599
1600 #[test]
1601 fn test_model_config_deserialization_with_defaults() {
1602 let json = r#"{"id":"test-model"}"#;
1603 let config: ModelConfig = serde_json::from_str(json).unwrap();
1604 assert_eq!(config.id, "test-model");
1605 assert_eq!(config.name, "");
1606 assert_eq!(config.family, "");
1607 assert!(config.api_key.is_none());
1608 assert!(!config.attachment);
1609 assert!(config.tool_call);
1610 assert!(config.temperature);
1611 }
1612
1613 #[test]
1614 fn test_model_config_all_optional_fields() {
1615 let json = r#"{
1616 "id": "claude-sonnet-4",
1617 "name": "Claude Sonnet 4",
1618 "family": "claude-sonnet",
1619 "apiKey": "sk-test",
1620 "baseUrl": "https://api.anthropic.com",
1621 "attachment": true,
1622 "reasoning": true,
1623 "toolCall": false,
1624 "temperature": false,
1625 "releaseDate": "2025-05-14"
1626 }"#;
1627 let config: ModelConfig = serde_json::from_str(json).unwrap();
1628 assert_eq!(config.id, "claude-sonnet-4");
1629 assert_eq!(config.name, "Claude Sonnet 4");
1630 assert_eq!(config.api_key, Some("sk-test".to_string()));
1631 assert_eq!(
1632 config.base_url,
1633 Some("https://api.anthropic.com".to_string())
1634 );
1635 assert!(config.attachment);
1636 assert!(config.reasoning);
1637 assert!(!config.tool_call);
1638 assert!(!config.temperature);
1639 }
1640
1641 #[test]
1642 fn test_provider_config_serialization() {
1643 let provider = ProviderConfig {
1644 name: "anthropic".to_string(),
1645 api_key: Some("sk-test".to_string()),
1646 base_url: Some("https://api.anthropic.com".to_string()),
1647 headers: HashMap::new(),
1648 session_id_header: None,
1649 models: vec![],
1650 };
1651 let json = serde_json::to_string(&provider).unwrap();
1652 assert!(json.contains("\"name\":\"anthropic\""));
1653 assert!(json.contains("\"apiKey\":\"sk-test\""));
1654 }
1655
1656 #[test]
1657 fn test_provider_config_deserialization_missing_optional() {
1658 let json = r#"{"name":"openai"}"#;
1659 let provider: ProviderConfig = serde_json::from_str(json).unwrap();
1660 assert_eq!(provider.name, "openai");
1661 assert!(provider.api_key.is_none());
1662 assert!(provider.base_url.is_none());
1663 assert!(provider.models.is_empty());
1664 }
1665
1666 #[test]
1667 fn test_provider_config_find_model() {
1668 let provider = ProviderConfig {
1669 name: "anthropic".to_string(),
1670 api_key: None,
1671 base_url: None,
1672 headers: HashMap::new(),
1673 session_id_header: None,
1674 models: vec![ModelConfig {
1675 id: "claude-sonnet-4".to_string(),
1676 name: "Claude Sonnet 4".to_string(),
1677 family: "claude-sonnet".to_string(),
1678 api_key: None,
1679 base_url: None,
1680 headers: HashMap::new(),
1681 session_id_header: None,
1682 attachment: false,
1683 reasoning: false,
1684 tool_call: true,
1685 temperature: true,
1686 release_date: None,
1687 modalities: ModelModalities::default(),
1688 cost: ModelCost::default(),
1689 limit: ModelLimit::default(),
1690 }],
1691 };
1692
1693 let found = provider.find_model("claude-sonnet-4");
1694 assert!(found.is_some());
1695 assert_eq!(found.unwrap().id, "claude-sonnet-4");
1696
1697 let not_found = provider.find_model("gpt-4o");
1698 assert!(not_found.is_none());
1699 }
1700
1701 #[test]
1702 fn test_provider_config_get_api_key() {
1703 let provider = ProviderConfig {
1704 name: "anthropic".to_string(),
1705 api_key: Some("provider-key".to_string()),
1706 base_url: None,
1707 headers: HashMap::new(),
1708 session_id_header: None,
1709 models: vec![],
1710 };
1711
1712 let model_with_key = ModelConfig {
1713 id: "test".to_string(),
1714 name: "".to_string(),
1715 family: "".to_string(),
1716 api_key: Some("model-key".to_string()),
1717 base_url: None,
1718 headers: HashMap::new(),
1719 session_id_header: None,
1720 attachment: false,
1721 reasoning: false,
1722 tool_call: true,
1723 temperature: true,
1724 release_date: None,
1725 modalities: ModelModalities::default(),
1726 cost: ModelCost::default(),
1727 limit: ModelLimit::default(),
1728 };
1729
1730 let model_without_key = ModelConfig {
1731 id: "test2".to_string(),
1732 name: "".to_string(),
1733 family: "".to_string(),
1734 api_key: None,
1735 base_url: None,
1736 headers: HashMap::new(),
1737 session_id_header: None,
1738 attachment: false,
1739 reasoning: false,
1740 tool_call: true,
1741 temperature: true,
1742 release_date: None,
1743 modalities: ModelModalities::default(),
1744 cost: ModelCost::default(),
1745 limit: ModelLimit::default(),
1746 };
1747
1748 assert_eq!(provider.get_api_key(&model_with_key), Some("model-key"));
1749 assert_eq!(
1750 provider.get_api_key(&model_without_key),
1751 Some("provider-key")
1752 );
1753 }
1754
1755 #[test]
1756 fn test_provider_config_get_headers_and_session_id_header() {
1757 let mut provider_headers = HashMap::new();
1758 provider_headers.insert("X-Provider".to_string(), "provider".to_string());
1759 provider_headers.insert("X-Shared".to_string(), "provider".to_string());
1760
1761 let mut model_headers = HashMap::new();
1762 model_headers.insert("X-Model".to_string(), "model".to_string());
1763 model_headers.insert("X-Shared".to_string(), "model".to_string());
1764
1765 let provider = ProviderConfig {
1766 name: "openai".to_string(),
1767 api_key: Some("provider-key".to_string()),
1768 base_url: None,
1769 headers: provider_headers,
1770 session_id_header: Some("X-Session-Id".to_string()),
1771 models: vec![],
1772 };
1773
1774 let model = ModelConfig {
1775 id: "gpt-4o".to_string(),
1776 name: "".to_string(),
1777 family: "".to_string(),
1778 api_key: None,
1779 base_url: None,
1780 headers: model_headers,
1781 session_id_header: Some("X-Model-Session".to_string()),
1782 attachment: false,
1783 reasoning: false,
1784 tool_call: true,
1785 temperature: true,
1786 release_date: None,
1787 modalities: ModelModalities::default(),
1788 cost: ModelCost::default(),
1789 limit: ModelLimit::default(),
1790 };
1791
1792 let headers = provider.get_headers(&model);
1793 assert_eq!(headers.get("X-Provider"), Some(&"provider".to_string()));
1794 assert_eq!(headers.get("X-Model"), Some(&"model".to_string()));
1795 assert_eq!(headers.get("X-Shared"), Some(&"model".to_string()));
1796 assert_eq!(
1797 provider.get_session_id_header(&model),
1798 Some("X-Model-Session")
1799 );
1800 }
1801
1802 #[test]
1803 fn test_llm_config_includes_headers_and_runtime_session_header() {
1804 let mut provider_headers = HashMap::new();
1805 provider_headers.insert("X-Provider".to_string(), "provider".to_string());
1806
1807 let config = CodeConfig {
1808 default_model: Some("openai/gpt-4o".to_string()),
1809 providers: vec![ProviderConfig {
1810 name: "openai".to_string(),
1811 api_key: Some("sk-test".to_string()),
1812 base_url: Some("https://api.example.com".to_string()),
1813 headers: provider_headers,
1814 session_id_header: Some("X-Session-Id".to_string()),
1815 models: vec![ModelConfig {
1816 id: "gpt-4o".to_string(),
1817 name: "".to_string(),
1818 family: "".to_string(),
1819 api_key: None,
1820 base_url: None,
1821 headers: HashMap::new(),
1822 session_id_header: None,
1823 attachment: false,
1824 reasoning: false,
1825 tool_call: true,
1826 temperature: true,
1827 release_date: None,
1828 modalities: ModelModalities::default(),
1829 cost: ModelCost::default(),
1830 limit: ModelLimit::default(),
1831 }],
1832 }],
1833 ..Default::default()
1834 };
1835
1836 let llm_config = config.default_llm_config().unwrap();
1837 assert_eq!(
1838 llm_config.headers.get("X-Provider"),
1839 Some(&"provider".to_string())
1840 );
1841 assert_eq!(
1842 llm_config.session_id_header.as_deref(),
1843 Some("X-Session-Id")
1844 );
1845 }
1846
1847 #[test]
1848 fn test_code_config_default_provider_config() {
1849 let config = CodeConfig {
1850 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1851 providers: vec![ProviderConfig {
1852 name: "anthropic".to_string(),
1853 api_key: Some("sk-test".to_string()),
1854 base_url: None,
1855 headers: HashMap::new(),
1856 session_id_header: None,
1857 models: vec![],
1858 }],
1859 ..Default::default()
1860 };
1861
1862 let provider = config.default_provider_config();
1863 assert!(provider.is_some());
1864 assert_eq!(provider.unwrap().name, "anthropic");
1865 }
1866
1867 #[test]
1868 fn test_code_config_default_model_config() {
1869 let config = CodeConfig {
1870 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1871 providers: vec![ProviderConfig {
1872 name: "anthropic".to_string(),
1873 api_key: Some("sk-test".to_string()),
1874 base_url: None,
1875 headers: HashMap::new(),
1876 session_id_header: None,
1877 models: vec![ModelConfig {
1878 id: "claude-sonnet-4".to_string(),
1879 name: "Claude Sonnet 4".to_string(),
1880 family: "claude-sonnet".to_string(),
1881 api_key: None,
1882 base_url: None,
1883 headers: HashMap::new(),
1884 session_id_header: None,
1885 attachment: false,
1886 reasoning: false,
1887 tool_call: true,
1888 temperature: true,
1889 release_date: None,
1890 modalities: ModelModalities::default(),
1891 cost: ModelCost::default(),
1892 limit: ModelLimit::default(),
1893 }],
1894 }],
1895 ..Default::default()
1896 };
1897
1898 let result = config.default_model_config();
1899 assert!(result.is_some());
1900 let (provider, model) = result.unwrap();
1901 assert_eq!(provider.name, "anthropic");
1902 assert_eq!(model.id, "claude-sonnet-4");
1903 }
1904
1905 #[test]
1906 fn test_code_config_default_llm_config() {
1907 let config = CodeConfig {
1908 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1909 providers: vec![ProviderConfig {
1910 name: "anthropic".to_string(),
1911 api_key: Some("sk-test".to_string()),
1912 base_url: Some("https://api.anthropic.com".to_string()),
1913 headers: HashMap::new(),
1914 session_id_header: None,
1915 models: vec![ModelConfig {
1916 id: "claude-sonnet-4".to_string(),
1917 name: "Claude Sonnet 4".to_string(),
1918 family: "claude-sonnet".to_string(),
1919 api_key: None,
1920 base_url: None,
1921 headers: HashMap::new(),
1922 session_id_header: None,
1923 attachment: false,
1924 reasoning: false,
1925 tool_call: true,
1926 temperature: true,
1927 release_date: None,
1928 modalities: ModelModalities::default(),
1929 cost: ModelCost::default(),
1930 limit: ModelLimit::default(),
1931 }],
1932 }],
1933 ..Default::default()
1934 };
1935
1936 let llm_config = config.default_llm_config();
1937 assert!(llm_config.is_some());
1938 }
1939
1940 #[test]
1941 fn test_code_config_list_models() {
1942 let config = CodeConfig {
1943 providers: vec![
1944 ProviderConfig {
1945 name: "anthropic".to_string(),
1946 api_key: None,
1947 base_url: None,
1948 headers: HashMap::new(),
1949 session_id_header: None,
1950 models: vec![ModelConfig {
1951 id: "claude-sonnet-4".to_string(),
1952 name: "".to_string(),
1953 family: "".to_string(),
1954 api_key: None,
1955 base_url: None,
1956 headers: HashMap::new(),
1957 session_id_header: None,
1958 attachment: false,
1959 reasoning: false,
1960 tool_call: true,
1961 temperature: true,
1962 release_date: None,
1963 modalities: ModelModalities::default(),
1964 cost: ModelCost::default(),
1965 limit: ModelLimit::default(),
1966 }],
1967 },
1968 ProviderConfig {
1969 name: "openai".to_string(),
1970 api_key: None,
1971 base_url: None,
1972 headers: HashMap::new(),
1973 session_id_header: None,
1974 models: vec![ModelConfig {
1975 id: "gpt-4o".to_string(),
1976 name: "".to_string(),
1977 family: "".to_string(),
1978 api_key: None,
1979 base_url: None,
1980 headers: HashMap::new(),
1981 session_id_header: None,
1982 attachment: false,
1983 reasoning: false,
1984 tool_call: true,
1985 temperature: true,
1986 release_date: None,
1987 modalities: ModelModalities::default(),
1988 cost: ModelCost::default(),
1989 limit: ModelLimit::default(),
1990 }],
1991 },
1992 ],
1993 ..Default::default()
1994 };
1995
1996 let models = config.list_models();
1997 assert_eq!(models.len(), 2);
1998 }
1999
2000 #[test]
2001 fn test_llm_config_specific_provider_model() {
2002 let model: ModelConfig = serde_json::from_value(serde_json::json!({
2003 "id": "claude-3",
2004 "name": "Claude 3"
2005 }))
2006 .unwrap();
2007
2008 let config = CodeConfig {
2009 providers: vec![ProviderConfig {
2010 name: "anthropic".to_string(),
2011 api_key: Some("sk-test".to_string()),
2012 base_url: None,
2013 headers: HashMap::new(),
2014 session_id_header: None,
2015 models: vec![model],
2016 }],
2017 ..Default::default()
2018 };
2019
2020 let llm = config.llm_config("anthropic", "claude-3");
2021 assert!(llm.is_some());
2022 let llm = llm.unwrap();
2023 assert_eq!(llm.provider, "anthropic");
2024 assert_eq!(llm.model, "claude-3");
2025 }
2026
2027 #[test]
2028 fn test_llm_config_missing_provider() {
2029 let config = CodeConfig::default();
2030 assert!(config.llm_config("nonexistent", "model").is_none());
2031 }
2032
2033 #[test]
2034 fn test_llm_config_missing_model() {
2035 let config = CodeConfig {
2036 providers: vec![ProviderConfig {
2037 name: "anthropic".to_string(),
2038 api_key: Some("sk-test".to_string()),
2039 base_url: None,
2040 headers: HashMap::new(),
2041 session_id_header: None,
2042 models: vec![],
2043 }],
2044 ..Default::default()
2045 };
2046 assert!(config.llm_config("anthropic", "nonexistent").is_none());
2047 }
2048
2049 #[test]
2050 fn test_agentic_search_config_normalizes_invalid_values() {
2051 let config = AgenticSearchConfig {
2052 enabled: true,
2053 default_mode: "weird".to_string(),
2054 max_results: 0,
2055 context_lines: 999,
2056 }
2057 .normalized();
2058
2059 assert_eq!(config.default_mode, "fast");
2060 assert_eq!(config.max_results, 1);
2061 assert_eq!(config.context_lines, 20);
2062 }
2063
2064 #[test]
2065 fn test_agentic_parse_config_normalizes_invalid_values() {
2066 let config = AgenticParseConfig {
2067 enabled: true,
2068 default_strategy: "unknown".to_string(),
2069 max_chars: 1,
2070 }
2071 .normalized();
2072
2073 assert_eq!(config.default_strategy, "auto");
2074 assert_eq!(config.max_chars, 500);
2075 }
2076
2077 #[test]
2078 fn test_document_parser_config_normalizes_nested_ocr_values() {
2079 let config = DocumentParserConfig {
2080 enabled: true,
2081 max_file_size_mb: 0,
2082 cache: Some(DocumentCacheConfig {
2083 enabled: true,
2084 directory: Some(PathBuf::from("/tmp/cache")),
2085 }),
2086 ocr: Some(DocumentOcrConfig {
2087 enabled: true,
2088 model: Some("openai/gpt-4.1-mini".to_string()),
2089 prompt: None,
2090 max_images: 0,
2091 dpi: 10,
2092 provider: None,
2093 base_url: None,
2094 api_key: None,
2095 }),
2096 }
2097 .normalized();
2098
2099 assert_eq!(config.max_file_size_mb, 1);
2100 let cache = config.cache.unwrap();
2101 assert!(cache.enabled);
2102 assert_eq!(cache.directory, Some(PathBuf::from("/tmp/cache")));
2103 let ocr = config.ocr.unwrap();
2104 assert_eq!(ocr.max_images, 1);
2105 assert_eq!(ocr.dpi, 72);
2106 }
2107}