1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::Path;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Config {
9 pub providers: ProvidersConfig,
10 pub agent: AgentConfig,
11 pub computer_control: ComputerControlConfig,
12 pub webdriver: WebDriverConfig,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ProvidersConfig {
18 pub default_provider: String,
20
21 pub planner: Option<String>,
23
24 pub coach: Option<String>,
26
27 pub player: Option<String>,
29
30 #[serde(default)]
32 pub anthropic: HashMap<String, AnthropicConfig>,
33
34 #[serde(default)]
36 pub openai: HashMap<String, OpenAIConfig>,
37
38 #[serde(default)]
40 pub databricks: HashMap<String, DatabricksConfig>,
41
42 #[serde(default)]
44 pub embedded: HashMap<String, EmbeddedConfig>,
45
46 #[serde(default)]
48 pub openai_compatible: HashMap<String, OpenAIConfig>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct OpenAIConfig {
53 pub api_key: String,
54 pub model: String,
55 pub base_url: Option<String>,
56 pub max_tokens: Option<u32>,
57 pub temperature: Option<f32>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct AnthropicConfig {
62 pub api_key: String,
63 pub model: String,
64 pub max_tokens: Option<u32>,
65 pub temperature: Option<f32>,
66 pub cache_config: Option<String>,
67 pub enable_1m_context: Option<bool>,
68 pub thinking_budget_tokens: Option<u32>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct DatabricksConfig {
73 pub host: String,
74 pub token: Option<String>,
75 pub model: String,
76 pub max_tokens: Option<u32>,
77 pub temperature: Option<f32>,
78 pub use_oauth: Option<bool>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct EmbeddedConfig {
83 pub model_path: String,
84 pub model_type: String,
85 pub context_length: Option<u32>,
86 pub max_tokens: Option<u32>,
87 pub temperature: Option<f32>,
88 pub gpu_layers: Option<u32>,
89 pub threads: Option<u32>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct AgentConfig {
94 pub max_context_length: Option<u32>,
95 pub fallback_default_max_tokens: usize,
96 pub enable_streaming: bool,
97 pub timeout_seconds: u64,
98 pub auto_compact: bool,
99 pub max_retry_attempts: u32,
100 pub autonomous_max_retry_attempts: u32,
101 #[serde(default = "default_check_todo_staleness")]
102 pub check_todo_staleness: bool,
103}
104
105fn default_check_todo_staleness() -> bool {
106 true
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct ComputerControlConfig {
111 pub enabled: bool,
112 pub require_confirmation: bool,
113 pub max_actions_per_second: u32,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
118#[serde(rename_all = "lowercase")]
119pub enum WebDriverBrowser {
120 #[default]
121 Safari,
122 #[serde(rename = "chrome-headless")]
123 ChromeHeadless,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct WebDriverConfig {
128 pub enabled: bool,
129 pub safari_port: u16,
130 #[serde(default)]
131 pub chrome_port: u16,
132 #[serde(default)]
133 pub chrome_binary: Option<String>,
136 #[serde(default)]
137 pub browser: WebDriverBrowser,
138}
139
140impl Default for WebDriverConfig {
141 fn default() -> Self {
142 Self {
143 enabled: true,
144 safari_port: 4444,
145 chrome_port: 9515,
146 chrome_binary: None,
147 browser: WebDriverBrowser::Safari,
148 }
149 }
150}
151
152impl Default for ComputerControlConfig {
153 fn default() -> Self {
154 Self {
155 enabled: false,
156 require_confirmation: true,
157 max_actions_per_second: 5,
158 }
159 }
160}
161
162impl Default for Config {
163 fn default() -> Self {
164 let mut databricks_configs = HashMap::new();
165 databricks_configs.insert(
166 "default".to_string(),
167 DatabricksConfig {
168 host: "https://your-workspace.cloud.databricks.com".to_string(),
169 token: None,
170 model: "databricks-claude-sonnet-4".to_string(),
171 max_tokens: Some(4096),
172 temperature: Some(0.1),
173 use_oauth: Some(true),
174 },
175 );
176
177 Self {
178 providers: ProvidersConfig {
179 default_provider: "databricks.default".to_string(),
180 planner: None,
181 coach: None,
182 player: None,
183 anthropic: HashMap::new(),
184 openai: HashMap::new(),
185 databricks: databricks_configs,
186 embedded: HashMap::new(),
187 openai_compatible: HashMap::new(),
188 },
189 agent: AgentConfig {
190 max_context_length: None,
191 fallback_default_max_tokens: 8192,
192 enable_streaming: true,
193 timeout_seconds: 60,
194 auto_compact: true,
195 max_retry_attempts: 3,
196 autonomous_max_retry_attempts: 6,
197 check_todo_staleness: true,
198 },
199 computer_control: ComputerControlConfig::default(),
200 webdriver: WebDriverConfig::default(),
201 }
202 }
203}
204
205const OLD_CONFIG_FORMAT_ERROR: &str = r#"Your configuration file uses an old format that is no longer supported.
207
208Please update your configuration to use the new provider format:
209
210```toml
211[providers]
212default_provider = "anthropic.default" # Format: "<provider_type>.<config_name>"
213planner = "anthropic.planner" # Optional: specific provider for planner
214coach = "anthropic.default" # Optional: specific provider for coach
215player = "openai.player" # Optional: specific provider for player
216
217# Named configs per provider type
218[providers.anthropic.default]
219api_key = "your-api-key"
220model = "claude-sonnet-4-5"
221max_tokens = 64000
222
223[providers.anthropic.planner]
224api_key = "your-api-key"
225model = "claude-opus-4-5"
226thinking_budget_tokens = 16000
227
228[providers.openai.player]
229api_key = "your-api-key"
230model = "gpt-5"
231```
232
233Each mode (planner, coach, player) can specify a full path like "<provider_type>.<config_name>".
234If not specified, they fall back to `default_provider`."#;
235
236impl Config {
237 pub fn load(config_path: Option<&str>) -> Result<Self> {
238 let config_exists = if let Some(path) = config_path {
240 Path::new(path).exists()
241 } else {
242 let default_paths = ["./g3.toml", "~/.config/g3/config.toml", "~/.g3.toml"];
243 default_paths.iter().any(|path| {
244 let expanded_path = shellexpand::tilde(path);
245 Path::new(expanded_path.as_ref()).exists()
246 })
247 };
248
249 if !config_exists {
251 let default_config = Self::default();
252
253 let config_dir = dirs::home_dir()
254 .map(|mut path| {
255 path.push(".config");
256 path.push("g3");
257 path
258 })
259 .unwrap_or_else(|| std::path::PathBuf::from("."));
260
261 std::fs::create_dir_all(&config_dir).ok();
262
263 let config_file = config_dir.join("config.toml");
264 if let Err(e) = default_config.save(config_file.to_str().unwrap()) {
265 eprintln!("Warning: Could not save default config: {}", e);
266 } else {
267 println!(
268 "Created default configuration at: {}",
269 config_file.display()
270 );
271 }
272
273 return Ok(default_config);
274 }
275
276 let config_path_to_load = if let Some(path) = config_path {
278 Some(path.to_string())
279 } else {
280 let default_paths = ["./g3.toml", "~/.config/g3/config.toml", "~/.g3.toml"];
281 default_paths.iter().find_map(|path| {
282 let expanded_path = shellexpand::tilde(path);
283 if Path::new(expanded_path.as_ref()).exists() {
284 Some(expanded_path.to_string())
285 } else {
286 None
287 }
288 })
289 };
290
291 if let Some(path) = config_path_to_load {
292 let config_content = std::fs::read_to_string(&path)?;
294
295 if Self::is_old_format(&config_content) {
297 anyhow::bail!("{}", OLD_CONFIG_FORMAT_ERROR);
298 }
299
300 let config: Config = toml::from_str(&config_content)?;
301
302 config.validate_provider_reference(&config.providers.default_provider)?;
304
305 return Ok(config);
306 }
307
308 Ok(Self::default())
309 }
310
311 fn is_old_format(content: &str) -> bool {
313 if let Ok(value) = content.parse::<toml::Value>() {
318 if let Some(providers) = value.get("providers") {
319 if let Some(providers_table) = providers.as_table() {
320 if let Some(anthropic) = providers_table.get("anthropic") {
322 if let Some(anthropic_table) = anthropic.as_table() {
323 if anthropic_table.contains_key("api_key") {
325 return true;
326 }
327 }
328 }
329 if let Some(databricks) = providers_table.get("databricks") {
331 if let Some(databricks_table) = databricks.as_table() {
332 if databricks_table.contains_key("host") {
334 return true;
335 }
336 }
337 }
338 if let Some(openai) = providers_table.get("openai") {
340 if let Some(openai_table) = openai.as_table() {
341 if openai_table.contains_key("api_key") {
343 return true;
344 }
345 }
346 }
347 }
348 }
349 }
350 false
351 }
352
353 fn validate_provider_reference(&self, reference: &str) -> Result<()> {
355 let parts: Vec<&str> = reference.split('.').collect();
356 if parts.len() != 2 {
357 anyhow::bail!(
358 "Invalid provider reference '{}'. Expected format: '<provider_type>.<config_name>'",
359 reference
360 );
361 }
362
363 let (provider_type, config_name) = (parts[0], parts[1]);
364
365 match provider_type {
366 "anthropic" => {
367 if !self.providers.anthropic.contains_key(config_name) {
368 anyhow::bail!(
369 "Provider config 'anthropic.{}' not found. Available: {:?}",
370 config_name,
371 self.providers.anthropic.keys().collect::<Vec<_>>()
372 );
373 }
374 }
375 "openai" => {
376 if !self.providers.openai.contains_key(config_name) {
377 anyhow::bail!(
378 "Provider config 'openai.{}' not found. Available: {:?}",
379 config_name,
380 self.providers.openai.keys().collect::<Vec<_>>()
381 );
382 }
383 }
384 "databricks" => {
385 if !self.providers.databricks.contains_key(config_name) {
386 anyhow::bail!(
387 "Provider config 'databricks.{}' not found. Available: {:?}",
388 config_name,
389 self.providers.databricks.keys().collect::<Vec<_>>()
390 );
391 }
392 }
393 "embedded" => {
394 if !self.providers.embedded.contains_key(config_name) {
395 anyhow::bail!(
396 "Provider config 'embedded.{}' not found. Available: {:?}",
397 config_name,
398 self.providers.embedded.keys().collect::<Vec<_>>()
399 );
400 }
401 }
402 _ => {
403 if !self.providers.openai_compatible.contains_key(provider_type) {
405 anyhow::bail!(
406 "Unknown provider type '{}'. Valid types: anthropic, openai, databricks, embedded, or openai_compatible names",
407 provider_type
408 );
409 }
410 }
411 }
412
413 Ok(())
414 }
415
416 pub fn parse_provider_reference(reference: &str) -> Result<(String, String)> {
418 let parts: Vec<&str> = reference.split('.').collect();
419 if parts.len() != 2 {
420 anyhow::bail!(
421 "Invalid provider reference '{}'. Expected format: '<provider_type>.<config_name>'",
422 reference
423 );
424 }
425 Ok((parts[0].to_string(), parts[1].to_string()))
426 }
427
428 pub fn save(&self, path: &str) -> Result<()> {
429 let toml_string = toml::to_string_pretty(self)?;
430 std::fs::write(path, toml_string)?;
431 Ok(())
432 }
433
434 pub fn load_with_overrides(
435 config_path: Option<&str>,
436 provider_override: Option<String>,
437 model_override: Option<String>,
438 ) -> Result<Self> {
439 let mut config = Self::load(config_path)?;
440
441 if let Some(provider) = provider_override {
443 config.validate_provider_reference(&provider)?;
445 config.providers.default_provider = provider;
446 }
447
448 if let Some(model) = model_override {
450 let (provider_type, config_name) = Self::parse_provider_reference(
451 &config.providers.default_provider
452 )?;
453
454 match provider_type.as_str() {
455 "anthropic" => {
456 if let Some(ref mut anthropic_config) = config.providers.anthropic.get_mut(&config_name) {
457 anthropic_config.model = model;
458 } else {
459 return Err(anyhow::anyhow!(
460 "Provider config 'anthropic.{}' not found.",
461 config_name
462 ));
463 }
464 }
465 "databricks" => {
466 if let Some(ref mut databricks_config) = config.providers.databricks.get_mut(&config_name) {
467 databricks_config.model = model;
468 } else {
469 return Err(anyhow::anyhow!(
470 "Provider config 'databricks.{}' not found.",
471 config_name
472 ));
473 }
474 }
475 "embedded" => {
476 if let Some(ref mut embedded_config) = config.providers.embedded.get_mut(&config_name) {
477 embedded_config.model_path = model;
478 } else {
479 return Err(anyhow::anyhow!(
480 "Provider config 'embedded.{}' not found.",
481 config_name
482 ));
483 }
484 }
485 "openai" => {
486 if let Some(ref mut openai_config) = config.providers.openai.get_mut(&config_name) {
487 openai_config.model = model;
488 } else {
489 return Err(anyhow::anyhow!(
490 "Provider config 'openai.{}' not found.",
491 config_name
492 ));
493 }
494 }
495 _ => {
496 if let Some(ref mut compat_config) = config.providers.openai_compatible.get_mut(&provider_type) {
498 compat_config.model = model;
499 } else {
500 return Err(anyhow::anyhow!(
501 "Unknown provider type: {}",
502 provider_type
503 ));
504 }
505 }
506 }
507 }
508
509 Ok(config)
510 }
511
512 pub fn get_planner_provider(&self) -> &str {
514 self.providers
515 .planner
516 .as_deref()
517 .unwrap_or(&self.providers.default_provider)
518 }
519
520 pub fn get_coach_provider(&self) -> &str {
522 self.providers
523 .coach
524 .as_deref()
525 .unwrap_or(&self.providers.default_provider)
526 }
527
528 pub fn get_player_provider(&self) -> &str {
530 self.providers
531 .player
532 .as_deref()
533 .unwrap_or(&self.providers.default_provider)
534 }
535
536 pub fn with_provider_override(&self, provider_ref: &str) -> Result<Self> {
538 self.validate_provider_reference(provider_ref)?;
540
541 let mut config = self.clone();
542 config.providers.default_provider = provider_ref.to_string();
543 Ok(config)
544 }
545
546 pub fn for_planner(&self) -> Result<Self> {
548 self.with_provider_override(self.get_planner_provider())
549 }
550
551 pub fn for_coach(&self) -> Result<Self> {
553 self.with_provider_override(self.get_coach_provider())
554 }
555
556 pub fn for_player(&self) -> Result<Self> {
558 self.with_provider_override(self.get_player_provider())
559 }
560
561 pub fn get_anthropic_config(&self, name: &str) -> Option<&AnthropicConfig> {
563 self.providers.anthropic.get(name)
564 }
565
566 pub fn get_openai_config(&self, name: &str) -> Option<&OpenAIConfig> {
568 self.providers.openai.get(name)
569 }
570
571 pub fn get_databricks_config(&self, name: &str) -> Option<&DatabricksConfig> {
573 self.providers.databricks.get(name)
574 }
575
576 pub fn get_embedded_config(&self, name: &str) -> Option<&EmbeddedConfig> {
578 self.providers.embedded.get(name)
579 }
580
581 pub fn get_default_provider_config(&self) -> Result<ProviderConfigRef<'_>> {
583 let (provider_type, config_name) = Self::parse_provider_reference(
584 &self.providers.default_provider
585 )?;
586
587 match provider_type.as_str() {
588 "anthropic" => {
589 self.providers.anthropic.get(&config_name)
590 .map(ProviderConfigRef::Anthropic)
591 .ok_or_else(|| anyhow::anyhow!("Anthropic config '{}' not found", config_name))
592 }
593 "openai" => {
594 self.providers.openai.get(&config_name)
595 .map(ProviderConfigRef::OpenAI)
596 .ok_or_else(|| anyhow::anyhow!("OpenAI config '{}' not found", config_name))
597 }
598 "databricks" => {
599 self.providers.databricks.get(&config_name)
600 .map(ProviderConfigRef::Databricks)
601 .ok_or_else(|| anyhow::anyhow!("Databricks config '{}' not found", config_name))
602 }
603 "embedded" => {
604 self.providers.embedded.get(&config_name)
605 .map(ProviderConfigRef::Embedded)
606 .ok_or_else(|| anyhow::anyhow!("Embedded config '{}' not found", config_name))
607 }
608 _ => {
609 self.providers.openai_compatible.get(&provider_type)
610 .map(ProviderConfigRef::OpenAICompatible)
611 .ok_or_else(|| anyhow::anyhow!("OpenAI compatible config '{}' not found", provider_type))
612 }
613 }
614 }
615}
616
617#[derive(Debug)]
619pub enum ProviderConfigRef<'a> {
620 Anthropic(&'a AnthropicConfig),
621 OpenAI(&'a OpenAIConfig),
622 Databricks(&'a DatabricksConfig),
623 Embedded(&'a EmbeddedConfig),
624 OpenAICompatible(&'a OpenAIConfig),
625}
626
627#[cfg(test)]
628mod tests;