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