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
@@ -3,6 +3,23 @@
|
|||||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||||
until 1.0, minor versions may contain breaking changes.
|
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
|
## [v202611-00022] – 2026-11-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+22
-1
@@ -159,7 +159,13 @@ impl ModelConfig {
|
|||||||
/// Cost of one request now. Cache prices default to the input price.
|
/// Cost of one request now. Cache prices default to the input price.
|
||||||
/// The provider's own figure wins when it reports one (opencode).
|
/// The provider's own figure wins when it reports one (opencode).
|
||||||
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
|
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.
|
/// 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() }
|
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]
|
#[test]
|
||||||
fn tiers_and_multipliers() {
|
fn tiers_and_multipliers() {
|
||||||
let m = ModelConfig {
|
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 {
|
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 s = readable(&app, &user, &id)?;
|
||||||
let busy = app.is_busy(&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() {
|
match q.window() {
|
||||||
Some(w) => {
|
Some(w) => {
|
||||||
let (messages, older, newer) = app.store.messages_window(&id, &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_older"] = json!(older);
|
||||||
out["has_newer"] = json!(newer);
|
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)?;
|
readable(&app, &user, &id)?;
|
||||||
let w = q.window().ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "before, after, around or limit is required".into()))?;
|
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:#}")))?;
|
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).
|
/// 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"),
|
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:#}"),
|
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) {
|
match crate::images::backfill_copies(&self) {
|
||||||
Ok(0) => {}
|
Ok(0) => {}
|
||||||
Ok(n) => tracing::info!("kept copies of {n} generated images in the image store"),
|
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))
|
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).
|
/// Model row from opencode's model list (prices per 1M tokens, tiers, time factors).
|
||||||
fn opencode_model(m: &Value) -> Value {
|
fn opencode_model(m: &Value) -> Value {
|
||||||
let cost = &m["cost"]["text"];
|
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() {
|
for o in side["overrides"].as_array().into_iter().flatten() {
|
||||||
let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue };
|
let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue };
|
||||||
let t = tiers.entry(gt).or_default();
|
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 field == "input_price" {
|
||||||
if o["cache"]["read"].is_number() {
|
for (key, k) in [("read", "cache_read_price"), ("write", "cache_write_price")] {
|
||||||
t.insert("cache_read_price".into(), o["cache"]["read"].clone());
|
let p = price_of(&o["cache"][key]);
|
||||||
}
|
if !p.is_null() {
|
||||||
if o["cache"]["write"].is_number() {
|
t.insert(k.into(), p);
|
||||||
t.insert("cache_write_price".into(), o["cache"]["write"].clone());
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,10 +455,10 @@ fn opencode_model(m: &Value) -> Value {
|
|||||||
"max_input": m["limit"]["input"],
|
"max_input": m["limit"]["input"],
|
||||||
"vision": images,
|
"vision": images,
|
||||||
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
|
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
|
||||||
"input_price": input["price"],
|
"input_price": price_of(&input["price"]),
|
||||||
"output_price": output["price"],
|
"output_price": price_of(&output["price"]),
|
||||||
"cache_read_price": input["cache"]["read"],
|
"cache_read_price": price_of(&input["cache"]["read"]),
|
||||||
"cache_write_price": input["cache"]["write"],
|
"cache_write_price": price_of(&input["cache"]["write"]),
|
||||||
"price_tiers": price_tiers,
|
"price_tiers": price_tiers,
|
||||||
"price_multipliers": price_multipliers,
|
"price_multipliers": price_multipliers,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
@@ -481,6 +524,25 @@ pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn opencode_cost_conversion() {
|
fn opencode_cost_conversion() {
|
||||||
let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000},
|
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);
|
let mut message = Message::new(Role::Assistant, parts);
|
||||||
message.id = msg_id;
|
message.id = msg_id;
|
||||||
message.model = Some(model_spec.to_string());
|
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() {
|
if !message.parts.is_empty() {
|
||||||
app.store.add_message(&session.id, &message)?;
|
app.store.add_message(&session.id, &message)?;
|
||||||
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
|
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}))
|
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
|
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
|
||||||
/// into the viewer's local time.
|
/// into the viewer's local time.
|
||||||
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64, user: Option<&str>) -> Result<Value> {
|
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,
|
baseDir,
|
||||||
onRewind,
|
onRewind,
|
||||||
highlight,
|
highlight,
|
||||||
|
currency,
|
||||||
}: {
|
}: {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
|
currency: string;
|
||||||
results: Map<string, ToolResult>;
|
results: Map<string, ToolResult>;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
canEdit: 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).
|
// 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[];
|
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>;
|
const usage = !(busy && i === lastWork) && <TurnUsage messages={b.messages} currency={currency} />;
|
||||||
return <WorkBlock key={key} messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>{items}</WorkBlock>;
|
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. */
|
/** 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 }) {
|
function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messages: Message[]; steps: ToolCall[]; open: boolean; baseDir: string; children: ReactNode }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
@@ -825,6 +866,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
baseDir={session.cwd}
|
baseDir={session.cwd}
|
||||||
onRewind={onRewind}
|
onRewind={onRewind}
|
||||||
highlight={highlight}
|
highlight={highlight}
|
||||||
|
currency={stats?.currency ?? "USD"}
|
||||||
/>
|
/>
|
||||||
{win?.newer && (
|
{win?.newer && (
|
||||||
<button className="back-to-latest" onClick={() => void showLatest()}>
|
<button className="back-to-latest" onClick={() => void showLatest()}>
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ export const de: Messages = {
|
|||||||
"media.prev": "Vorheriges",
|
"media.prev": "Vorheriges",
|
||||||
"media.next": "Nächstes",
|
"media.next": "Nächstes",
|
||||||
"media.back": "Zurück zur Übersicht",
|
"media.back": "Zurück zur Übersicht",
|
||||||
|
"chat.turnUsageHint": "Alle {n} Anfragen dieser Antwort (Tokens gesendet/empfangen, Kosten)",
|
||||||
"chat.reviewRound": "Review {round}: Änderungen nötig",
|
"chat.reviewRound": "Review {round}: Änderungen nötig",
|
||||||
"agents.refine": "Review-Schleife",
|
"agents.refine": "Review-Schleife",
|
||||||
"agents.refineRounds": "Review-Runden pro Aufgabe",
|
"agents.refineRounds": "Review-Runden pro Aufgabe",
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ export const en = {
|
|||||||
"media.prev": "Previous",
|
"media.prev": "Previous",
|
||||||
"media.next": "Next",
|
"media.next": "Next",
|
||||||
"media.back": "Back to the overview",
|
"media.back": "Back to the overview",
|
||||||
|
"chat.turnUsageHint": "All {n} requests of this answer (tokens sent/received, cost)",
|
||||||
"chat.reviewRound": "Review {round}: changes requested",
|
"chat.reviewRound": "Review {round}: changes requested",
|
||||||
"agents.refine": "Review loop",
|
"agents.refine": "Review loop",
|
||||||
"agents.refineRounds": "Review rounds per task",
|
"agents.refineRounds": "Review rounds per task",
|
||||||
|
|||||||
+13
-1
@@ -456,7 +456,8 @@ button.session-group:hover {
|
|||||||
}
|
}
|
||||||
.topbar .grow {
|
.topbar .grow {
|
||||||
flex: 1;
|
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 {
|
.topbar h1 {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -4398,3 +4399,14 @@ span.chat-tab > button {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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;
|
output_tokens: number;
|
||||||
cache_read_tokens: number;
|
cache_read_tokens: number;
|
||||||
cache_write_tokens: number;
|
cache_write_tokens: number;
|
||||||
|
/** Cost of the request (charged, or valued at list prices for plan requests). */
|
||||||
|
cost?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||||
*/
|
*/
|
||||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||||
|
unreleased: {
|
||||||
|
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": {
|
"202611-00022": {
|
||||||
en: ["New theme “Tintenblau” (Settings → General → Theme)"],
|
en: ["New theme “Tintenblau” (Settings → General → Theme)"],
|
||||||
de: ["Neues Theme „Tintenblau“ (Einstellungen → Allgemein → Theme)"],
|
de: ["Neues Theme „Tintenblau“ (Einstellungen → Allgemein → Theme)"],
|
||||||
|
|||||||
Reference in new issue
Block a user