aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/cost.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/src/cost.rs')
-rw-r--r--crates/dinggerd/src/cost.rs72
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), "");