Skip to main content

opendev_models/config/
mod.rs

1//! Configuration models.
2
3mod agent;
4mod formatter;
5mod permissions;
6
7pub use agent::{AgentConfigInline, ModelVariant};
8pub use formatter::{FormatterConfig, FormatterOverride, FormatterOverrides};
9pub use permissions::{PermissionConfig, ToolPermission};
10
11use permissions::default_true;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15// ── Shared default functions used by sub-modules via `super::` ──
16
17pub(crate) fn default_temperature() -> f64 {
18    0.6
19}
20pub(crate) fn default_max_tokens() -> u32 {
21    16384
22}
23
24// ── AutoModeConfig ──
25
26/// Auto mode configuration.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct AutoModeConfig {
29    #[serde(default)]
30    pub enabled: bool,
31    #[serde(default = "default_max_operations")]
32    pub max_operations: u32,
33    #[serde(default = "default_require_confirmation_after")]
34    pub require_confirmation_after: u32,
35    #[serde(default = "default_true")]
36    pub dangerous_operations_require_approval: bool,
37}
38
39fn default_max_operations() -> u32 {
40    10
41}
42fn default_require_confirmation_after() -> u32 {
43    5
44}
45
46impl Default for AutoModeConfig {
47    fn default() -> Self {
48        Self {
49            enabled: false,
50            max_operations: 10,
51            require_confirmation_after: 5,
52            dangerous_operations_require_approval: true,
53        }
54    }
55}
56
57// ── OperationConfig ──
58
59/// Operation-specific settings.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct OperationConfig {
62    #[serde(default = "default_true")]
63    pub show_diffs: bool,
64    #[serde(default = "default_true")]
65    pub backup_before_edit: bool,
66    #[serde(default = "default_max_file_size")]
67    pub max_file_size: u64,
68    #[serde(default)]
69    pub allowed_extensions: Vec<String>,
70}
71
72fn default_max_file_size() -> u64 {
73    1_000_000
74}
75
76impl Default for OperationConfig {
77    fn default() -> Self {
78        Self {
79            show_diffs: true,
80            backup_before_edit: true,
81            max_file_size: 1_000_000,
82            allowed_extensions: Vec::new(),
83        }
84    }
85}
86
87// ── PlaybookConfig ──
88
89/// Scoring weights for ACE playbook bullet selection.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct PlaybookScoringWeights {
92    #[serde(default = "default_effectiveness")]
93    pub effectiveness: f64,
94    #[serde(default = "default_recency")]
95    pub recency: f64,
96    #[serde(default = "default_semantic")]
97    pub semantic: f64,
98}
99
100fn default_effectiveness() -> f64 {
101    0.5
102}
103fn default_recency() -> f64 {
104    0.3
105}
106fn default_semantic() -> f64 {
107    0.2
108}
109
110impl Default for PlaybookScoringWeights {
111    fn default() -> Self {
112        Self {
113            effectiveness: 0.5,
114            recency: 0.3,
115            semantic: 0.2,
116        }
117    }
118}
119
120impl PlaybookScoringWeights {
121    /// Validate that all weights are between 0.0 and 1.0.
122    pub fn validate(&self) -> Result<(), String> {
123        for (name, value) in [
124            ("effectiveness", self.effectiveness),
125            ("recency", self.recency),
126            ("semantic", self.semantic),
127        ] {
128            if !(0.0..=1.0).contains(&value) {
129                return Err(format!(
130                    "{name} weight must be between 0.0 and 1.0, got {value}"
131                ));
132            }
133        }
134        Ok(())
135    }
136}
137
138/// ACE playbook configuration.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct PlaybookConfig {
141    #[serde(default = "default_max_strategies")]
142    pub max_strategies: u32,
143    #[serde(default = "default_true")]
144    pub use_selection: bool,
145    #[serde(default = "default_embedding_model")]
146    pub embedding_model: String,
147    #[serde(default = "default_embedding_provider")]
148    pub embedding_provider: String,
149    #[serde(default)]
150    pub scoring_weights: PlaybookScoringWeights,
151    #[serde(default = "default_true")]
152    pub cache_embeddings: bool,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub cache_file: Option<String>,
155}
156
157fn default_max_strategies() -> u32 {
158    30
159}
160fn default_embedding_model() -> String {
161    "text-embedding-3-small".to_string()
162}
163fn default_embedding_provider() -> String {
164    "openai".to_string()
165}
166
167impl Default for PlaybookConfig {
168    fn default() -> Self {
169        Self {
170            max_strategies: 30,
171            use_selection: true,
172            embedding_model: "text-embedding-3-small".to_string(),
173            embedding_provider: "openai".to_string(),
174            scoring_weights: PlaybookScoringWeights::default(),
175            cache_embeddings: true,
176            cache_file: None,
177        }
178    }
179}
180
181// ── AppConfig ──
182
183/// Application configuration.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct AppConfig {
186    // AI Provider settings - Three model system
187    #[serde(default = "default_model_provider")]
188    pub model_provider: String,
189    #[serde(default = "default_model")]
190    pub model: String,
191
192    // Vision/Multi-modal model
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub model_vlm: Option<String>,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub model_vlm_provider: Option<String>,
197
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub api_key: Option<String>,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub api_base_url: Option<String>,
202    #[serde(default = "default_max_tokens")]
203    pub max_tokens: u32,
204    #[serde(default = "default_temperature")]
205    pub temperature: f64,
206
207    // Reasoning effort for models that support extended thinking ("low", "medium", "high", "none")
208    #[serde(default = "default_reasoning_effort")]
209    pub reasoning_effort: String,
210
211    // Session settings
212    #[serde(default = "default_auto_save_interval")]
213    pub auto_save_interval: u32,
214    #[serde(default = "default_max_context_tokens")]
215    pub max_context_tokens: u64,
216
217    // UI settings
218    #[serde(default)]
219    pub verbose: bool,
220    #[serde(default)]
221    pub debug_logging: bool,
222    #[serde(default = "default_color_scheme")]
223    pub color_scheme: String,
224    #[serde(default = "default_true")]
225    pub show_token_count: bool,
226    #[serde(default = "default_true")]
227    pub enable_sound: bool,
228
229    // Permissions
230    #[serde(default)]
231    pub permissions: PermissionConfig,
232
233    // Operation settings
234    #[serde(default = "default_true")]
235    pub enable_bash: bool,
236    #[serde(default = "default_bash_timeout")]
237    pub bash_timeout: u32,
238    #[serde(default)]
239    pub auto_mode: AutoModeConfig,
240    #[serde(default)]
241    pub operation: OperationConfig,
242    #[serde(default = "default_max_undo_history")]
243    pub max_undo_history: u32,
244
245    // Session intelligence
246    #[serde(default = "default_true")]
247    pub topic_detection: bool,
248
249    // ACE Playbook settings
250    #[serde(default)]
251    pub playbook: PlaybookConfig,
252
253    // Plan mode configuration
254    #[serde(default = "default_plan_mode_workflow")]
255    pub plan_mode_workflow: String,
256    #[serde(default = "default_plan_mode_explore_agent_count")]
257    pub plan_mode_explore_agent_count: u32,
258    #[serde(default = "default_plan_mode_plan_agent_count")]
259    pub plan_mode_plan_agent_count: u32,
260    #[serde(default = "default_plan_mode_explore_variant")]
261    pub plan_mode_explore_variant: String,
262
263    // Custom instructions -- file paths, glob patterns, or `~/` paths
264    #[serde(default, skip_serializing_if = "Vec::is_empty")]
265    pub instructions: Vec<String>,
266
267    // Additional skill directories -- file paths or `~/` paths
268    #[serde(default, skip_serializing_if = "Vec::is_empty")]
269    pub skill_paths: Vec<String>,
270
271    // Remote URLs to discover skills from (fetches index.json)
272    #[serde(default, skip_serializing_if = "Vec::is_empty")]
273    pub skill_urls: Vec<String>,
274
275    // Default agent to use for new sessions (e.g. "general", "explore")
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub default_agent: Option<String>,
278
279    // Inline agent definitions/overrides from config.
280    // Keys are agent identifiers (e.g. "build", "explore", or custom names).
281    // Overrides merge onto builtin agents; new keys create custom agents.
282    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
283    pub agents: HashMap<String, AgentConfigInline>,
284
285    // Model variants
286    #[serde(default)]
287    pub model_variants: HashMap<String, ModelVariant>,
288
289    // Formatter configuration (disable built-in or add custom formatters)
290    #[serde(default, skip_serializing_if = "FormatterConfig::is_default")]
291    pub formatter: FormatterConfig,
292
293    // Config version for migration support
294    #[serde(default = "default_config_version")]
295    pub config_version: u32,
296}
297
298fn default_config_version() -> u32 {
299    1
300}
301fn default_model_provider() -> String {
302    "fireworks".to_string()
303}
304fn default_model() -> String {
305    "accounts/fireworks/models/kimi-k2-instruct-0905".to_string()
306}
307fn default_auto_save_interval() -> u32 {
308    5
309}
310fn default_max_context_tokens() -> u64 {
311    100_000
312}
313fn default_color_scheme() -> String {
314    "monokai".to_string()
315}
316fn default_bash_timeout() -> u32 {
317    30
318}
319fn default_max_undo_history() -> u32 {
320    50
321}
322fn default_plan_mode_workflow() -> String {
323    "5-phase".to_string()
324}
325fn default_plan_mode_explore_agent_count() -> u32 {
326    3
327}
328fn default_plan_mode_plan_agent_count() -> u32 {
329    1
330}
331fn default_plan_mode_explore_variant() -> String {
332    "enabled".to_string()
333}
334fn default_reasoning_effort() -> String {
335    "medium".to_string()
336}
337
338impl Default for AppConfig {
339    fn default() -> Self {
340        Self {
341            model_provider: default_model_provider(),
342            model: default_model(),
343            model_vlm: None,
344            model_vlm_provider: None,
345            api_key: None,
346            api_base_url: None,
347            max_tokens: 16384,
348            temperature: 0.6,
349            reasoning_effort: "medium".to_string(),
350            auto_save_interval: 5,
351            max_context_tokens: 100_000,
352            verbose: false,
353            debug_logging: false,
354            color_scheme: "monokai".to_string(),
355            show_token_count: true,
356            enable_sound: true,
357            permissions: PermissionConfig::default(),
358            enable_bash: true,
359            bash_timeout: 30,
360            auto_mode: AutoModeConfig::default(),
361            operation: OperationConfig::default(),
362            max_undo_history: 50,
363            topic_detection: true,
364            playbook: PlaybookConfig::default(),
365            plan_mode_workflow: "5-phase".to_string(),
366            plan_mode_explore_agent_count: 3,
367            plan_mode_plan_agent_count: 1,
368            plan_mode_explore_variant: "enabled".to_string(),
369            instructions: Vec::new(),
370            skill_paths: Vec::new(),
371            skill_urls: Vec::new(),
372            default_agent: None,
373            agents: HashMap::new(),
374            model_variants: HashMap::new(),
375            formatter: FormatterConfig::default(),
376            config_version: default_config_version(),
377        }
378    }
379}
380
381impl AppConfig {
382    /// Resolve the model and provider for a named agent role (e.g. "compact").
383    ///
384    /// Looks up `self.agents[role]` and falls back to the primary model/provider.
385    pub fn resolve_agent_role(&self, role: &str) -> (String, String) {
386        if let Some(agent) = self.agents.get(role) {
387            let model = agent.model.as_deref().unwrap_or(&self.model);
388            let provider = agent.provider.as_deref().unwrap_or(&self.model_provider);
389            (model.to_string(), provider.to_string())
390        } else {
391            (self.model.clone(), self.model_provider.clone())
392        }
393    }
394
395    /// Get the API key from config or the environment.
396    ///
397    /// Resolution order:
398    /// 1. `registry_env_var` (from models.dev registry, e.g. `ZHIPU_API_KEY`)
399    /// 2. Well-known env var for the provider (hardcoded fallback)
400    /// 3. Convention-based env var: `{PROVIDER}_API_KEY` (e.g. `zai` → `ZAI_API_KEY`)
401    /// 4. `self.api_key` (stored in config by the setup wizard)
402    /// 5. `OPENAI_API_KEY` (last resort for truly unknown providers)
403    pub fn get_api_key_with_env(&self, registry_env_var: Option<&str>) -> Result<String, String> {
404        // Try registry env var first (authoritative for models.dev providers)
405        if let Some(env_var) = registry_env_var
406            && !env_var.is_empty()
407            && let Ok(key) = std::env::var(env_var)
408            && !key.is_empty()
409        {
410            return Ok(key);
411        }
412
413        // Try well-known env var for built-in providers
414        let builtin_env = Self::builtin_env_var(&self.model_provider);
415        if !builtin_env.is_empty()
416            && let Ok(key) = std::env::var(builtin_env)
417            && !key.is_empty()
418        {
419            return Ok(key);
420        }
421
422        // Convention-based: derive env var from provider ID → {PROVIDER}_API_KEY
423        // e.g. "zai" → "ZAI_API_KEY", "siliconflow" → "SILICONFLOW_API_KEY"
424        let convention_env = Self::convention_env_var(&self.model_provider);
425        if !convention_env.is_empty()
426            && convention_env != builtin_env
427            && registry_env_var != Some(convention_env.as_str())
428            && let Ok(key) = std::env::var(&convention_env)
429            && !key.is_empty()
430        {
431            return Ok(key);
432        }
433
434        // Fall back to config-stored API key (from setup wizard)
435        if let Some(ref key) = self.api_key {
436            return Ok(key.clone());
437        }
438
439        // Last resort: try OPENAI_API_KEY for unknown providers
440        if builtin_env.is_empty()
441            && registry_env_var.is_none_or(str::is_empty)
442            && let Ok(key) = std::env::var("OPENAI_API_KEY")
443            && !key.is_empty()
444        {
445            return Ok(key);
446        }
447
448        let hint = registry_env_var
449            .filter(|s| !s.is_empty())
450            .or(Some(builtin_env).filter(|s| !s.is_empty()))
451            .or(Some(convention_env.as_str()).filter(|s| !s.is_empty()))
452            .unwrap_or("OPENAI_API_KEY");
453        Err(format!(
454            "No API key found. Set {} environment variable",
455            hint
456        ))
457    }
458
459    /// Convenience wrapper that calls [`get_api_key_with_env`] without registry info.
460    pub fn get_api_key(&self) -> Result<String, String> {
461        self.get_api_key_with_env(None)
462    }
463
464    /// Map well-known provider IDs to their conventional env var names.
465    /// Only covers providers that predate the models.dev registry.
466    fn builtin_env_var(provider: &str) -> &'static str {
467        match provider {
468            "fireworks" | "fireworks-ai" => "FIREWORKS_API_KEY",
469            "anthropic" => "ANTHROPIC_API_KEY",
470            "openai" => "OPENAI_API_KEY",
471            "azure" => "AZURE_OPENAI_API_KEY",
472            "groq" => "GROQ_API_KEY",
473            "mistral" => "MISTRAL_API_KEY",
474            "deepinfra" => "DEEPINFRA_API_KEY",
475            "openrouter" => "OPENROUTER_API_KEY",
476            "deepseek" => "DEEPSEEK_API_KEY",
477            "cohere" => "COHERE_API_KEY",
478            "togetherai" | "together" => "TOGETHER_API_KEY",
479            "perplexity" | "perplexity-agent" => "PERPLEXITY_API_KEY",
480            "xai" => "XAI_API_KEY",
481            "google" | "gemini" => "GOOGLE_GENERATIVE_AI_API_KEY",
482            _ => "",
483        }
484    }
485
486    /// Derive a convention-based env var from the provider ID.
487    ///
488    /// Strips common suffixes like `-coding-plan`, `-cn`, `-agent`, then
489    /// uppercases and converts hyphens to underscores: `zai` → `ZAI_API_KEY`,
490    /// `siliconflow-cn` → `SILICONFLOW_API_KEY`.
491    fn convention_env_var(provider: &str) -> String {
492        // Strip common suffixes that don't affect the key name
493        let base = provider
494            .strip_suffix("-coding-plan")
495            .or_else(|| provider.strip_suffix("-cn"))
496            .or_else(|| provider.strip_suffix("-agent"))
497            .unwrap_or(provider);
498        if base.is_empty() {
499            return String::new();
500        }
501        format!("{}_API_KEY", base.to_uppercase().replace('-', "_"))
502    }
503}
504
505#[cfg(test)]
506mod tests;