opencode prices and Go limits, settings out of .env
- Fetch prices for opencode (Zen and Go) incl. limits, image input, reasoning,
long-context tiers and peak-hour/date factors; costs apply them per request
- Record the cost opencode reports per response; track Go allowance in 5h/7d/month
windows against a provider plan limit (budget table, status overlay)
- Settings → Config file: move values of ${NAME} references into the config
- Examples, presets and forms keep keys in the config instead of .env
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
70367211c1
commit
bfa1f641d7
23 files changed
+723
-69
No files matched your search
+129
-6
@@ -37,6 +37,10 @@ pub struct ProviderConfig {
|
||||
/// 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")]
|
||||
@@ -76,6 +80,13 @@ pub struct ModelConfig {
|
||||
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>,
|
||||
@@ -87,15 +98,127 @@ pub struct ModelConfig {
|
||||
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. Cache prices default to the input price.
|
||||
/// 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 {
|
||||
let inp = self.input_price.unwrap_or(0.0);
|
||||
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<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;
|
||||
per(u.input_tokens, inp)
|
||||
+ per(u.output_tokens, self.output_price.unwrap_or(0.0))
|
||||
+ per(u.cache_read_tokens, self.cache_read_price.unwrap_or(inp))
|
||||
+ per(u.cache_write_tokens, self.cache_write_price.unwrap_or(inp))
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user