use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct Usage {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cache_read_tokens: Option<u64>,
pub cache_write_tokens: Option<u64>,
pub cost_usd: Option<f64>,
}
impl Usage {
fn is_empty(&self) -> bool {
self.input_tokens.is_none()
&& self.output_tokens.is_none()
&& self.cache_read_tokens.is_none()
&& self.cache_write_tokens.is_none()
&& self.cost_usd.is_none()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct UsageReading {
pub usage: Usage,
pub source: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FailureReading {
pub kind: String,
pub source: String,
}
pub fn extract_usage(stdout: &str) -> Option<UsageReading> {
let candidates = json_candidates(stdout);
if let Some(reading) = candidates.iter().rev().find_map(single_object_usage) {
return Some(reading);
}
summed_step_usage(&candidates)
}
fn single_object_usage(value: &Value) -> Option<UsageReading> {
let obj = value.as_object()?;
let mut usage = Usage::default();
if let Some(u) = obj.get("usage").and_then(Value::as_object) {
usage.input_tokens = u.get("input_tokens").and_then(Value::as_u64);
usage.output_tokens = u.get("output_tokens").and_then(Value::as_u64);
usage.cache_read_tokens = u.get("cache_read_input_tokens").and_then(Value::as_u64);
usage.cache_write_tokens = u.get("cache_creation_input_tokens").and_then(Value::as_u64);
}
usage.cost_usd = obj
.get("total_cost_usd")
.or_else(|| obj.get("cost_usd"))
.and_then(Value::as_f64);
(!usage.is_empty()).then(|| UsageReading {
usage,
source: "json".to_string(),
})
}
fn summed_step_usage(candidates: &[Value]) -> Option<UsageReading> {
let mut usage = Usage::default();
let mut saw_usage = false;
for value in candidates {
let Some(obj) = value.as_object() else {
continue;
};
if obj.get("type").and_then(Value::as_str) != Some("step_finish") {
continue;
}
let Some(part) = obj.get("part").and_then(Value::as_object) else {
continue;
};
if let Some(tokens) = part.get("tokens").and_then(Value::as_object) {
saw_usage |= add_u64(&mut usage.input_tokens, tokens.get("input"));
saw_usage |= add_u64(&mut usage.output_tokens, tokens.get("output"));
if let Some(cache) = tokens.get("cache").and_then(Value::as_object) {
saw_usage |= add_u64(&mut usage.cache_read_tokens, cache.get("read"));
saw_usage |= add_u64(&mut usage.cache_write_tokens, cache.get("write"));
}
}
saw_usage |= add_f64(&mut usage.cost_usd, part.get("cost"));
}
saw_usage.then(|| UsageReading {
usage,
source: "json:summed-steps".to_string(),
})
}
fn add_u64(acc: &mut Option<u64>, value: Option<&Value>) -> bool {
match value.and_then(Value::as_u64) {
Some(v) => {
*acc = Some(acc.unwrap_or(0) + v);
true
}
None => false,
}
}
fn add_f64(acc: &mut Option<f64>, value: Option<&Value>) -> bool {
match value.and_then(Value::as_f64) {
Some(v) => {
*acc = Some(acc.unwrap_or(0.0) + v);
true
}
None => false,
}
}
pub fn extract_session(stdout: &str) -> Option<String> {
json_candidates(stdout).into_iter().find_map(|value| {
let obj = value.as_object()?;
obj.get("session_id")
.or_else(|| obj.get("sessionID"))
.or_else(|| obj.get("thread_id"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
})
}
pub fn classify_failure(stdout: &str, stderr: &str) -> Option<FailureReading> {
for (source, text) in [("stderr", stderr), ("stdout", stdout)] {
if let Some(kind) = match_failure(text) {
return Some(FailureReading {
kind: kind.to_string(),
source: source.to_string(),
});
}
}
None
}
fn match_failure(text: &str) -> Option<&'static str> {
let h = text.to_lowercase();
const SIGNALS: &[(&str, &[&str])] = &[
(
"model_not_found",
&[
"model not found",
"model_not_found",
"unknown model",
"no such model",
"invalid model",
"is not a valid model",
],
),
(
"rate_limit",
&[
"rate limit",
"rate-limit",
"ratelimit",
"too many requests",
"429",
],
),
(
"quota",
&[
"insufficient_quota",
"quota",
"credit balance",
"out of credits",
"billing",
],
),
(
"auth",
&[
"unauthorized",
"authentication",
"not authenticated",
"invalid api key",
"missing api key",
"no api key",
"credentials",
"forbidden",
"401",
"403",
],
),
];
SIGNALS
.iter()
.find(|(_, needles)| needles.iter().any(|n| h.contains(n)))
.map(|(kind, _)| *kind)
}
fn json_candidates(stdout: &str) -> Vec<Value> {
let trimmed = stdout.trim();
if trimmed.is_empty() {
return Vec::new();
}
if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
return vec![value];
}
stdout
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line.trim()).ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn usage_from_claude_shaped_json() {
let raw = r#"{"type":"result","result":"hi","session_id":"abc","total_cost_usd":0.0095,
"usage":{"input_tokens":1234,"output_tokens":56,"cache_read_input_tokens":7,
"cache_creation_input_tokens":89}}"#;
let got = extract_usage(raw).unwrap();
assert_eq!(got.usage.input_tokens, Some(1234));
assert_eq!(got.usage.output_tokens, Some(56));
assert_eq!(got.usage.cache_read_tokens, Some(7));
assert_eq!(got.usage.cache_write_tokens, Some(89));
assert_eq!(got.usage.cost_usd, Some(0.0095));
assert_eq!(got.source, "json");
}
#[test]
fn usage_cache_only_counts_as_usage() {
let raw = r#"{"type":"result","usage":{"cache_read_input_tokens":42}}"#;
let got = extract_usage(raw).unwrap();
assert_eq!(got.usage.cache_read_tokens, Some(42));
assert_eq!(got.usage.input_tokens, None);
assert_eq!(got.usage.cache_write_tokens, None);
}
#[test]
fn usage_without_cache_fields_yields_none_cache() {
let raw = r#"{"type":"result","usage":{"input_tokens":9,"output_tokens":3}}"#;
let got = extract_usage(raw).unwrap();
assert_eq!(got.usage.input_tokens, Some(9));
assert_eq!(got.usage.cache_read_tokens, None);
assert_eq!(got.usage.cache_write_tokens, None);
}
#[test]
fn usage_cost_only_still_reported() {
let got = extract_usage(r#"{"cost_usd":0.5}"#).unwrap();
assert_eq!(got.usage.cost_usd, Some(0.5));
assert_eq!(got.usage.input_tokens, None);
}
#[test]
fn usage_absent_yields_none() {
assert!(extract_usage(r#"{"result":"hi"}"#).is_none());
assert!(extract_usage("not json").is_none());
assert!(extract_usage("").is_none());
}
#[test]
fn usage_prefers_terminal_stream_event() {
let raw = concat!(
"{\"type\":\"system\",\"session_id\":\"s\"}\n",
"{\"type\":\"result\",\"usage\":{\"input_tokens\":9},\"total_cost_usd\":0.01}\n",
);
let got = extract_usage(raw).unwrap();
assert_eq!(got.usage.input_tokens, Some(9));
assert_eq!(got.usage.cost_usd, Some(0.01));
}
#[test]
fn session_id_extracted_when_present() {
assert_eq!(
extract_session(r#"{"session_id":"sess-123","result":"x"}"#),
Some("sess-123".to_string())
);
assert_eq!(extract_session(r#"{"result":"x"}"#), None);
assert_eq!(extract_session(r#"{"session_id":""}"#), None);
}
#[test]
fn session_id_read_from_camelcase_and_codex_thread_id() {
assert_eq!(
extract_session(r#"{"sessionID":"ses_abc","type":"text"}"#),
Some("ses_abc".to_string())
);
assert_eq!(
extract_session(r#"{"type":"thread.started","thread_id":"0199-xyz"}"#),
Some("0199-xyz".to_string())
);
}
#[test]
fn usage_summed_across_opencode_step_finish_events() {
let raw = concat!(
"{\"type\":\"step_start\",\"sessionID\":\"ses_1\",\"part\":{}}\n",
"{\"type\":\"step_finish\",\"sessionID\":\"ses_1\",\"part\":{\"cost\":0.001,\
\"tokens\":{\"input\":671,\"output\":8,\"reasoning\":0,\
\"cache\":{\"read\":21415,\"write\":100}}}}\n",
"{\"type\":\"step_finish\",\"sessionID\":\"ses_1\",\"part\":{\"cost\":0.002,\
\"tokens\":{\"input\":12,\"output\":34,\"cache\":{\"read\":5,\"write\":3}}}}\n",
);
let got = extract_usage(raw).unwrap();
assert_eq!(got.usage.input_tokens, Some(683));
assert_eq!(got.usage.output_tokens, Some(42));
assert_eq!(got.usage.cache_read_tokens, Some(21420));
assert_eq!(got.usage.cache_write_tokens, Some(103));
assert!((got.usage.cost_usd.unwrap() - 0.003).abs() < 1e-9);
assert_eq!(got.source, "json:summed-steps");
}
#[test]
fn session_from_opencode_camelcase_sessionid() {
let raw = r#"{"type":"step_start","sessionID":"ses_494719016ffe85","part":{}}"#;
assert_eq!(extract_session(raw), Some("ses_494719016ffe85".to_string()));
}
#[test]
fn cursor_session_from_stream_json_lines_without_usage() {
let raw = concat!(
"{\"type\":\"system\",\"subtype\":\"init\",\
\"session_id\":\"11111111-2222-3333-4444-555555555555\",\"model\":\"x\"}\n",
"{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\
\"result\":\"pong\",\"session_id\":\"11111111-2222-3333-4444-555555555555\"}\n",
);
assert_eq!(
extract_session(raw),
Some("11111111-2222-3333-4444-555555555555".to_string())
);
assert!(extract_usage(raw).is_none());
}
#[test]
fn classify_distinguishes_common_failures() {
assert_eq!(
classify_failure("", "Error: 401 Unauthorized")
.unwrap()
.kind,
"auth"
);
assert_eq!(
classify_failure("", "HTTP 429: rate limit exceeded")
.unwrap()
.kind,
"rate_limit"
);
assert_eq!(
classify_failure("", "model not found: gpt-9").unwrap().kind,
"model_not_found"
);
assert_eq!(
classify_failure("", "insufficient_quota; check billing")
.unwrap()
.kind,
"quota"
);
}
#[test]
fn classify_records_source_and_prefers_stderr() {
let got = classify_failure("rate limit in stdout", "unauthorized in stderr").unwrap();
assert_eq!(got.kind, "auth");
assert_eq!(got.source, "stderr");
let got = classify_failure("model not found", "").unwrap();
assert_eq!(got.source, "stdout");
}
#[test]
fn classify_none_when_no_signal() {
assert!(classify_failure("just some output", "a normal error").is_none());
}
#[test]
fn summed_usage_skips_partless_and_usageless_step_events() {
let raw = concat!(
"42\n",
"{\"type\":\"step_finish\",\"sessionID\":\"ses_1\"}\n",
"{\"type\":\"step_finish\",\"sessionID\":\"ses_1\",\"part\":{}}\n",
"{\"type\":\"step_finish\",\"sessionID\":\"ses_1\",\"part\":\
{\"cost\":0.005,\"tokens\":{\"input\":10,\"output\":2}}}\n",
);
let got = extract_usage(raw).unwrap();
assert_eq!(got.usage.input_tokens, Some(10));
assert_eq!(got.usage.output_tokens, Some(2));
assert!((got.usage.cost_usd.unwrap() - 0.005).abs() < 1e-9);
assert_eq!(got.source, "json:summed-steps");
}
#[test]
fn summed_usage_partial_step_reports_only_present_fields() {
let cost_only = "{\"type\":\"step_finish\",\"part\":{\"cost\":0.02}}\n";
let got = extract_usage(cost_only).unwrap();
assert_eq!(got.usage.cost_usd, Some(0.02));
assert_eq!(got.usage.input_tokens, None);
assert_eq!(got.usage.output_tokens, None);
let tokens_only = "{\"type\":\"step_finish\",\"part\":{\"tokens\":{\"input\":5}}}\n";
let got = extract_usage(tokens_only).unwrap();
assert_eq!(got.usage.input_tokens, Some(5));
assert_eq!(got.usage.output_tokens, None);
assert_eq!(got.usage.cost_usd, None);
}
#[test]
fn summed_usage_read_only_cache_hit_leaves_write_null() {
let raw = "{\"type\":\"step_finish\",\"part\":\
{\"tokens\":{\"input\":2,\"output\":1,\"cache\":{\"read\":9000}}}}\n";
let got = extract_usage(raw).unwrap();
assert_eq!(got.usage.cache_read_tokens, Some(9000));
assert_eq!(got.usage.cache_write_tokens, None);
assert_eq!(got.source, "json:summed-steps");
}
}