//! Global configuration: TOML file, defaults and CLI overrides. use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::error::{GatewayError, Result}; use crate::paths::{self, Layout}; /// When to save screenshots and DOM dumps for debugging. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "snake_case")] pub enum ScreenshotMode { Never, #[default] OnError, Always, } /// How a thread is continued on the provider web UI. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "snake_case")] pub enum ConversationStrategy { #[default] Auto, Reuse, Replay, } /// Where the client conversation id comes from. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "snake_case")] pub enum ConversationIdSource { #[default] Fingerprint, Header, Uuid, } /// How much the browser hides the fact that it is automated. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "snake_case")] pub enum StealthMode { /// Leave the page alone. Chromium is launched with a switch that already /// hides navigator.webdriver and the user agent stays genuine, which is /// what sign-in providers such as Google expect. #[default] Off, /// Also patch navigator.webdriver from an injected script. Minimal, /// Full chromiumoxide stealth, including a spoofed user agent. /// /// Warning: it advertises an outdated Chrome version, which is itself a /// bot signal and makes OAuth providers refuse the sign-in. Aggressive, } impl StealthMode { /// Parse the --stealth flag. pub fn parse(raw: &str) -> Result { match raw.trim().to_lowercase().as_str() { "off" | "none" => Ok(Self::Off), "minimal" | "light" => Ok(Self::Minimal), "aggressive" | "full" => Ok(Self::Aggressive), other => Err(GatewayError::Config(format!( "unknown stealth mode '{other}', expected off, minimal or aggressive" ))), } } } /// How the prompt text is pushed into the web editor. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "snake_case")] pub enum InjectMethod { #[default] Auto, InsertText, ExecCommand, TypeStr, } /// Where the final answer text comes from. /// /// A web UI renders its answer as rich HTML, and `innerText` flattens it: code /// fences, tables and lists all collapse into plain text. The fidelity pass /// runs once, when the answer has settled, and this setting decides how. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "snake_case")] pub enum MarkdownMode { /// Clipboard when the provider has a copy button, otherwise the DOM. #[default] Auto, /// Always click the provider copy button and read the clipboard over CDP. /// Falls back to the DOM when the clipboard cannot be read. Clipboard, /// Always convert the answer node's HTML to Markdown. Dom, /// Keep the historical behaviour: the visible text, formatting flattened. Text, } impl MarkdownMode { /// Parse the --markdown flag. pub fn parse(raw: &str) -> Result { match raw.trim().to_lowercase().as_str() { "auto" => Ok(Self::Auto), "clipboard" | "copy" => Ok(Self::Clipboard), "dom" | "html" => Ok(Self::Dom), "text" | "plain" | "off" => Ok(Self::Text), other => Err(GatewayError::Config(format!( "unknown markdown mode '{other}', expected auto, clipboard, dom or text" ))), } } } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct ServerSection { pub host: String, pub port: u16, pub api_key: String, pub include_warnings: bool, pub request_body_limit: usize, } impl Default for ServerSection { fn default() -> Self { Self { host: "127.0.0.1".to_string(), port: 8080, api_key: String::new(), include_warnings: true, request_body_limit: 32 * 1024 * 1024, } } } impl ServerSection { /// True when the server is reachable from another host. pub fn is_public_bind(&self) -> bool { let host = self.host.trim(); !(host == "127.0.0.1" || host == "localhost" || host == "::1") } } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct BrowserSection { pub executable: String, pub headless: bool, pub profile_dir: String, /// Concurrent turns allowed per provider account, i.e. tabs kept open. pub max_tabs: usize, /// How long a request waits for a free tab before it is refused with 429. pub busy_wait_s: u64, pub launch_timeout_s: u64, pub request_timeout_s: u64, pub extra_args: Vec, pub stealth: StealthMode, /// Attach to a browser the user started instead of launching one. pub attach: bool, /// Debug port of that browser. pub debug_port: u16, } impl Default for BrowserSection { fn default() -> Self { Self { executable: String::new(), headless: false, profile_dir: String::new(), max_tabs: 1, busy_wait_s: 30, launch_timeout_s: 60, request_timeout_s: 60, extra_args: vec![ "--disable-blink-features=AutomationControlled".to_string(), "--no-first-run".to_string(), "--no-default-browser-check".to_string(), ], stealth: StealthMode::Off, attach: false, debug_port: 9222, } } } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct ConversationSection { pub store: String, pub strategy: ConversationStrategy, pub id_source: ConversationIdSource, } impl Default for ConversationSection { fn default() -> Self { Self { store: String::new(), strategy: ConversationStrategy::Auto, id_source: ConversationIdSource::Fingerprint, } } } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct CaptureSection { pub poll_interval_ms: u64, pub quiet_ms: u64, pub response_timeout_s: u64, pub inject_method: InjectMethod, /// Fidelity of the captured answer (see `MarkdownMode`). pub markdown: MarkdownMode, /// How long the clipboard read is given before the DOM conversion wins. pub clipboard_timeout_ms: u64, /// Upper bound accepted for the OpenAI `n` parameter. pub max_variants: u32, } impl Default for CaptureSection { fn default() -> Self { Self { poll_interval_ms: 400, quiet_ms: 1500, response_timeout_s: 180, inject_method: InjectMethod::Auto, markdown: MarkdownMode::Auto, clipboard_timeout_ms: 1500, max_variants: 4, } } } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct TokensSection { pub encoding: String, pub fallback_heuristic: bool, } impl Default for TokensSection { fn default() -> Self { Self { encoding: "o200k_base".to_string(), fallback_heuristic: true, } } } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct DebugSection { pub screenshots: ScreenshotMode, pub dir: String, } impl Default for DebugSection { fn default() -> Self { Self { screenshots: ScreenshotMode::OnError, dir: String::new(), } } } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct LoggingSection { pub level: String, pub format: String, pub file: String, } impl Default for LoggingSection { fn default() -> Self { Self { level: "info".to_string(), format: "pretty".to_string(), file: String::new(), } } } /// Root configuration object. #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(default)] pub struct GlobalConfig { pub server: ServerSection, pub browser: BrowserSection, pub conversation: ConversationSection, pub capture: CaptureSection, pub tokens: TokensSection, pub debug: DebugSection, pub logging: LoggingSection, } impl GlobalConfig { /// Read the configuration file, returning defaults when it does not exist. pub fn load(path: &Path) -> Result { if !path.exists() { return Ok(Self::default()); } let raw = std::fs::read_to_string(path) .map_err(|e| GatewayError::Config(format!("reading {}: {e}", path.display())))?; Self::from_toml(&raw) } pub fn from_toml(raw: &str) -> Result { toml::from_str::(raw).map_err(|e| GatewayError::Config(e.to_string())) } pub fn validate(&self) -> Result<()> { if self.server.port == 0 { return Err(GatewayError::Config("server.port must not be 0".into())); } if self.browser.max_tabs == 0 { return Err(GatewayError::Config( "browser.max_tabs must be at least 1".into(), )); } if self.capture.poll_interval_ms < 50 { return Err(GatewayError::Config( "capture.poll_interval_ms must be at least 50".into(), )); } if self.capture.response_timeout_s == 0 { return Err(GatewayError::Config( "capture.response_timeout_s must not be 0".into(), )); } if self.capture.max_variants == 0 { return Err(GatewayError::Config( "capture.max_variants must be at least 1".into(), )); } if self.browser.attach && self.browser.debug_port == 0 { return Err(GatewayError::Config( "browser.debug_port must be set when browser.attach is true".into(), )); } if self.browser.headless && self.server.api_key.is_empty() { // Not fatal: headless mode simply cannot ask the user to log in. tracing::debug!("headless mode cannot prompt for a manual login"); } Ok(()) } pub fn resolved_profile_root(&self, layout: &Layout) -> PathBuf { non_empty_path(&self.browser.profile_dir) .map(|raw| paths::resolve_against(layout.root(), &raw)) .unwrap_or_else(|| layout.profiles_dir()) } pub fn profile_dir_for(&self, layout: &Layout, provider: &str) -> PathBuf { self.resolved_profile_root(layout) .join(provider) .join("user-data-dir") } pub fn resolved_debug_dir(&self, layout: &Layout) -> PathBuf { non_empty_path(&self.debug.dir) .map(|raw| paths::resolve_against(layout.root(), &raw)) .unwrap_or_else(|| layout.debug_dir()) } pub fn resolved_store_path(&self, layout: &Layout) -> PathBuf { non_empty_path(&self.conversation.store) .map(|raw| paths::resolve_against(layout.root(), &raw)) .unwrap_or_else(|| layout.conversations_file()) } pub fn resolved_log_file(&self, layout: &Layout) -> PathBuf { non_empty_path(&self.logging.file) .map(|raw| paths::resolve_against(layout.root(), &raw)) .unwrap_or_else(|| layout.logs_dir().join("llm-gateway.log")) } } fn non_empty_path(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } /// Values coming from the command line that override the configuration file. #[derive(Debug, Clone, Default)] pub struct CliOverrides { pub host: Option, pub port: Option, pub browser_executable: Option, pub headless: Option, pub profile_dir: Option, pub max_tabs: Option, pub debug_screenshots: Option, pub log_level: Option, pub log_format: Option, pub api_key: Option, pub attach: Option, pub debug_port: Option, pub stealth: Option, pub markdown: Option, } impl GlobalConfig { /// Apply CLI flags on top of the file configuration. pub fn apply(&mut self, overrides: &CliOverrides) { if let Some(host) = &overrides.host { self.server.host = host.clone(); } if let Some(port) = overrides.port { self.server.port = port; } if let Some(key) = &overrides.api_key { self.server.api_key = key.clone(); } if let Some(exe) = &overrides.browser_executable { self.browser.executable = exe.clone(); } if let Some(headless) = overrides.headless { self.browser.headless = headless; } if let Some(dir) = &overrides.profile_dir { self.browser.profile_dir = dir.clone(); } if let Some(max_tabs) = overrides.max_tabs { self.browser.max_tabs = max_tabs; } if let Some(debug) = overrides.debug_screenshots { self.debug.screenshots = if debug { ScreenshotMode::Always } else { ScreenshotMode::OnError }; } if let Some(level) = &overrides.log_level { self.logging.level = level.clone(); } if let Some(format) = &overrides.log_format { self.logging.format = format.clone(); } if let Some(attach) = overrides.attach { self.browser.attach = attach; } if let Some(port) = overrides.debug_port { self.browser.debug_port = port; } if let Some(stealth) = overrides.stealth { self.browser.stealth = stealth; } if let Some(markdown) = overrides.markdown { self.capture.markdown = markdown; } } } #[cfg(test)] mod tests { use super::*; #[test] fn defaults_are_loopback_and_serialised() { let cfg = GlobalConfig::default(); assert_eq!(cfg.server.host, "127.0.0.1"); assert_eq!(cfg.server.port, 8080); assert_eq!(cfg.browser.max_tabs, 1); assert_eq!(cfg.conversation.strategy, ConversationStrategy::Auto); assert_eq!(cfg.capture.poll_interval_ms, 400); cfg.validate().unwrap(); assert!(!cfg.server.is_public_bind()); } #[test] fn toml_round_trip_and_partial_files() { let full = toml::to_string(&GlobalConfig::default()).unwrap(); let parsed = GlobalConfig::from_toml(&full).unwrap(); assert_eq!(parsed.server.port, 8080); let partial = "[server]\nport = 9999\n"; let parsed = GlobalConfig::from_toml(partial).unwrap(); assert_eq!(parsed.server.port, 9999); assert_eq!(parsed.server.host, "127.0.0.1"); } #[test] fn invalid_toml_is_a_config_error() { let err = GlobalConfig::from_toml("port = ").unwrap_err(); assert_eq!(err.code(), "invalid_configuration"); } #[test] fn attach_mode_requires_a_port() { let mut cfg = GlobalConfig::default(); assert_eq!(cfg.browser.stealth, StealthMode::Off); assert_eq!(cfg.browser.debug_port, 9222); cfg.browser.attach = true; cfg.validate().unwrap(); cfg.browser.debug_port = 0; assert!(cfg.validate().is_err()); } #[test] fn stealth_mode_is_parsed_from_the_file() { let cfg = GlobalConfig::from_toml("[browser]\nstealth = \"aggressive\"\n").unwrap(); assert_eq!(cfg.browser.stealth, StealthMode::Aggressive); let cfg = GlobalConfig::from_toml("[browser]\nstealth = \"minimal\"\n").unwrap(); assert_eq!(cfg.browser.stealth, StealthMode::Minimal); } #[test] fn markdown_modes_are_parsed_and_default_to_auto() { assert_eq!( MarkdownMode::parse("clipboard").unwrap(), MarkdownMode::Clipboard ); assert_eq!(MarkdownMode::parse("DOM").unwrap(), MarkdownMode::Dom); assert_eq!(MarkdownMode::parse(" text ").unwrap(), MarkdownMode::Text); assert!(MarkdownMode::parse("rich").is_err()); assert_eq!(GlobalConfig::default().capture.markdown, MarkdownMode::Auto); assert_eq!(GlobalConfig::default().capture.max_variants, 4); let cfg = GlobalConfig::from_toml("[capture]\nmarkdown = \"text\"\n").unwrap(); assert_eq!(cfg.capture.markdown, MarkdownMode::Text); } #[test] fn cli_overrides_win() { let mut cfg = GlobalConfig::default(); cfg.apply(&CliOverrides { port: Some(1234), headless: Some(true), debug_screenshots: Some(true), ..Default::default() }); assert_eq!(cfg.server.port, 1234); assert!(cfg.browser.headless); assert_eq!(cfg.debug.screenshots, ScreenshotMode::Always); } #[test] fn paths_fall_back_to_the_layout() { let layout = Layout::rooted_at(PathBuf::from("/state")); let cfg = GlobalConfig::default(); assert_eq!( cfg.resolved_profile_root(&layout), PathBuf::from("/state/profiles") ); assert_eq!( cfg.profile_dir_for(&layout, "chatgpt"), PathBuf::from("/state/profiles/chatgpt/user-data-dir") ); assert_eq!( cfg.resolved_store_path(&layout), PathBuf::from("/state/conversations.json") ); } }