use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub mod pricing;
pub use pricing::{ModelPricing, PricingTable};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ProviderKind {
#[serde(rename = "openai")]
OpenAI,
#[serde(rename = "anthropic")]
Anthropic,
#[serde(rename = "ollama")]
Ollama,
#[serde(rename = "deepseek")]
DeepSeek,
#[serde(rename = "gemini")]
Gemini,
}
impl ProviderKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::OpenAI => "openai",
Self::Anthropic => "anthropic",
Self::Ollama => "ollama",
Self::DeepSeek => "deepseek",
Self::Gemini => "gemini",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
pub name: String,
pub kind: ProviderKind,
pub base_url: String,
#[serde(default)]
pub api_key_env: Option<String>,
#[serde(default)]
pub default_model: Option<String>,
}
impl ProviderConfig {
pub fn openai() -> Self {
Self {
name: "openai".into(),
kind: ProviderKind::OpenAI,
base_url: "https://api.openai.com/v1".into(),
api_key_env: Some("OPENAI_API_KEY".into()),
default_model: Some("gpt-5".into()),
}
}
pub fn anthropic() -> Self {
Self {
name: "anthropic".into(),
kind: ProviderKind::Anthropic,
base_url: "https://api.anthropic.com/v1".into(),
api_key_env: Some("ANTHROPIC_API_KEY".into()),
default_model: Some("claude-sonnet-4-6".into()),
}
}
pub fn ollama_local() -> Self {
Self {
name: "ollama".into(),
kind: ProviderKind::Ollama,
base_url: "http://localhost:11434".into(),
api_key_env: None,
default_model: Some("llama3.2".into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenmiserConfig {
pub listen: ListenConfig,
pub providers: Vec<ProviderConfig>,
#[serde(default)]
pub routing: RoutingConfig,
#[serde(default)]
pub cache: CacheConfig,
#[serde(default)]
pub budget: BudgetConfig,
#[serde(default)]
pub security: SecurityConfig,
}
impl Default for TokenmiserConfig {
fn default() -> Self {
Self {
listen: ListenConfig::default(),
providers: vec![
ProviderConfig::openai(),
ProviderConfig::anthropic(),
ProviderConfig::ollama_local(),
],
routing: RoutingConfig::default(),
cache: CacheConfig::default(),
budget: BudgetConfig::default(),
security: SecurityConfig::default(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecurityConfig {
#[serde(default)]
pub allowed_origins: Vec<String>,
}
impl SecurityConfig {
pub fn origin_allowed(&self, origin: &str) -> bool {
self.allowed_origins.iter().any(|o| o == "*" || o == origin)
}
pub fn allows_any_origin(&self) -> bool {
self.allowed_origins.iter().any(|o| o == "*")
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BudgetConfig {
#[serde(default)]
pub daily_usd: Option<f64>,
#[serde(default)]
pub total_usd: Option<f64>,
#[serde(default)]
pub enforce: bool,
}
impl BudgetConfig {
pub fn is_active(&self) -> bool {
self.daily_usd.is_some() || self.total_usd.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListenConfig {
pub proxy_addr: String,
pub admin_addr: String,
}
impl Default for ListenConfig {
fn default() -> Self {
Self {
proxy_addr: "127.0.0.1:8443".into(),
admin_addr: "127.0.0.1:9443".into(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RoutingConfig {
#[serde(default)]
pub aliases: HashMap<String, ModelTarget>,
#[serde(default)]
pub default_provider: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelTarget {
pub provider: String,
pub model: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
#[serde(default = "default_true")]
pub l1_enabled: bool,
#[serde(default = "default_true")]
pub l2_enabled: bool,
#[serde(default = "default_semantic_threshold")]
pub semantic_threshold: f32,
#[serde(default = "default_true")]
pub numeric_guard: bool,
#[serde(default = "default_scope")]
pub scope: String,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
l1_enabled: true,
l2_enabled: true,
semantic_threshold: default_semantic_threshold(),
numeric_guard: true,
scope: "tenant".into(),
}
}
}
fn default_true() -> bool {
true
}
fn default_semantic_threshold() -> f32 {
0.87
}
fn default_scope() -> String {
"tenant".into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn budget_defaults_to_inactive_warn_only() {
let cfg = TokenmiserConfig::default();
assert!(!cfg.budget.is_active());
assert!(!cfg.budget.enforce);
assert!(cfg.budget.daily_usd.is_none());
assert!(cfg.budget.total_usd.is_none());
}
#[test]
fn budget_parses_from_yaml() {
let yaml = r#"
listen:
proxy_addr: "127.0.0.1:1"
admin_addr: "127.0.0.1:2"
providers: []
budget:
daily_usd: 5.0
enforce: true
"#;
let cfg: TokenmiserConfig = serde_yaml::from_str(yaml).unwrap();
assert!(cfg.budget.is_active());
assert_eq!(cfg.budget.daily_usd, Some(5.0));
assert_eq!(cfg.budget.total_usd, None);
assert!(cfg.budget.enforce);
}
#[test]
fn numeric_guard_defaults_on_and_is_config_exposed() {
assert!(CacheConfig::default().numeric_guard);
let yaml = r#"
listen:
proxy_addr: "127.0.0.1:1"
admin_addr: "127.0.0.1:2"
providers: []
cache:
numeric_guard: false
semantic_threshold: 0.91
"#;
let cfg: TokenmiserConfig = serde_yaml::from_str(yaml).unwrap();
assert!(!cfg.cache.numeric_guard);
assert_eq!(cfg.cache.semantic_threshold, 0.91);
let yaml = r#"
listen:
proxy_addr: "127.0.0.1:1"
admin_addr: "127.0.0.1:2"
providers: []
cache:
semantic_threshold: 0.87
"#;
let cfg: TokenmiserConfig = serde_yaml::from_str(yaml).unwrap();
assert!(cfg.cache.numeric_guard);
}
#[test]
fn security_defaults_to_no_allowed_origins() {
let cfg = TokenmiserConfig::default();
assert!(cfg.security.allowed_origins.is_empty());
assert!(!cfg.security.allows_any_origin());
assert!(!cfg.security.origin_allowed("https://evil.example"));
assert!(!cfg.security.origin_allowed("http://localhost:3000"));
}
#[test]
fn security_allowed_origins_parse_and_match_exactly() {
let yaml = r#"
listen:
proxy_addr: "127.0.0.1:1"
admin_addr: "127.0.0.1:2"
providers: []
security:
allowed_origins:
- "http://localhost:3000"
- "https://app.example.com"
"#;
let cfg: TokenmiserConfig = serde_yaml::from_str(yaml).unwrap();
assert!(cfg.security.origin_allowed("http://localhost:3000"));
assert!(cfg.security.origin_allowed("https://app.example.com"));
assert!(!cfg.security.origin_allowed("http://localhost:3001"));
assert!(!cfg.security.origin_allowed("https://localhost:3000"));
assert!(!cfg
.security
.origin_allowed("https://app.example.com.evil.test"));
assert!(!cfg.security.allows_any_origin());
}
#[test]
fn security_wildcard_opens_every_origin() {
let yaml = r#"
listen:
proxy_addr: "127.0.0.1:1"
admin_addr: "127.0.0.1:2"
providers: []
security:
allowed_origins: ["*"]
"#;
let cfg: TokenmiserConfig = serde_yaml::from_str(yaml).unwrap();
assert!(cfg.security.allows_any_origin());
assert!(cfg.security.origin_allowed("https://evil.example"));
}
#[test]
fn config_without_budget_section_still_parses() {
let yaml = r#"
listen:
proxy_addr: "127.0.0.1:1"
admin_addr: "127.0.0.1:2"
providers: []
"#;
let cfg: TokenmiserConfig = serde_yaml::from_str(yaml).unwrap();
assert!(!cfg.budget.is_active());
}
}