mod raw;
mod secret;
use std::fs;
use std::net::SocketAddr;
use std::path::Path;
use indexmap::IndexMap;
use serde::Deserialize;
use url::Url;
use self::raw::RawConfig;
pub(crate) use self::raw::is_loopback;
pub(crate) use self::secret::Secret;
#[derive(Clone, Debug)]
pub struct Config {
pub(crate) server: ServerConfig,
pub(crate) observability: ObservabilityConfig,
pub(crate) payment: PaymentConfig,
pub(crate) upstreams: Vec<UpstreamConfig>,
pub(crate) models: Vec<ModelConfig>,
pub(crate) pricing: Option<PricingConfig>,
}
#[derive(Clone, Debug)]
pub(crate) struct ServerConfig {
pub(crate) bind: SocketAddr,
pub(crate) base_url: Option<Url>,
pub(crate) shutdown_timeout_secs: u64,
pub(crate) body_limit_bytes: usize,
pub(crate) request_timeout_secs: u64,
pub(crate) cors_origins: Vec<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct ObservabilityConfig {
pub(crate) level: String,
pub(crate) format: LogFormat,
pub(crate) metrics_bind: Option<SocketAddr>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum LogFormat {
#[default]
Json,
Pretty,
}
#[derive(Clone, Debug)]
pub(crate) struct PaymentConfig {
pub(crate) enabled: bool,
pub(crate) missing_usage: UsagePolicy,
pub(crate) abort_usage: UsagePolicy,
pub(crate) max_timeout_seconds: u64,
pub(crate) facilitator: Option<FacilitatorConfig>,
#[allow(dead_code, reason = "loaded from TOML")]
pub(crate) settlement: SettlementConfig,
pub(crate) pay_to: IndexMap<String, String>,
pub(crate) accepts: Vec<AcceptConfig>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum UsagePolicy {
#[default]
Ceiling,
Floor,
}
#[derive(Clone, Debug)]
pub(crate) struct FacilitatorConfig {
pub(crate) url: Option<Url>,
pub(crate) timeout_secs: u64,
pub(crate) supported_cache_ttl_secs: u64,
pub(crate) auth: Option<FacilitatorAuth>,
}
#[derive(Clone, Debug)]
pub(crate) struct FacilitatorAuth {
pub(crate) verify: IndexMap<String, Secret<String>>,
pub(crate) settle: IndexMap<String, Secret<String>>,
pub(crate) supported: IndexMap<String, Secret<String>>,
}
#[allow(dead_code, reason = "loaded from TOML")]
#[derive(Clone, Copy, Debug)]
pub(crate) struct SettlementConfig {
pub(crate) exact_non_stream: Scheduler,
pub(crate) exact_stream: Scheduler,
pub(crate) upto_non_stream: Scheduler,
pub(crate) upto_stream: Scheduler,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
pub(crate) enum Scheduler {
#[serde(rename = "sequential-wait-settle")]
SequentialWaitSettle,
#[serde(rename = "wait-2xx-then-spawn")]
Wait2xxThenSpawn,
#[serde(rename = "stream-then-settle")]
StreamThenSettle,
}
#[derive(Clone, Debug)]
pub(crate) struct AcceptConfig {
pub(crate) scheme: String,
pub(crate) network: String,
pub(crate) asset: Option<String>,
pub(crate) asset_address: Option<String>,
pub(crate) decimals: Option<u32>,
pub(crate) transfer_method: Option<String>,
pub(crate) eip712_name: Option<String>,
pub(crate) eip712_version: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct UpstreamConfig {
pub(crate) name: String,
pub(crate) base_url: Url,
pub(crate) api_key: Secret<String>,
pub(crate) timeout_secs: u64,
pub(crate) connect_timeout_secs: u64,
#[allow(dead_code, reason = "enforced at load; not read at request time")]
pub(crate) allow_insecure: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct ModelConfig {
pub(crate) id: String,
pub(crate) upstream: String,
pub(crate) upstream_model: Option<String>,
pub(crate) owned_by: Option<String>,
pub(crate) scheme: Option<String>,
pub(crate) input_per_million: Option<String>,
pub(crate) output_per_million: Option<String>,
pub(crate) cached_input_per_million: Option<String>,
pub(crate) reasoning_per_million: Option<String>,
pub(crate) request_floor: Option<String>,
pub(crate) ceiling_multiplier: Option<String>,
pub(crate) max_ceiling: Option<String>,
pub(crate) max_input_tokens: Option<u32>,
pub(crate) default_max_output_tokens: Option<u32>,
pub(crate) price: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct PricingConfig {
pub(crate) default: Option<PricingDefault>,
}
#[derive(Clone, Debug)]
pub(crate) struct PricingDefault {
pub(crate) scheme: Option<String>,
pub(crate) request_floor: Option<String>,
pub(crate) input_per_million: Option<String>,
pub(crate) output_per_million: Option<String>,
pub(crate) cached_input_per_million: Option<String>,
pub(crate) reasoning_per_million: Option<String>,
pub(crate) ceiling_multiplier: Option<String>,
pub(crate) max_ceiling: Option<String>,
pub(crate) max_input_tokens: Option<u32>,
pub(crate) default_max_output_tokens: Option<u32>,
pub(crate) price: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read config file '{path}': {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("failed to parse config file '{path}': {source}")]
Parse {
path: String,
#[source]
source: toml::de::Error,
},
#[error("env var '{name}' not found (referenced as '{reference}')")]
MissingEnv {
name: String,
reference: String,
},
#[error("{0}")]
Validation(String),
}
impl Config {
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
let path = path.as_ref();
let origin = path.display().to_string();
let text = fs::read_to_string(path).map_err(|source| ConfigError::Io {
path: origin.clone(),
source,
})?;
Self::from_toml(origin, &text, env_lookup)
}
fn from_toml(
origin: String,
text: &str,
lookup: impl Fn(&str) -> Option<String>,
) -> Result<Self, ConfigError> {
let value: toml::Value = toml::from_str(text).map_err(|source| ConfigError::Parse {
path: origin.clone(),
source,
})?;
let value = interpolate_value(&value, &lookup)?;
let raw: RawConfig = value.try_into().map_err(|source| ConfigError::Parse {
path: origin,
source,
})?;
raw.into_config()
}
}
fn env_lookup(key: &str) -> Option<String> {
std::env::var(key).ok()
}
fn resolve_env_impl(
value: &str,
lookup: impl Fn(&str) -> Option<String>,
) -> Result<String, ConfigError> {
if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
return lookup(var_name).ok_or_else(|| ConfigError::MissingEnv {
name: var_name.to_owned(),
reference: value.to_owned(),
});
}
if let Some(var_name) = value.strip_prefix('$')
&& !var_name.is_empty()
&& var_name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
{
return lookup(var_name).ok_or_else(|| ConfigError::MissingEnv {
name: var_name.to_owned(),
reference: value.to_owned(),
});
}
Ok(value.to_owned())
}
fn interpolate_value(
val: &toml::Value,
lookup: &impl Fn(&str) -> Option<String>,
) -> Result<toml::Value, ConfigError> {
match val {
toml::Value::String(s) => Ok(toml::Value::String(resolve_env_impl(s, lookup)?)),
toml::Value::Array(arr) => {
let resolved = arr
.iter()
.map(|v| interpolate_value(v, lookup))
.collect::<Result<Vec<_>, _>>()?;
Ok(toml::Value::Array(resolved))
}
toml::Value::Table(table) => {
let mut out = toml::map::Map::new();
for (key, value) in table {
out.insert(key.clone(), interpolate_value(value, lookup)?);
}
Ok(toml::Value::Table(out))
}
other => Ok(other.clone()),
}
}
#[cfg(test)]
impl Config {
pub(crate) fn from_toml_str(text: &str) -> Result<Self, ConfigError> {
Self::from_toml("<test>".to_owned(), text, env_lookup)
}
pub(crate) fn from_toml_str_with(
text: &str,
lookup: impl Fn(&str) -> Option<String>,
) -> Result<Self, ConfigError> {
Self::from_toml("<test>".to_owned(), text, lookup)
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use super::{Config, ConfigError, UsagePolicy};
const UNPAID: &str = r#"
[payment]
enabled = false
[[upstreams]]
name = "openai"
base_url = "https://api.openai.com"
api_key = "sk-test"
"#;
fn lookup(map: &BTreeMap<String, String>) -> impl Fn(&str) -> Option<String> + '_ {
|key| map.get(key).cloned()
}
fn unpaid_with_key(api_key: &str) -> String {
format!(
r#"
[payment]
enabled = false
[[upstreams]]
name = "openai"
base_url = "https://api.openai.com"
api_key = "{api_key}"
"#
)
}
#[test]
fn unpaid_defaults_bind() {
let cfg = Config::from_toml_str(UNPAID).expect("unpaid");
assert_eq!(cfg.server.bind.to_string(), "0.0.0.0:8080", "default bind");
assert!(cfg.observability.metrics_bind.is_none(), "metrics off");
assert!(!cfg.payment.enabled, "payment off");
assert_eq!(cfg.upstreams.len(), 1, "one upstream");
}
#[test]
fn metrics_bind_parsed() {
let toml = format!(
r#"
[observability]
metrics_bind = "127.0.0.1:9090"
{UNPAID}
"#
);
let cfg = Config::from_toml_str(&toml).expect("metrics");
assert_eq!(
cfg.observability.metrics_bind.expect("set").to_string(),
"127.0.0.1:9090",
"metrics bind"
);
}
#[test]
fn metrics_bind_must_differ_from_server_bind() {
let toml = format!(
r#"
[server]
bind = "0.0.0.0:8080"
[observability]
metrics_bind = "0.0.0.0:8080"
{UNPAID}
"#
);
let err = Config::from_toml_str(&toml).expect_err("same bind");
assert!(
err.to_string().contains("metrics_bind"),
"overlap, got {err}"
);
}
#[test]
fn metrics_bind_rejects_unspecified_vs_loopback_same_port() {
let toml = format!(
r#"
[server]
bind = "0.0.0.0:8080"
[observability]
metrics_bind = "127.0.0.1:8080"
{UNPAID}
"#
);
let err = Config::from_toml_str(&toml).expect_err("overlap");
assert!(
err.to_string().contains("metrics_bind"),
"unspecified vs loopback, got {err}"
);
}
#[test]
fn metrics_bind_allows_distinct_ports() {
let toml = format!(
r#"
[server]
bind = "0.0.0.0:8080"
[observability]
metrics_bind = "127.0.0.1:9090"
{UNPAID}
"#
);
let cfg = Config::from_toml_str(&toml).expect("distinct ports");
assert_eq!(
cfg.observability.metrics_bind.expect("set").to_string(),
"127.0.0.1:9090",
"metrics on 9090"
);
}
#[test]
fn whole_string_interpolation() {
let env = BTreeMap::from([("OPENAI_API_KEY".to_owned(), "sk-from-env".to_owned())]);
let cfg = Config::from_toml_str_with(&unpaid_with_key("$OPENAI_API_KEY"), lookup(&env))
.expect("interpolated");
let key = cfg.upstreams.first().expect("upstream").api_key.expose();
assert_eq!(key, "sk-from-env", "$VAR");
}
#[test]
fn brace_interpolation() {
let env = BTreeMap::from([("OPENAI_API_KEY".to_owned(), "sk-braces".to_owned())]);
let cfg = Config::from_toml_str_with(&unpaid_with_key("${OPENAI_API_KEY}"), lookup(&env))
.expect("interpolated");
let key = cfg.upstreams.first().expect("upstream").api_key.expose();
assert_eq!(key, "sk-braces", "${{VAR}}");
}
#[test]
fn bearer_prefix_stays_literal() {
let env = BTreeMap::from([("X".to_owned(), "leaked".to_owned())]);
let cfg = Config::from_toml_str_with(&unpaid_with_key("Bearer $X"), lookup(&env))
.expect("literal");
let key = cfg.upstreams.first().expect("upstream").api_key.expose();
assert_eq!(key, "Bearer $X", "embedded $VAR is not expanded");
}
#[test]
fn missing_var_is_error() {
let env = BTreeMap::new();
let err = Config::from_toml_str_with(&unpaid_with_key("$MISSING_O402"), lookup(&env))
.expect_err("missing");
assert!(
matches!(
&err,
ConfigError::MissingEnv { name, reference }
if name == "MISSING_O402" && reference == "$MISSING_O402"
),
"expected MissingEnv, got {err}"
);
}
#[test]
fn deny_unknown_keys() {
let toml = format!(
r#"
[server]
bind = "0.0.0.0:8080"
not_a_field = true
{UNPAID}
"#
);
let err = Config::from_toml_str(&toml).expect_err("unknown");
let msg = err.to_string();
assert!(
msg.contains("not_a_field") || msg.contains("unknown"),
"serde unknown-field message, got {msg}"
);
}
#[test]
fn payment_on_without_facilitator_fails() {
let toml = r#"
[server]
base_url = "https://o402.example.com"
[payment]
enabled = true
[[upstreams]]
name = "openai"
base_url = "https://api.openai.com"
api_key = "sk-test"
"#;
let err = Config::from_toml_str(toml).expect_err("facilitator");
assert!(
err.to_string().contains("facilitator"),
"facilitator required, got {err}"
);
}
#[test]
fn unpaid_example_file_loads() {
let text = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/unpaid.example.toml"));
let env = BTreeMap::from([("OPENAI_API_KEY".to_owned(), "sk-example".to_owned())]);
let cfg = Config::from_toml_str_with(text, lookup(&env)).expect("example");
assert!(!cfg.payment.enabled, "example is unpaid");
assert_eq!(
cfg.upstreams.first().expect("upstream").api_key.expose(),
"sk-example",
"example interpolates OPENAI_API_KEY"
);
assert_eq!(cfg.models.len(), 1, "example catalogs a model");
assert_eq!(
cfg.models.first().expect("model").id,
"gpt-4o-mini",
"example model id"
);
}
#[test]
fn paid_example_file_loads() {
let text = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/paid.example.toml"));
let env = BTreeMap::from([
(
"O402_EVM_PAY_TO".to_owned(),
"0x0000000000000000000000000000000000000001".to_owned(),
),
(
"O402_SOLANA_PAY_TO".to_owned(),
"11111111111111111111111111111111".to_owned(),
),
("OPENAI_API_KEY".to_owned(), "sk-example".to_owned()),
("BIFROST_API_KEY".to_owned(), "sk-o402".to_owned()),
(
"FACILITATOR_AUTH_HEADER".to_owned(),
"Bearer example-token".to_owned(),
),
]);
let cfg = Config::from_toml_str_with(text, lookup(&env)).expect("example");
assert!(cfg.payment.enabled, "example is paid");
assert_eq!(
cfg.payment.abort_usage,
UsagePolicy::Floor,
"stop charges floor"
);
assert_eq!(cfg.models.len(), 0, "catalog optional");
assert_eq!(
cfg.upstreams.first().expect("upstream").name,
"bifrost",
"bifrost upstream"
);
assert_eq!(
cfg.payment
.accepts
.iter()
.filter(|accept| accept.scheme == "upto")
.count(),
2,
"evm upto accepts"
);
assert!(
cfg.payment
.accepts
.iter()
.any(|accept| accept.scheme == "exact" && accept.network.starts_with("solana:")),
"svm exact"
);
drop(crate::http::app(cfg).expect("example router"));
}
#[test]
fn crate_examples_match_deploy() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
for (crate_rel, deploy_rel) in [
(
"unpaid.example.toml",
"../../deploy/gateway.unpaid.example.toml",
),
("paid.example.toml", "../../deploy/gateway.example.toml"),
] {
let deploy = root.join(deploy_rel);
if !deploy.exists() {
continue;
}
let packaged = fs::read_to_string(root.join(crate_rel)).expect(crate_rel);
let operator = fs::read_to_string(&deploy).expect(deploy_rel);
assert_eq!(packaged, operator, "{crate_rel} vs {deploy_rel}");
}
}
fn paid_exact(accepts: &str, extra_model: &str) -> String {
format!(
r#"
[server]
base_url = "https://o402.example.com"
[payment]
enabled = true
[payment.facilitator]
url = "http://127.0.0.1:9"
[payment.pay_to]
"eip155:*" = "0x0000000000000000000000000000000000000001"
{accepts}
[pricing.default]
scheme = "exact"
price = "0.001"
request_floor = "0.00001"
input_per_million = "0.15"
output_per_million = "0.60"
max_input_tokens = 128000
default_max_output_tokens = 16384
[[upstreams]]
name = "openai"
base_url = "https://api.openai.com"
api_key = "sk-test"
[[models]]
id = "gpt-4o-mini"
upstream = "openai"
{extra_model}
"#
)
}
#[test]
fn empty_accepts_when_enabled_fails() {
let err = Config::from_toml_str(&paid_exact("", "")).expect_err("empty");
assert!(
err.to_string().contains("accepts"),
"empty accepts, got {err}"
);
}
#[test]
fn svm_upto_is_rejected() {
let toml = paid_exact(
r#"
[[payment.accepts]]
scheme = "upto"
network = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
asset = "usdc"
"#,
"",
);
let err = Config::from_toml_str(&toml).expect_err("svm upto");
assert!(
err.to_string().contains("eip155"),
"svm upto rejected, got {err}"
);
}
#[test]
fn eip155_upto_accept_loads() {
let toml = r#"
[server]
base_url = "https://o402.example.com"
[payment]
enabled = true
[payment.facilitator]
url = "http://127.0.0.1:9"
[payment.pay_to]
"eip155:*" = "0x0000000000000000000000000000000000000001"
[[payment.accepts]]
scheme = "upto"
network = "eip155:8453"
asset = "usdc"
[pricing.default]
scheme = "upto"
price = "0.001"
request_floor = "0.00001"
input_per_million = "0.15"
output_per_million = "0.60"
max_input_tokens = 128000
default_max_output_tokens = 16384
[[upstreams]]
name = "openai"
base_url = "https://api.openai.com"
api_key = "sk-test"
[[models]]
id = "gpt-4o-mini"
upstream = "openai"
"#;
let cfg = Config::from_toml_str(toml).expect("upto");
assert_eq!(
cfg.payment
.accepts
.first()
.map(|accept| accept.scheme.as_str()),
Some("upto"),
"upto accept"
);
}
#[test]
fn upto_without_input_rate_fails() {
let toml = r#"
[server]
base_url = "https://o402.example.com"
[payment]
enabled = true
[payment.facilitator]
url = "http://127.0.0.1:9"
[payment.pay_to]
"eip155:*" = "0x0000000000000000000000000000000000000001"
[[payment.accepts]]
scheme = "upto"
network = "eip155:8453"
asset = "usdc"
[pricing.default]
scheme = "upto"
max_input_tokens = 128000
[[upstreams]]
name = "openai"
base_url = "https://api.openai.com"
api_key = "sk-test"
[[models]]
id = "gpt-4o-mini"
upstream = "openai"
"#;
let err = Config::from_toml_str(toml).expect_err("input");
assert!(
err.to_string().contains("input_per_million")
|| err.to_string().contains("pricing.default"),
"upto input, got {err}"
);
}
#[test]
fn upto_chat_without_output_rate_fails() {
let toml = r#"
[server]
base_url = "https://o402.example.com"
[payment]
enabled = true
[payment.facilitator]
url = "http://127.0.0.1:9"
[payment.pay_to]
"eip155:*" = "0x0000000000000000000000000000000000000001"
[[payment.accepts]]
scheme = "upto"
network = "eip155:8453"
asset = "usdc"
[pricing.default]
scheme = "upto"
input_per_million = "0.15"
max_input_tokens = 128000
[[upstreams]]
name = "openai"
base_url = "https://api.openai.com"
api_key = "sk-test"
[[models]]
id = "gpt-4o-mini"
upstream = "openai"
default_max_output_tokens = 16384
"#;
let err = Config::from_toml_str(toml).expect_err("output");
assert!(
err.to_string().contains("output_per_million")
|| err.to_string().contains("pricing.default"),
"upto output, got {err}"
);
}
#[test]
fn upto_chat_inherits_default_output_rate() {
let toml = r#"
[server]
base_url = "https://o402.example.com"
[payment]
enabled = true
[payment.facilitator]
url = "http://127.0.0.1:9"
[payment.pay_to]
"eip155:*" = "0x0000000000000000000000000000000000000001"
[[payment.accepts]]
scheme = "upto"
network = "eip155:8453"
asset = "usdc"
[pricing.default]
scheme = "upto"
price = "0.001"
request_floor = "0.00001"
input_per_million = "0.15"
output_per_million = "0.60"
cached_input_per_million = "0.075"
reasoning_per_million = "0.60"
max_ceiling = "5.00"
max_input_tokens = 128000
default_max_output_tokens = 16384
[[upstreams]]
name = "openai"
base_url = "https://api.openai.com"
api_key = "sk-test"
[[models]]
id = "gpt-4o-mini"
upstream = "openai"
"#;
let cfg = Config::from_toml_str(toml).expect("inherit");
let rates =
crate::payment::tags::validate_payment(&cfg.payment, &cfg.models, cfg.pricing.as_ref())
.expect("rates");
let mini = rates.models.get("gpt-4o-mini").expect("mini");
assert_eq!(mini.ppm.output, 600_000, "inherited output ppm");
assert_eq!(
crate::payment::price::ceiling_or_price(
mini,
u128::from(mini.default_max_output),
false,
),
29_040,
"inherit-all chat ceiling"
);
}
#[test]
fn exact_without_price_fails() {
let toml = r#"
[server]
base_url = "https://o402.example.com"
[payment]
enabled = true
[payment.facilitator]
url = "http://127.0.0.1:9"
[payment.pay_to]
"eip155:*" = "0x0000000000000000000000000000000000000001"
[[payment.accepts]]
scheme = "exact"
network = "eip155:8453"
asset = "usdc"
[pricing.default]
scheme = "exact"
[[upstreams]]
name = "openai"
base_url = "https://api.openai.com"
api_key = "sk-test"
[[models]]
id = "gpt-4o-mini"
upstream = "openai"
"#;
let err = Config::from_toml_str(toml).expect_err("price");
assert!(err.to_string().contains("price"), "exact price, got {err}");
}
}