Add initial llm-bridge project
This commit is contained in:
+562
@@ -0,0 +1,562 @@
|
||||
//! 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user