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        /// GitHub domain (defaults to `github.com`).
772        ///
773        /// For GitHub Enterprise, set this to the enterprise domain
774        /// (e.g. `company.ghe.com`). When set, `device_auth_url`,
775        /// `token_url`, and `api_base` are derived from the domain
776        /// unless explicitly overridden.
777        #[serde(default, skip_serializing_if = "Option::is_none")]
778        domain: Option<String>,
779    },
780    /// Extension point for non-standard auth methods.
781    Custom {
782        method: String,
783        #[serde(default)]
784        params: serde_json::Value,
785    },
786}
787
788/// OAuth grant type.
789#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
790#[serde(rename_all = "snake_case")]
791pub enum OAuthGrant {
792    /// OAuth 2.0 Device Authorization Grant (RFC 8628).
793    DeviceCode,
794}
795
796// ── Model routing configuration ──────────────────────────────────────
797
798/// Routing strategy for a model with multiple endpoints.
799#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
800#[serde(rename_all = "snake_case")]
801pub enum RoutingStrategy {
802    /// Try endpoints in declared order; failover to next on error.
803    #[default]
804    Priority,
805    /// Distribute requests evenly via round-robin.
806    LoadBalance,
807}
808
809/// A single endpoint that a model or tool can be routed to.
810#[derive(Debug, Clone, Serialize, Deserialize)]
811pub struct Endpoint {
812    /// Provider name (must exist in the providers section or built-ins).
813    pub provider: String,
814
815    /// Upstream service identifier: model ID for language models, tool ID for tools.
816    #[serde(alias = "model_id", alias = "tool_id")]
817    pub service_id: String,
818
819    /// Optional per-endpoint API protocol override.
820    ///
821    /// When set, overrides the provider's default `api_protocol` for this
822    /// endpoint only. Useful when a provider speaks multiple protocols.
823    #[serde(default, skip_serializing_if = "Option::is_none")]
824    pub api_protocol: Option<ApiProtocol>,
825
826    /// Optional per-endpoint API key override.
827    #[serde(default, skip_serializing_if = "Option::is_none")]
828    pub api_key: Option<String>,
829
830    /// Optional per-endpoint API base override.
831    #[serde(default, skip_serializing_if = "Option::is_none")]
832    pub api_base: Option<String>,
833}
834
835/// Routing configuration for a virtual model name.
836#[derive(Debug, Clone, Default, Serialize, Deserialize)]
837pub struct ModelConfig {
838    #[serde(default)]
839    pub strategy: RoutingStrategy,
840
841    pub endpoints: Vec<Endpoint>,
842
843    /// Human-readable display name.
844    #[serde(default, skip_serializing_if = "Option::is_none")]
845    pub name: Option<String>,
846
847    /// Maximum input context window in tokens.
848    #[serde(default, skip_serializing_if = "Option::is_none")]
849    pub max_input_tokens: Option<u64>,
850
851    /// Maximum number of output tokens the model can produce.
852    #[serde(default, skip_serializing_if = "Option::is_none")]
853    pub max_output_tokens: Option<u64>,
854
855    /// Input modalities the model accepts.
856    #[serde(default, skip_serializing_if = "Vec::is_empty")]
857    pub input_modalities: Vec<Modality>,
858
859    /// Output modalities the model can produce.
860    #[serde(default, skip_serializing_if = "Vec::is_empty")]
861    pub output_modalities: Vec<Modality>,
862
863    /// Token pricing per million tokens.
864    #[serde(default)]
865    pub pricing: ModelPricing,
866}
867
868// ── Tool routing configuration ──────────────────────────────────────
869
870/// Routing configuration for a virtual tool name.
871#[derive(Debug, Clone, Default, Serialize, Deserialize)]
872pub struct ToolConfig {
873    /// Strategy for selecting among multiple endpoints.
874    #[serde(default)]
875    pub strategy: RoutingStrategy,
876
877    /// One or more upstream endpoints to route this tool to.
878    pub endpoints: Vec<Endpoint>,
879
880    /// Optional per-tool invocation pricing.
881    #[serde(default, skip_serializing_if = "Option::is_none")]
882    pub pricing: Option<bitrouter_core::pricing::FlatPricing>,
883
884    /// Human-readable description for REST tool discoverability.
885    #[serde(default, skip_serializing_if = "Option::is_none")]
886    pub description: Option<String>,
887
888    /// JSON Schema for input parameters (REST tool discoverability).
889    #[serde(default, skip_serializing_if = "Option::is_none")]
890    pub input_schema: Option<serde_json::Value>,
891
892    /// Associated skill name (references a SKILL.md on disk).
893    ///
894    /// When set, the tool is enriched with skill metadata from the
895    /// filesystem skill registry. Skills are a metadata layer — they
896    /// do not affect the execution protocol.
897    #[serde(default, skip_serializing_if = "Option::is_none")]
898    pub skill: Option<String>,
899}
900
901// ── Content-based auto-routing configuration ────────────────────────
902
903/// Configuration for a content-based auto-routing rule.
904///
905/// When a request targets the trigger model name (the key in the `routing`
906/// map), the router inspects message content to detect keyword signals and
907/// estimate complexity, then selects a concrete model from the `models` map.
908#[derive(Debug, Clone, Default, Serialize, Deserialize)]
909pub struct RoutingRuleConfig {
910    /// When `true` (the default), built-in signal definitions are merged
911    /// before user-defined signals. User signals with the same name
912    /// override the built-in version.
913    #[serde(default = "default_true")]
914    pub inherit_defaults: bool,
915
916    /// User-defined keyword signals, merged on top of built-ins.
917    #[serde(default)]
918    pub signals: HashMap<String, SignalConfig>,
919
920    /// Complexity estimation heuristics. When omitted, built-in defaults
921    /// are used (if `inherit_defaults` is true).
922    #[serde(default)]
923    pub complexity: ComplexityConfig,
924
925    /// Maps `signal[.complexity]` → model name.
926    ///
927    /// Lookup order: `"{signal}.{complexity}"` → `"{signal}"` → `"default"`.
928    /// Target model names must exist in the top-level `models` section.
929    #[serde(default)]
930    pub models: HashMap<String, String>,
931}
932
933/// Keyword signal configuration.
934#[derive(Debug, Clone, Default, Serialize, Deserialize)]
935pub struct SignalConfig {
936    /// Keywords to match (case-insensitive substring matching).
937    #[serde(default)]
938    pub keywords: Vec<String>,
939}
940
941#[derive(Debug, Clone, Default, Serialize, Deserialize)]
942pub struct ComplexityConfig {
943    /// Keywords that indicate higher complexity.
944    #[serde(default)]
945    pub high_keywords: Vec<String>,
946
947    /// Character count threshold: messages longer than this are considered
948    /// more complex.
949    #[serde(default)]
950    pub message_length_threshold: Option<usize>,
951
952    /// Turn count threshold: conversations with more turns are considered
953    /// more complex.
954    #[serde(default)]
955    pub turn_count_threshold: Option<usize>,
956
957    /// When `true`, the presence of fenced code blocks increases complexity.
958    #[serde(default)]
959    pub code_blocks_increase_complexity: bool,
960}
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965
966    #[test]
967    fn default_config_round_trips_through_yaml() {
968        let config = BitrouterConfig::default();
969        let yaml = serde_saphyr::to_string(&config).unwrap();
970        let parsed: BitrouterConfig = serde_saphyr::from_str(&yaml).unwrap();
971        assert_eq!(parsed.server.listen, config.server.listen);
972    }
973
974    #[test]
975    fn load_minimal_yaml() {
976        let yaml = r#"
977server:
978  listen: "127.0.0.1:9090"
979providers:
980  openai:
981    api_key: "sk-test"
982"#;
983        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
984        assert_eq!(config.server.listen, "127.0.0.1:9090".parse().unwrap());
985        // Should have all builtins + user override merged
986        assert!(config.providers.contains_key("openai"));
987        assert!(config.providers.contains_key("anthropic"));
988        assert_eq!(
989            config.providers["openai"].api_key.as_deref(),
990            Some("sk-test")
991        );
992    }
993
994    #[test]
995    fn load_with_custom_derived_provider() {
996        let yaml = r#"
997providers:
998  my-company:
999    derives: openai
1000    api_base: "https://api.mycompany.com/v1"
1001    api_key: "sk-custom"
1002"#;
1003        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1004        let p = &config.providers["my-company"];
1005        assert_eq!(p.api_protocol, Some(ApiProtocol::Openai)); // inherited
1006        assert_eq!(p.api_base.as_deref(), Some("https://api.mycompany.com/v1")); // overridden
1007        assert_eq!(p.api_key.as_deref(), Some("sk-custom"));
1008        assert!(p.derives.is_none()); // resolved
1009    }
1010
1011    #[test]
1012    fn load_with_model_routing() {
1013        let yaml = r#"
1014providers:
1015  openai:
1016    api_key: "sk-test"
1017models:
1018  my-gpt4:
1019    strategy: load_balance
1020    endpoints:
1021      - provider: openai
1022        model_id: gpt-4o
1023        api_key: "sk-key-a"
1024      - provider: openai
1025        model_id: gpt-4o
1026        api_key: "sk-key-b"
1027"#;
1028        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1029        let model = &config.models["my-gpt4"];
1030        assert_eq!(model.strategy, RoutingStrategy::LoadBalance);
1031        assert_eq!(model.endpoints.len(), 2);
1032        assert_eq!(model.endpoints[0].api_key.as_deref(), Some("sk-key-a"));
1033    }
1034
1035    #[test]
1036    fn load_with_custom_auth() {
1037        let yaml = r#"
1038providers:
1039  aimo:
1040    derives: openai
1041    api_base: "https://api.aimo.network/v1"
1042    auth:
1043      type: custom
1044      method: siwx
1045      params:
1046        chain_id: 1
1047"#;
1048        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1049        let p = &config.providers["aimo"];
1050        assert!(matches!(p.auth, Some(AuthConfig::Custom { .. })));
1051        if let Some(AuthConfig::Custom { method, .. }) = &p.auth {
1052            assert_eq!(method, "siwx");
1053        }
1054    }
1055
1056    #[test]
1057    fn empty_yaml_gets_full_builtins() {
1058        let config = BitrouterConfig::load_from_str("{}", None).unwrap();
1059        assert!(config.providers.contains_key("openai"));
1060        assert!(config.providers.contains_key("anthropic"));
1061        assert!(config.providers.contains_key("google"));
1062    }
1063
1064    #[test]
1065    fn load_with_provider_model_metadata() {
1066        let yaml = r#"
1067providers:
1068  openai:
1069    api_key: "sk-test"
1070    models:
1071      gpt-4o:
1072        name: "GPT-4o"
1073        max_input_tokens: 128000
1074        max_output_tokens: 16384
1075        input_modalities: [text, image]
1076        output_modalities: [text]
1077        pricing:
1078          input_tokens:
1079            no_cache: 2.50
1080          output_tokens:
1081            text: 10.00
1082      gpt-4o-mini:
1083        name: "GPT-4o Mini"
1084"#;
1085        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1086        let openai = &config.providers["openai"];
1087        let models = openai.models.as_ref().unwrap();
1088
1089        let gpt4o = &models["gpt-4o"];
1090        assert_eq!(gpt4o.name.as_deref(), Some("GPT-4o"));
1091        assert_eq!(gpt4o.max_input_tokens, Some(128000));
1092        assert_eq!(gpt4o.max_output_tokens, Some(16384));
1093        assert_eq!(
1094            gpt4o.input_modalities,
1095            vec![Modality::Text, Modality::Image]
1096        );
1097        assert_eq!(gpt4o.pricing.input_tokens.no_cache, Some(2.50));
1098        assert_eq!(gpt4o.pricing.output_tokens.text, Some(10.00));
1099
1100        let mini = &models["gpt-4o-mini"];
1101        assert_eq!(mini.name.as_deref(), Some("GPT-4o Mini"));
1102        assert_eq!(mini.pricing.input_tokens.no_cache, None); // default
1103    }
1104
1105    #[test]
1106    fn derives_inherits_model_catalog() {
1107        let yaml = r#"
1108providers:
1109  my-openai:
1110    derives: openai
1111    api_key: "sk-custom"
1112"#;
1113        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1114        let my_openai = &config.providers["my-openai"];
1115        // Should inherit the built-in openai models catalog
1116        let models = my_openai.models.as_ref().unwrap();
1117        assert!(models.contains_key("gpt-4o"));
1118    }
1119
1120    #[test]
1121    fn inherit_defaults_true_by_default() {
1122        let config = BitrouterConfig::load_from_str("{}", None).unwrap();
1123        assert!(config.inherit_defaults);
1124        assert!(config.providers.contains_key("openai"));
1125        assert!(config.providers.contains_key("bitrouter"));
1126    }
1127
1128    #[test]
1129    fn inherit_defaults_false_excludes_builtins() {
1130        let yaml = r#"
1131inherit_defaults: false
1132providers:
1133  custom:
1134    api_protocol: openai
1135    api_base: "https://custom.example.com/v1"
1136    api_key: "sk-custom"
1137"#;
1138        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1139        assert!(!config.inherit_defaults);
1140        assert!(config.providers.contains_key("custom"));
1141        assert!(!config.providers.contains_key("openai"));
1142        assert!(!config.providers.contains_key("bitrouter"));
1143        assert_eq!(config.providers.len(), 1);
1144    }
1145
1146    #[test]
1147    fn load_with_tool_routing() {
1148        let yaml = r#"
1149providers:
1150  github-mcp:
1151    api_protocol: mcp
1152    api_base: "https://api.githubcopilot.com/mcp"
1153    api_key: "ghp-test"
1154tools:
1155  create_issue:
1156    strategy: priority
1157    endpoints:
1158      - provider: github-mcp
1159        tool_id: create_issue
1160  search_code:
1161    endpoints:
1162      - provider: github-mcp
1163        tool_id: search_code
1164        api_protocol: mcp
1165"#;
1166        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1167        // 2 user-defined + 6 built-in exa tools
1168        assert!(config.tools.len() >= 2);
1169        assert!(config.tools.contains_key("create_issue"));
1170        assert!(config.tools.contains_key("search_code"));
1171
1172        let tool = &config.tools["create_issue"];
1173        assert_eq!(tool.strategy, RoutingStrategy::Priority);
1174        assert_eq!(tool.endpoints.len(), 1);
1175        assert_eq!(tool.endpoints[0].provider, "github-mcp");
1176        assert_eq!(tool.endpoints[0].service_id, "create_issue");
1177        assert!(tool.endpoints[0].api_protocol.is_none());
1178
1179        let search = &config.tools["search_code"];
1180        assert_eq!(search.endpoints[0].api_protocol, Some(ApiProtocol::Mcp));
1181    }
1182
1183    #[test]
1184    fn full_template_deserializes() {
1185        let yaml = include_str!("../templates/full.yaml");
1186        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1187
1188        // Server
1189        assert_eq!(config.server.listen, "127.0.0.1:8787".parse().unwrap());
1190        assert_eq!(config.server.log_level, "info");
1191
1192        // Database
1193        assert!(config.database.url.is_some());
1194
1195        // Providers: builtins + user-defined
1196        assert!(config.providers.contains_key("openai"));
1197        assert!(config.providers.contains_key("anthropic"));
1198        assert!(config.providers.contains_key("google"));
1199        assert!(config.providers.contains_key("my-proxy"));
1200        assert!(config.providers.contains_key("custom-llm"));
1201        assert!(config.providers.contains_key("github-mcp"));
1202        assert!(config.providers.contains_key("header-auth-provider"));
1203        assert!(config.providers.contains_key("paid-provider"));
1204
1205        // Derived provider inherits api_protocol
1206        let my_proxy = &config.providers["my-proxy"];
1207        assert_eq!(my_proxy.api_protocol, Some(ApiProtocol::Openai));
1208        assert!(my_proxy.derives.is_none()); // resolved
1209
1210        // Custom provider
1211        let custom = &config.providers["custom-llm"];
1212        assert_eq!(custom.api_protocol, Some(ApiProtocol::Openai));
1213        let models = custom.models.as_ref().unwrap();
1214        assert!(models.contains_key("my-model-7b"));
1215
1216        // Model routing
1217        assert_eq!(config.models.len(), 3);
1218        assert!(config.models.contains_key("smart"));
1219        assert!(config.models.contains_key("fast"));
1220        assert!(config.models.contains_key("coding"));
1221        assert_eq!(config.models["smart"].strategy, RoutingStrategy::Priority);
1222        assert_eq!(config.models["fast"].strategy, RoutingStrategy::LoadBalance);
1223
1224        // Tool routing
1225        assert!(config.tools.contains_key("create_issue"));
1226        assert!(config.tools.contains_key("web_search"));
1227
1228        // Guardrails
1229        assert!(config.guardrails.enabled);
1230        assert!(!config.guardrails.disabled_patterns.is_empty());
1231        assert!(!config.guardrails.custom_patterns.is_empty());
1232        assert!(!config.guardrails.upgoing.is_empty());
1233        assert!(!config.guardrails.downgoing.is_empty());
1234
1235        // Wallet
1236        let wallet = config.wallet.as_ref().unwrap();
1237        assert_eq!(wallet.name, "my-wallet");
1238        assert!(wallet.payment.is_some());
1239
1240        // MPP
1241        let mpp = config.mpp.as_ref().unwrap();
1242        assert!(mpp.enabled);
1243    }
1244
1245    #[test]
1246    fn minimal_template_deserializes() {
1247        let yaml = "";
1248        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1249
1250        // Comments-only YAML deserializes with all defaults
1251        assert_eq!(config.server.listen, "127.0.0.1:8787".parse().unwrap());
1252        assert_eq!(config.server.log_level, "info");
1253
1254        // Builtins merged (inherit_defaults defaults to true)
1255        assert!(config.inherit_defaults);
1256        assert!(config.providers.contains_key("openai"));
1257        assert!(config.providers.contains_key("anthropic"));
1258        assert!(config.providers.contains_key("google"));
1259        assert!(config.providers.contains_key("bitrouter"));
1260
1261        // No custom models or tools defined
1262        assert!(config.models.is_empty());
1263
1264        // No wallet or MPP
1265        assert!(config.wallet.is_none());
1266        assert!(config.mpp.is_none());
1267
1268        // Guardrails enabled by default
1269        assert!(config.guardrails.enabled);
1270    }
1271
1272    #[test]
1273    fn empty_string_deserializes() {
1274        let config = BitrouterConfig::load_from_str("", None).unwrap();
1275
1276        // All defaults applied
1277        assert_eq!(config.server.listen, "127.0.0.1:8787".parse().unwrap());
1278        assert!(config.inherit_defaults);
1279        assert!(config.providers.contains_key("openai"));
1280        assert!(config.providers.contains_key("anthropic"));
1281        assert!(config.providers.contains_key("google"));
1282        assert!(config.models.is_empty());
1283        assert!(config.guardrails.enabled);
1284    }
1285
1286    #[test]
1287    fn load_with_oauth_auth() {
1288        let yaml = r#"
1289providers:
1290  github-copilot:
1291    api_protocol: openai
1292    api_base: "https://api.githubcopilot.com"
1293    auth:
1294      type: oauth
1295      grant: device_code
1296      client_id: "Iv23limb4eFHH5zfOCr2"
1297      scope: "read:user"
1298      device_auth_url: "https://github.com/login/device/code"
1299      token_url: "https://github.com/login/oauth/access_token"
1300"#;
1301        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1302        let p = &config.providers["github-copilot"];
1303        assert!(matches!(p.auth, Some(AuthConfig::OAuth { .. })));
1304        if let Some(AuthConfig::OAuth {
1305            grant,
1306            client_id,
1307            scope,
1308            device_auth_url,
1309            token_url,
1310            ..
1311        }) = &p.auth
1312        {
1313            assert_eq!(*grant, OAuthGrant::DeviceCode);
1314            assert_eq!(client_id, "Iv23limb4eFHH5zfOCr2");
1315            assert_eq!(scope.as_deref(), Some("read:user"));
1316            assert_eq!(
1317                device_auth_url.as_deref(),
1318                Some("https://github.com/login/device/code")
1319            );
1320            assert_eq!(
1321                token_url.as_deref(),
1322                Some("https://github.com/login/oauth/access_token")
1323            );
1324        }
1325    }
1326
1327    #[test]
1328    fn load_oauth_with_defaults() {
1329        let yaml = r#"
1330providers:
1331  test-oauth:
1332    api_protocol: openai
1333    api_base: "https://api.example.com"
1334    auth:
1335      type: oauth
1336      grant: device_code
1337      client_id: "test-client-id"
1338"#;
1339        let config = BitrouterConfig::load_from_str(yaml, None).unwrap();
1340        let p = &config.providers["test-oauth"];
1341        if let Some(AuthConfig::OAuth {
1342            scope,
1343            device_auth_url,
1344            token_url,
1345            ..
1346        }) = &p.auth
1347        {
1348            assert!(scope.is_none());
1349            assert!(device_auth_url.is_none());
1350            assert!(token_url.is_none());
1351        } else {
1352            panic!("expected OAuth auth config");
1353        }
1354    }
1355}