563 lines
18 KiB
Rust
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")
|
|
);
|
|
}
|
|
}
|