//! Configuration loading from TOML. Structs mirror config/gateway.example.toml. //! Secrets are read from environment variables by the names given in `*_env` fields. use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; /// Base directory for gateway-managed data (models, logs). Honors /// `$CORTIQ_GATEWAY_HOME`, then `$XDG_DATA_HOME/cortiq-gateway`, then /// `~/.cortiq-gateway` — so defaults work regardless of the process CWD /// (important once the gateway is installed rather than run from its repo). /// On Windows also checks `%APPDATA%`, `%LOCALAPPDATA%` and `%USERPROFILE%` /// so double-clicking the exe from `Downloads` does not try to write into /// `Program Files`. pub fn data_dir() -> PathBuf { for (var, sub) in [ ("CORTIQ_GATEWAY_HOME", ""), ("XDG_DATA_HOME", "cortiq-gateway"), ("HOME", ".cortiq-gateway"), ] { if let Ok(v) = std::env::var(var) { if !v.is_empty() { return if sub.is_empty() { PathBuf::from(v) } else { PathBuf::from(v).join(sub) }; } } } #[cfg(windows)] { for (var, sub) in [ ("APPDATA", "cortiq-gateway"), ("LOCALAPPDATA", "cortiq-gateway"), ("USERPROFILE", ".cortiq-gateway"), ] { if let Ok(v) = std::env::var(var) { if !v.is_empty() { return PathBuf::from(v).join(sub); } } } } PathBuf::from("data") } fn data_path(name: &str) -> String { data_dir().join(name).to_string_lossy().into_owned() } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Config { pub listen: String, pub router: RouterCfg, #[serde(default)] pub route: RouteCfg, #[serde(default)] pub models: Vec, pub routing: RoutingCfg, #[serde(default)] pub breaker: BreakerCfg, #[serde(default)] pub protocols: ProtocolsCfg, #[serde(default)] pub cortiq: CortiqCfg, #[serde(default)] pub api_keys: Vec, #[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, #[serde(default)] pub shadow: ShadowCfg, #[serde(default)] pub cmf: CmfCfg, } /// One managed local model: the gateway runs a dedicated `cortiq serve` for it /// on its own port and registers it in the pool under `id`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct CmfServer { /// Pool id it is registered under (e.g. "cmf-qwen3-5-2b"). pub id: String, /// Path to the `.cmf` to serve. pub model: String, /// TCP port the dedicated `cortiq serve` binds to (must be unique). pub port: u16, /// Worker threads (CMF_THREADS). 0 = runtime default. #[serde(default = "default_cmf_threads")] pub threads: usize, /// Offload matvecs to the GPU (CMF_GPU=1). #[serde(default)] pub gpu: bool, /// Default generation temperature (0.0 .. 2.0). #[serde(default, skip_serializing_if = "Option::is_none")] pub temperature: Option, /// Default nucleus sampling threshold (0.0 .. 1.0). #[serde(default, skip_serializing_if = "Option::is_none")] pub top_p: Option, /// Default maximum completion tokens. #[serde(default, skip_serializing_if = "Option::is_none")] pub max_tokens: Option, /// Reasoning / thinking token budget. #[serde(default, skip_serializing_if = "Option::is_none")] pub think_budget: Option, /// Default system prompt prepended if request lacks one. #[serde(default, skip_serializing_if = "Option::is_none")] pub system_prompt: Option, /// O(1) Nyström attention layer spec (all | deepN | i,j,k | off). #[serde(default, skip_serializing_if = "Option::is_none")] pub o1: Option, /// O(1) landmark budget (default 32; ≥4). #[serde(default, skip_serializing_if = "Option::is_none")] pub o1_m: Option, /// O(1) exact-window width (default 128). #[serde(default, skip_serializing_if = "Option::is_none")] pub o1_window: Option, /// O(1) permanent exact sink keys (default 4, StreamingLLM discipline). #[serde(default, skip_serializing_if = "Option::is_none")] pub o1_sink: Option, /// Disable Multi-Token Prediction speculative decoding (CMF_MTP=0). #[serde(default)] pub skip_mtp: bool, /// Manual capability override (e.g. ["tools"]). Usually unnecessary: the /// gateway auto-detects tool support from the server's healthz handshake. #[serde(default)] pub caps: Vec, } /// CMF-format model factory: import a HuggingFace model, convert it to a /// local `.cmf` (quantized), and serve it via cortiq-server. Points the /// gateway at the Python converter + runtime on the host. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CmfCfg { /// Python interpreter that has torch + the converter deps. #[serde(default = "default_cmf_python")] pub python_bin: String, /// Path to `convert_dtgma_to_cmf.py`. #[serde(default = "default_cmf_converter")] pub converter: String, /// Directory where produced `.cmf` files are written. #[serde(default = "default_cmf_models_dir")] pub models_dir: String, /// Base URL of the cortiq-server that serves the `.cmf` (OpenAI /v1), /// used when registering a converted model as a provider. #[serde(default = "default_cmf_server_url")] pub cortiq_server_url: String, /// Secret name of a HuggingFace token (higher search rate limits + /// gated/private repos). Empty = anonymous public search. #[serde(default)] pub hf_token_env: String, /// `cortiq` CLI binary for the local server (from `cargo install cortiq-cli`). #[serde(default = "default_cortiq_bin")] pub cortiq_bin: String, /// Install cortiq-cli from crates.io automatically if the binary is missing. #[serde(default)] pub auto_install: bool, /// On startup, check crates.io and reinstall if a newer cortiq-cli exists. #[serde(default)] pub auto_update: bool, /// Spawn and manage a local `cortiq serve` for `local_model`, and register /// it in the model pool — a local `.cmf` backend the gateway can use even /// when no external provider (or the router) is available. #[serde(default)] pub manage_server: bool, /// Path to the local `.cmf` to serve (empty = disabled). #[serde(default)] pub local_model: String, /// Host the managed local server binds to. #[serde(default = "default_cmf_local_host")] pub local_host: String, /// Port the managed local server binds to. #[serde(default = "default_cmf_local_port")] pub local_port: u16, /// Model id the local server is registered under in the pool. #[serde(default = "default_cmf_model_id")] pub model_id: String, /// Use ONLY local models: route every request to the local model, ignoring /// the router and any external providers ("use only local models"). #[serde(default)] pub local_only: bool, /// Worker threads for the managed `cortiq serve` (exported as `CMF_THREADS`). /// 0 = let the runtime decide; N>1 parallelises decode matvecs across N cores /// (measured ~2.8x faster than serial on a 15B model). Default 8. #[serde(default = "default_cmf_threads")] pub threads: usize, /// Offload large matvecs to the GPU (exports CMF_GPU=1). On Apple Silicon the /// decode loop is memory-bandwidth bound, so this is ~neutral for generation /// (helps prefill of long prompts). Off by default. #[serde(default)] pub gpu: bool, /// Managed local models. When non-empty (and `manage_server` is on), the /// gateway spawns and supervises one `cortiq serve` per entry — each on its /// own port — and registers each in the pool. Empty = fall back to the single /// legacy `local_model` / `local_port` / `model_id` below (back-compat). #[serde(default)] pub servers: Vec, } impl CmfCfg { /// The managed local models actually in effect: the `servers` list when set, /// otherwise the single legacy `local_model` (as a one-element list). Empty /// when `manage_server` is off or nothing is configured. pub fn effective_servers(&self) -> Vec { if !self.manage_server { return Vec::new(); } if !self.servers.is_empty() { return self.servers.clone(); } if !self.local_model.trim().is_empty() { return vec![CmfServer { id: self.model_id.clone(), model: self.local_model.clone(), port: self.local_port, threads: self.threads, gpu: self.gpu, temperature: None, top_p: None, max_tokens: None, think_budget: None, system_prompt: None, o1: None, o1_m: None, o1_window: None, o1_sink: None, skip_mtp: false, caps: Vec::new(), }]; } Vec::new() } } impl Default for CmfCfg { fn default() -> Self { Self { python_bin: default_cmf_python(), converter: default_cmf_converter(), models_dir: default_cmf_models_dir(), cortiq_server_url: default_cmf_server_url(), hf_token_env: String::new(), cortiq_bin: default_cortiq_bin(), auto_install: false, auto_update: false, manage_server: false, local_model: String::new(), local_host: default_cmf_local_host(), local_port: default_cmf_local_port(), model_id: default_cmf_model_id(), local_only: false, threads: default_cmf_threads(), gpu: false, servers: Vec::new(), } } } fn default_cmf_threads() -> usize { 8 } fn default_cortiq_bin() -> String { "cortiq".into() } fn default_cmf_local_host() -> String { "127.0.0.1".into() } fn default_cmf_local_port() -> u16 { 8081 } fn default_cmf_model_id() -> String { "cmf-local".into() } fn default_cmf_python() -> String { "python3".into() } fn default_cmf_converter() -> String { "../cmf/converter/convert_dtgma_to_cmf.py".into() } fn default_cmf_models_dir() -> String { data_path("models") } fn default_cmf_server_url() -> String { "http://127.0.0.1:8081/v1".into() } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RouterCfg { /// Master switch — when false the router is never called; the gateway /// serves from the local model pool (local `.cmf` / configured default). #[serde(default = "default_router_enabled")] pub enabled: bool, 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub taxonomy_id: Option, } fn default_router_enabled() -> bool { true } fn default_router_timeout() -> u64 { // the hosted router escalates hard prompts to an oracle (~10 s); // an 800 ms budget silently degraded every one of those to the fallback 15_000 } 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, // openai | anthropic | ollama | http 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, // chat | embedding #[serde(default, skip_serializing_if = "Option::is_none")] pub api_key_env: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub caps: Vec, /// Default generation temperature (0.0 .. 2.0). #[serde(default, skip_serializing_if = "Option::is_none")] pub temperature: Option, /// Default nucleus sampling threshold (0.0 .. 1.0). #[serde(default, skip_serializing_if = "Option::is_none")] pub top_p: Option, /// Default maximum completion tokens. #[serde(default, skip_serializing_if = "Option::is_none")] pub max_tokens: Option, /// Reasoning / thinking token budget. #[serde(default, skip_serializing_if = "Option::is_none")] pub think_budget: Option, /// Default system prompt prepended if request lacks one. #[serde(default, skip_serializing_if = "Option::is_none")] pub system_prompt: Option, /// O(1) constant-memory streaming attention hint (all, deep, custom, off). #[serde(default, skip_serializing_if = "Option::is_none")] pub o1: Option, /// Skip Multi-Token Prediction (MTP) heads. #[serde(default)] pub skip_mtp: bool, } fn default_kind() -> String { "chat".into() } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RoutingCfg { #[serde(flatten)] pub tiers: HashMap, pub default: String, #[serde(default)] pub policy: RoutingPolicy, /// Per-task rules: `task_label → ordered model list`. A matching rule's /// models go FIRST; the complexity-tier chain follows as fallback, so a /// task pin never weakens failover. Named field, so a tier cannot be /// called "tasks" — serde extracts it before the flattened tier map. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub tasks: HashMap, /// Local complexity bands: re-map the router's continuous score into /// low/medium/high with YOUR OWN boundaries. The hosted router's bands /// are global for all customers; these are per-gateway. `None` = keep /// the router's tier verdict as delivered. #[serde(default, skip_serializing_if = "Option::is_none")] pub bands: Option, } /// Score boundaries: `score ≤ low` → low, `≤ medium` → medium, else high. #[derive(Clone, Copy, Debug, Serialize, Deserialize)] pub struct TierBands { pub low: f32, pub medium: f32, } impl TierBands { pub fn tier_of(&self, score: f32) -> &'static str { if score <= self.low { "low" } else if score <= self.medium { "medium" } else { "high" } } } /// A routing tier is either a list of model_ids or the string `default`. /// (`default` is handled by a separate field; only tier lists appear here.) /// /// TASK rules additionally accept a per-complexity map — "which model serves /// this task at each tier": /// /// ```toml /// [routing.tasks.code] /// low = ["cheap-coder"] /// high = ["big-model"] /// any = ["fallback"] # leads at every tier after the tier-specific list /// ``` /// /// The plain-array form stays valid and means "leads at any tier". The /// transitional `{ models = [...], tier = "high" }` form is normalized into /// the map on load. Untagged: map is tried first, arrays keep parsing as is. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] pub enum TierTargets { Map(HashMap), List(Vec), One(String), } /// A value inside a task's per-tier map: a model list, a single model, or the /// transitional string value of the legacy `tier = "high"` key. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] pub enum TaskTierVal { List(Vec), Str(String), } impl TaskTierVal { fn list(&self) -> Vec { match self { TaskTierVal::List(v) => v.clone(), TaskTierVal::Str(s) => vec![s.clone()], } } } pub const TASK_TIER_KEYS: [&str; 4] = ["any", "low", "medium", "high"]; impl TierTargets { /// Every referenced model id, whatever the syntactic form (validation). pub fn models(&self) -> Vec { match self { TierTargets::Map(m) => { let mut out = Vec::new(); for (k, v) in m { if k == "tier" { continue; // legacy marker, not a model list } out.extend(v.list()); } out } TierTargets::List(v) => v.clone(), TierTargets::One(s) => vec![s.clone()], } } /// Normalized task rule: `(any_tier_models, per_tier_models)`. /// Legacy `{models, tier}` becomes `per[tier] = models`; a plain array /// becomes `any`. pub fn task_rule(&self) -> (Vec, HashMap>) { match self { TierTargets::Map(m) => { let mut any: Vec = Vec::new(); let mut per: HashMap> = HashMap::new(); let legacy_tier = m.get("tier").and_then(|v| match v { TaskTierVal::Str(s) => Some(s.clone()), _ => None, }); for (k, v) in m { match k.as_str() { "tier" => {} "any" | "models" => any.extend(v.list()), "low" | "medium" | "high" => { per.entry(k.clone()).or_default().extend(v.list()) } _ => {} } } if let Some(tier) = legacy_tier { // legacy pin: the models were meant for that tier let moved = std::mem::take(&mut any); per.entry(tier).or_default().extend(moved); } (any, per) } TierTargets::List(v) => (v.clone(), HashMap::new()), TierTargets::One(s) => (vec![s.clone()], HashMap::new()), } } /// Unknown keys inside a task map (validation). pub fn invalid_task_keys(&self) -> Vec { match self { TierTargets::Map(m) => m .keys() .filter(|k| !TASK_TIER_KEYS.contains(&k.as_str()) && *k != "models" && *k != "tier") .cloned() .collect(), _ => Vec::new(), } } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RoutingPolicy { #[serde(default = "default_mode")] pub mode: String, // fixed_table | cost_aware #[serde(default, skip_serializing_if = "Option::is_none")] pub max_cost_usd_per_request: Option, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub min_class: HashMap, } 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, /// Monthly spend ceiling in USD for this key's account; 0/absent = none. #[serde(default, skip_serializing_if = "Option::is_none")] pub monthly_budget_usd: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub allow_models: Vec, } #[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, } /// Access to the web management panel and admin API. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct AdminCfg { /// Whether the admin panel and admin API (`/admin`, `/admin/api/*`) are enabled. #[serde(default = "default_admin_enabled")] pub enabled: bool, /// Name of the env variable holding the admin token. #[serde(default, skip_serializing_if = "Option::is_none")] pub token_env: Option, /// Persistent admin token configured directly in toml. #[serde(default, skip_serializing_if = "Option::is_none")] pub token: Option, /// Optional separate bind address for the admin surface (e.g. `127.0.0.1:9001`). /// If unset, the admin interface lives on the main `listen` address under `/admin`. #[serde(default, skip_serializing_if = "Option::is_none")] pub listen: Option, } impl Default for AdminCfg { fn default() -> Self { Self { enabled: default_admin_enabled(), token_env: None, token: None, listen: None, } } } fn default_admin_enabled() -> bool { true } /// Request statistics tracking (for the dashboard and `/metrics`). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct StatsCfg { /// Whether to track request statistics. #[serde(default = "default_stats_enabled")] pub enabled: bool, /// Append-only request log file (JSONL). Empty means in-memory only. #[serde(default = "default_stats_file")] pub file: String, /// How many recent requests to keep in memory (the "recent" ring buffer). #[serde(default = "default_stats_ring")] pub ring_size: usize, /// Retention window for aggregates/time-series replayed on startup, e.g. `7d`. #[serde(default = "default_stats_retention")] pub retention: String, /// Compact the JSONL file once it exceeds this size: records older than /// `retention` fold into cumulative per-account totals (a small sidecar /// file) and are removed, so per-key accounting survives while the disk /// stays bounded. 0 disables compaction. #[serde(default = "default_stats_max_file_mb")] pub max_file_mb: u64, } 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(), max_file_mb: default_stats_max_file_mb(), } } } fn default_stats_enabled() -> bool { true } fn default_stats_file() -> String { data_path("stats.jsonl") } fn default_stats_ring() -> usize { 500 } fn default_stats_retention() -> String { "7d".into() } fn default_stats_max_file_mb() -> u64 { 16 } /// Self-warming shadow loop (docs/SELF_WARMING_GATEWAY.md). While a task /// label is not yet promoted, the client is served the CLOUD answer; in /// parallel (sampled, non-blocking) the local `cmf-local` model answers and /// a cheap `judge` model scores it against the cloud answer. The per-label /// Wilson-LB of that pass stream gates promotion to local serving. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ShadowCfg { /// Master switch. Off = zero overhead, classic gateway behavior. #[serde(default)] pub enabled: bool, /// model_id (from `[[models]]`) of the local CMF model (OpenAI-compatible /// cortiq-server). Empty disables the loop. #[serde(default)] pub local_model_id: String, /// model_id of the cheap judge (different family than the answerer). #[serde(default)] pub judge_model_id: String, /// Fraction of eligible requests to shadow+judge (0..1). Bounds cost. #[serde(default = "default_shadow_sample")] pub sample_rate: f64, /// judge.jsonl append+replay path. Empty = in-memory only. #[serde(default = "default_shadow_file")] pub file: String, /// Wilson-95 lower bound of pass-rate required to promote. #[serde(default = "default_shadow_lb")] pub promote_lb: f64, /// Min judgments before promotion is considered. #[serde(default = "default_shadow_nmin")] pub n_min: usize, /// Rolling window of judgments per label. #[serde(default = "default_shadow_window")] pub window: usize, /// Extra canary judgments before full local serving. #[serde(default = "default_shadow_soak")] pub soak: usize, /// GATE past shadow-only: actually SERVE promoted labels from the local /// model (with failover to cloud on error + a complexity veto). Default /// OFF — the memo's 3 risks (§8) should be retired by measurement first. #[serde(default)] pub serve_when_promoted: bool, } impl Default for ShadowCfg { fn default() -> Self { Self { enabled: false, local_model_id: String::new(), judge_model_id: String::new(), sample_rate: default_shadow_sample(), file: default_shadow_file(), promote_lb: default_shadow_lb(), n_min: default_shadow_nmin(), window: default_shadow_window(), soak: default_shadow_soak(), serve_when_promoted: false, } } } fn default_shadow_sample() -> f64 { 0.15 } fn default_shadow_file() -> String { data_path("judge.jsonl") } fn default_shadow_lb() -> f64 { 0.95 } fn default_shadow_nmin() -> usize { 200 } fn default_shadow_window() -> usize { 500 } fn default_shadow_soak() -> usize { 100 } /// Semantic (embedding-based) response cache — returns a cached answer for prompts /// that are semantically near a previous one, skipping the (expensive) model call. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CacheCfg { #[serde(default)] pub enabled: bool, /// Cosine-similarity threshold for a hit (0..1). f64 so the TOML holds /// the number you typed (an f32 round-trips as 0.9200000166893005). #[serde(default = "default_semcache_threshold")] pub threshold: f64, /// Entry time-to-live, e.g. `1h`. #[serde(default = "default_semcache_ttl")] pub ttl: String, /// Maximum number of cached entries (ring buffer). #[serde(default = "default_semcache_max")] pub max_entries: usize, /// Embedding model id (defaults to the first `kind = "embedding"` model). #[serde(default, skip_serializing_if = "Option::is_none")] pub embed_model: Option, } 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() -> f64 { 0.92 } fn default_semcache_ttl() -> String { "1h".into() } fn default_semcache_max() -> usize { 1000 } /// Sensible defaults for a fresh install (empty pool — user fills via wizard/UI). impl Default for Config { fn default() -> Self { Self { listen: "0.0.0.0:9000".to_string(), // The hosted allaigate router — the thing this gateway is built // around. Without a key the pipeline degrades gracefully to local // models and the console asks for one; nothing breaks. router: RouterCfg { enabled: true, url: "https://138.226.222.209".to_string(), timeout_ms: 15_000, // hard prompts escalate to an oracle (~10 s) verify_tls: false, // the hosted endpoint is IP-bound api_key_env: Some("CORTIQ_ROUTER_KEY".to_string()), taxonomy_id: Some("data-assistant".to_string()), }, route: RouteCfg::default(), models: Vec::new(), routing: RoutingCfg { tiers: HashMap::new(), default: String::new(), policy: RoutingPolicy::default(), tasks: HashMap::new(), bands: None, }, breaker: BreakerCfg::default(), protocols: ProtocolsCfg { openai_chat: true, openai_completions: true, openai_embeddings: true, openai_models: true, // on by default: the API-keys view ships a Claude Code // auto-setup snippet that targets /v1/messages anthropic_messages: true, mcp: false, native_passthrough: true, }, cortiq: CortiqCfg::default(), api_keys: Vec::new(), idempotency: IdempotencyCfg::default(), log: LogCfg::default(), telemetry: TelemetryCfg::default(), admin: AdminCfg::default(), stats: StatsCfg::default(), cache: CacheCfg::default(), shadow: ShadowCfg::default(), cmf: CmfCfg::default(), } } } /// Where the config lives when `--config` is not given. /// A readable `./config/gateway.toml` (a real gateway checkout / install dir) /// wins; otherwise the per-user home (`$CORTIQ_GATEWAY_HOME` or /// `~/.cortiq-gateway`) — so `cortiq-gateway` typed in any directory picks up /// the same configuration it was set up with. A foreign project that merely /// has `Cargo.toml` and a `config/` folder must NOT capture the config /// (that used to shadow the user's real config with a broken path). pub fn default_config_path() -> String { let repo_path = "config/gateway.toml"; // the file itself, readable — not just an entry that exists() if std::fs::read_to_string(repo_path).is_ok() { return repo_path.to_string(); } // our own repo checkout before first run: config/ exists, file not yet let ours = std::fs::read_to_string("Cargo.toml") .map(|t| t.contains("name = \"cortiq-gateway\"")) .unwrap_or(false); if ours && std::path::Path::new("config").is_dir() { return repo_path.to_string(); } data_dir() .join("gateway.toml") .to_string_lossy() .into_owned() } impl Config { /// Load and validate the config from a file. pub fn load(path: &str) -> anyhow::Result { 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) } /// Load if exists, otherwise create a fresh default config at `path` and return it. /// Never requires the user to create or copy a file by hand. pub fn load_or_create(path: &str) -> anyhow::Result { if std::path::Path::new(path).exists() { return Self::load(path); } let cfg = Self::default(); // ensure parent dir exists if let Some(dir) = std::path::Path::new(path).parent() { let _ = std::fs::create_dir_all(dir); } cfg.save(path)?; eprintln!("created default config at {path} — open /admin to finish setup"); Ok(cfg) } /// Integrity validation: non-empty model pool, valid routing targets. pub fn validate(&self) -> anyhow::Result<()> { // A managed local CMF model counts as an available model even though it // is registered dynamically rather than via a [[models]] entry — this // is what lets the gateway run on local models alone (no cloud, no router). let servers = self.cmf.effective_servers(); let has_local = !servers.is_empty(); if self.models.is_empty() && !has_local { // Fresh install: start with an EMPTY model pool so the admin console // comes up — the user adds models in /admin → Models (or points [cmf] // at a local .cmf). Routing simply has nothing to select until then. tracing::warn!( "no models configured — starting with an empty pool; add one in /admin → Models" ); } // uniqueness of model ids 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); } } // every target in [routing] must exist in the model pool let mut ids: std::collections::HashSet<&str> = self.models.iter().map(|m| m.id.as_str()).collect(); // managed local servers are known ids too; also enforce unique ports/ids let mut ports = std::collections::HashSet::new(); for s in &servers { if !ids.insert(s.id.as_str()) { anyhow::bail!("config: duplicate managed model id '{}'", s.id); } if !ports.insert(s.port) { anyhow::bail!("config: two managed models share port {}", s.port); } } if !self.routing.default.is_empty() && !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::Map(_) => targets.models(), 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 ); } } } if let Some(b) = &self.routing.bands { if !(b.low > 0.0 && b.low < b.medium && b.medium < 1.0) { anyhow::bail!( "config: routing.bands must satisfy 0 < low < medium < 1 (got {} / {})", b.low, b.medium ); } } for (task, targets) in &self.routing.tasks { for id in &targets.models() { if !ids.contains(id.as_str()) { anyhow::bail!( "config: routing.tasks.{} references unknown model id '{}'", task, id ); } } if let Some(bad) = targets.invalid_task_keys().first() { anyhow::bail!( "config: routing.tasks.{}.{} is not a tier (use any/low/medium/high)", task, bad ); } } Ok(()) } /// Serialize the config to a TOML string (no secrets — they are intentionally absent here). pub fn to_toml_string(&self) -> anyhow::Result { 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}")) } /// Atomically save the config to a file (write to a temporary file then rename). 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"); // key risk: serialization of flattened [routing] + nested policy 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::Map(_) => assert_eq!(low.models(), vec!["local-qwen".to_string()]), TierTargets::List(v) => assert_eq!(v, &vec!["local-qwen".to_string()]), TierTargets::One(s) => assert_eq!(s, "local-qwen"), } // admin/stats sections receive defaults 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()); } }