use anyhow::Result; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; /// Main configuration structure #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { pub providers: ProvidersConfig, #[serde(default)] pub agent: AgentConfig, #[serde(default)] pub computer_control: ComputerControlConfig, #[serde(default)] pub webdriver: WebDriverConfig, } /// Provider configuration with named configs per provider type #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProvidersConfig { /// Default provider in format "." pub default_provider: String, /// Provider for planner mode (optional, falls back to default_provider) pub planner: Option, /// Provider for coach in autonomous mode (optional, falls back to default_provider) pub coach: Option, /// Provider for player in autonomous mode (optional, falls back to default_provider) pub player: Option, /// Named Anthropic provider configs #[serde(default)] pub anthropic: HashMap, /// Named OpenAI provider configs #[serde(default)] pub openai: HashMap, /// Named Databricks provider configs #[serde(default)] pub databricks: HashMap, /// Named embedded provider configs #[serde(default)] pub embedded: HashMap, /// Named Gemini provider configs #[serde(default)] pub gemini: HashMap, /// Multiple named OpenAI-compatible providers (e.g., openrouter, groq, etc.) #[serde(default)] pub openai_compatible: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OpenAIConfig { pub api_key: String, pub model: String, pub base_url: Option, pub max_tokens: Option, pub temperature: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnthropicConfig { pub api_key: String, pub model: String, pub max_tokens: Option, pub temperature: Option, pub cache_config: Option, pub enable_1m_context: Option, pub thinking_budget_tokens: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DatabricksConfig { pub host: String, pub token: Option, pub model: String, pub max_tokens: Option, pub temperature: Option, pub use_oauth: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EmbeddedConfig { pub model_path: String, pub model_type: String, pub context_length: Option, pub max_tokens: Option, pub temperature: Option, pub gpu_layers: Option, pub threads: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GeminiConfig { pub api_key: String, pub model: String, pub max_tokens: Option, pub temperature: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentConfig { pub max_context_length: Option, #[serde(default = "default_fallback_max_tokens")] pub fallback_default_max_tokens: usize, #[serde(default = "default_true")] pub enable_streaming: bool, #[serde(default = "default_timeout_seconds")] pub timeout_seconds: u64, #[serde(default = "default_true")] pub auto_compact: bool, #[serde(default = "default_max_retry_attempts")] pub max_retry_attempts: u32, #[serde(default = "default_autonomous_max_retry_attempts")] pub autonomous_max_retry_attempts: u32, #[serde(default = "default_check_todo_staleness")] pub check_todo_staleness: bool, } fn default_fallback_max_tokens() -> usize { 32000 } fn default_true() -> bool { true } fn default_false() -> bool { false } fn default_timeout_seconds() -> u64 { 120 } fn default_max_retry_attempts() -> u32 { 3 } fn default_autonomous_max_retry_attempts() -> u32 { 6 } fn default_max_actions_per_second() -> u32 { 5 } fn default_check_todo_staleness() -> bool { true } fn default_safari_port() -> u16 { 4444 } fn default_chrome_port() -> u16 { 9515 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ComputerControlConfig { #[serde(default = "default_true")] pub enabled: bool, #[serde(default = "default_false")] pub require_confirmation: bool, #[serde(default = "default_max_actions_per_second")] pub max_actions_per_second: u32, } /// Browser type for WebDriver #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(rename_all = "lowercase")] pub enum WebDriverBrowser { Safari, #[default] #[serde(rename = "chrome-headless")] ChromeHeadless, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct WebDriverConfig { #[serde(default = "default_true")] pub enabled: bool, #[serde(default = "default_safari_port")] pub safari_port: u16, #[serde(default = "default_chrome_port")] pub chrome_port: u16, #[serde(default)] /// Optional path to Chrome binary (e.g., Chrome for Testing) /// If not set, ChromeDriver will use the default Chrome installation pub chrome_binary: Option, #[serde(default)] /// Optional path to ChromeDriver binary /// If not set, looks for 'chromedriver' in PATH pub chromedriver_binary: Option, #[serde(default)] pub browser: WebDriverBrowser, } impl Default for AgentConfig { fn default() -> Self { Self { max_context_length: None, fallback_default_max_tokens: 32000, enable_streaming: true, timeout_seconds: 120, auto_compact: true, max_retry_attempts: 3, autonomous_max_retry_attempts: 6, check_todo_staleness: true, } } } impl Default for ComputerControlConfig { fn default() -> Self { Self { enabled: true, require_confirmation: false, max_actions_per_second: 5, } } } impl Default for Config { fn default() -> Self { let mut databricks_configs = HashMap::new(); databricks_configs.insert( "default".to_string(), DatabricksConfig { host: "https://your-workspace.cloud.databricks.com".to_string(), token: None, model: "databricks-claude-sonnet-4".to_string(), max_tokens: Some(4096), temperature: Some(0.1), use_oauth: Some(true), }, ); Self { providers: ProvidersConfig { default_provider: "databricks.default".to_string(), planner: None, coach: None, player: None, anthropic: HashMap::new(), openai: HashMap::new(), databricks: databricks_configs, embedded: HashMap::new(), gemini: HashMap::new(), openai_compatible: HashMap::new(), }, agent: AgentConfig { max_context_length: None, fallback_default_max_tokens: 32000, enable_streaming: true, timeout_seconds: 60, auto_compact: true, max_retry_attempts: 3, autonomous_max_retry_attempts: 6, check_todo_staleness: true, }, computer_control: ComputerControlConfig::default(), webdriver: WebDriverConfig::default(), } } } /// Error message for old config format const OLD_CONFIG_FORMAT_ERROR: &str = r#"Your configuration file uses an old format that is no longer supported. Please update your configuration to use the new provider format: ```toml [providers] default_provider = "anthropic.default" # Format: "." planner = "anthropic.planner" # Optional: specific provider for planner coach = "anthropic.default" # Optional: specific provider for coach player = "openai.player" # Optional: specific provider for player # Named configs per provider type [providers.anthropic.default] api_key = "your-api-key" model = "claude-sonnet-4-5" max_tokens = 64000 [providers.anthropic.planner] api_key = "your-api-key" model = "claude-opus-4-5" thinking_budget_tokens = 16000 [providers.openai.player] api_key = "your-api-key" model = "gpt-5" ``` Each mode (planner, coach, player) can specify a full path like ".". If not specified, they fall back to `default_provider`."#; impl Config { pub fn load(config_path: Option<&str>) -> Result { // Check if any config file exists let config_exists = if let Some(path) = config_path { Path::new(path).exists() } else { let default_paths = ["./g3.toml", "~/.config/g3/config.toml", "~/.g3.toml"]; default_paths.iter().any(|path| { let expanded_path = shellexpand::tilde(path); Path::new(expanded_path.as_ref()).exists() }) }; // If no config exists, create and save a default config if !config_exists { let default_config = Self::default(); let config_dir = dirs::home_dir() .map(|mut path| { path.push(".config"); path.push("g3"); path }) .unwrap_or_else(|| std::path::PathBuf::from(".")); std::fs::create_dir_all(&config_dir).ok(); let config_file = config_dir.join("config.toml"); if let Err(e) = default_config.save(config_file.to_str().unwrap()) { eprintln!("Warning: Could not save default config: {}", e); } else { println!( "Created default configuration at: {}", config_file.display() ); } return Ok(default_config); } // Load config from file let config_path_to_load = if let Some(path) = config_path { Some(path.to_string()) } else { let default_paths = ["./g3.toml", "~/.config/g3/config.toml", "~/.g3.toml"]; default_paths.iter().find_map(|path| { let expanded_path = shellexpand::tilde(path); if Path::new(expanded_path.as_ref()).exists() { Some(expanded_path.to_string()) } else { None } }) }; if let Some(path) = config_path_to_load { // Read and parse the config file let config_content = std::fs::read_to_string(&path)?; // Check for old format (direct provider config without named configs) if Self::is_old_format(&config_content) { anyhow::bail!("{}", OLD_CONFIG_FORMAT_ERROR); } let config: Config = toml::from_str(&config_content)?; // Validate the default_provider format config.validate_provider_reference(&config.providers.default_provider)?; return Ok(config); } Ok(Self::default()) } /// Check if the config content uses the old format fn is_old_format(content: &str) -> bool { // Old format has [providers.anthropic] with api_key directly // New format has [providers.anthropic.] with api_key // Parse as TOML value to inspect structure if let Ok(value) = content.parse::() { if let Some(providers) = value.get("providers") { if let Some(providers_table) = providers.as_table() { // Check anthropic section if let Some(anthropic) = providers_table.get("anthropic") { if let Some(anthropic_table) = anthropic.as_table() { // If anthropic has api_key directly, it's old format if anthropic_table.contains_key("api_key") { return true; } } } // Check databricks section if let Some(databricks) = providers_table.get("databricks") { if let Some(databricks_table) = databricks.as_table() { // If databricks has host directly, it's old format if databricks_table.contains_key("host") { return true; } } } // Check openai section if let Some(openai) = providers_table.get("openai") { if let Some(openai_table) = openai.as_table() { // If openai has api_key directly, it's old format if openai_table.contains_key("api_key") { return true; } } } } } } false } /// Validate a provider reference (format: ".") fn validate_provider_reference(&self, reference: &str) -> Result<()> { let parts: Vec<&str> = reference.split('.').collect(); if parts.len() != 2 { anyhow::bail!( "Invalid provider reference '{}'. Expected format: '.'", reference ); } let (provider_type, config_name) = (parts[0], parts[1]); match provider_type { "anthropic" => { if !self.providers.anthropic.contains_key(config_name) { anyhow::bail!( "Provider config 'anthropic.{}' not found. Available: {:?}", config_name, self.providers.anthropic.keys().collect::>() ); } } "openai" => { if !self.providers.openai.contains_key(config_name) { anyhow::bail!( "Provider config 'openai.{}' not found. Available: {:?}", config_name, self.providers.openai.keys().collect::>() ); } } "databricks" => { if !self.providers.databricks.contains_key(config_name) { anyhow::bail!( "Provider config 'databricks.{}' not found. Available: {:?}", config_name, self.providers.databricks.keys().collect::>() ); } } "embedded" => { if !self.providers.embedded.contains_key(config_name) { anyhow::bail!( "Provider config 'embedded.{}' not found. Available: {:?}", config_name, self.providers.embedded.keys().collect::>() ); } } "gemini" => { if !self.providers.gemini.contains_key(config_name) { anyhow::bail!( "Provider config 'gemini.{}' not found. Available: {:?}", config_name, self.providers.gemini.keys().collect::>() ); } } _ => { // Check openai_compatible providers if !self.providers.openai_compatible.contains_key(provider_type) { anyhow::bail!( "Unknown provider type '{}'. Valid types: anthropic, openai, databricks, embedded, gemini, or openai_compatible names", provider_type ); } } } Ok(()) } /// Parse a provider reference into (provider_type, config_name) pub fn parse_provider_reference(reference: &str) -> Result<(String, String)> { let parts: Vec<&str> = reference.split('.').collect(); if parts.len() != 2 { anyhow::bail!( "Invalid provider reference '{}'. Expected format: '.'", reference ); } Ok((parts[0].to_string(), parts[1].to_string())) } pub fn save(&self, path: &str) -> Result<()> { let toml_string = toml::to_string_pretty(self)?; std::fs::write(path, toml_string)?; Ok(()) } pub fn load_with_overrides( config_path: Option<&str>, provider_override: Option, model_override: Option, ) -> Result { let mut config = Self::load(config_path)?; // Apply provider override if let Some(provider) = provider_override { // If provider doesn't contain '.', assume '.default' let provider = if provider.contains('.') { provider } else { format!("{}.default", provider) }; config.validate_provider_reference(&provider)?; config.providers.default_provider = provider; } // Apply model override to the active provider if let Some(model) = model_override { let (provider_type, config_name) = Self::parse_provider_reference(&config.providers.default_provider)?; match provider_type.as_str() { "anthropic" => { if let Some(ref mut anthropic_config) = config.providers.anthropic.get_mut(&config_name) { anthropic_config.model = model; } else { return Err(anyhow::anyhow!( "Provider config 'anthropic.{}' not found.", config_name )); } } "databricks" => { if let Some(ref mut databricks_config) = config.providers.databricks.get_mut(&config_name) { databricks_config.model = model; } else { return Err(anyhow::anyhow!( "Provider config 'databricks.{}' not found.", config_name )); } } "embedded" => { if let Some(ref mut embedded_config) = config.providers.embedded.get_mut(&config_name) { embedded_config.model_path = model; } else { return Err(anyhow::anyhow!( "Provider config 'embedded.{}' not found.", config_name )); } } "openai" => { if let Some(ref mut openai_config) = config.providers.openai.get_mut(&config_name) { openai_config.model = model; } else { return Err(anyhow::anyhow!( "Provider config 'openai.{}' not found.", config_name )); } } "gemini" => { if let Some(ref mut gemini_config) = config.providers.gemini.get_mut(&config_name) { gemini_config.model = model; } else { return Err(anyhow::anyhow!( "Provider config 'gemini.{}' not found.", config_name )); } } _ => { // Check openai_compatible if let Some(ref mut compat_config) = config.providers.openai_compatible.get_mut(&provider_type) { compat_config.model = model; } else { return Err(anyhow::anyhow!("Unknown provider type: {}", provider_type)); } } } } Ok(config) } /// Get the provider reference for planner mode pub fn get_planner_provider(&self) -> &str { self.providers .planner .as_deref() .unwrap_or(&self.providers.default_provider) } /// Get the provider reference for coach mode in autonomous execution pub fn get_coach_provider(&self) -> &str { self.providers .coach .as_deref() .unwrap_or(&self.providers.default_provider) } /// Get the provider reference for player mode in autonomous execution pub fn get_player_provider(&self) -> &str { self.providers .player .as_deref() .unwrap_or(&self.providers.default_provider) } /// Create a copy of the config with a different default provider pub fn with_provider_override(&self, provider_ref: &str) -> Result { // Validate that the provider is configured self.validate_provider_reference(provider_ref)?; let mut config = self.clone(); config.providers.default_provider = provider_ref.to_string(); Ok(config) } /// Create a copy of the config for planner mode pub fn for_planner(&self) -> Result { self.with_provider_override(self.get_planner_provider()) } /// Create a copy of the config for coach mode in autonomous execution pub fn for_coach(&self) -> Result { self.with_provider_override(self.get_coach_provider()) } /// Create a copy of the config for player mode in autonomous execution pub fn for_player(&self) -> Result { self.with_provider_override(self.get_player_provider()) } /// Get Anthropic config by name pub fn get_anthropic_config(&self, name: &str) -> Option<&AnthropicConfig> { self.providers.anthropic.get(name) } /// Get OpenAI config by name pub fn get_openai_config(&self, name: &str) -> Option<&OpenAIConfig> { self.providers.openai.get(name) } /// Get Databricks config by name pub fn get_databricks_config(&self, name: &str) -> Option<&DatabricksConfig> { self.providers.databricks.get(name) } /// Get Embedded config by name pub fn get_embedded_config(&self, name: &str) -> Option<&EmbeddedConfig> { self.providers.embedded.get(name) } /// Get Gemini config by name pub fn get_gemini_config(&self, name: &str) -> Option<&GeminiConfig> { self.providers.gemini.get(name) } /// Get the current default provider's config pub fn get_default_provider_config(&self) -> Result> { let (provider_type, config_name) = Self::parse_provider_reference(&self.providers.default_provider)?; match provider_type.as_str() { "anthropic" => self .providers .anthropic .get(&config_name) .map(ProviderConfigRef::Anthropic) .ok_or_else(|| anyhow::anyhow!("Anthropic config '{}' not found", config_name)), "openai" => self .providers .openai .get(&config_name) .map(ProviderConfigRef::OpenAI) .ok_or_else(|| anyhow::anyhow!("OpenAI config '{}' not found", config_name)), "databricks" => self .providers .databricks .get(&config_name) .map(ProviderConfigRef::Databricks) .ok_or_else(|| anyhow::anyhow!("Databricks config '{}' not found", config_name)), "embedded" => self .providers .embedded .get(&config_name) .map(ProviderConfigRef::Embedded) .ok_or_else(|| anyhow::anyhow!("Embedded config '{}' not found", config_name)), "gemini" => self .providers .gemini .get(&config_name) .map(ProviderConfigRef::Gemini) .ok_or_else(|| anyhow::anyhow!("Gemini config '{}' not found", config_name)), _ => self .providers .openai_compatible .get(&provider_type) .map(ProviderConfigRef::OpenAICompatible) .ok_or_else(|| { anyhow::anyhow!("OpenAI compatible config '{}' not found", provider_type) }), } } } /// Reference to a provider configuration #[derive(Debug)] pub enum ProviderConfigRef<'a> { Anthropic(&'a AnthropicConfig), OpenAI(&'a OpenAIConfig), Databricks(&'a DatabricksConfig), Embedded(&'a EmbeddedConfig), Gemini(&'a GeminiConfig), OpenAICompatible(&'a OpenAIConfig), } #[cfg(test)] mod tests;