Skip to main content

a3s_code_core/
config.rs

1//! Configuration module for A3S Code
2//!
3//! Provides configuration for:
4//! - LLM providers and models (defaultModel in "provider/model" format, providers)
5//! - Queue configuration (a3s-lane integration)
6//! - Search configuration (a3s-search integration)
7//! - Directories for dynamic skill and agent loading
8//!
9//! Configuration is loaded from ACL-compatible files or strings.
10//! Existing `.acl` config filenames are still accepted for compatibility.
11//! JSON support has been removed.
12
13use 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// ============================================================================
21// Provider Configuration
22// ============================================================================
23
24/// Model cost information (per million tokens)
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26#[serde(rename_all = "camelCase")]
27pub struct ModelCost {
28    /// Input token cost
29    #[serde(default)]
30    pub input: f64,
31    /// Output token cost
32    #[serde(default)]
33    pub output: f64,
34    /// Cache read cost
35    #[serde(default)]
36    pub cache_read: f64,
37    /// Cache write cost
38    #[serde(default)]
39    pub cache_write: f64,
40}
41
42/// Model limits
43#[derive(Debug, Clone, Serialize, Deserialize, Default)]
44pub struct ModelLimit {
45    /// Maximum context tokens
46    #[serde(default)]
47    pub context: u32,
48    /// Maximum output tokens
49    #[serde(default)]
50    pub output: u32,
51}
52
53/// Model modalities (input/output types)
54#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55pub struct ModelModalities {
56    /// Supported input types
57    #[serde(default)]
58    pub input: Vec<String>,
59    /// Supported output types
60    #[serde(default)]
61    pub output: Vec<String>,
62}
63
64/// Model configuration
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct ModelConfig {
68    /// Model ID (e.g., "claude-sonnet-4-20250514")
69    pub id: String,
70    /// Display name
71    #[serde(default)]
72    pub name: String,
73    /// Model family (e.g., "claude-sonnet")
74    #[serde(default)]
75    pub family: String,
76    /// Per-model API key override
77    #[serde(default)]
78    pub api_key: Option<String>,
79    /// Per-model base URL override
80    #[serde(default)]
81    pub base_url: Option<String>,
82    /// Static HTTP headers for this model
83    #[serde(default)]
84    pub headers: HashMap<String, String>,
85    /// Header name to receive the runtime session ID
86    #[serde(default)]
87    pub session_id_header: Option<String>,
88    /// Supports file attachments
89    #[serde(default)]
90    pub attachment: bool,
91    /// Supports reasoning/thinking
92    #[serde(default)]
93    pub reasoning: bool,
94    /// Supports tool calling
95    #[serde(default = "default_true")]
96    pub tool_call: bool,
97    /// Supports temperature setting
98    #[serde(default = "default_true")]
99    pub temperature: bool,
100    /// Release date
101    #[serde(default)]
102    pub release_date: Option<String>,
103    /// Input/output modalities
104    #[serde(default)]
105    pub modalities: ModelModalities,
106    /// Cost information
107    #[serde(default)]
108    pub cost: ModelCost,
109    /// Token limits
110    #[serde(default)]
111    pub limit: ModelLimit,
112}
113
114fn default_true() -> bool {
115    true
116}
117
118/// Provider configuration
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(rename_all = "camelCase")]
121pub struct ProviderConfig {
122    /// Provider name (e.g., "anthropic", "openai")
123    pub name: String,
124    /// API key for this provider
125    #[serde(default)]
126    pub api_key: Option<String>,
127    /// Base URL for the API
128    #[serde(default)]
129    pub base_url: Option<String>,
130    /// Static HTTP headers for this provider
131    #[serde(default)]
132    pub headers: HashMap<String, String>,
133    /// Header name to receive the runtime session ID
134    #[serde(default)]
135    pub session_id_header: Option<String>,
136    /// Available models
137    #[serde(default)]
138    pub models: Vec<ModelConfig>,
139}
140
141/// Apply model capability flags to an LlmConfig.
142///
143/// - `temperature = false` → omit temperature (model ignores it, e.g. o1)
144/// - `reasoning = true` + `thinking_budget` set → pass budget to client
145/// - `limit.output > 0` → use as max_tokens
146fn apply_model_caps(
147    mut config: LlmConfig,
148    model: &ModelConfig,
149    thinking_budget: Option<usize>,
150) -> LlmConfig {
151    // reasoning=true + thinking_budget set → pass budget to client (Anthropic only)
152    if model.reasoning {
153        if let Some(budget) = thinking_budget {
154            config = config.with_thinking_budget(budget);
155        }
156    }
157
158    // limit.output > 0 → use as max_tokens cap
159    if model.limit.output > 0 {
160        config = config.with_max_tokens(model.limit.output as usize);
161    }
162
163    // temperature=false models (e.g. o1) must not receive a temperature param.
164    // Store the flag so the LLM client can gate it at call time.
165    if !model.temperature {
166        config.disable_temperature = true;
167    }
168
169    config
170}
171
172impl ProviderConfig {
173    /// Find a model by ID
174    pub fn find_model(&self, model_id: &str) -> Option<&ModelConfig> {
175        self.models.iter().find(|m| m.id == model_id)
176    }
177
178    /// Get the effective API key for a model (model override or provider default)
179    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    /// Get the effective base URL for a model (model override or provider default)
184    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    /// Get the effective static headers for a model (provider defaults with model overrides)
189    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    /// Get the header name that should carry the runtime session ID.
196    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// ============================================================================
205// Storage Configuration
206// ============================================================================
207
208/// Session storage backend type
209#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
210#[serde(rename_all = "lowercase")]
211pub enum StorageBackend {
212    /// In-memory storage (no persistence)
213    Memory,
214    /// File-based storage (JSON files)
215    #[default]
216    File,
217    /// Custom external storage (Redis, PostgreSQL, etc.)
218    ///
219    /// Requires a `SessionStore` implementation registered on `AgentSession` options.
220    /// Use `storage_url` in config to pass connection details.
221    Custom,
222}
223
224// ============================================================================
225// Main Configuration
226// ============================================================================
227
228/// Configuration for A3S Code
229#[derive(Debug, Clone, Serialize, Deserialize, Default)]
230#[serde(rename_all = "camelCase")]
231pub struct CodeConfig {
232    /// Default model in "provider/model" format (e.g., "anthropic/claude-sonnet-4-20250514")
233    #[serde(default, alias = "default_model")]
234    pub default_model: Option<String>,
235
236    /// Provider configurations
237    #[serde(default)]
238    pub providers: Vec<ProviderConfig>,
239
240    /// Session storage backend
241    #[serde(default)]
242    pub storage_backend: StorageBackend,
243
244    /// Sessions directory (for file backend)
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub sessions_dir: Option<PathBuf>,
247
248    /// Connection URL for custom storage backend (e.g., "redis://localhost:6379", "postgres://user:pass@localhost/a3s")
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub storage_url: Option<String>,
251
252    /// Directories to scan for skill files (*.md with tool definitions)
253    #[serde(default, alias = "skill_dirs")]
254    pub skill_dirs: Vec<PathBuf>,
255
256    /// Directories to scan for agent files (*.yaml or *.md)
257    #[serde(default, alias = "agent_dirs")]
258    pub agent_dirs: Vec<PathBuf>,
259
260    /// Maximum tool execution rounds per turn (default: 25)
261    #[serde(default, alias = "max_tool_rounds")]
262    pub max_tool_rounds: Option<usize>,
263
264    /// Thinking/reasoning budget in tokens
265    #[serde(default, alias = "thinking_budget")]
266    pub thinking_budget: Option<usize>,
267
268    /// Memory system configuration
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub memory: Option<MemoryConfig>,
271
272    /// Queue configuration (a3s-lane integration)
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub queue: Option<crate::queue::SessionQueueConfig>,
275
276    /// Search configuration (a3s-search integration)
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub search: Option<SearchConfig>,
279
280    /// Agentic search tool configuration.
281    #[serde(
282        default,
283        alias = "agentic_search",
284        skip_serializing_if = "Option::is_none"
285    )]
286    pub agentic_search: Option<AgenticSearchConfig>,
287
288    /// Agentic parse tool configuration.
289    #[serde(
290        default,
291        alias = "agentic_parse",
292        skip_serializing_if = "Option::is_none"
293    )]
294    pub agentic_parse: Option<AgenticParseConfig>,
295
296    /// Built-in document context extraction configuration.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub document_parser: Option<DocumentParserConfig>,
299
300    /// MCP server configurations
301    #[serde(default, alias = "mcp_servers")]
302    pub mcp_servers: Vec<crate::mcp::McpServerConfig>,
303}
304
305/// Search engine configuration (a3s-search integration)
306#[derive(Debug, Clone, Serialize, Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct SearchConfig {
309    /// Default timeout in seconds for all engines
310    #[serde(default = "default_search_timeout")]
311    pub timeout: u64,
312
313    /// Health monitor configuration
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub health: Option<SearchHealthConfig>,
316
317    /// Engine configurations
318    #[serde(default, rename = "engine")]
319    pub engines: std::collections::HashMap<String, SearchEngineConfig>,
320
321    /// Headless browser configuration for JS-rendered engines (google, baidu, bing_cn).
322    /// When enabled, the browser binary is auto-detected or downloaded.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub headless: Option<HeadlessConfig>,
325}
326
327/// Browser backend for JS-rendered search engines.
328#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(rename_all = "lowercase")]
330pub enum BrowserBackend {
331    /// Chrome/Chromium headless browser.
332    #[default]
333    Chrome,
334    /// Lightpanda headless browser.
335    Lightpanda,
336}
337
338/// Headless browser configuration for JS-rendered engines.
339/// Uses a3s-search's browser pool, backed by Chrome/Chromium or Lightpanda.
340#[derive(Debug, Clone, Serialize, Deserialize)]
341#[serde(rename_all = "camelCase")]
342pub struct HeadlessConfig {
343    /// Browser backend to use.
344    #[serde(default)]
345    pub backend: BrowserBackend,
346
347    /// Maximum number of concurrent browser tabs.
348    #[serde(default = "default_headless_max_tabs")]
349    pub max_tabs: usize,
350
351    /// Path to the browser executable. If None, auto-detected or downloaded.
352    #[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    /// Additional browser launch arguments.
363    #[serde(default, skip_serializing_if = "Vec::is_empty")]
364    pub launch_args: Vec<String>,
365
366    /// Proxy URL for the browser to use.
367    #[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/// Default configuration for the built-in `agentic_search` tool.
390#[derive(Debug, Clone, Serialize, Deserialize)]
391#[serde(rename_all = "camelCase")]
392pub struct AgenticSearchConfig {
393    /// Whether the tool is registered by default.
394    #[serde(default = "default_enabled")]
395    pub enabled: bool,
396
397    /// Default search mode when tool input omits `mode`.
398    #[serde(default = "default_agentic_search_mode")]
399    pub default_mode: String,
400
401    /// Default max results when tool input omits `max_results`.
402    #[serde(default = "default_agentic_search_max_results")]
403    pub max_results: usize,
404
405    /// Default context lines when tool input omits `context_lines`.
406    #[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/// Default configuration for the built-in `agentic_parse` tool.
440#[derive(Debug, Clone, Serialize, Deserialize)]
441#[serde(rename_all = "camelCase")]
442pub struct AgenticParseConfig {
443    /// Whether the tool is registered by default.
444    #[serde(default = "default_enabled")]
445    pub enabled: bool,
446
447    /// Default parse strategy when tool input omits `strategy`.
448    #[serde(default = "default_agentic_parse_strategy")]
449    pub default_strategy: String,
450
451    /// Default maximum characters sent to the LLM when tool input omits `max_chars`.
452    #[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/// Default configuration for built-in document context extraction.
486#[derive(Debug, Clone, Serialize, Deserialize)]
487#[serde(rename_all = "camelCase")]
488pub struct DocumentParserConfig {
489    /// Whether the default document extraction stack is registered in the parser registry.
490    #[serde(default = "default_enabled")]
491    pub enabled: bool,
492
493    /// Maximum file size accepted by the parser, in MiB.
494    #[serde(default = "default_document_parser_max_file_size_mb")]
495    pub max_file_size_mb: u64,
496
497    /// Optional OCR / vision-model settings for image-heavy documents.
498    ///
499    /// These settings control OCR fallback when context extraction reaches
500    /// scanned or image-heavy inputs. Current parsers may not execute OCR for
501    /// every format.
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub ocr: Option<DocumentOcrConfig>,
504
505    /// Optional cache settings for parsed / normalized document context.
506    #[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/// OCR / vision-model configuration for built-in document context extraction.
561#[derive(Debug, Clone, Serialize, Deserialize)]
562#[serde(rename_all = "camelCase")]
563pub struct DocumentOcrConfig {
564    /// Whether OCR fallback is enabled for image-heavy documents.
565    #[serde(default = "default_enabled")]
566    pub enabled: bool,
567
568    /// Vision-capable model identifier, for example `openai/gpt-4.1-mini`.
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub model: Option<String>,
571
572    /// Optional custom OCR prompt / extraction instruction.
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub prompt: Option<String>,
575
576    /// Maximum number of rendered images/pages to send for OCR fallback.
577    #[serde(default = "default_document_ocr_max_images")]
578    pub max_images: usize,
579
580    /// Render DPI when rasterizing pages for OCR fallback.
581    #[serde(default = "default_document_ocr_dpi")]
582    pub dpi: u32,
583
584    /// OCR provider backend. Defaults to "vision" when model is set.
585    /// "vision" - Vision API (OpenAI-compatible)
586    /// "builtin" - Local tesseract (requires tesseract + pdftoppm binaries)
587    #[serde(default, skip_serializing_if = "Option::is_none")]
588    pub provider: Option<String>,
589
590    /// Base URL for vision API. Defaults to OpenAI API if not set.
591    #[serde(default, skip_serializing_if = "Option::is_none")]
592    pub base_url: Option<String>,
593
594    /// API key for vision API.
595    #[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/// Search health monitor configuration
685#[derive(Debug, Clone, Serialize, Deserialize)]
686#[serde(rename_all = "camelCase")]
687pub struct SearchHealthConfig {
688    /// Number of consecutive failures before suspending
689    #[serde(default = "default_max_failures")]
690    pub max_failures: u32,
691
692    /// Suspension duration in seconds
693    #[serde(default = "default_suspend_seconds")]
694    pub suspend_seconds: u64,
695}
696
697/// Per-engine search configuration
698#[derive(Debug, Clone, Serialize, Deserialize)]
699#[serde(rename_all = "camelCase")]
700pub struct SearchEngineConfig {
701    /// Whether the engine is enabled
702    #[serde(default = "default_enabled")]
703    pub enabled: bool,
704
705    /// Weight for ranking (higher = more influence)
706    #[serde(default = "default_weight")]
707    pub weight: f64,
708
709    /// Per-engine timeout override in seconds
710    #[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    /// Create a new empty configuration
772    pub fn new() -> Self {
773        Self::default()
774    }
775
776    /// Load configuration from an ACL-compatible config file.
777    ///
778    /// `.acl` is the only supported config file extension. JSON and legacy
779    /// `.hcl` config files are not supported.
780    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    /// Parse configuration from an ACL string.
799    ///
800    /// ACL (Agent Configuration Language) uses labeled blocks like
801    /// `providers "openai" { }`.
802    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                    // ACL: default_model = "openai/gpt-4" or just "openai/gpt-4" as label
814                    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                    // Process nested models blocks
896                    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                    // Other top-level blocks are not mapped by the lightweight
988                    // ACL loader yet (queue, search, memory, MCP, etc.).
989                }
990            }
991        }
992
993        Ok(config)
994    }
995
996    /// Find a provider by name
997    pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> {
998        self.providers.iter().find(|p| p.name == name)
999    }
1000
1001    /// Get the default provider configuration (parsed from `default_model` "provider/model" format)
1002    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    /// Get the default model configuration (parsed from `default_model` "provider/model" format)
1009    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    /// Get LlmConfig for the default provider and model
1018    ///
1019    /// Returns None if default provider/model is not configured or API key is missing.
1020    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    /// Get LlmConfig for a specific provider and model
1042    ///
1043    /// Returns None if provider/model is not found or API key is missing.
1044    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    /// List all available models across all providers
1067    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    /// Add a skill directory
1075    pub fn add_skill_dir(mut self, dir: impl Into<PathBuf>) -> Self {
1076        self.skill_dirs.push(dir.into());
1077        self
1078    }
1079
1080    /// Add an agent directory
1081    pub fn add_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
1082        self.agent_dirs.push(dir.into());
1083        self
1084    }
1085
1086    /// Check if any directories are configured
1087    pub fn has_directories(&self) -> bool {
1088        !self.skill_dirs.is_empty() || !self.agent_dirs.is_empty()
1089    }
1090
1091    /// Check if provider configuration is available
1092    pub fn has_providers(&self) -> bool {
1093        !self.providers.is_empty()
1094    }
1095}
1096
1097// ============================================================================
1098// ACL Parsing Helpers
1099// ============================================================================
1100
1101#[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        // Test serialization
1125        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        // Test deserialization
1134        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, // Uses provider key
1308                    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()), // Override
1325                    base_url: Some("https://custom.api.com".to_string()), // Override
1326                    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        // Model without override uses provider key
1341        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        // Model with override uses its own key
1349        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        // Custom variant is now serializable
1484        let json = serde_json::to_string(&custom).unwrap();
1485        assert_eq!(json, "\"custom\"");
1486
1487        // And deserializable
1488        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}