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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-12 07:58:23 +01:00
1 parent 33612bf6d4
commit 1e8418336d
13 files changed
+231 -17

No files matched your search

+15 -2
View File
@@ -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).
+5
View File
@@ -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
View File
@@ -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},
+2 -1
View File
@@ -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() });
+33
View File
@@ -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> {