Skip to main content

bitrouter_config/
config.rs

1use std::{
2    collections::HashMap,
3    fmt,
4    net::{IpAddr, Ipv4Addr, SocketAddr},
5    path::{Path, PathBuf},
6};
7
8use serde::{Deserialize, Serialize};
9
10use bitrouter_core::routers::routing_table::ApiProtocol;
11
12use crate::env::{load_env, substitute_in_value};
13use crate::registry::{
14    builtin_agent_defs, builtin_providers, builtin_tool_provider_defs, merge_provider,
15    resolve_providers,
16};
17
18fn default_true() -> bool {
19    true
20}
21
22// ── Policy configuration ────────────────────────────────────────────
23
24// ── Top-level configuration ──────────────────────────────────────────
25
26/// Root configuration file, typically `bitrouter.yaml`.
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28pub struct BitrouterConfig {
29    #[serde(default)]
30    pub server: ServerConfig,
31
32    /// Database configuration.
33    #[serde(default)]
34    pub database: DatabaseConfig,
35
36    /// Guardrails configuration — content inspection firewall for AI traffic.
37    #[serde(default)]
38    pub guardrails: bitrouter_guardrails::GuardrailConfig,
39
40    /// Solana RPC endpoint used for Swig wallet operations.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub solana_rpc_url: Option<String>,
43
44    /// MPP (Machine Payment Protocol) configuration.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub mpp: Option<MppConfig>,
47
48    /// OWS (Open Wallet Standard) wallet configuration.
49    ///
50    /// When set, the OWS wallet is used for policy-gated signing in place
51    /// of raw private keys. Requires the `wallet-ows` feature.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub wallet: Option<WalletConfig>,
54
55    /// When `true` (the default), built-in provider definitions are merged
56    /// into the provider set before user overrides are applied.  Set to
57    /// `false` to use *only* the providers declared in the config file.
58    #[serde(default = "default_true")]
59    pub inherit_defaults: bool,
60
61    /// Provider definitions (merged on top of built-in providers).
62    #[serde(default)]
63    pub providers: HashMap<String, ProviderConfig>,
64
65    /// Model routing definitions.
66    #[serde(default)]
67    pub models: HashMap<String, ModelConfig>,
68
69    /// Tool routing definitions.
70    #[serde(default)]
71    pub tools: HashMap<String, ToolConfig>,
72
73    /// Agent definitions (ACP-compatible coding agents).
74    #[serde(default)]
75    pub agents: HashMap<String, AgentConfig>,
76
77    /// Content-based auto-routing rules.
78    ///
79    /// Each key is a virtual model name (e.g. `"auto"`) that triggers
80    /// content-aware classification when a request targets it. The rule
81    /// maps detected signals and complexity levels to concrete model names
82    /// defined in the `models` section.
83    #[serde(default)]
84    pub routing: HashMap<String, RoutingRuleConfig>,
85}
86
87impl BitrouterConfig {
88    /// Returns true if at least one provider has an API key configured.
89    pub fn has_configured_providers(&self) -> bool {
90        self.providers.values().any(|p| p.api_key.is_some())
91    }
92
93    /// Returns the names of providers that have API keys configured.
94    pub fn configured_provider_names(&self) -> Vec<String> {
95        let mut names: Vec<String> = self
96            .providers
97            .iter()
98            .filter(|(_, p)| p.api_key.is_some())
99            .map(|(name, _)| name.clone())
100            .collect();
101        names.sort();
102        names
103    }
104
105    /// Full config loading pipeline:
106    ///
107    /// 1. Read and parse YAML
108    /// 2. Load `.env` file (provided externally by the runtime)
109    /// 3. Substitute `${VAR}` references in all string values
110    /// 4. Merge user providers on top of the built-in registry
111    /// 5. Resolve `derives` chains
112    /// 6. Apply `env_prefix` auto-overrides
113    pub fn load_from_file(path: &Path, env_file: Option<&Path>) -> crate::error::Result<Self> {
114        let raw =
115            std::fs::read_to_string(path).map_err(|e| crate::error::ConfigError::ConfigRead {
116                path: path.to_path_buf(),
117                source: e,
118            })?;
119        Self::load_from_str(&raw, env_file)
120    }
121
122    /// Loads from an in-memory YAML string (useful for testing).
123    ///
124    /// The optional `env_file` path is resolved by the caller (runtime layer).
125    pub fn load_from_str(raw: &str, env_file: Option<&Path>) -> crate::error::Result<Self> {
126        // Load environment (.env + process env)
127        let env = load_env(env_file);
128
129        // Substitute env vars in the YAML tree, then deserialize.
130        // A comments-only YAML file parses as `null`; treat it as an empty object
131        // so that all `#[serde(default)]` fields are populated normally.
132        let yaml_value: serde_json::Value = serde_saphyr::from_str(raw)
133            .map_err(|e| crate::error::ConfigError::ConfigParse(e.to_string()))?;
134        let substituted = substitute_in_value(yaml_value, &env);
135        let substituted = if substituted.is_null() {
136            serde_json::Value::Object(serde_json::Map::new())
137        } else {
138            substituted
139        };
140        let mut config: BitrouterConfig = serde_json::from_value(substituted)
141            .map_err(|e| crate::error::ConfigError::ConfigParse(e.to_string()))?;
142
143        // Merge built-in providers with user overrides (unless opted out)
144        let mut providers = if config.inherit_defaults {
145            let mut base = builtin_providers();
146            for (name, user_provider) in config.providers.drain() {
147                if let Some(existing) = base.get_mut(&name) {
148                    merge_provider(existing, user_provider);
149                } else {
150                    base.insert(name, user_provider);
151                }
152            }
153            base
154        } else {
155            std::mem::take(&mut config.providers)
156        };
157
158        // Merge built-in tool provider definitions (providers + tool routes).
159        // Uses the same merge_provider pattern as model providers so that
160        // a user declaring `exa: api_key: "..."` inherits the builtin
161        // api_protocol, api_base, auth, etc.
162        if config.inherit_defaults {
163            for (name, builtin) in builtin_tool_provider_defs() {
164                if let Some(existing) = providers.get_mut(&name) {
165                    // User declared this provider — merge builtin as base.
166                    let mut base = builtin.config;
167                    merge_provider(&mut base, std::mem::take(existing));
168                    *existing = base;
169                } else {
170                    providers.insert(name, builtin.config);
171                }
172                for (tool_name, tool_config) in builtin.tool_configs {
173                    config.tools.entry(tool_name).or_insert(tool_config);
174                }
175            }
176        }
177
178        // Merge built-in agent definitions.
179        // User-declared agents override built-ins by name.
180        if config.inherit_defaults {
181            for (name, builtin) in builtin_agent_defs() {
182                config.agents.entry(name).or_insert(builtin);
183            }
184        }
185
186        // Resolve derives + env_prefix
187        config.providers = resolve_providers(providers, &env);
188
189        Ok(config)
190    }
191}
192
193// ── Agent configuration ──────────────────────────────────────────────
194
195/// Communication protocol for an agent.
196#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "lowercase")]
198pub enum AgentProtocol {
199    /// Agent Client Protocol (JSON-RPC over stdio).
200    #[default]
201    Acp,
202}
203
204impl fmt::Display for AgentProtocol {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        match self {
207            Self::Acp => write!(f, "acp"),
208        }
209    }
210}
211
212/// A downloadable binary archive for a specific platform.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct BinaryArchive {
215    /// URL to a `.tar.gz` or `.zip` archive.
216    pub archive: String,
217    /// Command to run within the extracted archive (relative path).
218    pub cmd: String,
219    /// Additional arguments passed when launching the binary.
220    #[serde(default)]
221    pub args: Vec<String>,
222}
223
224/// How to obtain an agent if its binary is not on PATH.
225#[derive(Debug, Clone, Serialize, Deserialize)]
226#[serde(rename_all = "lowercase")]
227pub enum Distribution {
228    /// Run via `npx <package> [args...]`.
229    Npx {
230        package: String,
231        #[serde(default)]
232        args: Vec<String>,
233    },
234    /// Run via `uvx <package> [args...]`.
235    Uvx {
236        package: String,
237        #[serde(default)]
238        args: Vec<String>,
239    },
240    /// Download a platform-specific binary archive.
241    Binary {
242        /// Map of platform target (e.g. `darwin-aarch64`) to archive info.
243        platforms: HashMap<String, BinaryArchive>,
244    },
245}
246
247/// Session pool configuration for an agent.
248///
249/// Controls how many concurrent sessions can run and when idle sessions
250/// are cleaned up. When omitted from agent config, defaults produce
251/// single-session behavior (compatible with the TUI path).
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct AgentSessionConfig {
254    /// Idle timeout in seconds before a session is cleaned up.
255    /// Default: 600 (10 minutes).
256    #[serde(default = "default_idle_timeout_secs")]
257    pub idle_timeout_secs: u64,
258
259    /// Maximum number of concurrent sessions for this agent.
260    /// Default: 1.
261    #[serde(default = "default_max_concurrent")]
262    pub max_concurrent: usize,
263}
264
265fn default_idle_timeout_secs() -> u64 {
266    600
267}
268
269fn default_max_concurrent() -> usize {
270    1
271}
272
273impl Default for AgentSessionConfig {
274    fn default() -> Self {
275        Self {
276            idle_timeout_secs: default_idle_timeout_secs(),
277            max_concurrent: default_max_concurrent(),
278        }
279    }
280}
281
282/// A2A exposure configuration for an agent.
283///
284/// Controls whether the agent is exposed via the A2A protocol.
285/// Consumed by downstream endpoint wiring, not by this crate.
286#[derive(Debug, Clone, Default, Serialize, Deserialize)]
287pub struct AgentA2aConfig {
288    /// Whether to expose this agent via A2A.
289    #[serde(default)]
290    pub enabled: bool,
291
292    /// Skills advertised in the A2A Agent Card.
293    #[serde(default, skip_serializing_if = "Vec::is_empty")]
294    pub skills: Vec<String>,
295}
296
297/// Configuration for a single agent.
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct AgentConfig {
300    /// Communication protocol.
301    #[serde(default)]
302    pub protocol: AgentProtocol,
303
304    /// Binary name or path. Resolved from PATH if relative.
305    pub binary: String,
306
307    /// Arguments passed when spawning the agent subprocess.
308    #[serde(default)]
309    pub args: Vec<String>,
310
311    /// Whether this agent is enabled (available for connection).
312    #[serde(default = "default_true")]
313    pub enabled: bool,
314
315    /// Ordered list of distribution methods (tried in sequence as fallbacks).
316    #[serde(default, skip_serializing_if = "Vec::is_empty")]
317    pub distribution: Vec<Distribution>,
318
319    /// Session pool configuration (idle timeout, concurrency cap).
320    ///
321    /// When omitted, defaults to single-session behavior.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub session: Option<AgentSessionConfig>,
324
325    /// A2A exposure configuration.
326    ///
327    /// When omitted or `enabled: false`, the agent is not exposed via A2A.
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub a2a: Option<AgentA2aConfig>,
330}
331
332// ── Database configuration ────────────────────────────────────────────
333
334/// Database connection configuration.
335#[derive(Debug, Clone, Serialize, Deserialize, Default)]
336pub struct DatabaseConfig {
337    /// Database connection URL.
338    ///
339    /// Supports `sqlite://`, `postgres://`, and `mysql://` schemes.
340    /// Accepts `${VAR}` environment variable placeholders.
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub url: Option<String>,
343}
344
345// ── Server configuration ─────────────────────────────────────────────
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct ServerConfig {
349    #[serde(default = "default_listen")]
350    pub listen: SocketAddr,
351
352    #[serde(default)]
353    pub control: ControlEndpoint,
354
355    #[serde(default = "default_log_level")]
356    pub log_level: String,
357}
358
359impl Default for ServerConfig {
360    fn default() -> Self {
361        Self {
362            listen: default_listen(),
363            control: ControlEndpoint::default(),
364            log_level: default_log_level(),
365        }
366    }
367}
368
369fn default_listen() -> SocketAddr {
370    SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8787)
371}
372
373fn default_log_level() -> String {
374    "info".into()
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct ControlEndpoint {
379    #[serde(default = "default_socket_path")]
380    pub socket: PathBuf,
381}
382
383impl Default for ControlEndpoint {
384    fn default() -> Self {
385        Self {
386            socket: default_socket_path(),
387        }
388    }
389}
390
391fn default_socket_path() -> PathBuf {
392    PathBuf::from("bitrouter.sock")
393}
394
395// ── Provider configuration ───────────────────────────────────────────
396
397/// Configuration for a single provider.
398///
399/// All fields are `Option` so that partial overlays via `derives` work correctly:
400/// only the fields the user explicitly sets will override the parent.
401#[derive(Debug, Clone, Default, Serialize, Deserialize)]
402pub struct ProviderConfig {
403    /// Inherit from another provider.
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub derives: Option<String>,
406
407    /// The API protocol / adapter to use.
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub api_protocol: Option<ApiProtocol>,
410
411    /// Base URL for the upstream API.
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub api_base: Option<String>,
414
415    /// Default API key.
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub api_key: Option<String>,
418
419    /// Auth configuration override (e.g. custom auth methods).
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub auth: Option<AuthConfig>,
422
423    /// Environment variable prefix for auto-loading
424    /// `{PREFIX}_API_KEY` / `{PREFIX}_BASE_URL`.
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub env_prefix: Option<String>,
427
428    /// Extra default HTTP headers sent with every request.
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub default_headers: Option<HashMap<String, String>>,
431
432    /// Per-model metadata and pricing catalog.
433    ///
434    /// Keys are upstream model IDs (e.g. `"gpt-4o"`). Values carry optional
435    /// display name, description, context length, supported modalities, and
436    /// token pricing.
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub models: Option<HashMap<String, ModelInfo>>,
439
440    // ── MCP-specific provider fields ────────────────────────────────
441    /// When `true`, this MCP provider is also exposed as a standalone
442    /// Streamable HTTP endpoint at `POST /mcp/{name}` and `GET /mcp/{name}/sse`.
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub bridge: Option<bool>,
445}
446
447// ── Model metadata & pricing ─────────────────────────────────────────
448
449/// Media modality supported by a model.
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
451#[serde(rename_all = "snake_case")]
452pub enum Modality {
453    Text,
454    Image,
455    Audio,
456    Video,
457    File,
458}
459
460impl fmt::Display for Modality {
461    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
462        f.write_str(match self {
463            Self::Text => "text",
464            Self::Image => "image",
465            Self::Audio => "audio",
466            Self::Video => "video",
467            Self::File => "file",
468        })
469    }
470}
471
472/// Metadata and pricing for a single model offered by a provider.
473#[derive(Debug, Clone, Default, Serialize, Deserialize)]
474pub struct ModelInfo {
475    /// Human-readable display name (e.g. "GPT-4o").
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub name: Option<String>,
478
479    /// Brief description of the model's capabilities.
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub description: Option<String>,
482
483    /// Maximum input context window in tokens.
484    ///
485    /// Accepts both `max_input_tokens` and the legacy `context_length` name in
486    /// YAML; they map to the same field.
487    #[serde(
488        default,
489        skip_serializing_if = "Option::is_none",
490        alias = "context_length"
491    )]
492    pub max_input_tokens: Option<u64>,
493
494    /// Maximum number of output tokens the model can produce.
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub max_output_tokens: Option<u64>,
497
498    /// Input modalities the model accepts.
499    #[serde(default, skip_serializing_if = "Vec::is_empty")]
500    pub input_modalities: Vec<Modality>,
501
502    /// Output modalities the model can produce.
503    #[serde(default, skip_serializing_if = "Vec::is_empty")]
504    pub output_modalities: Vec<Modality>,
505
506    /// Token pricing per million tokens.
507    #[serde(default)]
508    pub pricing: ModelPricing,
509}
510
511// Model pricing types are defined in `bitrouter-core::routers::routing_table`
512// and re-exported from this crate's `lib.rs` for backward compatibility.
513pub use bitrouter_core::routers::routing_table::{
514    InputTokenPricing, ModelPricing, OutputTokenPricing,
515};
516
517// ── MPP (Machine Payment Protocol) configuration ─────────────────────
518
519/// Top-level MPP configuration.
520#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct MppConfig {
522    /// Whether MPP payment gating is enabled.
523    #[serde(default)]
524    pub enabled: bool,
525
526    /// Server realm for `WWW-Authenticate` headers.
527    ///
528    /// Auto-detected from environment if omitted.
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub realm: Option<String>,
531
532    /// HMAC secret for stateless challenge ID verification.
533    ///
534    /// Reads `MPP_SECRET_KEY` environment variable if omitted.
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub secret_key: Option<String>,
537
538    /// Per-network configuration.
539    ///
540    /// Each supported payment network (Tempo, Solana, …) has its own
541    /// section with a network-specific recipient address and settings.
542    #[serde(default)]
543    pub networks: MppNetworksConfig,
544}
545
546/// Per-network MPP configuration.
547#[derive(Debug, Clone, Default, Serialize, Deserialize)]
548pub struct MppNetworksConfig {
549    /// Tempo network configuration.
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub tempo: Option<TempoMppConfig>,
552
553    /// Solana network configuration.
554    #[cfg(feature = "mpp-solana")]
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub solana: Option<SolanaMppConfig>,
557}
558
559/// Tempo-specific MPP configuration.
560#[derive(Debug, Clone, Serialize, Deserialize)]
561pub struct TempoMppConfig {
562    /// Recipient address for payments (required).
563    pub recipient: String,
564
565    /// Escrow contract address (required for session support).
566    pub escrow_contract: String,
567
568    /// Tempo RPC endpoint URL.
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub rpc_url: Option<String>,
571
572    /// TIP-20 token address for charges.
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub currency: Option<String>,
575
576    /// Enable fee sponsorship for all challenges.
577    #[serde(default)]
578    pub fee_payer: bool,
579
580    /// EVM hex private key for server-initiated channel close and settlement.
581    /// When set, the server can call `close()` on the escrow contract on behalf
582    /// of the payee after a client sends a close credential.
583    #[serde(default, skip_serializing_if = "Option::is_none")]
584    pub close_signer: Option<String>,
585
586    /// Default deposit amount (in base units) for client-side session channels.
587    /// Used when the server challenge does not include `suggestedDeposit`.
588    #[serde(default, skip_serializing_if = "Option::is_none")]
589    pub default_deposit: Option<String>,
590}
591
592/// Solana-specific MPP configuration.
593#[cfg(feature = "mpp-solana")]
594#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct SolanaMppConfig {
596    /// Recipient address for payments (Solana base58 pubkey, required).
597    pub recipient: String,
598
599    /// Channel (escrow) program address (required for session support).
600    pub channel_program: String,
601
602    /// Solana network name (e.g., "mainnet-beta", "devnet").
603    #[serde(default = "default_solana_network")]
604    pub network: String,
605
606    /// Payment asset configuration. Defaults to native SOL.
607    #[serde(default)]
608    pub asset: SolanaAssetConfig,
609
610    /// Default deposit amount (in base units) suggested to clients when
611    /// opening a session channel. Included in the 402 challenge as
612    /// `sessionDefaults.suggestedDeposit`.
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub suggested_deposit: Option<String>,
615}
616
617/// Payment asset descriptor for Solana MPP.
618#[cfg(feature = "mpp-solana")]
619#[derive(Debug, Clone, Serialize, Deserialize)]
620pub struct SolanaAssetConfig {
621    /// Asset kind: `"sol"` for native SOL, `"spl"` for an SPL token.
622    #[serde(default = "default_solana_asset_kind")]
623    pub kind: String,
624
625    /// Decimal precision (9 for SOL, 6 for USDC).
626    #[serde(default = "default_solana_asset_decimals")]
627    pub decimals: u8,
628
629    /// SPL token mint address. Required when `kind` is `"spl"`.
630    #[serde(default, skip_serializing_if = "Option::is_none")]
631    pub mint: Option<String>,
632
633    /// Display symbol (e.g. `"SOL"`, `"USDC"`).
634    #[serde(default, skip_serializing_if = "Option::is_none")]
635    pub symbol: Option<String>,
636}
637
638#[cfg(feature = "mpp-solana")]
639impl Default for SolanaAssetConfig {
640    fn default() -> Self {
641        Self {
642            kind: default_solana_asset_kind(),
643            decimals: default_solana_asset_decimals(),
644            mint: None,
645            symbol: None,
646        }
647    }
648}
649
650#[cfg(feature = "mpp-solana")]
651fn default_solana_asset_kind() -> String {
652    "sol".into()
653}
654
655#[cfg(feature = "mpp-solana")]
656fn default_solana_asset_decimals() -> u8 {
657    9
658}
659
660#[cfg(feature = "mpp-solana")]
661fn default_solana_network() -> String {
662    "mainnet-beta".into()
663}
664
665// ── Wallet configuration ─────────────────────────────────────────────
666
667/// OWS (Open Wallet Standard) wallet configuration.
668///
669/// When present, BitRouter uses the named OWS wallet for signing
670/// operations (e.g. MPP close transactions) instead of raw private keys.
671///
672/// The passphrase (or API key) is **not** stored in the config file.
673/// At server startup the runtime reads `OWS_PASSPHRASE` from the
674/// environment, or prompts interactively if a TTY is available.
675///
676/// ```yaml
677/// wallet:
678///   name: treasury
679///   vault_path: ~/.ows  # optional, defaults to OWS standard path
680///   payment:
681///     tempo_rpc_url: https://rpc.moderato.tempo.xyz
682///     solana_rpc_url: https://api.mainnet-beta.solana.com
683/// ```
684#[derive(Debug, Clone, Serialize, Deserialize)]
685pub struct WalletConfig {
686    /// OWS wallet name (or UUID).
687    pub name: String,
688
689    /// Custom OWS vault directory. Defaults to `~/.ows`.
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub vault_path: Option<String>,
692
693    /// Client-side payment configuration.
694    ///
695    /// When set, enables automatic 402 Payment Required handling for
696    /// providers configured with `auth: mpp`. The wallet signs payment
697    /// transactions using Tempo or Solana.
698    #[serde(default, skip_serializing_if = "Option::is_none")]
699    pub payment: Option<PaymentClientConfig>,
700}
701
702/// Client-side payment configuration for the OWS wallet.
703///
704/// Controls how the wallet pays upstream providers when they return
705/// `402 Payment Required`.
706#[derive(Debug, Clone, Serialize, Deserialize)]
707pub struct PaymentClientConfig {
708    /// Tempo RPC URL for session channel operations.
709    ///
710    /// Required for Tempo session and charge payments.
711    /// Defaults to the Moderato testnet if omitted.
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub tempo_rpc_url: Option<String>,
714
715    /// Solana RPC URL for broadcasting transactions.
716    ///
717    /// Required for Solana charge payments.
718    /// Falls back to `solana_rpc_url` at the top-level config.
719    #[serde(default, skip_serializing_if = "Option::is_none")]
720    pub solana_rpc_url: Option<String>,
721
722    /// Maximum session channel deposit in base units.
723    ///
724    /// Caps the server's `suggestedDeposit` to prevent overspending.
725    #[serde(default, skip_serializing_if = "Option::is_none")]
726    pub session_max_deposit: Option<u128>,
727
728    /// Default session channel deposit in base units.
729    ///
730    /// Used when the server challenge does not include `suggestedDeposit`.
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub session_default_deposit: Option<u128>,
733}
734
735/// Authentication configuration.
736#[derive(Debug, Clone, Serialize, Deserialize)]
737#[serde(tag = "type", rename_all = "snake_case")]
738pub enum AuthConfig {
739    /// Standard bearer token (`Authorization: Bearer <key>`).
740    Bearer { api_key: String },
741    /// Key in a custom header (e.g. `x-api-key`).
742    Header {
743        header_name: String,
744        api_key: String,
745    },
746    /// x402 payment protocol — requests are paid via a Solana wallet.
747    X402,
748    /// MPP (Machine Payment Protocol) — requests are paid via an EVM wallet.
749    Mpp,
750    /// OWS wallet authentication — requests are signed by a local wallet.
751    Wallet,
752    /// OAuth 2.0 authentication.
753    ///
754    /// Tokens are acquired interactively via the device code flow (RFC 8628)
755    /// and persisted to the token store (`tokens.json`).
756    #[serde(rename = "oauth")]
757    OAuth {
758        /// OAuth grant type (currently only `device_code`).
759        grant: OAuthGrant,
760        /// OAuth client ID.
761        client_id: String,
762        /// Requested scopes (space-separated).
763        #[serde(default, skip_serializing_if = "Option::is_none")]
764        scope: Option<String>,
765        /// Device authorization endpoint URL.
766        #[serde(default, skip_serializing_if = "Option::is_none")]
767        device_auth_url: Option<String>,
768        /// Token endpoint URL.
769        #[serde(default, skip_serializing_if = "Option::is_none")]
770        token_url: Option<String>,
771    },
772    /// Extension point for non-standard auth methods.
773    Custom {
774        method: String,
775        #[serde(default)]
776        params: serde_json::Value,
777    },
778}
779
780/// OAuth grant type.
781#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
782#[serde(rename_all = "snake_case")]
783pub enum OAuthGrant {
784    /// OAuth 2.0 Device Authorization Grant (RFC 8628).
785    DeviceCode,
786}
787
788// ── Model routing configuration ──────────────────────────────────────
789
790/// Routing strategy for a model with multiple endpoints.
791#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
792#[serde(rename_all = "snake_case")]
793pub enum RoutingStrategy {
794    /// Try endpoints in declared order; failover to next on error.
795    #[default]
796    Priority,
797    /// Distribute requests evenly via round-robin.
798    LoadBalance,
799}
800
801/// A single endpoint that a model or tool can be routed to.
802#[derive(Debug, Clone, Serialize, Deserialize)]
803pub struct Endpoint {
804    /// Provider name (must exist in the providers section or built-ins).
805    pub provider: String,
806
807    /// Upstream service identifier: model ID for language models, tool ID for tools.
808    #[serde(alias = "model_id", alias = "tool_id")]
809    pub service_id: String,
810
811    /// Optional per-endpoint API protocol override.
812    ///
813    /// When set, overrides the provider's default `api_protocol` for this
814    /// endpoint only. Useful when a provider speaks multiple protocols.
815    #[serde(default, skip_serializing_if = "Option::is_none")]
816    pub api_protocol: Option<ApiProtocol>,
817
818    /// Optional per-endpoint API key override.
819    #[serde(default, skip_serializing_if = "Option::is_none")]
820    pub api_key: Option<String>,
821
822    /// Optional per-endpoint API base override.
823    #[serde(default, skip_serializing_if = "Option::is_none")]
824    pub api_base: Option<String>,
825}
826
827/// Routing configuration for a virtual model name.
828#[derive(Debug, Clone, Default, Serialize, Deserialize)]
829pub struct ModelConfig {
830    #[serde(default)]
831    pub strategy: RoutingStrategy,
832
833    pub endpoints: Vec<Endpoint>,
834
835    /// Human-readable display name.
836    #[serde(default, skip_serializing_if = "Option::is_none")]
837    pub name: Option<String>,
838
839    /// Maximum input context window in tokens.
840    #[serde(default, skip_serializing_if = "Option::is_none")]
841    pub max_input_tokens: Option<u64>,
842
843    /// Maximum number of output tokens the model can produce.
844    #[serde(default, skip_serializing_if = "Option::is_none")]
845    pub max_output_tokens: Option<u64>,
846
847    /// Input modalities the model accepts.
848    #[serde(default, skip_serializing_if = "Vec::is_empty")]
849    pub input_modalities: Vec<Modality>,
850
851    /// Output modalities the model can produce.
852    #[serde(default, skip_serializing_if = "Vec::is_empty")]
853    pub output_modalities: Vec<Modality>,
854
855    /// Token pricing per million tokens.
856    #[serde(default)]
857    pub pricing: ModelPricing,
858}
859
860// ── Tool routing configuration ──────────────────────────────────────
861
862/// Routing configuration for a virtual tool name.
863#[derive(Debug, Clone, Default, Serialize, Deserialize)]
864pub struct ToolConfig {
865    /// Strategy for selecting among multiple endpoints.
866    #[serde(default)]
867    pub strategy: RoutingStrategy,
868
869    /// One or more upstream endpoints to route this tool to.
870    pub endpoints: Vec<Endpoint>,
871
872    /// Optional per-tool invocation pricing.
873    #[serde(default, skip_serializing_if = "Option::is_none")]
874    pub pricing: Option<bitrouter_core::pricing::FlatPricing>,
875
876    /// Human-readable description for REST tool discoverability.
877    #[serde(default, skip_serializing_if = "Option::is_none")]
878    pub description: Option<String>,
879
880    /// JSON Schema for input parameters (REST tool discoverability).
881    #[serde(default, skip_serializing_if = "Option::is_none")]
882    pub input_schema: Option<serde_json::Value>,
883
884    /// Associated skill name (references a SKILL.md on disk).
885    ///
886    /// When set, the tool is enriched with skill metadata from the
887    /// filesystem skill registry. Skills are a metadata layer — they
888    /// do not affect the execution protocol.
889    #[serde(default, skip_serializing_if = "Option::is_none")]
890    pub skill: Option<String>,
891}
892
893// ── Content-based auto-routing configuration ────────────────────────
894
895/// Configuration for a content-based auto-routing rule.
896///
897/// When a request targets the trigger model name (the key in the `routing`
898/// map), the router inspects message content to detect keyword signals and
899/// estimate complexity, then selects a concrete model from the `models` map.
900#[derive(Debug, Clone, Default, Serialize, Deserialize)]
901pub struct RoutingRuleConfig {
902    /// When `true` (the default), built-in signal definitions are merged
903    /// before user-defined signals. User signals with the same name
904    /// override the built-in version.
905    #[serde(default = "default_true")]
906    pub inherit_defaults: bool,
907
908    /// User-defined keyword signals, merged on top of built-ins.
909    #[serde(default)]
910    pub signals: HashMap<String, SignalConfig>,
911
912    /// Complexity estimation heuristics. When omitted, built-in defaults
913    /// are used (if `inherit_defaults` is true).
914    #[serde(default)]
915    pub complexity: ComplexityConfig,
916
917    /// Maps `signal[.complexity]` → model name.
918    ///
919    /// Lookup order: `"{signal}.{complexity}"` → `"{signal}"` → `"default"`.
920    /// Target model names must exist in the top-level `models` section.
921    #[serde(default)]
922    pub models: HashMap<String, String>,
923}
924
925/// Keyword signal configuration.
926#[derive(Debug, Clone, Default, Serialize, Deserialize)]
927pub struct SignalConfig {
928    /// Keywords to match (case-insensitive substring matching).
929    #[serde(default)]
930    pub keywords: Vec<String>,
931}
932
933#[derive(Debug, Clone, Default, Serialize, Deserialize)]
934pub struct ComplexityConfig {
935    /// Keywords that indicate higher complexity.
936    #[serde(default)]
937    pub high_keywords: Vec<String>,
938
939    /// Character count threshold: messages longer than this are considered
940    /// more complex.
941    #[serde(default)]
942    pub message_length_threshold: Option<usize>,
943
944    /// Turn count threshold: conversations with more turns are considered
945    /// more complex.
946    #[serde(default)]
947    pub turn_count_threshold: Option<usize>,
948
949    /// When `true`, the presence of fenced code blocks increases complexity.
950    #[serde(default)]
951    pub code_blocks_increase_complexity: bool,
952}
953
954#[cfg(test)]
955mod tests {
956    use super::*;
957
958    #[test]
959    fn default_config_round_trips_through_yaml() {
960        let config = BitrouterConfig::default();
961        let yaml = serde_saphyr::to_string(&config).unwrap();
962        let parsed: BitrouterConfig = serde_saphyr::from_str(&yaml).unwrap();
963        assert_eq!(parsed.server.listen, config.server.listen);
964    }
965
966    #[test]
967    fn load_minimal_yaml() {
968        let yaml = r#"
969server:
970  listen: "127.0.0.1:9090"
971providers:
972  openai:
973    api_key: "sk-test"
974"#;
975        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
976        assert_eq!(config.server.listen, "127.0.0.1:9090".parse().unwrap());
977        // Should have all builtins + user override merged
978        assert!(config.providers.contains_key("openai"));
979        assert!(config.providers.contains_key("anthropic"));
980        assert_eq!(
981            config.providers["openai"].api_key.as_deref(),
982            Some("sk-test")
983        );
984    }
985
986    #[test]
987    fn load_with_custom_derived_provider() {
988        let yaml = r#"
989providers:
990  my-company:
991    derives: openai
992    api_base: "https://api.mycompany.com/v1"
993    api_key: "sk-custom"
994"#;
995        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
996        let p = &config.providers["my-company"];
997        assert_eq!(p.api_protocol, Some(ApiProtocol::Openai)); // inherited
998        assert_eq!(p.api_base.as_deref(), Some("https://api.mycompany.com/v1")); // overridden
999        assert_eq!(p.api_key.as_deref(), Some("sk-custom"));
1000        assert!(p.derives.is_none()); // resolved
1001    }
1002
1003    #[test]
1004    fn load_with_model_routing() {
1005        let yaml = r#"
1006providers:
1007  openai:
1008    api_key: "sk-test"
1009models:
1010  my-gpt4:
1011    strategy: load_balance
1012    endpoints:
1013      - provider: openai
1014        model_id: gpt-4o
1015        api_key: "sk-key-a"
1016      - provider: openai
1017        model_id: gpt-4o
1018        api_key: "sk-key-b"
1019"#;
1020        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1021        let model = &config.models["my-gpt4"];
1022        assert_eq!(model.strategy, RoutingStrategy::LoadBalance);
1023        assert_eq!(model.endpoints.len(), 2);
1024        assert_eq!(model.endpoints[0].api_key.as_deref(), Some("sk-key-a"));
1025    }
1026
1027    #[test]
1028    fn load_with_custom_auth() {
1029        let yaml = r#"
1030providers:
1031  aimo:
1032    derives: openai
1033    api_base: "https://api.aimo.network/v1"
1034    auth:
1035      type: custom
1036      method: siwx
1037      params:
1038        chain_id: 1
1039"#;
1040        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1041        let p = &config.providers["aimo"];
1042        assert!(matches!(p.auth, Some(AuthConfig::Custom { .. })));
1043        if let Some(AuthConfig::Custom { method, .. }) = &p.auth {
1044            assert_eq!(method, "siwx");
1045        }
1046    }
1047
1048    #[test]
1049    fn empty_yaml_gets_full_builtins() {
1050        let config = BitrouterConfig::load_from_str("{}", None).unwrap();
1051        assert!(config.providers.contains_key("openai"));
1052        assert!(config.providers.contains_key("anthropic"));
1053        assert!(config.providers.contains_key("google"));
1054    }
1055
1056    #[test]
1057    fn load_with_provider_model_metadata() {
1058        let yaml = r#"
1059providers:
1060  openai:
1061    api_key: "sk-test"
1062    models:
1063      gpt-4o:
1064        name: "GPT-4o"
1065        max_input_tokens: 128000
1066        max_output_tokens: 16384
1067        input_modalities: [text, image]
1068        output_modalities: [text]
1069        pricing:
1070          input_tokens:
1071            no_cache: 2.50
1072          output_tokens:
1073            text: 10.00
1074      gpt-4o-mini:
1075        name: "GPT-4o Mini"
1076"#;
1077        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1078        let openai = &config.providers["openai"];
1079        let models = openai.models.as_ref().unwrap();
1080
1081        let gpt4o = &models["gpt-4o"];
1082        assert_eq!(gpt4o.name.as_deref(), Some("GPT-4o"));
1083        assert_eq!(gpt4o.max_input_tokens, Some(128000));
1084        assert_eq!(gpt4o.max_output_tokens, Some(16384));
1085        assert_eq!(
1086            gpt4o.input_modalities,
1087            vec![Modality::Text, Modality::Image]
1088        );
1089        assert_eq!(gpt4o.pricing.input_tokens.no_cache, Some(2.50));
1090        assert_eq!(gpt4o.pricing.output_tokens.text, Some(10.00));
1091
1092        let mini = &models["gpt-4o-mini"];
1093        assert_eq!(mini.name.as_deref(), Some("GPT-4o Mini"));
1094        assert_eq!(mini.pricing.input_tokens.no_cache, None); // default
1095    }
1096
1097    #[test]
1098    fn derives_inherits_model_catalog() {
1099        let yaml = r#"
1100providers:
1101  my-openai:
1102    derives: openai
1103    api_key: "sk-custom"
1104"#;
1105        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1106        let my_openai = &config.providers["my-openai"];
1107        // Should inherit the built-in openai models catalog
1108        let models = my_openai.models.as_ref().unwrap();
1109        assert!(models.contains_key("gpt-4o"));
1110    }
1111
1112    #[test]
1113    fn inherit_defaults_true_by_default() {
1114        let config = BitrouterConfig::load_from_str("{}", None).unwrap();
1115        assert!(config.inherit_defaults);
1116        assert!(config.providers.contains_key("openai"));
1117        assert!(config.providers.contains_key("bitrouter"));
1118    }
1119
1120    #[test]
1121    fn inherit_defaults_false_excludes_builtins() {
1122        let yaml = r#"
1123inherit_defaults: false
1124providers:
1125  custom:
1126    api_protocol: openai
1127    api_base: "https://custom.example.com/v1"
1128    api_key: "sk-custom"
1129"#;
1130        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1131        assert!(!config.inherit_defaults);
1132        assert!(config.providers.contains_key("custom"));
1133        assert!(!config.providers.contains_key("openai"));
1134        assert!(!config.providers.contains_key("bitrouter"));
1135        assert_eq!(config.providers.len(), 1);
1136    }
1137
1138    #[test]
1139    fn load_with_tool_routing() {
1140        let yaml = r#"
1141providers:
1142  github-mcp:
1143    api_protocol: mcp
1144    api_base: "https://api.githubcopilot.com/mcp"
1145    api_key: "ghp-test"
1146tools:
1147  create_issue:
1148    strategy: priority
1149    endpoints:
1150      - provider: github-mcp
1151        tool_id: create_issue
1152  search_code:
1153    endpoints:
1154      - provider: github-mcp
1155        tool_id: search_code
1156        api_protocol: mcp
1157"#;
1158        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1159        // 2 user-defined + 6 built-in exa tools
1160        assert!(config.tools.len() >= 2);
1161        assert!(config.tools.contains_key("create_issue"));
1162        assert!(config.tools.contains_key("search_code"));
1163
1164        let tool = &config.tools["create_issue"];
1165        assert_eq!(tool.strategy, RoutingStrategy::Priority);
1166        assert_eq!(tool.endpoints.len(), 1);
1167        assert_eq!(tool.endpoints[0].provider, "github-mcp");
1168        assert_eq!(tool.endpoints[0].service_id, "create_issue");
1169        assert!(tool.endpoints[0].api_protocol.is_none());
1170
1171        let search = &config.tools["search_code"];
1172        assert_eq!(search.endpoints[0].api_protocol, Some(ApiProtocol::Mcp));
1173    }
1174
1175    #[test]
1176    fn full_template_deserializes() {
1177        let yaml = include_str!("../templates/full.yaml");
1178        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1179
1180        // Server
1181        assert_eq!(config.server.listen, "127.0.0.1:8787".parse().unwrap());
1182        assert_eq!(config.server.log_level, "info");
1183
1184        // Database
1185        assert!(config.database.url.is_some());
1186
1187        // Providers: builtins + user-defined
1188        assert!(config.providers.contains_key("openai"));
1189        assert!(config.providers.contains_key("anthropic"));
1190        assert!(config.providers.contains_key("google"));
1191        assert!(config.providers.contains_key("my-proxy"));
1192        assert!(config.providers.contains_key("custom-llm"));
1193        assert!(config.providers.contains_key("github-mcp"));
1194        assert!(config.providers.contains_key("header-auth-provider"));
1195        assert!(config.providers.contains_key("paid-provider"));
1196
1197        // Derived provider inherits api_protocol
1198        let my_proxy = &config.providers["my-proxy"];
1199        assert_eq!(my_proxy.api_protocol, Some(ApiProtocol::Openai));
1200        assert!(my_proxy.derives.is_none()); // resolved
1201
1202        // Custom provider
1203        let custom = &config.providers["custom-llm"];
1204        assert_eq!(custom.api_protocol, Some(ApiProtocol::Openai));
1205        let models = custom.models.as_ref().unwrap();
1206        assert!(models.contains_key("my-model-7b"));
1207
1208        // Model routing
1209        assert_eq!(config.models.len(), 3);
1210        assert!(config.models.contains_key("smart"));
1211        assert!(config.models.contains_key("fast"));
1212        assert!(config.models.contains_key("coding"));
1213        assert_eq!(config.models["smart"].strategy, RoutingStrategy::Priority);
1214        assert_eq!(config.models["fast"].strategy, RoutingStrategy::LoadBalance);
1215
1216        // Tool routing
1217        assert!(config.tools.contains_key("create_issue"));
1218        assert!(config.tools.contains_key("web_search"));
1219
1220        // Guardrails
1221        assert!(config.guardrails.enabled);
1222        assert!(!config.guardrails.disabled_patterns.is_empty());
1223        assert!(!config.guardrails.custom_patterns.is_empty());
1224        assert!(!config.guardrails.upgoing.is_empty());
1225        assert!(!config.guardrails.downgoing.is_empty());
1226
1227        // Wallet
1228        let wallet = config.wallet.as_ref().unwrap();
1229        assert_eq!(wallet.name, "my-wallet");
1230        assert!(wallet.payment.is_some());
1231
1232        // MPP
1233        let mpp = config.mpp.as_ref().unwrap();
1234        assert!(mpp.enabled);
1235    }
1236
1237    #[test]
1238    fn minimal_template_deserializes() {
1239        let yaml = "";
1240        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1241
1242        // Comments-only YAML deserializes with all defaults
1243        assert_eq!(config.server.listen, "127.0.0.1:8787".parse().unwrap());
1244        assert_eq!(config.server.log_level, "info");
1245
1246        // Builtins merged (inherit_defaults defaults to true)
1247        assert!(config.inherit_defaults);
1248        assert!(config.providers.contains_key("openai"));
1249        assert!(config.providers.contains_key("anthropic"));
1250        assert!(config.providers.contains_key("google"));
1251        assert!(config.providers.contains_key("bitrouter"));
1252
1253        // No custom models or tools defined
1254        assert!(config.models.is_empty());
1255
1256        // No wallet or MPP
1257        assert!(config.wallet.is_none());
1258        assert!(config.mpp.is_none());
1259
1260        // Guardrails enabled by default
1261        assert!(config.guardrails.enabled);
1262    }
1263
1264    #[test]
1265    fn empty_string_deserializes() {
1266        let config = BitrouterConfig::load_from_str("", None).unwrap();
1267
1268        // All defaults applied
1269        assert_eq!(config.server.listen, "127.0.0.1:8787".parse().unwrap());
1270        assert!(config.inherit_defaults);
1271        assert!(config.providers.contains_key("openai"));
1272        assert!(config.providers.contains_key("anthropic"));
1273        assert!(config.providers.contains_key("google"));
1274        assert!(config.models.is_empty());
1275        assert!(config.guardrails.enabled);
1276    }
1277
1278    #[test]
1279    fn load_with_oauth_auth() {
1280        let yaml = r#"
1281providers:
1282  github-copilot:
1283    api_protocol: openai
1284    api_base: "https://api.githubcopilot.com"
1285    auth:
1286      type: oauth
1287      grant: device_code
1288      client_id: "Iv23limb4eFHH5zfOCr2"
1289      scope: "read:user"
1290      device_auth_url: "https://github.com/login/device/code"
1291      token_url: "https://github.com/login/oauth/access_token"
1292"#;
1293        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1294        let p = &config.providers["github-copilot"];
1295        assert!(matches!(p.auth, Some(AuthConfig::OAuth { .. })));
1296        if let Some(AuthConfig::OAuth {
1297            grant,
1298            client_id,
1299            scope,
1300            device_auth_url,
1301            token_url,
1302        }) = &p.auth
1303        {
1304            assert_eq!(*grant, OAuthGrant::DeviceCode);
1305            assert_eq!(client_id, "Iv23limb4eFHH5zfOCr2");
1306            assert_eq!(scope.as_deref(), Some("read:user"));
1307            assert_eq!(
1308                device_auth_url.as_deref(),
1309                Some("https://github.com/login/device/code")
1310            );
1311            assert_eq!(
1312                token_url.as_deref(),
1313                Some("https://github.com/login/oauth/access_token")
1314            );
1315        }
1316    }
1317
1318    #[test]
1319    fn load_oauth_with_defaults() {
1320        let yaml = r#"
1321providers:
1322  test-oauth:
1323    api_protocol: openai
1324    api_base: "https://api.example.com"
1325    auth:
1326      type: oauth
1327      grant: device_code
1328      client_id: "test-client-id"
1329"#;
1330        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1331        let p = &config.providers["test-oauth"];
1332        if let Some(AuthConfig::OAuth {
1333            scope,
1334            device_auth_url,
1335            token_url,
1336            ..
1337        }) = &p.auth
1338        {
1339            assert!(scope.is_none());
1340            assert!(device_auth_url.is_none());
1341            assert!(token_url.is_none());
1342        } else {
1343            panic!("expected OAuth auth config");
1344        }
1345    }
1346}