Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1708,17 +1708,18 @@ impl App {
msg.role == crate::session::types::MessageRole::Assistant && msg.is_complete
})?;

// Upstream formula (opencode #46108): billed output / decode time,
// 250ms floor, no inter-token adjustment.
let format_tps = |precomputed: Option<f64>, tokens: usize, decode_ms: u64| -> Option<f64> {
if let Some(tps) = precomputed {
if tps.is_finite() && tps > 0.0 {
return Some(tps);
}
}
// OpenCode inter-token: (n - 1) / duration; need >1 token.
if decode_ms == 0 || tokens < 2 {
if tokens == 0 || decode_ms < 250 {
return None;
}
let tps = ((tokens - 1) as f64) / (decode_ms as f64 / 1000.0);
let tps = tokens as f64 / (decode_ms as f64 / 1000.0);
if tps.is_finite() && tps > 0.0 {
Some(tps)
} else {
Expand All @@ -1727,7 +1728,9 @@ impl App {
};

if let (Some(t0), Some(t1), Some(tn)) = (message.t0_ms, message.t1_ms, message.tn_ms) {
let output_tokens = message.output_tokens.or(message.token_count).unwrap_or(0);
// t/s inputs are output tokens only — `token_count` is the billed
// total and would inflate the rate on reloaded sessions.
let output_tokens = message.output_tokens.unwrap_or(0);
let ttft_ms = t1.saturating_sub(t0);
let decode_ms = message.duration_ms.unwrap_or_else(|| tn.saturating_sub(t1));
let total_ms = ttft_ms.saturating_add(decode_ms);
Expand All @@ -1741,16 +1744,24 @@ impl App {
return Some(format!("{:.1}s", total_sec));
}

if let (Some(token_count), Some(duration_ms)) = (message.token_count, message.duration_ms) {
if let (Some(output_tokens), Some(duration_ms)) =
(message.output_tokens, message.duration_ms)
{
let duration_sec = duration_ms as f64 / 1000.0;
if let Some(tokens_per_sec) =
format_tps(message.tokens_per_sec, token_count, duration_ms)
format_tps(message.tokens_per_sec, output_tokens, duration_ms)
{
return Some(format!("{:.1}s | {:.0}t/s", duration_sec, tokens_per_sec));
}
return Some(format!("{:.1}s", duration_sec));
}

if message.duration_ms.is_some() {
// Total-only legacy row: duration without t/s.
let duration_sec = message.duration_ms.unwrap_or(0) as f64 / 1000.0;
return Some(format!("{:.1}s", duration_sec));
}

None
}

Expand Down
83 changes: 79 additions & 4 deletions src/persistence/conversions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ impl From<SessionMessage> for Message {
t1_ms: msg.t1_ms.map(|v| v as i64),
tn_ms: msg.tn_ms.map(|v| v as i64),
output_tokens: msg.output_tokens.map(|v| v as i64),
tokens_per_sec: msg.tokens_per_sec,
}
}
}
Expand Down Expand Up @@ -185,6 +186,31 @@ impl TryFrom<Message> for SessionMessage {
_ => return Err(anyhow::anyhow!("Unknown role: {}", msg.role)),
};

// Billed output buckets are exact; prefer them over the persisted
// text estimate when backfilling rows stored before output_tokens
// existed. Never derive output tokens from `tokens_used` (total).
let billed_output: Option<usize> = {
let mut total = 0u64;
let mut found = false;
for part in &session_parts {
if part.part_type == "usage" {
if let Some(output) = part.data.get("output").and_then(|v| v.as_u64()) {
total = total.saturating_add(output);
found = true;
}
}
}
if found && total > 0 && total <= usize::MAX as u64 {
Some(total as usize)
} else {
None
}
};
let persisted_output_tokens: Option<usize> =
msg.output_tokens
.and_then(|v| if v > 0 { Some(v as usize) } else { None });
let output_tokens = persisted_output_tokens.or(billed_output);

Ok(SessionMessage {
role,
content,
Expand Down Expand Up @@ -213,10 +239,8 @@ impl TryFrom<Message> for SessionMessage {
tn_ms: msg
.tn_ms
.and_then(|v| if v > 0 { Some(v as u64) } else { None }),
output_tokens: msg
.output_tokens
.and_then(|v| if v > 0 { Some(v as usize) } else { None }),
tokens_per_sec: None,
output_tokens,
tokens_per_sec: msg.tokens_per_sec.filter(|v| v.is_finite() && *v > 0.0),
model: msg.model.clone(),
provider: msg.provider.clone(),
local_image_paths,
Expand Down Expand Up @@ -358,4 +382,55 @@ mod tests {
assert_eq!(usage.input, 80_000);
assert_eq!(usage.output, 400);
}

#[test]
fn precomputed_tps_round_trips_through_persistence() {
let mut session_message = SessionMessage::assistant("done");
session_message.output_tokens = Some(390);
session_message.tokens_per_sec = Some(145.0);

let persistence_message: Message = session_message.into();
assert_eq!(persistence_message.tokens_per_sec, Some(145.0));

let restored = SessionMessage::try_from(persistence_message).unwrap();
assert_eq!(restored.tokens_per_sec, Some(145.0));
assert_eq!(restored.output_tokens, Some(390));
}

#[test]
fn billed_output_backfills_output_tokens_not_total() {
// Legacy row stored before output_tokens existed: tokens_used is the
// billed total (in+out+cache), usage part carries exact buckets.
let mut legacy = Message {
id: "legacy".to_string(),
session_id: 1,
role: "assistant".to_string(),
parts: vec![PersistenceMessagePart {
part_type: "text".to_string(),
data: serde_json::json!({ "text": "done" }),
}],
timestamp: 0,
tokens_used: 8000,
model: None,
provider: None,
agent_mode: None,
duration_ms: 2600,
t0_ms: Some(1000),
t1_ms: Some(10_000),
tn_ms: Some(12_600),
output_tokens: None,
tokens_per_sec: None,
};
legacy.parts.push(PersistenceMessagePart {
part_type: "usage".to_string(),
data: serde_json::json!({
"input": 7000, "output": 390,
"cache_read": 500, "cache_write": 110, "cost": 0.01,
}),
});

let restored = SessionMessage::try_from(legacy).unwrap();
// Output bucket (390), never the billed total (8000).
assert_eq!(restored.output_tokens, Some(390));
}
}
14 changes: 9 additions & 5 deletions src/persistence/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ pub struct Message {
pub t1_ms: Option<i64>,
pub tn_ms: Option<i64>,
pub output_tokens: Option<i64>,
pub tokens_per_sec: Option<f64>,
}

pub struct HistoryDAO {
Expand Down Expand Up @@ -438,9 +439,9 @@ impl HistoryDAO {
self.conn.execute(
"INSERT INTO messages (
id, session_id, role, parts, timestamp, tokens_used, model, provider, agent_mode, duration_ms,
t0_ms, t1_ms, tn_ms, output_tokens
t0_ms, t1_ms, tn_ms, output_tokens, tokens_per_sec
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
params![
&msg.id,
msg.session_id,
Expand All @@ -456,6 +457,7 @@ impl HistoryDAO {
msg.t1_ms,
msg.tn_ms,
msg.output_tokens,
msg.tokens_per_sec,
],
)?;

Expand Down Expand Up @@ -488,9 +490,9 @@ impl HistoryDAO {
let mut insert = tx.prepare_cached(
"INSERT INTO messages (
id, session_id, role, parts, timestamp, tokens_used, model, provider, agent_mode, duration_ms,
t0_ms, t1_ms, tn_ms, output_tokens
t0_ms, t1_ms, tn_ms, output_tokens, tokens_per_sec
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
)?;

for msg in messages {
Expand All @@ -514,6 +516,7 @@ impl HistoryDAO {
msg.t1_ms,
msg.tn_ms,
msg.output_tokens,
msg.tokens_per_sec,
])?;
}
}
Expand Down Expand Up @@ -555,7 +558,7 @@ impl HistoryDAO {
pub fn get_messages(&self, session_id: i64) -> Result<Vec<Message>> {
let mut stmt = self.conn.prepare(
"SELECT id, session_id, role, parts, timestamp, tokens_used, model, provider, agent_mode, duration_ms,
t0_ms, t1_ms, tn_ms, output_tokens
t0_ms, t1_ms, tn_ms, output_tokens, tokens_per_sec
FROM messages WHERE session_id = ?1 ORDER BY timestamp ASC, rowid ASC",
)?;

Expand All @@ -578,6 +581,7 @@ impl HistoryDAO {
t1_ms: row.get(11)?,
tn_ms: row.get(12)?,
output_tokens: row.get(13)?,
tokens_per_sec: row.get(14).unwrap_or(None),
})
})?;

Expand Down
21 changes: 21 additions & 0 deletions src/persistence/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ pub fn run_migrations(db: &mut Connection) -> Result<()> {
migrate_to_v3(db)?;
}

if current_version < 4 {
migrate_to_v4(db)?;
}

Ok(())
}

Expand Down Expand Up @@ -63,6 +67,7 @@ fn migrate_to_v1(db: &mut Connection) -> Result<()> {
t1_ms INTEGER,
tn_ms INTEGER,
output_tokens INTEGER,
tokens_per_sec REAL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);

Expand Down Expand Up @@ -181,3 +186,19 @@ fn migrate_to_v3(db: &mut Connection) -> Result<()> {
tx.commit()?;
Ok(())
}

fn migrate_to_v4(db: &mut Connection) -> Result<()> {
let tx = db.transaction()?;

// Precomputed inter-token TPS so a reloaded session shows the same t/s
// as the live stream instead of recomputing from token estimates.
let _ = tx.execute("ALTER TABLE messages ADD COLUMN tokens_per_sec REAL", []);

tx.execute(
"INSERT OR IGNORE INTO migrations (version, applied_at) VALUES (4, strftime('%s', 'now'))",
params![],
)?;

tx.commit()?;
Ok(())
}
Loading
Loading