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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 10:19:15 +01:00
1 parent 70367211c1
commit bfa1f641d7
23 files changed
+723 -69

No files matched your search

+129 -6
View File
@@ -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()));
}
}
+1
View File
@@ -266,6 +266,7 @@ impl Provider for Anthropic {
if let Some(o) = data["usage"]["output_tokens"].as_u64() {
usage.output_tokens = o;
}
usage.read_reported_cost(&data["usage"]);
}
"error" => {
let msg = data["error"]["message"].as_str().unwrap_or("unknown error");
+1
View File
@@ -231,6 +231,7 @@ impl Provider for OpenAi {
usage.input_tokens = u["prompt_tokens"].as_u64().unwrap_or(0).saturating_sub(cached);
usage.cache_read_tokens = cached;
usage.output_tokens = u["completion_tokens"].as_u64().unwrap_or(0);
usage.read_reported_cost(u);
}
let Some(choice) = data["choices"].get(0) else { continue };
let d = &choice["delta"];
+18
View File
@@ -93,6 +93,24 @@ pub struct Usage {
pub cache_read_tokens: u64,
#[serde(default)]
pub cache_write_tokens: u64,
/// Cost the provider reported for the request (USD; opencode: what was charged).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
/// opencode Go: how much the request counted toward the plan's usage limits (USD).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowance: Option<f64>,
}
impl Usage {
/// Read the cost fields gateways add to `usage` (opencode: `cost`, `opencode.allowance`).
pub fn read_reported_cost(&mut self, u: &serde_json::Value) {
if let Some(c) = u["cost"].as_f64() {
self.cost = Some(c);
}
if let Some(a) = u["opencode"]["allowance"].as_f64() {
self.allowance = Some(a);
}
}
}
impl Usage {