use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Config {
pub listen: String,
pub router: RouterCfg,
#[serde(default)]
pub route: RouteCfg,
#[serde(default)]
pub models: Vec<ModelCfg>,
pub routing: RoutingCfg,
#[serde(default)]
pub breaker: BreakerCfg,
#[serde(default)]
pub protocols: ProtocolsCfg,
#[serde(default)]
pub cortiq: CortiqCfg,
#[serde(default)]
pub api_keys: Vec<ApiKeyCfg>,
#[serde(default)]
pub idempotency: IdempotencyCfg,
#[serde(default)]
pub log: LogCfg,
#[serde(default)]
pub telemetry: TelemetryCfg,
#[serde(default)]
pub admin: AdminCfg,
#[serde(default)]
pub stats: StatsCfg,
#[serde(default)]
pub cache: CacheCfg,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RouterCfg {
pub url: String,
#[serde(default = "default_router_timeout")]
pub timeout_ms: u64,
#[serde(default = "default_verify_tls")]
pub verify_tls: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub taxonomy_id: Option<String>,
}
fn default_router_timeout() -> u64 {
800
}
fn default_verify_tls() -> bool {
true
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RouteCfg {
#[serde(default = "default_text_strategy")]
pub text_strategy: String,
#[serde(default = "default_max_chars")]
pub max_chars: usize,
#[serde(default = "default_cache_ttl")]
pub cache_ttl: String,
#[serde(default = "default_profile")]
pub profile: String,
}
impl Default for RouteCfg {
fn default() -> Self {
Self {
text_strategy: default_text_strategy(),
max_chars: default_max_chars(),
cache_ttl: default_cache_ttl(),
profile: default_profile(),
}
}
}
fn default_text_strategy() -> String {
"last_user".into()
}
fn default_max_chars() -> usize {
4000
}
fn default_cache_ttl() -> String {
"60s".into()
}
fn default_profile() -> String {
"balanced".into()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ModelCfg {
pub id: String,
pub provider: String, pub base_url: String,
pub model: String,
#[serde(default)]
pub cost_tier: String,
#[serde(default)]
pub price_in: f64,
#[serde(default)]
pub price_out: f64,
#[serde(default = "default_kind")]
pub kind: String, #[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub caps: Vec<String>,
}
fn default_kind() -> String {
"chat".into()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RoutingCfg {
#[serde(flatten)]
pub tiers: HashMap<String, TierTargets>,
pub default: String,
#[serde(default)]
pub policy: RoutingPolicy,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TierTargets {
List(Vec<String>),
One(String),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RoutingPolicy {
#[serde(default = "default_mode")]
pub mode: String, #[serde(default, skip_serializing_if = "Option::is_none")]
pub max_cost_usd_per_request: Option<f64>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub min_class: HashMap<String, String>,
}
impl Default for RoutingPolicy {
fn default() -> Self {
Self {
mode: default_mode(),
min_class: HashMap::new(),
max_cost_usd_per_request: None,
}
}
}
fn default_mode() -> String {
"fixed_table".into()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BreakerCfg {
#[serde(default = "default_breaker_threshold")]
pub threshold: u32,
#[serde(default = "default_breaker_cooldown")]
pub cooldown: String,
}
impl Default for BreakerCfg {
fn default() -> Self {
Self {
threshold: default_breaker_threshold(),
cooldown: default_breaker_cooldown(),
}
}
}
fn default_breaker_threshold() -> u32 {
5
}
fn default_breaker_cooldown() -> String {
"30s".into()
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct ProtocolsCfg {
#[serde(default)]
pub openai_chat: bool,
#[serde(default)]
pub openai_completions: bool,
#[serde(default)]
pub openai_embeddings: bool,
#[serde(default)]
pub openai_models: bool,
#[serde(default)]
pub anthropic_messages: bool,
#[serde(default)]
pub mcp: bool,
#[serde(default)]
pub native_passthrough: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CortiqCfg {
#[serde(default)]
pub echo: bool,
}
impl Default for CortiqCfg {
fn default() -> Self {
Self { echo: true }
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ApiKeyCfg {
pub key: String,
pub account: String,
#[serde(default)]
pub rate_per_min: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow_models: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct IdempotencyCfg {
#[serde(default = "default_idem_ttl")]
pub ttl: String,
}
impl Default for IdempotencyCfg {
fn default() -> Self {
Self {
ttl: default_idem_ttl(),
}
}
}
fn default_idem_ttl() -> String {
"10m".into()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LogCfg {
#[serde(default)]
pub bodies: bool,
#[serde(default = "default_log_level")]
pub level: String,
}
impl Default for LogCfg {
fn default() -> Self {
Self {
bodies: false,
level: default_log_level(),
}
}
}
fn default_log_level() -> String {
"info".into()
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct TelemetryCfg {
#[serde(default)]
pub metrics: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub otlp_endpoint_env: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AdminCfg {
#[serde(default = "default_admin_enabled")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_env: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub listen: Option<String>,
}
impl Default for AdminCfg {
fn default() -> Self {
Self {
enabled: default_admin_enabled(),
token_env: None,
listen: None,
}
}
}
fn default_admin_enabled() -> bool {
true
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StatsCfg {
#[serde(default = "default_stats_enabled")]
pub enabled: bool,
#[serde(default = "default_stats_file")]
pub file: String,
#[serde(default = "default_stats_ring")]
pub ring_size: usize,
#[serde(default = "default_stats_retention")]
pub retention: String,
}
impl Default for StatsCfg {
fn default() -> Self {
Self {
enabled: default_stats_enabled(),
file: default_stats_file(),
ring_size: default_stats_ring(),
retention: default_stats_retention(),
}
}
}
fn default_stats_enabled() -> bool {
true
}
fn default_stats_file() -> String {
"config/stats.jsonl".into()
}
fn default_stats_ring() -> usize {
500
}
fn default_stats_retention() -> String {
"7d".into()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CacheCfg {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_semcache_threshold")]
pub threshold: f32,
#[serde(default = "default_semcache_ttl")]
pub ttl: String,
#[serde(default = "default_semcache_max")]
pub max_entries: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embed_model: Option<String>,
}
impl Default for CacheCfg {
fn default() -> Self {
Self {
enabled: false,
threshold: default_semcache_threshold(),
ttl: default_semcache_ttl(),
max_entries: default_semcache_max(),
embed_model: None,
}
}
}
fn default_semcache_threshold() -> f32 {
0.92
}
fn default_semcache_ttl() -> String {
"1h".into()
}
fn default_semcache_max() -> usize {
1000
}
impl Config {
pub fn load(path: &str) -> anyhow::Result<Self> {
let raw = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("cannot read config {path}: {e}"))?;
let cfg: Config = toml::from_str(&raw)?;
cfg.validate()?;
Ok(cfg)
}
pub fn validate(&self) -> anyhow::Result<()> {
if self.models.is_empty() {
anyhow::bail!("config: at least one [[models]] entry is required");
}
let mut seen = std::collections::HashSet::new();
for m in &self.models {
if !seen.insert(m.id.as_str()) {
anyhow::bail!("config: duplicate model id '{}'", m.id);
}
}
let ids: std::collections::HashSet<&str> =
self.models.iter().map(|m| m.id.as_str()).collect();
if !ids.contains(self.routing.default.as_str()) {
anyhow::bail!(
"config: routing.default '{}' is not a known model id",
self.routing.default
);
}
for (tier, targets) in &self.routing.tiers {
let list = match targets {
TierTargets::List(v) => v.clone(),
TierTargets::One(s) => vec![s.clone()],
};
for id in &list {
if !ids.contains(id.as_str()) {
anyhow::bail!(
"config: routing.{} references unknown model id '{}'",
tier,
id
);
}
}
}
Ok(())
}
pub fn to_toml_string(&self) -> anyhow::Result<String> {
let header = "# Cortiq Gateway — configuration (managed via the admin panel).\n\
# This file is rewritten by the panel; manual edits are preserved\n\
# only until the next change made through the UI.\n\n";
let body = toml::to_string_pretty(self)?;
Ok(format!("{header}{body}"))
}
pub fn save(&self, path: &str) -> anyhow::Result<()> {
self.validate()?;
let data = self.to_toml_string()?;
let p = std::path::Path::new(path);
let dir = p.parent().unwrap_or_else(|| std::path::Path::new("."));
std::fs::create_dir_all(dir).ok();
let tmp = p.with_extension("toml.tmp");
std::fs::write(&tmp, data.as_bytes())
.map_err(|e| anyhow::anyhow!("cannot write config tmp {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, p)
.map_err(|e| anyhow::anyhow!("cannot replace config {path}: {e}"))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
listen = "0.0.0.0:9000"
[router]
url = "http://localhost:8080"
api_key_env = "CORTIQ_ROUTER_KEY"
[[models]]
id = "local-qwen"
provider = "openai"
base_url = "http://localhost:8000/v1"
model = "qwen2.5-7b-instruct"
cost_tier = "cheap"
caps = ["tools"]
[[models]]
id = "gpt-4o-mini"
provider = "openai"
base_url = "https://api.openai.com/v1"
model = "gpt-4o-mini"
api_key_env = "OPENAI_API_KEY"
cost_tier = "mid"
price_in = 0.15
price_out = 0.6
[routing]
low = ["local-qwen"]
medium = ["gpt-4o-mini", "local-qwen"]
default = "gpt-4o-mini"
[routing.policy]
mode = "cost_aware"
max_cost_usd_per_request = 0.5
min_class = { low = "cheap", medium = "mid" }
"#;
#[test]
fn roundtrip_preserves_routing_and_models() {
let cfg: Config = toml::from_str(SAMPLE).expect("parse sample");
cfg.validate().expect("sample valid");
let serialized = cfg.to_toml_string().expect("serialize");
let back: Config = toml::from_str(&serialized).expect("re-parse serialized");
back.validate().expect("roundtrip valid");
assert_eq!(back.listen, "0.0.0.0:9000");
assert_eq!(back.models.len(), 2);
assert_eq!(back.routing.default, "gpt-4o-mini");
assert_eq!(back.routing.policy.mode, "cost_aware");
assert_eq!(back.routing.policy.max_cost_usd_per_request, Some(0.5));
assert_eq!(
back.routing.policy.min_class.get("low").map(String::as_str),
Some("cheap")
);
let low = back.routing.tiers.get("low").expect("low tier present");
match low {
TierTargets::List(v) => assert_eq!(v, &vec!["local-qwen".to_string()]),
TierTargets::One(s) => assert_eq!(s, "local-qwen"),
}
assert!(back.admin.enabled);
assert!(back.stats.enabled);
}
#[test]
fn validate_rejects_unknown_routing_target() {
let mut cfg: Config = toml::from_str(SAMPLE).unwrap();
cfg.routing.default = "does-not-exist".into();
assert!(cfg.validate().is_err());
}
}