//! Provider, model and permission configuration shared by server and CLI. use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; fn yes() -> bool { true } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ProviderKind { Anthropic, Openai, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ProviderConfig { pub kind: ProviderKind, #[serde(default)] pub base_url: String, #[serde(default)] pub api_key: String, #[serde(default)] pub auth_header: Option, #[serde(default)] pub auth_prefix: Option, #[serde(default)] pub headers: BTreeMap, #[serde(default)] pub models: Vec, #[serde(default = "yes")] pub enabled: bool, /// Max. simultaneous requests to this provider/host (None = unlimited). #[serde(default, skip_serializing_if = "Option::is_none")] pub max_concurrency: Option, /// Monthly budget in the configured currency (tracked from recorded costs). #[serde(default, skip_serializing_if = "Option::is_none")] pub monthly_budget: Option, /// Monthly usage limit of a subscription plan in USD (opencode Go). Usage is tracked /// from the allowance each response reports: 5 hours 20 %, week 50 %, month 100 %. #[serde(default, skip_serializing_if = "Option::is_none")] pub plan_limit: Option, /// Optional admin key to read the provider's own cost report /// (Anthropic: sk-ant-admin01-…, organisation-wide). #[serde(default, skip_serializing_if = "String::is_empty")] pub admin_key: String, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum ModelEntry { Id(String), Full(ModelConfig), } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ModelConfig { pub id: String, #[serde(default)] pub name: Option, #[serde(default)] pub context: Option, #[serde(default)] pub max_output: Option, /// Input limit when the provider sets one below the context window (e.g. 272k of 400k). #[serde(default, skip_serializing_if = "Option::is_none")] pub max_input: Option, #[serde(default)] pub reasoning: bool, #[serde(default)] pub vision: bool, /// Reasoning effort (Anthropic: low|medium|high|xhigh|max, OpenAI: low|medium|high). #[serde(default)] pub effort: Option, /// Prices in currency units per 1M tokens (used for cost statistics). #[serde(default, skip_serializing_if = "Option::is_none")] pub input_price: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub output_price: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_read_price: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_write_price: Option, /// Other prices from a context size on (e.g. long-context pricing); the largest /// threshold below the request's context wins, missing prices keep the base price. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub price_tiers: Vec, /// Time-based price factors (peak hours, promotions); matching factors multiply. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub price_multipliers: Vec, /// Max. simultaneous requests to this model (None = unlimited). #[serde(default, skip_serializing_if = "Option::is_none")] pub max_concurrency: Option, /// Model to use when this one's host is unreachable or its budget is used up ("provider/model"). #[serde(default, skip_serializing_if = "Option::is_none")] pub fallback: Option, /// Extra request body fields sent as-is (e.g. temperature, reasoning_budget_tokens). #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] pub options: serde_json::Map, } /// Prices from a context size on (tokens of the whole prompt, cache included). #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] pub struct PriceTier { pub above_context: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub input_price: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub output_price: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_read_price: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_write_price: Option, } /// A price factor that applies at certain times (all given conditions must hold, UTC). #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] pub struct PriceMultiplier { pub factor: f64, /// Weekdays ("mon" … "sun"); empty = every day. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub days: Vec, /// Time ranges ["HH:MM", "HH:MM") in UTC; empty = all day. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub hours_utc: Vec<(String, String)>, /// First day it applies (YYYY-MM-DD, UTC). #[serde(default, skip_serializing_if = "Option::is_none")] pub from: Option, /// First day it no longer applies (YYYY-MM-DD, UTC). #[serde(default, skip_serializing_if = "Option::is_none")] pub until: Option, } impl PriceMultiplier { pub fn applies(&self, at: chrono::DateTime) -> bool { use chrono::{Datelike, Timelike}; let day = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"][at.weekday().num_days_from_monday() as usize]; if !self.days.is_empty() && !self.days.iter().any(|d| d.eq_ignore_ascii_case(day)) { return false; } let minutes = |s: &str| s.split_once(':').and_then(|(h, m)| Some(h.trim().parse::().ok()? * 60 + m.trim().parse::().ok()?)); let now = at.hour() * 60 + at.minute(); if !self.hours_utc.is_empty() && !self.hours_utc.iter().any(|(a, b)| matches!((minutes(a), minutes(b)), (Some(a), Some(b)) if now >= a && now < b)) { return false; } let date = at.format("%Y-%m-%d").to_string(); if self.from.as_deref().is_some_and(|f| date.as_str() < f) || self.until.as_deref().is_some_and(|u| date.as_str() >= u) { return false; } true } } impl ModelConfig { /// Cost of one request now. Cache prices default to the input price. /// The provider's own figure wins when it reports one (opencode). pub fn cost(&self, u: &crate::types::Usage) -> f64 { u.cost.unwrap_or_else(|| self.cost_at(u, chrono::Utc::now())) } /// Cost of one request at a given time: context tiers and time factors applied. pub fn cost_at(&self, u: &crate::types::Usage, at: chrono::DateTime) -> f64 { let context = u.input_tokens + u.cache_read_tokens + u.cache_write_tokens; let tier = self.price_tiers.iter().filter(|t| context > t.above_context).max_by_key(|t| t.above_context); let pick = |base: Option, tiered: fn(&PriceTier) -> Option| tier.and_then(tiered).or(base); let inp = pick(self.input_price, |t| t.input_price).unwrap_or(0.0); let out = pick(self.output_price, |t| t.output_price).unwrap_or(0.0); let read = pick(self.cache_read_price, |t| t.cache_read_price).unwrap_or(inp); let write = pick(self.cache_write_price, |t| t.cache_write_price).unwrap_or(inp); let per = |tokens: u64, price: f64| tokens as f64 * price / 1_000_000.0; let factor: f64 = self.price_multipliers.iter().filter(|m| m.applies(at)).map(|m| m.factor).product(); (per(u.input_tokens, inp) + per(u.output_tokens, out) + per(u.cache_read_tokens, read) + per(u.cache_write_tokens, write)) * factor } } #[cfg(test)] mod price_tests { use super::*; use crate::types::Usage; use chrono::TimeZone; fn usage(input: u64, output: u64, read: u64) -> Usage { Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() } } #[test] fn tiers_and_multipliers() { let m = ModelConfig { input_price: Some(2.0), output_price: Some(6.0), cache_read_price: Some(0.5), price_tiers: vec![PriceTier { above_context: 200_000, input_price: Some(4.0), output_price: Some(12.0), cache_read_price: Some(1.0), ..Default::default() }], price_multipliers: vec![PriceMultiplier { factor: 2.0, days: vec!["mon".into(), "tue".into(), "wed".into(), "thu".into(), "fri".into()], hours_utc: vec![("01:00".into(), "04:00".into()), ("06:00".into(), "10:00".into())], ..Default::default() }], ..Default::default() }; let sat = chrono::Utc.with_ymd_and_hms(2026, 11, 7, 7, 0, 0).unwrap(); // Saturday 07:00 let mon_peak = chrono::Utc.with_ymd_and_hms(2026, 11, 9, 7, 30, 0).unwrap(); // Monday 07:30 let mon_off = chrono::Utc.with_ymd_and_hms(2026, 11, 9, 10, 0, 0).unwrap(); // Monday 10:00 (end excluded) let small = usage(100_000, 1_000_000, 0); assert!((m.cost_at(&small, sat) - (0.2 + 6.0)).abs() < 1e-9); assert!((m.cost_at(&small, mon_peak) - 2.0 * (0.2 + 6.0)).abs() < 1e-9); assert!((m.cost_at(&small, mon_off) - (0.2 + 6.0)).abs() < 1e-9); // 150k fresh + 100k cached = 250k context: long-context tier. let big = usage(150_000, 0, 100_000); assert!((m.cost_at(&big, sat) - (0.6 + 0.1)).abs() < 1e-9); } #[test] fn date_ranges() { let m = PriceMultiplier { factor: 2.0, from: Some("2026-12-01".into()), ..Default::default() }; assert!(!m.applies(chrono::Utc.with_ymd_and_hms(2026, 11, 30, 23, 59, 0).unwrap())); assert!(m.applies(chrono::Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap())); let until = PriceMultiplier { factor: 0.5, until: Some("2026-12-01".into()), ..Default::default() }; assert!(until.applies(chrono::Utc.with_ymd_and_hms(2026, 11, 30, 12, 0, 0).unwrap())); assert!(!until.applies(chrono::Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap())); } } impl ModelEntry { pub fn resolved(&self) -> ModelConfig { match self { ModelEntry::Id(id) => ModelConfig { id: id.clone(), ..Default::default() }, ModelEntry::Full(m) => m.clone(), } } } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct PermissionConfig { #[serde(default)] pub bash_rules: BTreeMap, #[serde(flatten)] pub tools: BTreeMap, } /// Replace `${NAME}` with the environment variable (empty if unset). pub fn expand_env(s: &str) -> String { let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap(); re.replace_all(s, |c: ®ex::Captures| std::env::var(&c[1]).unwrap_or_default()) .into_owned() }