- opencode Go reports plan requests as cost 0 with the usage in allowance;
ModelConfig::cost values them at the model's list prices (charged amounts
and free requests stay as reported). Rows booked at 0 are valued once on
start (idempotent).
- opencode's price list now has per-duration cache prices for some models
({"30m": ..., "12h": ...}): the shortest duration is used, also in tiers.
Saving the provider's models failed with "untagged enum ModelEntry".
- Each answer shows tokens sent/received and the cost of the turn's requests
(usage.cost filled when messages are saved and loaded).
- The chat title keeps a minimum width in a crowded top bar.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
12 KiB
Rust
273 lines
12 KiB
Rust
//! 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<String>,
|
||
#[serde(default)]
|
||
pub auth_prefix: Option<String>,
|
||
#[serde(default)]
|
||
pub headers: BTreeMap<String, String>,
|
||
#[serde(default)]
|
||
pub models: Vec<ModelEntry>,
|
||
#[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<usize>,
|
||
/// Monthly budget in the configured currency (tracked from recorded costs).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub monthly_budget: Option<f64>,
|
||
/// 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<f64>,
|
||
/// 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<String>,
|
||
#[serde(default)]
|
||
pub context: Option<u64>,
|
||
#[serde(default)]
|
||
pub max_output: Option<u64>,
|
||
/// 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<u64>,
|
||
#[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<String>,
|
||
/// Prices in currency units per 1M tokens (used for cost statistics).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub input_price: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub output_price: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub cache_read_price: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub cache_write_price: Option<f64>,
|
||
/// 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<PriceTier>,
|
||
/// Time-based price factors (peak hours, promotions); matching factors multiply.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub price_multipliers: Vec<PriceMultiplier>,
|
||
/// Max. simultaneous requests to this model (None = unlimited).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub max_concurrency: Option<usize>,
|
||
/// 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<String>,
|
||
/// 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<String, serde_json::Value>,
|
||
}
|
||
|
||
/// 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<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub output_price: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub cache_read_price: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub cache_write_price: Option<f64>,
|
||
}
|
||
|
||
/// 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<String>,
|
||
/// 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<String>,
|
||
/// First day it no longer applies (YYYY-MM-DD, UTC).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub until: Option<String>,
|
||
}
|
||
|
||
impl PriceMultiplier {
|
||
pub fn applies(&self, at: chrono::DateTime<chrono::Utc>) -> 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::<u32>().ok()? * 60 + m.trim().parse::<u32>().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 {
|
||
match u.cost {
|
||
// A plan request (opencode Go): nothing charged, the usage counts toward the plan –
|
||
// its value at the model's list prices.
|
||
Some(c) if c == 0.0 && u.allowance.is_some_and(|a| a > 0.0) => self.cost_at(u, chrono::Utc::now()),
|
||
Some(c) => c,
|
||
None => 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<chrono::Utc>) -> 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<f64>, tiered: fn(&PriceTier) -> Option<f64>| 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 plan_requests_are_valued_at_list_prices() {
|
||
let m = ModelConfig { input_price: Some(1.0), output_price: Some(2.0), ..Default::default() };
|
||
let mut u = usage(1_000_000, 1_000_000, 0);
|
||
u.cost = Some(0.0);
|
||
u.allowance = Some(0.4);
|
||
assert_eq!(m.cost(&u), 3.0);
|
||
// Charged by the provider: that amount; free without a plan: free.
|
||
u.cost = Some(0.7);
|
||
assert_eq!(m.cost(&u), 0.7);
|
||
u.cost = Some(0.0);
|
||
u.allowance = None;
|
||
assert_eq!(m.cost(&u), 0.0);
|
||
}
|
||
|
||
#[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<String, String>,
|
||
#[serde(flatten)]
|
||
pub tools: BTreeMap<String, String>,
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
|