use std::collections::BTreeMap;
use serde::Deserialize;
use crate::error::Result;
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct Usage {
#[serde(default)]
pub input_tokens: u64,
#[serde(default)]
pub output_tokens: u64,
#[serde(default)]
pub cache_creation_input_tokens: u64,
#[serde(default)]
pub cache_read_input_tokens: u64,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelUsage {
#[serde(default)]
pub input_tokens: u64,
#[serde(default)]
pub output_tokens: u64,
#[serde(default)]
pub cache_read_input_tokens: u64,
#[serde(default)]
pub cache_creation_input_tokens: u64,
#[serde(rename = "costUSD", default)]
pub cost_usd: f64,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct Outcome {
#[serde(rename = "total_cost_usd")]
pub cost_usd: f64,
pub usage: Usage,
#[serde(rename = "modelUsage", default)]
pub model_usage: BTreeMap<String, ModelUsage>,
#[serde(rename = "result")]
pub text: String,
pub is_error: bool,
#[serde(default)]
pub api_error_status: Option<u16>,
#[serde(default)]
pub structured_output: Option<serde_json::Value>,
}
impl Outcome {
#[must_use]
pub fn primary_model(&self) -> Option<&str> {
self.model_usage
.iter()
.max_by(|(key_a, a), (key_b, b)| {
a.cost_usd
.partial_cmp(&b.cost_usd)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.output_tokens.cmp(&b.output_tokens))
.then(key_b.cmp(key_a))
})
.map(|(key, _)| key.as_str())
}
}
pub fn parse_result(json: &str) -> Result<Outcome> {
Ok(serde_json::from_str(json)?)
}
#[cfg(test)]
mod tests {
use super::*;
fn model_usage(cost_usd: f64, output_tokens: u64) -> ModelUsage {
ModelUsage {
input_tokens: 0,
output_tokens,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
cost_usd,
}
}
fn outcome_with(models: &[(&str, f64, u64)]) -> Outcome {
Outcome {
cost_usd: 0.0,
usage: Usage {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
model_usage: models
.iter()
.map(|(name, cost, out)| ((*name).to_string(), model_usage(*cost, *out)))
.collect(),
text: "hi".to_string(),
is_error: false,
api_error_status: None,
structured_output: None,
}
}
#[test]
fn missing_result_field_fails_parse() {
let json = r#"{
"total_cost_usd": 0.01,
"usage": {"input_tokens": 1, "output_tokens": 1},
"is_error": false,
"modelUsage": {"claude-opus-4-8": {"outputTokens": 1, "costUSD": 0.01}}
}"#;
assert!(
parse_result(json).is_err(),
"absent `result` must fail loudly, never default to an empty reply"
);
}
#[test]
fn missing_cost_field_fails_parse() {
let json = r#"{"usage": {"input_tokens": 1}, "result": "hi", "is_error": false}"#;
assert!(parse_result(json).is_err());
}
#[test]
fn unknown_fields_do_not_fail_parse() {
let json = r#"{
"total_cost_usd": 0.01,
"usage": {"input_tokens": 1, "output_tokens": 1},
"result": "hi",
"is_error": false,
"some_future_field": {"nested": true}
}"#;
let outcome = parse_result(json).expect("an added vendor field must not break the parse");
assert_eq!(outcome.text, "hi");
}
#[test]
fn primary_model_picks_sole_entry() {
let outcome = outcome_with(&[("claude-opus-4-8", 0.23, 4)]);
assert_eq!(outcome.primary_model(), Some("claude-opus-4-8"));
}
#[test]
fn primary_model_is_none_when_model_usage_is_empty() {
let outcome = outcome_with(&[]);
assert_eq!(
outcome.primary_model(),
None,
"the error envelope reports `modelUsage: {{}}`"
);
}
#[test]
fn primary_model_ranks_by_cost_over_output_tokens() {
let outcome = outcome_with(&[
("claude-haiku-4-5", 0.001, 900),
("claude-opus-4-8", 0.42, 12),
]);
assert_eq!(outcome.primary_model(), Some("claude-opus-4-8"));
}
#[test]
fn primary_model_falls_back_to_output_tokens_when_cost_ties() {
let outcome = outcome_with(&[("model-a", 0.10, 5), ("model-b", 0.10, 50)]);
assert_eq!(outcome.primary_model(), Some("model-b"));
}
#[test]
fn primary_model_breaks_total_ties_lexicographically() {
let outcome = outcome_with(&[("model-z", 0.10, 5), ("model-a", 0.10, 5)]);
assert_eq!(outcome.primary_model(), Some("model-a"));
}
}