use std::net::{Ipv4Addr, SocketAddr};
use indexmap::IndexMap;
use serde::Deserialize;
use url::Url;
use super::secret::Secret;
use super::{
AcceptConfig, Config, ConfigError, FacilitatorAuth, FacilitatorConfig, LogFormat, ModelConfig,
ObservabilityConfig, PaymentConfig, PricingConfig, PricingDefault, Scheduler, ServerConfig,
SettlementConfig, UpstreamConfig, UsagePolicy,
};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RawConfig {
#[serde(default)]
server: RawServer,
#[serde(default)]
observability: RawObservability,
payment: RawPayment,
#[serde(default)]
upstreams: Vec<RawUpstream>,
#[serde(default)]
models: Vec<RawModel>,
#[serde(default)]
pricing: Option<RawPricing>,
}
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawServer {
bind: SocketAddr,
base_url: Option<Url>,
shutdown_timeout_secs: u64,
body_limit_bytes: usize,
request_timeout_secs: u64,
cors: RawCors,
}
impl Default for RawServer {
fn default() -> Self {
Self {
bind: SocketAddr::from((Ipv4Addr::UNSPECIFIED, 8080)),
base_url: None,
shutdown_timeout_secs: 30,
body_limit_bytes: 8_388_608,
request_timeout_secs: 180,
cors: RawCors::default(),
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawCors {
#[serde(default)]
origins: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawObservability {
level: String,
format: LogFormat,
metrics_bind: Option<SocketAddr>,
}
impl Default for RawObservability {
fn default() -> Self {
Self {
level: "info".to_owned(),
format: LogFormat::Json,
metrics_bind: None,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPayment {
enabled: bool,
#[serde(default)]
missing_usage: UsagePolicy,
#[serde(default)]
abort_usage: UsagePolicy,
#[serde(default = "default_max_timeout_seconds")]
max_timeout_seconds: u64,
#[serde(default)]
facilitator: Option<RawFacilitator>,
#[serde(default)]
settlement: RawSettlement,
#[serde(default)]
pay_to: IndexMap<String, String>,
#[serde(default)]
accepts: Vec<RawAccept>,
}
const fn default_max_timeout_seconds() -> u64 {
300
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawFacilitator {
url: Option<Url>,
#[serde(default = "default_facilitator_timeout")]
timeout_secs: u64,
#[serde(default = "default_supported_cache_ttl")]
supported_cache_ttl_secs: u64,
#[serde(default)]
auth: Option<RawFacilitatorAuth>,
}
const fn default_facilitator_timeout() -> u64 {
60
}
const fn default_supported_cache_ttl() -> u64 {
600
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawFacilitatorAuth {
#[serde(default)]
verify: IndexMap<String, Secret<String>>,
#[serde(default)]
settle: IndexMap<String, Secret<String>>,
#[serde(default)]
supported: IndexMap<String, Secret<String>>,
}
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawSettlement {
exact_non_stream: Scheduler,
exact_stream: Scheduler,
upto_non_stream: Scheduler,
upto_stream: Scheduler,
}
impl Default for RawSettlement {
fn default() -> Self {
Self {
exact_non_stream: Scheduler::SequentialWaitSettle,
exact_stream: Scheduler::Wait2xxThenSpawn,
upto_non_stream: Scheduler::SequentialWaitSettle,
upto_stream: Scheduler::StreamThenSettle,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawAccept {
scheme: String,
network: String,
#[serde(default)]
asset: Option<String>,
#[serde(default)]
asset_address: Option<String>,
#[serde(default)]
decimals: Option<u32>,
#[serde(default)]
transfer_method: Option<String>,
#[serde(default)]
eip712_name: Option<String>,
#[serde(default)]
eip712_version: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawUpstream {
name: String,
base_url: Url,
api_key: Secret<String>,
#[serde(default = "default_upstream_timeout")]
timeout_secs: u64,
#[serde(default = "default_connect_timeout")]
connect_timeout_secs: u64,
#[serde(default)]
allow_insecure: bool,
}
const fn default_upstream_timeout() -> u64 {
120
}
const fn default_connect_timeout() -> u64 {
10
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawModel {
id: String,
upstream: String,
#[serde(default)]
upstream_model: Option<String>,
#[serde(default)]
owned_by: Option<String>,
#[serde(default)]
scheme: Option<String>,
#[serde(default)]
input_per_million: Option<String>,
#[serde(default)]
output_per_million: Option<String>,
#[serde(default)]
cached_input_per_million: Option<String>,
#[serde(default)]
reasoning_per_million: Option<String>,
#[serde(default)]
request_floor: Option<String>,
#[serde(default)]
ceiling_multiplier: Option<String>,
#[serde(default)]
max_ceiling: Option<String>,
#[serde(default)]
max_input_tokens: Option<u32>,
#[serde(default)]
default_max_output_tokens: Option<u32>,
#[serde(default)]
price: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPricing {
#[serde(default)]
default: Option<RawPricingDefault>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPricingDefault {
#[serde(default)]
scheme: Option<String>,
#[serde(default)]
request_floor: Option<String>,
#[serde(default)]
input_per_million: Option<String>,
#[serde(default)]
output_per_million: Option<String>,
#[serde(default)]
cached_input_per_million: Option<String>,
#[serde(default)]
reasoning_per_million: Option<String>,
#[serde(default)]
ceiling_multiplier: Option<String>,
#[serde(default)]
max_ceiling: Option<String>,
#[serde(default)]
max_input_tokens: Option<u32>,
#[serde(default)]
default_max_output_tokens: Option<u32>,
#[serde(default)]
price: Option<String>,
}
impl RawConfig {
pub(super) fn into_config(self) -> Result<Config, ConfigError> {
validate_cors(&self.server.cors.origins)?;
if self.observability.level.trim().is_empty() {
return Err(ConfigError::Validation(
"observability.level must not be empty".to_owned(),
));
}
if let Some(metrics_bind) = self.observability.metrics_bind
&& metrics_bind_overlaps(metrics_bind, self.server.bind)
{
return Err(ConfigError::Validation(
"observability.metrics_bind port overlaps server.bind".to_owned(),
));
}
let payment = self.payment.into_config(self.server.base_url.as_ref())?;
let upstreams = validate_upstreams(&self.upstreams)?;
let pricing = validate_pricing(self.pricing, payment.enabled)?;
let models = validate_models(&self.models, &upstreams, payment.enabled, pricing.as_ref())?;
Ok(Config {
server: ServerConfig {
bind: self.server.bind,
base_url: self.server.base_url,
shutdown_timeout_secs: self.server.shutdown_timeout_secs,
body_limit_bytes: self.server.body_limit_bytes,
request_timeout_secs: self.server.request_timeout_secs,
cors_origins: self.server.cors.origins,
},
observability: ObservabilityConfig {
level: self.observability.level,
format: self.observability.format,
metrics_bind: self.observability.metrics_bind,
},
payment,
upstreams,
models,
pricing,
})
}
}
impl RawPayment {
fn into_config(self, base_url: Option<&Url>) -> Result<PaymentConfig, ConfigError> {
validate_settlement(&self.settlement)?;
if self.enabled {
validate_enabled_payment(&self, base_url)?;
}
let facilitator = self
.facilitator
.map(|fac| FacilitatorConfig {
url: fac.url,
timeout_secs: fac.timeout_secs,
supported_cache_ttl_secs: fac.supported_cache_ttl_secs,
auth: fac.auth.map(|auth| FacilitatorAuth {
verify: auth.verify,
settle: auth.settle,
supported: auth.supported,
}),
})
.filter(|fac| self.enabled || fac.url.is_some());
Ok(PaymentConfig {
enabled: self.enabled,
missing_usage: self.missing_usage,
abort_usage: self.abort_usage,
max_timeout_seconds: self.max_timeout_seconds,
facilitator,
settlement: SettlementConfig {
exact_non_stream: self.settlement.exact_non_stream,
exact_stream: self.settlement.exact_stream,
upto_non_stream: self.settlement.upto_non_stream,
upto_stream: self.settlement.upto_stream,
},
pay_to: self.pay_to,
accepts: self
.accepts
.into_iter()
.map(|row| AcceptConfig {
scheme: row.scheme,
network: row.network,
asset: row.asset,
asset_address: row.asset_address,
decimals: row.decimals,
transfer_method: row.transfer_method,
eip712_name: row.eip712_name,
eip712_version: row.eip712_version,
})
.collect(),
})
}
}
fn validate_cors(origins: &[String]) -> Result<(), ConfigError> {
let star = origins.iter().filter(|origin| *origin == "*").count();
if star > 0 && origins.len() != 1 {
return Err(ConfigError::Validation(
"server.cors.origins cannot mix '*' with explicit origins".to_owned(),
));
}
for origin in origins {
if origin.is_empty() {
return Err(ConfigError::Validation(
"server.cors.origins entries must not be empty".to_owned(),
));
}
}
Ok(())
}
fn validate_settlement(settlement: &RawSettlement) -> Result<(), ConfigError> {
if settlement.exact_non_stream != Scheduler::SequentialWaitSettle {
return Err(ConfigError::Validation(
"payment.settlement.exact_non_stream must be sequential-wait-settle".to_owned(),
));
}
if settlement.exact_stream != Scheduler::Wait2xxThenSpawn {
return Err(ConfigError::Validation(
"payment.settlement.exact_stream must be wait-2xx-then-spawn".to_owned(),
));
}
if settlement.upto_non_stream != Scheduler::SequentialWaitSettle {
return Err(ConfigError::Validation(
"payment.settlement.upto_non_stream must be sequential-wait-settle".to_owned(),
));
}
if settlement.upto_stream != Scheduler::StreamThenSettle {
return Err(ConfigError::Validation(
"payment.settlement.upto_stream must be stream-then-settle".to_owned(),
));
}
Ok(())
}
fn validate_enabled_payment(
payment: &RawPayment,
base_url: Option<&Url>,
) -> Result<(), ConfigError> {
let Some(base_url) = base_url else {
return Err(ConfigError::Validation(
"server.base_url is required when payment.enabled = true".to_owned(),
));
};
validate_public_base_url(base_url)?;
let Some(facilitator) = payment.facilitator.as_ref() else {
return Err(ConfigError::Validation(
"payment.facilitator.url is required when payment.enabled = true".to_owned(),
));
};
if facilitator.url.is_none() {
return Err(ConfigError::Validation(
"payment.facilitator.url is required when payment.enabled = true".to_owned(),
));
}
if payment.pay_to.is_empty() {
return Err(ConfigError::Validation(
"payment.pay_to is required when payment.enabled = true".to_owned(),
));
}
if payment.accepts.is_empty() {
return Err(ConfigError::Validation(
"payment.accepts must contain at least one entry when payment.enabled = true"
.to_owned(),
));
}
for accept in &payment.accepts {
validate_accept(accept)?;
}
Ok(())
}
fn validate_public_base_url(url: &Url) -> Result<(), ConfigError> {
match url.scheme() {
"https" => Ok(()),
"http" if is_loopback(url) => Ok(()),
"http" => Err(ConfigError::Validation(
"server.base_url must be https except on loopback".to_owned(),
)),
other => Err(ConfigError::Validation(format!(
"server.base_url scheme '{other}' is not supported"
))),
}
}
fn validate_accept(accept: &RawAccept) -> Result<(), ConfigError> {
match accept.scheme.as_str() {
"exact" => {}
"upto" => {
let namespace = accept
.network
.split_once(':')
.map_or("", |(namespace, _)| namespace);
if namespace != "eip155" {
return Err(ConfigError::Validation(format!(
"scheme = \"upto\" is only supported on eip155 networks (got {})",
accept.network
)));
}
if accept.transfer_method.is_some() {
return Err(ConfigError::Validation(
"transfer_method is not valid on upto accepts".to_owned(),
));
}
}
other => {
return Err(ConfigError::Validation(format!(
"payment.accepts scheme '{other}' is not supported"
)));
}
}
let named = accept.asset.is_some();
let custom = accept.asset_address.is_some() || accept.decimals.is_some();
match (named, custom) {
(true, false) => Ok(()),
(false, true) if accept.asset_address.is_some() && accept.decimals.is_some() => Ok(()),
(false, true) => Err(ConfigError::Validation(
"custom payment.accepts entries need both asset_address and decimals".to_owned(),
)),
(true, true) => Err(ConfigError::Validation(
"payment.accepts cannot set both asset and asset_address".to_owned(),
)),
(false, false) => Err(ConfigError::Validation(
"payment.accepts needs a named asset or asset_address + decimals".to_owned(),
)),
}
}
fn validate_upstreams(rows: &[RawUpstream]) -> Result<Vec<UpstreamConfig>, ConfigError> {
if rows.is_empty() {
return Err(ConfigError::Validation(
"at least one [[upstreams]] entry is required".to_owned(),
));
}
let mut names = hashbrown::HashSet::new();
let mut out = Vec::with_capacity(rows.len());
for row in rows {
if row.name.is_empty() {
return Err(ConfigError::Validation(
"upstreams.name must not be empty".to_owned(),
));
}
if !names.insert(row.name.as_str()) {
return Err(ConfigError::Validation(format!(
"duplicate upstream name '{}'",
row.name
)));
}
if row.base_url.scheme() != "https" && !is_loopback(&row.base_url) && !row.allow_insecure {
return Err(ConfigError::Validation(format!(
"upstream '{}' is not HTTPS; set allow_insecure = true for non-loopback HTTP",
row.name
)));
}
out.push(UpstreamConfig {
name: row.name.clone(),
base_url: row.base_url.clone(),
api_key: row.api_key.clone(),
timeout_secs: row.timeout_secs,
connect_timeout_secs: row.connect_timeout_secs,
allow_insecure: row.allow_insecure,
});
}
Ok(out)
}
fn validate_models(
rows: &[RawModel],
upstreams: &[UpstreamConfig],
payment_enabled: bool,
pricing: Option<&PricingConfig>,
) -> Result<Vec<ModelConfig>, ConfigError> {
let mut ids = hashbrown::HashSet::new();
let mut out = Vec::with_capacity(rows.len());
for row in rows {
if row.id.is_empty() {
return Err(ConfigError::Validation(
"models.id must not be empty".to_owned(),
));
}
if !ids.insert(row.id.as_str()) {
return Err(ConfigError::Validation(format!(
"duplicate model id '{}'",
row.id
)));
}
if !upstreams.iter().any(|up| up.name == row.upstream) {
return Err(ConfigError::Validation(format!(
"model '{}' references unknown upstream '{}'",
row.id, row.upstream
)));
}
if let Some(scheme) = row.scheme.as_deref()
&& scheme != "exact"
&& scheme != "upto"
{
return Err(ConfigError::Validation(format!(
"model '{}' scheme '{scheme}' is not supported",
row.id
)));
}
if payment_enabled && row.scheme.is_some() {
validate_paid_model(row, pricing)?;
}
out.push(ModelConfig {
id: row.id.clone(),
upstream: row.upstream.clone(),
upstream_model: row.upstream_model.clone(),
owned_by: row.owned_by.clone(),
scheme: row.scheme.clone(),
input_per_million: row.input_per_million.clone(),
output_per_million: row.output_per_million.clone(),
cached_input_per_million: row.cached_input_per_million.clone(),
reasoning_per_million: row.reasoning_per_million.clone(),
request_floor: row.request_floor.clone(),
ceiling_multiplier: row.ceiling_multiplier.clone(),
max_ceiling: row.max_ceiling.clone(),
max_input_tokens: row.max_input_tokens,
default_max_output_tokens: row.default_max_output_tokens,
price: row.price.clone(),
});
}
Ok(out)
}
fn validate_pricing(
pricing: Option<RawPricing>,
payment_enabled: bool,
) -> Result<Option<PricingConfig>, ConfigError> {
let missing = || {
ConfigError::Validation(
"pricing.default is required when payment.enabled = true".to_owned(),
)
};
let Some(pricing) = pricing else {
return if payment_enabled {
Err(missing())
} else {
Ok(None)
};
};
let Some(default) = pricing.default else {
return if payment_enabled {
Err(missing())
} else {
Ok(Some(PricingConfig { default: None }))
};
};
if payment_enabled {
if default.price.is_none() {
return Err(ConfigError::Validation(
"pricing.default.price is required when payment.enabled = true".to_owned(),
));
}
if default.input_per_million.is_none()
|| default.output_per_million.is_none()
|| default.max_input_tokens.is_none()
{
return Err(ConfigError::Validation(
"pricing.default needs input_per_million, output_per_million, and max_input_tokens when payment.enabled = true"
.to_owned(),
));
}
}
Ok(Some(PricingConfig {
default: Some(PricingDefault {
scheme: default.scheme,
request_floor: default.request_floor,
input_per_million: default.input_per_million,
output_per_million: default.output_per_million,
cached_input_per_million: default.cached_input_per_million,
reasoning_per_million: default.reasoning_per_million,
ceiling_multiplier: default.ceiling_multiplier,
max_ceiling: default.max_ceiling,
max_input_tokens: default.max_input_tokens,
default_max_output_tokens: default.default_max_output_tokens,
price: default.price,
}),
}))
}
fn validate_paid_model(row: &RawModel, pricing: Option<&PricingConfig>) -> Result<(), ConfigError> {
let default = pricing.and_then(|pricing| pricing.default.as_ref());
let scheme = row
.scheme
.as_deref()
.or_else(|| default.and_then(|defaults| defaults.scheme.as_deref()))
.ok_or_else(|| {
ConfigError::Validation(format!(
"model '{}' needs scheme or pricing.default.scheme when payment.enabled = true",
row.id
))
})?;
match scheme {
"upto" => {
let input = row
.input_per_million
.as_deref()
.or_else(|| default.and_then(|defaults| defaults.input_per_million.as_deref()));
if input.is_none() {
return Err(ConfigError::Validation(format!(
"model '{}' requires input_per_million when scheme is upto",
row.id
)));
}
let max_input = row
.max_input_tokens
.or_else(|| default.and_then(|defaults| defaults.max_input_tokens));
if max_input.is_none() {
return Err(ConfigError::Validation(format!(
"model '{}' requires max_input_tokens when scheme is upto",
row.id
)));
}
let output = row
.output_per_million
.as_deref()
.or_else(|| default.and_then(|defaults| defaults.output_per_million.as_deref()));
if output.is_none() {
return Err(ConfigError::Validation(format!(
"model '{}' requires output_per_million when scheme is upto",
row.id
)));
}
}
"exact" => {
let price = row
.price
.as_deref()
.or_else(|| default.and_then(|defaults| defaults.price.as_deref()));
if price.is_none() {
return Err(ConfigError::Validation(format!(
"model '{}' requires price when scheme is exact",
row.id
)));
}
}
other => {
return Err(ConfigError::Validation(format!(
"model '{}' scheme '{other}' is not supported",
row.id
)));
}
}
Ok(())
}
fn metrics_bind_overlaps(metrics: SocketAddr, server: SocketAddr) -> bool {
metrics.port() == server.port()
&& (metrics.ip() == server.ip()
|| metrics.ip().is_unspecified()
|| server.ip().is_unspecified())
}
pub(crate) fn is_loopback(url: &Url) -> bool {
match url.host() {
Some(url::Host::Ipv4(addr)) => addr.is_loopback(),
Some(url::Host::Ipv6(addr)) => addr.is_loopback(),
Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
None => false,
}
}