Files
llm-bridge/src/config.rs
T
bruno 7b5aa6fcc9
CI / fmt, clippy and tests (ubuntu-latest) (push) Successful in 21m0s
CI / browser end-to-end (Chromium + fixture) (push) Skipped
CI / fmt, clippy and tests (windows-latest) (push) Canceled after 0s
Add initial llm-bridge project
2026-09-18 18:01:07 -04:00

563 lines
18 KiB
Rust

//! 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<Self> {
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<Self> {
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<String>,
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<Self> {
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<Self> {
toml::from_str::<Self>(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<String> {
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<String>,
pub port: Option<u16>,
pub browser_executable: Option<String>,
pub headless: Option<bool>,
pub profile_dir: Option<String>,
pub max_tabs: Option<usize>,
pub debug_screenshots: Option<bool>,
pub log_level: Option<String>,
pub log_format: Option<String>,
pub api_key: Option<String>,
pub attach: Option<bool>,
pub debug_port: Option<u16>,
pub stealth: Option<StealthMode>,
pub markdown: Option<MarkdownMode>,
}
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")
);
}
}