diff --git a/CHANGELOG.md b/CHANGELOG.md index 65e1353..20ed51c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Added + +- **Tokens and cost under every answer:** a small line under each answer of the model shows the + tokens sent/received and the cost of all requests of that turn. + +### Fixed + +- **Total cost of chats stopped growing (opencode Go):** opencode now reports plan requests as + "nothing charged" (cost 0, usage in `allowance`), and CodeCraft took the 0. Plan requests are + valued at the model's list prices again; earlier ones are corrected once on start. +- **Saving opencode Go models failed after fetching prices** ("data did not match any variant of + untagged enum ModelEntry"): opencode lists cache prices per duration for some models + (`{"30m": …, "12h": …}`); the shortest duration is used now. +- The chat title in the top bar no longer shrinks to a single letter when there are many chips. + ## [v202611-00022] – 2026-11-11 ### Added diff --git a/core/src/config.rs b/core/src/config.rs index 6ffbc86..7642c05 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -159,7 +159,13 @@ impl ModelConfig { /// 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 { - u.cost.unwrap_or_else(|| self.cost_at(u, chrono::Utc::now())) + match u.cost { + // A plan request (opencode Go): nothing charged, the usage counts toward the plan – + // its value at the model's list prices. + Some(c) if c == 0.0 && u.allowance.is_some_and(|a| a > 0.0) => self.cost_at(u, chrono::Utc::now()), + Some(c) => c, + None => self.cost_at(u, chrono::Utc::now()), + } } /// Cost of one request at a given time: context tiers and time factors applied. @@ -187,6 +193,21 @@ mod price_tests { Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() } } + #[test] + fn plan_requests_are_valued_at_list_prices() { + let m = ModelConfig { input_price: Some(1.0), output_price: Some(2.0), ..Default::default() }; + let mut u = usage(1_000_000, 1_000_000, 0); + u.cost = Some(0.0); + u.allowance = Some(0.4); + assert_eq!(m.cost(&u), 3.0); + // Charged by the provider: that amount; free without a plan: free. + u.cost = Some(0.7); + assert_eq!(m.cost(&u), 0.7); + u.cost = Some(0.0); + u.allowance = None; + assert_eq!(m.cost(&u), 0.0); + } + #[test] fn tiers_and_multipliers() { let m = ModelConfig { diff --git a/server/src/api.rs b/server/src/api.rs index 8b21ea0..4240a23 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -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) -> Vec { + 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, State(app): State, Path(id): Path, Query(q): Query) -> ApiResult { let s = readable(&app, &user, &id)?; let busy = app.is_busy(&id); @@ -853,7 +866,7 @@ async fn get_session(Extension(user): Extension, State(app): State { 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, State(app): State 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"), diff --git a/server/src/budget.rs b/server/src/budget.rs index 6f26b80..57b3776 100644 --- a/server/src/budget.rs +++ b/server/src/budget.rs @@ -361,6 +361,49 @@ fn opencode_when(w: &Value) -> Option { 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 { + 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 { + 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 { #[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}, diff --git a/server/src/runner.rs b/server/src/runner.rs index 8e72db7..ebc2955 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -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() }); diff --git a/server/src/store.rs b/server/src/store.rs index ae30a12..86a523a 100644 --- a/server/src/store.rs +++ b/server/src/store.rs @@ -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> { + 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::>>()?; + 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 { diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 8f45859..6b9e6b6 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -245,8 +245,10 @@ const MessageList = memo(function MessageList({ baseDir, onRewind, highlight, + currency, }: { messages: Message[]; + currency: string; results: Map; busy: boolean; canEdit: boolean; @@ -280,13 +282,52 @@ const MessageList = memo(function MessageList({ )); // Finished turns with tool steps fold into one line (the answer stays visible). const steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[]; - if (i === lastWork || steps.length < 2) return
{items}
; - return m.id === highlight)} baseDir={baseDir}>{items}; + const usage = !(busy && i === lastWork) && ; + if (i === lastWork || steps.length < 2) + return ( +
+ {items} + {usage} +
+ ); + return ( +
+ m.id === highlight)} baseDir={baseDir}> + {items} + + {usage} +
+ ); })} ); }); +/** Tokens (in/out) and cost of one answer: all requests of the turn together. */ +function TurnUsage({ messages, currency }: { messages: Message[]; currency: string }) { + const t = useT(); + let inp = 0; + let out = 0; + let cost = 0; + let requests = 0; + for (const m of messages) { + const u = m.role === "assistant" ? m.usage : undefined; + if (!u) continue; + requests++; + inp += u.input_tokens + u.cache_read_tokens + u.cache_write_tokens; + out += u.output_tokens; + cost += u.cost ?? 0; + } + if (!requests) return null; + return ( +
+ {fmtCompact(inp)} + {fmtCompact(out)} + · {fmtCost(cost, currency)} +
+ ); +} + /** A finished turn: "42 steps · 3 files" and the answer; the steps on click. */ function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messages: Message[]; steps: ToolCall[]; open: boolean; baseDir: string; children: ReactNode }) { const t = useT(); @@ -825,6 +866,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string baseDir={session.cwd} onRewind={onRewind} highlight={highlight} + currency={stats?.currency ?? "USD"} /> {win?.newer && (