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
@@ -66,6 +66,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/folders/{id}/access", put(set_folder_access))
|
||||
.route("/config", get(get_config).patch(patch_config))
|
||||
.route("/config/raw", get(get_config_raw).put(put_config_raw))
|
||||
.route("/config/inline-env", get(inline_env_preview).post(inline_env_apply))
|
||||
.route("/providers/reload", post(reload_providers))
|
||||
.route("/models", get(models))
|
||||
.route("/agents", get(agents))
|
||||
@@ -289,6 +290,24 @@ struct RawBody {
|
||||
text: String,
|
||||
}
|
||||
|
||||
/// Which ${VAR} references the config still has (and whether they are set).
|
||||
async fn inline_env_preview(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
let (_, set, unset) = config::inline_env(&app.config_text()?)?;
|
||||
Ok(Json(json!({"set": set, "unset": unset})))
|
||||
}
|
||||
|
||||
/// Move the values of set environment variables into the config file.
|
||||
async fn inline_env_apply(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
let (text, set, unset) = config::inline_env(&app.config_text()?)?;
|
||||
if !set.is_empty() {
|
||||
app.save_config(&text).await?;
|
||||
app.audit(Some(&user), "config.inline_env", set.join(", "), "");
|
||||
}
|
||||
Ok(Json(json!({"set": set, "unset": unset})))
|
||||
}
|
||||
|
||||
async fn put_config_raw(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
app.save_config(&body.text).await?;
|
||||
@@ -1192,6 +1211,10 @@ async fn status_overview(Extension(user): Extension<User>, State(app): State<App
|
||||
}),
|
||||
None => json!({"state": "unknown", "models": models}),
|
||||
};
|
||||
let mut v = v;
|
||||
if let Some(limit) = cfg.providers.get(name).and_then(|p| p.plan_limit).filter(|l| *l > 0.0) {
|
||||
v["plan"] = crate::budget::plan_usage(&app, name, limit).unwrap_or(Value::Null);
|
||||
}
|
||||
(name.clone(), v)
|
||||
})
|
||||
.collect();
|
||||
|
||||
+175
-2
@@ -129,6 +129,28 @@ fn entry(name: &str, budget: Option<f64>, spent: f64, warn: f64) -> Value {
|
||||
}
|
||||
|
||||
/// Budget overview for the current month.
|
||||
/// Subscription windows of opencode Go, as share of the plan's monthly limit: rolling
|
||||
/// 5 hours (20 %) and 7 days (50 %), and the calendar month in UTC (100 %). Only usage
|
||||
/// through CodeCraft is counted.
|
||||
pub fn plan_usage(app: &App, provider: &str, limit: f64) -> Result<Value> {
|
||||
let now = chrono::Utc::now();
|
||||
let month = chrono::Utc.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0).single().map(|d| d.timestamp_millis()).unwrap_or(0);
|
||||
let ms = now.timestamp_millis();
|
||||
let window = |name: &str, from: i64, share: f64| -> Result<Value> {
|
||||
let used = app.store.allowance_since(provider, from)?;
|
||||
let cap = limit * share;
|
||||
Ok(json!({"window": name, "used": used, "limit": cap, "ratio": if cap > 0.0 { used / cap } else { 0.0 }}))
|
||||
};
|
||||
Ok(json!({
|
||||
"limit": limit,
|
||||
"windows": [
|
||||
window("5h", ms - 5 * 3_600_000, 0.2)?,
|
||||
window("week", ms - 7 * 86_400_000, 0.5)?,
|
||||
window("month", month, 1.0)?,
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn overview(app: &App) -> Result<Value> {
|
||||
let cfg = app.config();
|
||||
let spent = app.store.cost_by_provider(month_start())?;
|
||||
@@ -139,7 +161,11 @@ pub async fn overview(app: &App) -> Result<Value> {
|
||||
if !p.enabled {
|
||||
continue;
|
||||
}
|
||||
rows.push(entry(name, p.monthly_budget, spent.get(name).copied().unwrap_or(0.0), warn));
|
||||
let mut row = entry(name, p.monthly_budget, spent.get(name).copied().unwrap_or(0.0), warn);
|
||||
if let Some(limit) = p.plan_limit.filter(|l| *l > 0.0) {
|
||||
row["plan"] = plan_usage(app, name, limit)?;
|
||||
}
|
||||
rows.push(row);
|
||||
let p = p.clone();
|
||||
tasks.push(tokio::spawn(async move { remote_balance(&p).await }));
|
||||
}
|
||||
@@ -174,13 +200,138 @@ pub async fn overview(app: &App) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Per-token prices from the provider's model list, converted to per 1M tokens.
|
||||
fn is_opencode(p: &ProviderConfig) -> bool {
|
||||
p.base_url.contains("opencode.ai")
|
||||
}
|
||||
|
||||
async fn get_json(url: &str) -> Result<Value> {
|
||||
Ok(crate::provider::http_client().get(url).timeout(Duration::from_secs(30)).send().await?.error_for_status()?.json().await?)
|
||||
}
|
||||
|
||||
/// One opencode condition object → (days, hours, from, until); None if it uses
|
||||
/// something we do not understand.
|
||||
type When = (Vec<String>, Vec<(String, String)>, Option<String>, Option<String>);
|
||||
|
||||
fn opencode_when(w: &Value) -> Option<When> {
|
||||
let obj = w.as_object()?;
|
||||
let (mut days, mut hours, mut from, mut until) = (Vec::new(), Vec::new(), None, None);
|
||||
// A pair of utc_hours bounds, given as {"all": [{"utc_hours": {"gte"}}, {"utc_hours": {"lt"}}]}
|
||||
// or directly as {"utc_hours": {"gte", "lt"}}.
|
||||
let range = |v: &Value| -> Option<(String, String)> {
|
||||
if let Some(h) = v.get("utc_hours") {
|
||||
return Some((h["gte"].as_str()?.to_string(), h["lt"].as_str()?.to_string()));
|
||||
}
|
||||
let all = v.get("all")?.as_array()?;
|
||||
let get = |k: &str| all.iter().find_map(|x| x["utc_hours"][k].as_str()).map(String::from);
|
||||
Some((get("gte")?, get("lt")?))
|
||||
};
|
||||
for (k, v) in obj {
|
||||
match k.as_str() {
|
||||
"utc_days" => days = v["in"].as_array()?.iter().map(|d| d.as_str().map(String::from)).collect::<Option<_>>()?,
|
||||
"any" => hours = v.as_array()?.iter().map(range).collect::<Option<_>>()?,
|
||||
"all" | "utc_hours" => hours = vec![range(w)?],
|
||||
"date" => {
|
||||
from = v["gte"].as_str().map(String::from);
|
||||
until = v["lt"].as_str().map(String::from);
|
||||
if from.is_none() && until.is_none() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some((days, hours, from, until))
|
||||
}
|
||||
|
||||
/// Model row from opencode's model list (prices per 1M tokens, tiers, time factors).
|
||||
fn opencode_model(m: &Value) -> Value {
|
||||
let cost = &m["cost"]["text"];
|
||||
let (input, output) = (&cost["input"], &cost["output"]);
|
||||
// Context tiers: input and output overrides share their thresholds.
|
||||
let mut tiers: std::collections::BTreeMap<u64, serde_json::Map<String, Value>> = Default::default();
|
||||
for (side, field) in [(input, "input_price"), (output, "output_price")] {
|
||||
for o in side["overrides"].as_array().into_iter().flatten() {
|
||||
let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue };
|
||||
let t = tiers.entry(gt).or_default();
|
||||
t.insert(field.into(), o["price"].clone());
|
||||
if field == "input_price" {
|
||||
if o["cache"]["read"].is_number() {
|
||||
t.insert("cache_read_price".into(), o["cache"]["read"].clone());
|
||||
}
|
||||
if o["cache"]["write"].is_number() {
|
||||
t.insert("cache_write_price".into(), o["cache"]["write"].clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let price_tiers: Vec<Value> = tiers.into_iter().map(|(gt, mut t)| {
|
||||
t.insert("above_context".into(), json!(gt));
|
||||
Value::Object(t)
|
||||
}).collect();
|
||||
let mut notes = Vec::new();
|
||||
let price_multipliers: Vec<Value> = m["cost"]["multipliers"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|x| match opencode_when(&x["when"]) {
|
||||
Some((days, hours, from, until)) => Some(json!({"factor": x["factor"], "days": days, "hours_utc": hours, "from": from, "until": until})),
|
||||
None => {
|
||||
notes.push(format!("unsupported price condition: {}", x["when"]));
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|mut v| {
|
||||
// Drop empty conditions so the config stays short.
|
||||
v.as_object_mut().unwrap().retain(|_, x| !(x.is_null() || x.as_array().is_some_and(|a| a.is_empty())));
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let images = m["modalities"]["input"].as_array().is_some_and(|a| a.iter().any(|x| x.as_str().is_some_and(|s| s.starts_with("image"))));
|
||||
json!({
|
||||
"id": m["id"],
|
||||
"context": m["limit"]["context"],
|
||||
"max_output": m["limit"]["output"],
|
||||
"vision": images,
|
||||
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
|
||||
"input_price": input["price"],
|
||||
"output_price": output["price"],
|
||||
"cache_read_price": input["cache"]["read"],
|
||||
"cache_write_price": input["cache"]["write"],
|
||||
"price_tiers": price_tiers,
|
||||
"price_multipliers": price_multipliers,
|
||||
"notes": notes,
|
||||
})
|
||||
}
|
||||
|
||||
/// opencode (Zen and Go) publishes prices in its model list. The Anthropic-protocol
|
||||
/// endpoint lists models without them, so fall back to the gateway's own /v1/models.
|
||||
async fn opencode_prices(base: &str) -> Result<Value> {
|
||||
let mut urls = vec![format!("{base}/models")];
|
||||
for suffix in ["/openai/v1", "/anthropic/v1"] {
|
||||
if let Some(root) = base.strip_suffix(suffix) {
|
||||
urls.push(format!("{root}/v1/models"));
|
||||
}
|
||||
}
|
||||
for url in &urls {
|
||||
let Ok(v) = get_json(url).await else { continue };
|
||||
let models: Vec<Value> = v["data"].as_array().into_iter().flatten().filter(|m| m.get("cost").is_some()).map(opencode_model).collect();
|
||||
if !models.is_empty() {
|
||||
return Ok(json!({"currency": "USD", "models": models}));
|
||||
}
|
||||
}
|
||||
bail!("opencode did not return prices for this endpoint")
|
||||
}
|
||||
|
||||
pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
||||
let cfg = app.config();
|
||||
let p = cfg.providers.get(provider).context("unknown provider")?;
|
||||
let base = expand_env(&p.base_url).trim_end_matches('/').to_string();
|
||||
if is_opencode(p) {
|
||||
return opencode_prices(&base).await;
|
||||
}
|
||||
if !is_openrouter(p) {
|
||||
bail!("this provider does not publish prices via its API – enter them manually");
|
||||
}
|
||||
let base = expand_env(&p.base_url).trim_end_matches('/').to_string();
|
||||
let v: Value = crate::provider::http_client()
|
||||
.get(format!("{base}/models"))
|
||||
.timeout(Duration::from_secs(30))
|
||||
@@ -211,6 +362,28 @@ pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn opencode_cost_conversion() {
|
||||
let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000},
|
||||
"modalities":{"input":["text","image/png"]},"capabilities":{"reasoning":{"supported":true}},
|
||||
"cost":{"text":{"input":{"price":2,"cache":{"read":0.5},"overrides":[{"when":{"context":{"gt":200000}},"price":4,"cache":{"read":1}}]},
|
||||
"output":{"price":6,"overrides":[{"when":{"context":{"gt":200000}},"price":12}]}},
|
||||
"multipliers":[{"when":{"utc_days":{"in":["mon","tue"]},"any":[{"all":[{"utc_hours":{"gte":"01:00"}},{"utc_hours":{"lt":"04:00"}}]}]},"factor":2},
|
||||
{"when":{"date":{"gte":"2026-12-01"}},"factor":2},
|
||||
{"when":{"region":"eu"},"factor":3}]}}"#).unwrap();
|
||||
let r = super::opencode_model(&m);
|
||||
assert_eq!(r["input_price"], 2);
|
||||
assert_eq!(r["cache_read_price"], 0.5);
|
||||
assert_eq!(r["vision"], true);
|
||||
assert_eq!(r["price_tiers"][0], serde_json::json!({"above_context": 200000, "input_price": 4, "cache_read_price": 1, "output_price": 12}));
|
||||
assert_eq!(r["price_multipliers"][0], serde_json::json!({"factor": 2, "days": ["mon", "tue"], "hours_utc": [["01:00", "04:00"]]}));
|
||||
assert_eq!(r["price_multipliers"][1], serde_json::json!({"factor": 2, "from": "2026-12-01"}));
|
||||
assert_eq!(r["notes"].as_array().unwrap().len(), 1, "unknown condition is reported, not guessed");
|
||||
// The result must deserialize into the model config.
|
||||
let mc: crate::config::ModelConfig = serde_json::from_value(serde_json::json!({"id": "grok", "price_tiers": r["price_tiers"], "price_multipliers": r["price_multipliers"]})).unwrap();
|
||||
assert_eq!(mc.price_tiers[0].above_context, 200000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_entry_flags() {
|
||||
let e = super::entry("x", Some(10.0), 8.5, 0.8);
|
||||
|
||||
@@ -175,6 +175,7 @@ pub async fn llm_stream(Extension(user): Extension<User>, State(app): State<AppS
|
||||
cost: model.cost(&usage),
|
||||
error,
|
||||
user_id: Some(user.id.clone()),
|
||||
allowance: usage.allowance,
|
||||
});
|
||||
});
|
||||
let stream = futures::stream::unfold(out_rx, |mut rx| async move { rx.recv().await.map(|ev| (ev, rx)) });
|
||||
|
||||
+72
-1
@@ -470,6 +470,57 @@ pub fn load_or_init(path: &Path) -> Result<(Config, String)> {
|
||||
Ok((cfg, text))
|
||||
}
|
||||
|
||||
/// Replace `${NAME}` references in all string values with the environment's values,
|
||||
/// so the settings live in the config only. Comments and formatting stay; references
|
||||
/// to unset variables are kept. Returns (new text, replaced names, unset names).
|
||||
pub fn inline_env(text: &str) -> Result<(String, Vec<String>, Vec<String>)> {
|
||||
use std::collections::BTreeSet;
|
||||
let mut doc: toml_edit::DocumentMut = text.parse().context("config is not valid TOML")?;
|
||||
let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
|
||||
let (mut done, mut missing) = (BTreeSet::new(), BTreeSet::new());
|
||||
let mut fix = |s: &str| -> Option<String> {
|
||||
if !re.is_match(s) {
|
||||
return None;
|
||||
}
|
||||
let out = re.replace_all(s, |c: ®ex::Captures| match std::env::var(&c[1]).ok().filter(|v| !v.is_empty()) {
|
||||
Some(v) => {
|
||||
done.insert(c[1].to_string());
|
||||
v
|
||||
}
|
||||
None => {
|
||||
missing.insert(c[1].to_string());
|
||||
c[0].to_string()
|
||||
}
|
||||
});
|
||||
(out != s).then(|| out.into_owned())
|
||||
};
|
||||
fn walk_value(v: &mut toml_edit::Value, fix: &mut dyn FnMut(&str) -> Option<String>) {
|
||||
match v {
|
||||
toml_edit::Value::String(f) => {
|
||||
if let Some(new) = fix(f.value()) {
|
||||
let decor = f.decor().clone();
|
||||
let mut nv = toml_edit::Formatted::new(new);
|
||||
*nv.decor_mut() = decor;
|
||||
*f = nv;
|
||||
}
|
||||
}
|
||||
toml_edit::Value::Array(a) => a.iter_mut().for_each(|x| walk_value(x, fix)),
|
||||
toml_edit::Value::InlineTable(t) => t.iter_mut().for_each(|(_, x)| walk_value(x, fix)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn walk_item(i: &mut toml_edit::Item, fix: &mut dyn FnMut(&str) -> Option<String>) {
|
||||
match i {
|
||||
toml_edit::Item::Value(v) => walk_value(v, fix),
|
||||
toml_edit::Item::Table(t) => t.iter_mut().for_each(|(_, x)| walk_item(x, fix)),
|
||||
toml_edit::Item::ArrayOfTables(a) => a.iter_mut().for_each(|t| t.iter_mut().for_each(|(_, x)| walk_item(x, fix))),
|
||||
toml_edit::Item::None => {}
|
||||
}
|
||||
}
|
||||
walk_item(doc.as_item_mut(), &mut fix);
|
||||
Ok((doc.to_string(), done.into_iter().collect(), missing.into_iter().collect()))
|
||||
}
|
||||
|
||||
/// A structured edit coming from the UI. Applied with toml_edit so comments survive.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConfigPatch {
|
||||
@@ -616,6 +667,26 @@ pub fn redacted_json(cfg: &Config) -> serde_json::Value {
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod inline_env_tests {
|
||||
#[test]
|
||||
fn replaces_set_variables_and_keeps_comments() {
|
||||
// SAFETY: test-only, the variable names are unique to this test.
|
||||
unsafe {
|
||||
std::env::set_var("CC_TEST_INLINE_KEY", "sk-123");
|
||||
std::env::remove_var("CC_TEST_INLINE_UNSET");
|
||||
}
|
||||
let text = "# keys: use ${NAME}\n[providers.a]\napi_key = \"${CC_TEST_INLINE_KEY}\" # stored\nheaders = { Authorization = \"Bearer ${CC_TEST_INLINE_KEY}\" }\nother = \"${CC_TEST_INLINE_UNSET}\"\n";
|
||||
let (out, done, missing) = super::inline_env(text).unwrap();
|
||||
assert!(out.contains("api_key = \"sk-123\" # stored"), "{out}");
|
||||
assert!(out.contains("Bearer sk-123"));
|
||||
assert!(out.contains("# keys: use ${NAME}"), "comments untouched");
|
||||
assert!(out.contains("${CC_TEST_INLINE_UNSET}"));
|
||||
assert_eq!(done, vec!["CC_TEST_INLINE_KEY"]);
|
||||
assert_eq!(missing, vec!["CC_TEST_INLINE_UNSET"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -639,7 +710,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert!(out.contains("# Google Gemini through its OpenAI-compatible endpoint."));
|
||||
assert!(out.contains("enabled = true # set to true once GEMINI_API_KEY is available"));
|
||||
assert!(out.contains("enabled = true # enable once the key is set"));
|
||||
let cfg = Config::parse(&out).unwrap();
|
||||
assert!(cfg.providers["gemini"].enabled);
|
||||
}
|
||||
|
||||
@@ -247,6 +247,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
||||
cost,
|
||||
error: false,
|
||||
user_id: None,
|
||||
allowance: None,
|
||||
});
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
@@ -882,6 +882,7 @@ async fn stream_step(
|
||||
cost: req.model.cost(usage),
|
||||
error,
|
||||
user_id: None,
|
||||
allowance: usage.allowance,
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
@@ -967,6 +968,7 @@ fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::co
|
||||
cost: model.cost(usage),
|
||||
error: false,
|
||||
user_id: None,
|
||||
allowance: usage.allowance,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+13
-3
@@ -162,6 +162,7 @@ impl Store {
|
||||
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
|
||||
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
|
||||
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
|
||||
("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
|
||||
] {
|
||||
let exists: bool = conn
|
||||
.prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))?
|
||||
@@ -331,6 +332,8 @@ pub struct UsageRecord {
|
||||
pub error: bool,
|
||||
/// User to bill when there is no session (CLI requests); else the session owner.
|
||||
pub user_id: Option<String>,
|
||||
/// opencode Go: share of the plan's usage limits (USD), as reported by the provider.
|
||||
pub allowance: Option<f64>,
|
||||
}
|
||||
|
||||
/// Aggregation SQL shared by all statistics queries.
|
||||
@@ -376,13 +379,14 @@ impl Store {
|
||||
conn.execute(
|
||||
"INSERT INTO usage (ts, session_id, root_session_id, provider, model, agent,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||
ttft_ms, duration_ms, cost, error, user_id)
|
||||
ttft_ms, duration_ms, cost, error, user_id, allowance)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,
|
||||
COALESCE(?15, (SELECT owner_id FROM sessions WHERE id = ?3)))",
|
||||
COALESCE(?15, (SELECT owner_id FROM sessions WHERE id = ?3)), ?16)",
|
||||
params![
|
||||
u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent,
|
||||
u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64,
|
||||
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64, u.user_id
|
||||
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64, u.user_id,
|
||||
u.allowance
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -886,6 +890,12 @@ impl Store {
|
||||
|
||||
impl Store {
|
||||
/// Cost per provider since `from` (ms).
|
||||
/// opencode Go: sum of reported allowance of one provider since `from` (ms).
|
||||
pub fn allowance_since(&self, provider: &str, from: i64) -> Result<f64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row("SELECT COALESCE(SUM(allowance), 0) FROM usage WHERE provider = ?1 AND ts >= ?2", params![provider, from], |r| r.get(0))?)
|
||||
}
|
||||
|
||||
pub fn cost_by_provider(&self, from: i64) -> Result<std::collections::BTreeMap<String, f64>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT provider, COALESCE(SUM(cost), 0) FROM usage WHERE ts >= ?1 GROUP BY provider")?;
|
||||
|
||||
Reference in new issue
Block a user