Value opencode Go plan requests; per-duration cache prices; usage under answers
- 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>
This commit is contained in:
1 parent
33612bf6d4
commit
1e8418336d
13 files changed
+231
-17
No files matched your search
+15
-2
@@ -843,6 +843,19 @@ impl WindowQ {
|
||||
}
|
||||
}
|
||||
|
||||
/// The cost of each answer for the chat view (usage.cost: charged, or valued at list prices).
|
||||
fn with_costs(app: &App, mut messages: Vec<crate::types::Message>) -> Vec<crate::types::Message> {
|
||||
let registry = app.registry();
|
||||
for m in &mut messages {
|
||||
if let (Some(u), Some(spec)) = (m.usage.as_mut(), m.model.as_deref()) {
|
||||
if let Ok((_, model)) = registry.resolve(spec) {
|
||||
u.cost = Some(model.cost(u));
|
||||
}
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<WindowQ>) -> ApiResult {
|
||||
let s = readable(&app, &user, &id)?;
|
||||
let busy = app.is_busy(&id);
|
||||
@@ -853,7 +866,7 @@ async fn get_session(Extension(user): Extension<User>, State(app): State<AppStat
|
||||
match q.window() {
|
||||
Some(w) => {
|
||||
let (messages, older, newer) = app.store.messages_window(&id, &w)?;
|
||||
out["messages"] = json!(messages);
|
||||
out["messages"] = json!(with_costs(&app, messages));
|
||||
out["has_older"] = json!(older);
|
||||
out["has_newer"] = json!(newer);
|
||||
}
|
||||
@@ -867,7 +880,7 @@ async fn session_messages(Extension(user): Extension<User>, State(app): State<Ap
|
||||
readable(&app, &user, &id)?;
|
||||
let w = q.window().ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "before, after, around or limit is required".into()))?;
|
||||
let (messages, older, newer) = app.store.messages_window(&id, &w).map_err(|e| ApiError(StatusCode::NOT_FOUND, format!("{e:#}")))?;
|
||||
Ok(Json(json!({"messages": messages, "has_older": older, "has_newer": newer})))
|
||||
Ok(Json(json!({"messages": with_costs(&app, messages), "has_older": older, "has_newer": newer})))
|
||||
}
|
||||
|
||||
/// Images, videos and audio files shown in a chat (all of it, with subagents).
|
||||
|
||||
@@ -144,6 +144,11 @@ impl App {
|
||||
Ok(n) => tracing::info!("moved {n} images of older chats to the image store"),
|
||||
Err(e) => tracing::warn!("moving images to the image store: {e:#}"),
|
||||
}
|
||||
match crate::budget::value_plan_requests(&self) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!("valued {n} plan requests (opencode Go) at list prices"),
|
||||
Err(e) => tracing::warn!("valuing plan requests: {e:#}"),
|
||||
}
|
||||
match crate::images::backfill_copies(&self) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!("kept copies of {n} generated images in the image store"),
|
||||
|
||||
+72
-10
@@ -361,6 +361,49 @@ fn opencode_when(w: &Value) -> Option<When> {
|
||||
Some((days, hours, from, until))
|
||||
}
|
||||
|
||||
/// Plan requests (opencode Go) that were booked at 0: value them at the model's list
|
||||
/// prices of the time of the request. Idempotent – valued rows no longer match.
|
||||
pub fn value_plan_requests(app: &App) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
for (row, ts, spec, usage) in app.store.plan_requests_without_cost()? {
|
||||
let Ok((_, model)) = app.registry().resolve(&spec) else { continue };
|
||||
let Some(at) = chrono::DateTime::from_timestamp_millis(ts) else { continue };
|
||||
let cost = model.cost_at(&usage, at);
|
||||
if cost > 0.0 {
|
||||
app.store.set_usage_cost(row, cost)?;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// A price from opencode's list: a number, or one price per cache duration
|
||||
/// (`{"30m": 0.125, "12h": 0.25}`) – then the shortest duration (the default caching).
|
||||
fn price_of(v: &Value) -> Value {
|
||||
match v {
|
||||
Value::Number(_) => v.clone(),
|
||||
Value::Object(o) => {
|
||||
let secs = |k: &str| -> Option<f64> {
|
||||
let (num, unit) = k.split_at(k.find(|c: char| c.is_ascii_alphabetic())?);
|
||||
let n: f64 = num.parse().ok()?;
|
||||
Some(n * match unit {
|
||||
"s" => 1.0,
|
||||
"m" => 60.0,
|
||||
"h" => 3600.0,
|
||||
"d" => 86400.0,
|
||||
_ => return None,
|
||||
})
|
||||
};
|
||||
o.iter()
|
||||
.filter(|(_, x)| x.is_number())
|
||||
.min_by(|a, b| secs(a.0).unwrap_or(f64::MAX).total_cmp(&secs(b.0).unwrap_or(f64::MAX)))
|
||||
.map(|(_, x)| x.clone())
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
_ => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"];
|
||||
@@ -371,13 +414,13 @@ fn opencode_model(m: &Value) -> Value {
|
||||
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());
|
||||
t.insert(field.into(), price_of(&o["price"]));
|
||||
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());
|
||||
for (key, k) in [("read", "cache_read_price"), ("write", "cache_write_price")] {
|
||||
let p = price_of(&o["cache"][key]);
|
||||
if !p.is_null() {
|
||||
t.insert(k.into(), p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,10 +455,10 @@ fn opencode_model(m: &Value) -> Value {
|
||||
"max_input": m["limit"]["input"],
|
||||
"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"],
|
||||
"input_price": price_of(&input["price"]),
|
||||
"output_price": price_of(&output["price"]),
|
||||
"cache_read_price": price_of(&input["cache"]["read"]),
|
||||
"cache_write_price": price_of(&input["cache"]["write"]),
|
||||
"price_tiers": price_tiers,
|
||||
"price_multipliers": price_multipliers,
|
||||
"notes": notes,
|
||||
@@ -481,6 +524,25 @@ pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn prices_per_cache_duration() {
|
||||
use super::{opencode_model, price_of};
|
||||
use serde_json::{Value, json};
|
||||
assert_eq!(price_of(&json!(0.5)), json!(0.5));
|
||||
assert_eq!(price_of(&json!({"12h": 0.25, "30m": 0.125})), json!(0.125));
|
||||
assert_eq!(price_of(&json!({"1h": 2.0, "5m": 1.25})), json!(1.25));
|
||||
assert_eq!(price_of(&Value::Null), Value::Null);
|
||||
// A model of opencode's list with per-duration cache write prices.
|
||||
let m = json!({"id": "gpt-6-luna", "limit": {"context": 1050000, "input": 922000, "output": 128000},
|
||||
"cost": {"text": {"input": {"price": 0.1, "cache": {"read": 0.01, "write": {"30m": 0.125, "12h": 0.25}},
|
||||
"overrides": [{"when": {"context": {"gt": 272000}}, "price": 0.2, "cache": {"read": 0.02, "write": {"30m": 0.25, "12h": 0.5}}}]},
|
||||
"output": {"price": 0.5, "overrides": [{"when": {"context": {"gt": 272000}}, "price": 0.75}]}}}});
|
||||
let row = opencode_model(&m);
|
||||
assert_eq!(row["cache_write_price"], json!(0.125));
|
||||
assert_eq!(row["price_tiers"][0]["cache_write_price"], json!(0.25));
|
||||
assert_eq!(row["max_input"], json!(922000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_cost_conversion() {
|
||||
let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000},
|
||||
|
||||
@@ -1113,7 +1113,8 @@ async fn stream_step(
|
||||
let mut message = Message::new(Role::Assistant, parts);
|
||||
message.id = msg_id;
|
||||
message.model = Some(model_spec.to_string());
|
||||
message.usage = Some(usage.clone());
|
||||
// With its cost (charged, or valued at list prices) for the line under the answer.
|
||||
message.usage = Some(Usage { cost: Some(req.model.cost(&usage)), ..usage.clone() });
|
||||
if !message.parts.is_empty() {
|
||||
app.store.add_message(&session.id, &message)?;
|
||||
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
|
||||
|
||||
@@ -653,6 +653,39 @@ impl Store {
|
||||
Ok(json!({"total": total, "by_model": by_model, "by_agent": by_agent, "first": first, "last": last}))
|
||||
}
|
||||
|
||||
/// Plan requests booked at 0 (opencode Go reports nothing charged, only `allowance`):
|
||||
/// (row id, ts, "provider/model", usage).
|
||||
pub fn plan_requests_without_cost(&self) -> Result<Vec<(i64, i64, String, crate::types::Usage)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, ts, provider || '/' || model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, allowance
|
||||
FROM usage WHERE cost = 0 AND allowance > 0",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
Ok((
|
||||
r.get(0)?,
|
||||
r.get(1)?,
|
||||
r.get(2)?,
|
||||
crate::types::Usage {
|
||||
input_tokens: r.get::<_, i64>(3)? as u64,
|
||||
output_tokens: r.get::<_, i64>(4)? as u64,
|
||||
cache_read_tokens: r.get::<_, i64>(5)? as u64,
|
||||
cache_write_tokens: r.get::<_, i64>(6)? as u64,
|
||||
allowance: r.get(7)?,
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn set_usage_cost(&self, row: i64, cost: f64) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE usage SET cost = ?2 WHERE id = ?1", params![row, cost])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
|
||||
/// into the viewer's local time.
|
||||
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64, user: Option<&str>) -> Result<Value> {
|
||||
|
||||
Reference in new issue
Block a user