use crate::render_run::count_text;
use saya_types::RunEvent;
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct EndpointUsage {
pub endpoint: String,
pub tokens: Option<u64>,
pub cached_input_tokens: Option<u64>,
pub cache_creation_input_tokens: Option<u64>,
pub turns: Option<u64>,
pub tool_calls: Option<u64>,
}
pub(crate) fn usage_by_endpoint(events: &[RunEvent]) -> Vec<EndpointUsage> {
let reported = |total: Option<u64>, seen: Option<u64>| match (total, seen) {
(Some(total), Some(seen)) => Some(total.saturating_add(seen)),
(None, seen) => seen,
(total, None) => total,
};
let mut by_endpoint: BTreeMap<&str, EndpointUsage> = BTreeMap::new();
for event in events {
if let RunEvent::Usage {
endpoint,
tokens,
turns,
tool_calls,
cached_input_tokens,
cache_creation_input_tokens,
} = event
{
let entry = by_endpoint
.entry(endpoint.as_str())
.or_insert_with(|| EndpointUsage {
endpoint: endpoint.clone(),
tokens: None,
cached_input_tokens: None,
cache_creation_input_tokens: None,
turns: None,
tool_calls: None,
});
entry.tokens = reported(entry.tokens, *tokens);
entry.cached_input_tokens = reported(entry.cached_input_tokens, *cached_input_tokens);
entry.cache_creation_input_tokens = reported(
entry.cache_creation_input_tokens,
*cache_creation_input_tokens,
);
entry.turns = reported(entry.turns, *turns);
entry.tool_calls = reported(entry.tool_calls, *tool_calls);
}
}
by_endpoint.into_values().collect()
}
pub(crate) fn usage_line(entry: &EndpointUsage) -> String {
format!(
"{endpoint} · tokens {tokens} · cache reads {reads} · cache writes {writes} · \
turns {turns} · tool calls {calls}",
endpoint = entry.endpoint,
tokens = count_text(entry.tokens),
reads = count_text(entry.cached_input_tokens),
writes = count_text(entry.cache_creation_input_tokens),
turns = count_text(entry.turns),
calls = count_text(entry.tool_calls),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render_run::{RunShowStanza, run_show_text};
fn usage_of(events: &[RunEvent]) -> Vec<EndpointUsage> {
usage_by_endpoint(events)
}
#[test]
fn an_unreported_cache_figure_renders_unknown_and_a_reported_zero_renders_zero() {
let never_reported = usage_of(&[
RunEvent::Usage {
endpoint: "primary".into(),
tokens: Some(100),
turns: None,
tool_calls: None,
cached_input_tokens: None,
cache_creation_input_tokens: None,
},
RunEvent::Usage {
endpoint: "primary".into(),
tokens: Some(60),
turns: None,
tool_calls: None,
cached_input_tokens: None,
cache_creation_input_tokens: None,
},
]);
let reported_zero = usage_of(&[RunEvent::Usage {
endpoint: "primary".into(),
tokens: Some(160),
turns: None,
tool_calls: None,
cached_input_tokens: Some(0),
cache_creation_input_tokens: None,
}]);
let unknown_text = run_show_text(stanza_with_usage(&never_reported));
let zero_text = run_show_text(stanza_with_usage(&reported_zero));
assert!(
unknown_text.contains("cache reads unknown"),
"a provider that never reported cache renders unknown: {unknown_text}"
);
assert!(
zero_text.contains("cache reads 0"),
"a provider that reported zero renders 0: {zero_text}"
);
assert_ne!(
unknown_text, zero_text,
"unknown and a reported zero are different answers"
);
assert!(
!unknown_text.contains("cache reads 0") && !zero_text.contains("cache reads unknown"),
"neither answer may stand in for the other: {unknown_text} | {zero_text}"
);
}
#[test]
fn usage_folds_per_endpoint_summing_only_the_figures_calls_reported() {
let usage = usage_of(&[
RunEvent::Usage {
endpoint: "primary".into(),
tokens: Some(100),
turns: Some(2),
tool_calls: None,
cached_input_tokens: None,
cache_creation_input_tokens: None,
},
RunEvent::Usage {
endpoint: "primary".into(),
tokens: Some(60),
turns: None,
tool_calls: Some(3),
cached_input_tokens: Some(90),
cache_creation_input_tokens: None,
},
RunEvent::Usage {
endpoint: "other".into(),
tokens: None,
turns: None,
tool_calls: None,
cached_input_tokens: None,
cache_creation_input_tokens: None,
},
]);
assert_eq!(usage.len(), 2, "one entry per endpoint, never merged");
let primary = &usage[usage
.iter()
.position(|entry| entry.endpoint == "primary")
.unwrap()];
assert_eq!(primary.endpoint, "primary");
assert_eq!(primary.tokens, Some(160), "reported figures sum");
assert_eq!(primary.cached_input_tokens, Some(90));
assert_eq!(
primary.cache_creation_input_tokens, None,
"a figure no call reported stays unknown"
);
assert_eq!(primary.turns, Some(2));
assert_eq!(primary.tool_calls, Some(3));
let other = &usage[usage
.iter()
.position(|entry| entry.endpoint == "other")
.unwrap()];
assert_eq!(other.endpoint, "other");
assert_eq!(
other.tokens, None,
"a call that reported nothing is unknown, not zero"
);
}
fn stanza_with_usage(usage: &[EndpointUsage]) -> RunShowStanza<'_> {
RunShowStanza {
id: "r-u",
status: "completed",
failure_cause: None,
created_unix_ms: 1,
updated_unix_ms: 2,
spec: None,
paused: None,
deliverables: &[],
usage,
}
}
}