Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7baa77922 | ||
|
|
1e8418336d |
No files matched your search
@@ -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.
|
||||
|
||||
## [v202611-00023] – 2026-11-12
|
||||
|
||||
### 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
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Version v202611-00022" src="https://img.shields.io/badge/version-v202611--00022-d69e2e?style=flat-square">
|
||||
<img alt="Version v202611-00023" src="https://img.shields.io/badge/version-v202611--00023-d69e2e?style=flat-square">
|
||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||
|
||||
+22
-1
@@ -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 {
|
||||
|
||||
+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> {
|
||||
|
||||
@@ -245,8 +245,10 @@ const MessageList = memo(function MessageList({
|
||||
baseDir,
|
||||
onRewind,
|
||||
highlight,
|
||||
currency,
|
||||
}: {
|
||||
messages: Message[];
|
||||
currency: string;
|
||||
results: Map<string, ToolResult>;
|
||||
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 <div key={key}>{items}</div>;
|
||||
return <WorkBlock key={key} messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>{items}</WorkBlock>;
|
||||
const usage = !(busy && i === lastWork) && <TurnUsage messages={b.messages} currency={currency} />;
|
||||
if (i === lastWork || steps.length < 2)
|
||||
return (
|
||||
<div key={key}>
|
||||
{items}
|
||||
{usage}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div key={key}>
|
||||
<WorkBlock messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>
|
||||
{items}
|
||||
</WorkBlock>
|
||||
{usage}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/** 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 (
|
||||
<div className="turn-usage" title={t("chat.turnUsageHint", { n: requests })}>
|
||||
{fmtCompact(inp)}
|
||||
<Icon name="arrow-up" className="tiny" /> {fmtCompact(out)}
|
||||
<Icon name="arrow-down" className="tiny" /> · {fmtCost(cost, currency)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 && (
|
||||
<button className="back-to-latest" onClick={() => void showLatest()}>
|
||||
|
||||
@@ -367,6 +367,7 @@ export const de: Messages = {
|
||||
"media.prev": "Vorheriges",
|
||||
"media.next": "Nächstes",
|
||||
"media.back": "Zurück zur Übersicht",
|
||||
"chat.turnUsageHint": "Alle {n} Anfragen dieser Antwort (Tokens gesendet/empfangen, Kosten)",
|
||||
"chat.reviewRound": "Review {round}: Änderungen nötig",
|
||||
"agents.refine": "Review-Schleife",
|
||||
"agents.refineRounds": "Review-Runden pro Aufgabe",
|
||||
|
||||
@@ -366,6 +366,7 @@ export const en = {
|
||||
"media.prev": "Previous",
|
||||
"media.next": "Next",
|
||||
"media.back": "Back to the overview",
|
||||
"chat.turnUsageHint": "All {n} requests of this answer (tokens sent/received, cost)",
|
||||
"chat.reviewRound": "Review {round}: changes requested",
|
||||
"agents.refine": "Review loop",
|
||||
"agents.refineRounds": "Review rounds per task",
|
||||
|
||||
+13
-1
@@ -456,7 +456,8 @@ button.session-group:hover {
|
||||
}
|
||||
.topbar .grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* The title stays readable: with many chips they wrap into a second row instead. */
|
||||
min-width: min(160px, 40vw);
|
||||
}
|
||||
.topbar h1 {
|
||||
font-size: 15px;
|
||||
@@ -4398,3 +4399,14 @@ span.chat-tab > button {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Tokens and cost under each answer. */
|
||||
.turn-usage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin: -6px 0 12px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export interface Usage {
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
/** Cost of the request (charged, or valued at list prices for plan requests). */
|
||||
cost?: number;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
"202611-00023": {
|
||||
en: ["Tokens and cost under every answer; the total cost of chats with opencode Go is shown correctly again"],
|
||||
de: ["Tokens und Kosten unter jeder Antwort; der Gesamtpreis von Chats mit opencode Go stimmt wieder"],
|
||||
},
|
||||
"202611-00022": {
|
||||
en: ["New theme “Tintenblau” (Settings → General → Theme)"],
|
||||
de: ["Neues Theme „Tintenblau“ (Einstellungen → Allgemein → Theme)"],
|
||||
|
||||
Reference in new issue
Block a user