diff options
| author | Igor Soarez <igor@soarez.org> | 2026-11-03 13:12:21 +0200 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-11-03 13:12:21 +0200 |
| commit | 9518a5442576268a04e7dbfcddeb2457add2f189 (patch) | |
| tree | 573e474d8ce9b742f9a905deefebeb9309024391 /crates | |
| parent | bd1eb9ef9b3b89ff5825eb8bf9b2e978167c9ad0 (diff) | |
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/dinggerd/src/cost.rs | 72 |
1 files changed, 64 insertions, 8 deletions
diff --git a/crates/dinggerd/src/cost.rs b/crates/dinggerd/src/cost.rs index 45d8bbe..ad65f32 100644 --- a/crates/dinggerd/src/cost.rs +++ b/crates/dinggerd/src/cost.rs @@ -2,7 +2,9 @@ //! starts at 00:00 UTC, a week on Monday, a month on its 1st). //! //! pi records each reply's cost in its session file (`message.usage.cost. -//! total`, at `message.timestamp`). A thread of its own reads every session +//! total`, at `message.timestamp`), and that of other calls (compaction and +//! branch summaries, `usage` entries) in the entry's own `usage`: dingger +//! counts what pi's footer counts. A thread of its own reads every session //! file changed this period, then only what was appended, and counts each //! reply once: a fork (`/fork`, `--fork`) copies its parent's entries into a //! new file unchanged, so a reply is known by its entry id and time, and @@ -116,10 +118,17 @@ pub struct Ledger { #[derive(Deserialize)] struct Entry { + #[serde(default, rename = "type")] + kind: Option<String>, #[serde(default)] id: Option<String>, + /// ISO 8601, in UTC ("2026-11-01T08:30:00.123Z"). + #[serde(default)] + timestamp: Option<String>, #[serde(default)] message: Option<Message>, + #[serde(default)] + usage: Option<Usage>, } #[derive(Deserialize)] @@ -150,20 +159,44 @@ struct Header { timestamp: Option<String>, } -/// A reply's cost, its time, and its id ("" for old entries without one). +/// A priced call's cost, its time, and its entry id ("" for old entries +/// without one): what pi's footer counts in a session's cost. fn reply(line: &[u8]) -> Option<(String, i64, f64)> { - // Most lines are not replies; a cost is rare enough to look for first. + // Most lines are not priced; a cost is rare enough to look for first. if !line.windows(6).any(|w| w == b"\"cost\"") { return None; } let entry: Entry = serde_json::from_slice(line).ok()?; - let message = entry.message?; - if message.role.as_deref() != Some("assistant") { + let at_entry = || entry.timestamp.as_deref().and_then(iso_millis); + let (usage, at) = match entry.kind.as_deref()? { + "message" => { + let message = entry.message?; + if !matches!(message.role.as_deref(), Some("assistant" | "toolResult")) { + return None; + } + let at = message.timestamp.or_else(at_entry)?; + (message.usage?, at) + } + "usage" | "compaction" | "branch_summary" => (entry.usage?, at_entry()?), + _ => return None, + }; + let cost = usage.cost?.total?; + (cost > 0.0 && cost.is_finite()).then(|| (entry.id.unwrap_or_default(), at, cost)) +} + +/// "2026-11-01T08:30:00.123Z" (UTC, as pi writes it) in milliseconds. +fn iso_millis(text: &str) -> Option<i64> { + let (date, time) = text.strip_suffix('Z')?.split_once('T')?; + let mut date = date.splitn(3, '-').map(|part| part.parse::<i64>().ok()); + let (year, month, day) = (date.next()??, date.next()??, date.next()??); + let (hms, fraction) = time.split_once('.').unwrap_or((time, "0")); + let mut hms = hms.splitn(3, ':').map(|part| part.parse::<i64>().ok()); + let (hour, minute, second) = (hms.next()??, hms.next()??, hms.next()??); + let millis: i64 = format!("{fraction:0<3}")[..3].parse().ok()?; + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { return None; } - let cost = message.usage?.cost?.total?; - let at = message.timestamp?; - (cost > 0.0 && cost.is_finite()).then(|| (entry.id.unwrap_or_default(), at, cost)) + Some(days_from_civil(year, month as u32, day as u32) * DAY_MS + ((hour * 60 + minute) * 60 + second) * 1000 + millis) } fn header_time(path: &Path) -> String { @@ -484,6 +517,29 @@ mod tests { } #[test] + fn counts_what_pis_footer_counts() { + let at = ms("2026-11-01T08:30"); + let iso = "2026-11-01T08:30:00.000Z"; + let usage = |kind: &str| format!("{{\"type\":\"{kind}\",\"id\":\"{kind}1\",\"timestamp\":\"{iso}\",\"usage\":{{\"cost\":{{\"total\":0.5}}}}}}"); + for kind in ["usage", "compaction", "branch_summary"] { + assert_eq!(reply(usage(kind).as_bytes()), Some((format!("{kind}1"), at, 0.5)), "{kind}"); + } + let tool = format!("{{\"type\":\"message\",\"id\":\"t1\",\"message\":{{\"role\":\"toolResult\",\"timestamp\":{at},\"usage\":{{\"cost\":{{\"total\":0.25}}}}}}}}"); + assert_eq!(reply(tool.as_bytes()), Some(("t1".into(), at, 0.25))); + assert_eq!(reply(line("a1", at, 2.0).as_bytes()), Some(("a1".into(), at, 2.0))); + // Not session entries (an event log saved alongside): pi's footer + // does not count them. + let event = format!("{{\"type\":\"message_end\",\"message\":{{\"role\":\"assistant\",\"timestamp\":{at},\"usage\":{{\"cost\":{{\"total\":2}}}}}}}}"); + assert_eq!(reply(event.as_bytes()), None); + assert_eq!(reply(usage("custom").as_bytes()), None); + assert_eq!(iso_millis("2026-11-01T08:30:00.5Z"), Some(at + 500)); + assert_eq!(iso_millis("2026-11-01T08:30:00Z"), Some(at)); + for bad in ["2026-11-01T08:30:00", "2026-13-01T08:30:00Z", "x"] { + assert_eq!(iso_millis(bad), None, "{bad}"); + } + } + + #[test] fn dollars_as_an_option() { assert_eq!(option_value(0.0), ""); assert_eq!(option_value(0.004), ""); |
