Skip to main content

codei_config/
model.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6/// Full configuration after merging all sources.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Config {
9    #[serde(default)]
10    pub defaults: DefaultsConfig,
11
12    #[serde(default)]
13    pub providers: HashMap<String, ProviderConfig>,
14
15    #[serde(default)]
16    pub agent: AgentConfig,
17
18    #[serde(default)]
19    pub tools: ToolsConfig,
20
21    #[serde(default)]
22    pub ui: UiConfig,
23
24    #[serde(default)]
25    pub session: SessionConfig,
26}
27
28/// Resolved configuration with metadata about where values came from.
29#[derive(Debug, Clone)]
30pub struct ResolvedConfig {
31    pub config: Config,
32    pub cwd: PathBuf,
33    pub project_root: Option<PathBuf>,
34    pub user_config_path: PathBuf,
35    pub project_config_path: Option<PathBuf>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct DefaultsConfig {
40    pub model: String,
41    pub provider: String,
42    pub temperature: f32,
43    pub max_tokens: u32,
44    pub language: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ProviderConfig {
49    /// Direct API key; takes precedence over `api_key_env` when set.
50    #[serde(default)]
51    pub api_key: Option<String>,
52    #[serde(default)]
53    pub api_key_env: Option<String>,
54    #[serde(default)]
55    pub base_url: Option<String>,
56    #[serde(default)]
57    pub api_style: Option<String>,
58    /// `tools` (OpenAI tools API) or `functions` (legacy function calling). Default: `functions`.
59    #[serde(default)]
60    pub tool_format: Option<String>,
61}
62
63impl ProviderConfig {
64    /// Resolve API key: `api_key` first, then the environment variable named by `api_key_env`.
65    pub fn resolve_api_key(&self) -> Result<String, crate::ConfigError> {
66        if let Some(key) = self
67            .api_key
68            .as_deref()
69            .map(str::trim)
70            .filter(|k| !k.is_empty())
71        {
72            return Ok(key.to_string());
73        }
74        let env_name = self
75            .api_key_env
76            .as_deref()
77            .filter(|name| !name.is_empty())
78            .unwrap_or("OPENAI_API_KEY");
79        std::env::var(env_name).map_err(|_| crate::ConfigError::MissingApiKey {
80            env: env_name.to_string(),
81        })
82    }
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct AgentConfig {
87    pub max_turns: u32,
88    pub max_tool_rounds_per_turn: u32,
89    pub context_window_tokens: u32,
90    /// Fraction of `context_window_tokens` that triggers automatic session compaction (0.0–1.0).
91    pub compaction_threshold: f32,
92    /// Number of recent session messages to keep after compaction.
93    pub compaction_keep_messages: u32,
94    /// Max tokens for the LLM-generated compaction summary.
95    pub compaction_summary_max_tokens: u32,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ToolsConfig {
100    pub shell: ShellToolConfig,
101    pub write: EnabledToolConfig,
102    pub web_search: EnabledToolConfig,
103    #[serde(default)]
104    pub grep: GrepToolConfig,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct GrepToolConfig {
109    pub max_matches: usize,
110    pub max_files: usize,
111}
112
113#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
114#[serde(rename_all = "lowercase")]
115pub enum ShellSandboxMode {
116    #[default]
117    Off,
118    Restricted,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ShellToolConfig {
123    pub enabled: bool,
124    pub timeout_secs: u64,
125    #[serde(default)]
126    pub sandbox: ShellSandboxMode,
127    #[serde(default)]
128    pub allowlist: Vec<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct EnabledToolConfig {
133    pub enabled: bool,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct UiConfig {
138    pub theme: UiTheme,
139    pub show_tool_output: bool,
140    pub confirm_destructive: bool,
141}
142
143#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
144#[serde(rename_all = "lowercase")]
145pub enum UiTheme {
146    #[default]
147    Auto,
148    Dark,
149    Light,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct SessionConfig {
154    pub storage: SessionStorage,
155    pub dir: String,
156}
157
158#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
159#[serde(rename_all = "lowercase")]
160pub enum SessionStorage {
161    #[default]
162    Sqlite,
163    Json,
164}
165
166impl Default for Config {
167    fn default() -> Self {
168        Self {
169            defaults: DefaultsConfig::default(),
170            providers: default_providers(),
171            agent: AgentConfig::default(),
172            tools: ToolsConfig::default(),
173            ui: UiConfig::default(),
174            session: SessionConfig::default(),
175        }
176    }
177}
178
179impl Default for DefaultsConfig {
180    fn default() -> Self {
181        Self {
182            model: "gpt-4o".to_string(),
183            provider: "openai".to_string(),
184            temperature: 0.2,
185            max_tokens: 8192,
186            language: "zh-CN".to_string(),
187        }
188    }
189}
190
191impl Default for AgentConfig {
192    fn default() -> Self {
193        Self {
194            max_turns: 50,
195            max_tool_rounds_per_turn: 25,
196            context_window_tokens: 128_000,
197            compaction_threshold: 0.85,
198            compaction_keep_messages: 12,
199            compaction_summary_max_tokens: 2048,
200        }
201    }
202}
203
204impl Default for ToolsConfig {
205    fn default() -> Self {
206        Self {
207            shell: ShellToolConfig {
208                enabled: true,
209                timeout_secs: 120,
210                sandbox: ShellSandboxMode::Off,
211                allowlist: Vec::new(),
212            },
213            write: EnabledToolConfig { enabled: true },
214            web_search: EnabledToolConfig { enabled: false },
215            grep: GrepToolConfig::default(),
216        }
217    }
218}
219
220impl Default for GrepToolConfig {
221    fn default() -> Self {
222        Self {
223            max_matches: 200,
224            max_files: 5_000,
225        }
226    }
227}
228
229impl Default for UiConfig {
230    fn default() -> Self {
231        Self {
232            theme: UiTheme::Auto,
233            show_tool_output: true,
234            confirm_destructive: true,
235        }
236    }
237}
238
239impl Default for SessionConfig {
240    fn default() -> Self {
241        Self {
242            storage: SessionStorage::Sqlite,
243            dir: "~/.local/share/codei/sessions".to_string(),
244        }
245    }
246}
247
248fn default_providers() -> HashMap<String, ProviderConfig> {
249    HashMap::from([
250        (
251            "openai".to_string(),
252            ProviderConfig {
253                api_key: None,
254                api_key_env: Some("OPENAI_API_KEY".to_string()),
255                base_url: Some("https://api.openai.com/v1".to_string()),
256                api_style: Some("openai".to_string()),
257                tool_format: Some("tools".to_string()),
258            },
259        ),
260        (
261            "anthropic".to_string(),
262            ProviderConfig {
263                api_key: None,
264                api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
265                base_url: None,
266                api_style: Some("anthropic".to_string()),
267                tool_format: None,
268            },
269        ),
270        (
271            "custom".to_string(),
272            ProviderConfig {
273                api_key: None,
274                api_key_env: Some("CUSTOM_API_KEY".to_string()),
275                base_url: Some("http://localhost:8080/v1".to_string()),
276                api_style: Some("openai".to_string()),
277                tool_format: Some("tools".to_string()),
278            },
279        ),
280    ])
281}
282
283impl ResolvedConfig {
284    pub fn validate(&self) -> Result<(), crate::ConfigError> {
285        let lang = &self.config.defaults.language;
286        if lang != "zh-CN" && lang != "en-US" {
287            return Err(crate::ConfigError::InvalidLanguage {
288                language: lang.clone(),
289            });
290        }
291        let threshold = self.config.agent.compaction_threshold;
292        if !(0.0..=1.0).contains(&threshold) {
293            return Err(crate::ConfigError::InvalidCompactionThreshold { threshold });
294        }
295        Ok(())
296    }
297}
298
299#[cfg(test)]
300mod provider_tests {
301    use super::ProviderConfig;
302
303    #[test]
304    fn resolve_api_key_prefers_direct_config() {
305        let cfg = ProviderConfig {
306            api_key: Some("sk-direct".into()),
307            api_key_env: Some("OPENAI_API_KEY".into()),
308            base_url: None,
309            api_style: None,
310            tool_format: None,
311        };
312        assert_eq!(cfg.resolve_api_key().unwrap(), "sk-direct");
313    }
314
315    #[test]
316    fn blank_api_key_falls_back_to_env() {
317        let cfg = ProviderConfig {
318            api_key: Some("   ".into()),
319            api_key_env: Some("NONEXISTENT_CODEI_API_KEY".into()),
320            base_url: None,
321            api_style: None,
322            tool_format: None,
323        };
324        let err = cfg.resolve_api_key().unwrap_err();
325        assert!(matches!(
326            err,
327            crate::ConfigError::MissingApiKey {
328                env: ref name
329            } if name == "NONEXISTENT_CODEI_API_KEY"
330        ));
331    }
332}