use serde::{Deserialize, Serialize};
use crate::pricing::PricingCatalog;
pub const DEFAULT_BATCH_ELIGIBLE_TAGS: &[&str] =
&["background", "offline", "nightly", "batch", "bulk", "async"];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RequestAggregate {
pub provider: String,
pub model: String,
pub tag: Option<String>,
pub input_tokens: u64,
pub output_tokens: u64,
pub cost_usd: f64,
pub request_count: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BatchFinding {
pub tag: String,
pub eligible_spend_usd: f64,
pub projected_batch_cost_usd: f64,
pub projected_savings_usd: f64,
pub share_of_spend_pct: f64,
pub request_count: u64,
}
impl BatchFinding {
#[must_use]
pub fn discount_pct(&self) -> f64 {
if self.eligible_spend_usd <= 0.0 {
0.0
} else {
self.projected_savings_usd / self.eligible_spend_usd * 100.0
}
}
#[must_use]
pub fn summary(&self) -> String {
format!(
"tag={} is {:.1}% of spend and batch-eligible → ~${:.2} saved at the Batch API rate (−{:.0}%) on {} request(s)",
self.tag,
self.share_of_spend_pct,
self.projected_savings_usd,
self.discount_pct(),
self.request_count,
)
}
}
#[must_use]
pub fn project_batch_savings(
aggregates: &[RequestAggregate],
catalog: &PricingCatalog,
) -> Vec<BatchFinding> {
project_batch_savings_with_tags(aggregates, catalog, DEFAULT_BATCH_ELIGIBLE_TAGS)
}
#[must_use]
pub fn project_batch_savings_with_tags(
aggregates: &[RequestAggregate],
catalog: &PricingCatalog,
eligible_tags: &[&str],
) -> Vec<BatchFinding> {
let total_spend: f64 = aggregates.iter().map(|a| a.cost_usd).sum();
let mut by_tag: std::collections::BTreeMap<String, TagAccumulator> =
std::collections::BTreeMap::new();
for agg in aggregates {
let Some(tag) = agg.tag.as_deref() else {
continue; };
if !eligible_tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) {
continue; }
let Some(pricing) = catalog.latest(&agg.provider, &agg.model) else {
continue;
};
let (Some(batch_in), Some(batch_out)) = (
pricing.batch_input_per_million,
pricing.batch_output_per_million,
) else {
continue; };
let projected = (agg.input_tokens as f64) * batch_in / 1_000_000.0
+ (agg.output_tokens as f64) * batch_out / 1_000_000.0;
let entry = by_tag.entry(tag.to_string()).or_default();
entry.eligible_spend += agg.cost_usd;
entry.projected_batch += projected;
entry.request_count += agg.request_count;
}
let mut findings: Vec<BatchFinding> = by_tag
.into_iter()
.filter(|(_, acc)| acc.eligible_spend > 0.0)
.map(|(tag, acc)| {
let savings = (acc.eligible_spend - acc.projected_batch).max(0.0);
let share = if total_spend > 0.0 {
acc.eligible_spend / total_spend * 100.0
} else {
0.0
};
BatchFinding {
tag,
eligible_spend_usd: acc.eligible_spend,
projected_batch_cost_usd: acc.projected_batch,
projected_savings_usd: savings,
share_of_spend_pct: share,
request_count: acc.request_count,
}
})
.collect();
findings.sort_by(|a, b| {
b.projected_savings_usd
.partial_cmp(&a.projected_savings_usd)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.tag.cmp(&b.tag))
});
findings
}
#[derive(Default)]
struct TagAccumulator {
eligible_spend: f64,
projected_batch: f64,
request_count: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pricing::catalog;
fn agg(
provider: &str,
model: &str,
tag: Option<&str>,
input: u64,
output: u64,
cost: f64,
count: u64,
) -> RequestAggregate {
RequestAggregate {
provider: provider.into(),
model: model.into(),
tag: tag.map(str::to_string),
input_tokens: input,
output_tokens: output,
cost_usd: cost,
request_count: count,
}
}
#[test]
fn flags_eligible_segment_with_catalog_rate_savings() {
let c = catalog();
let aggs = vec![
agg(
"openai",
"gpt-5.5",
Some("nightly"),
1_000_000,
1_000_000,
35.0,
10,
),
agg("openai", "gpt-5.5", None, 1_000_000, 1_000_000, 35.0, 10),
];
let findings = project_batch_savings(&aggs, c);
assert_eq!(findings.len(), 1, "only the tagged segment is flagged");
let f = &findings[0];
assert_eq!(f.tag, "nightly");
assert!(
(f.eligible_spend_usd - 35.0).abs() < 1e-9,
"eligible spend = actual spend of the tagged segment"
);
assert!(
(f.projected_batch_cost_usd - 17.50).abs() < 1e-9,
"batch cost from catalog rates, got {}",
f.projected_batch_cost_usd
);
assert!(
(f.projected_savings_usd - 17.50).abs() < 1e-9,
"−50% of $35 = $17.50, got {}",
f.projected_savings_usd
);
assert!((f.share_of_spend_pct - 50.0).abs() < 1e-9);
assert!((f.discount_pct() - 50.0).abs() < 1e-9);
assert_eq!(f.request_count, 10);
}
#[test]
fn ignores_non_eligible_tags() {
let c = catalog();
let aggs = vec![
agg(
"openai",
"gpt-5.5",
Some("chat"),
1_000_000,
1_000_000,
35.0,
5,
),
agg("openai", "gpt-5.5", Some("interactive"), 500_000, 0, 2.5, 3),
];
let findings = project_batch_savings(&aggs, c);
assert!(
findings.is_empty(),
"no eligible tags present → no findings: {findings:?}"
);
}
#[test]
fn model_without_batch_tier_is_not_projected() {
let c = catalog();
let aggs = vec![agg(
"groq",
"llama-3.1-8b-instant",
Some("background"),
1_000_000,
1_000_000,
1.0,
4,
)];
let findings = project_batch_savings(&aggs, c);
assert!(
findings.is_empty(),
"no batch tier → nothing to project, got {findings:?}"
);
}
#[test]
fn folds_multiple_models_under_one_tag() {
let c = catalog();
let aggs = vec![
agg("openai", "gpt-5.5", Some("bulk"), 1_000_000, 0, 5.0, 2),
agg("openai", "gpt-5.4", Some("bulk"), 1_000_000, 0, 2.5, 3),
];
let findings = project_batch_savings(&aggs, c);
assert_eq!(findings.len(), 1);
let f = &findings[0];
assert_eq!(f.tag, "bulk");
assert!((f.eligible_spend_usd - 7.5).abs() < 1e-9, "5.0 + 2.5");
assert!((f.projected_batch_cost_usd - 3.75).abs() < 1e-9);
assert!((f.projected_savings_usd - 3.75).abs() < 1e-9);
assert_eq!(f.request_count, 5);
}
#[test]
fn findings_sorted_by_savings_desc() {
let c = catalog();
let aggs = vec![
agg("openai", "gpt-5.4", Some("offline"), 1_000_000, 0, 2.5, 1),
agg("openai", "gpt-5.5", Some("nightly"), 10_000_000, 0, 50.0, 1),
];
let findings = project_batch_savings(&aggs, c);
assert_eq!(findings.len(), 2);
assert_eq!(findings[0].tag, "nightly", "bigger savings first");
assert!(findings[0].projected_savings_usd > findings[1].projected_savings_usd);
}
#[test]
fn tag_match_is_case_insensitive() {
let c = catalog();
let aggs = vec![agg(
"openai",
"gpt-5.5",
Some("Background"),
1_000_000,
0,
5.0,
1,
)];
let findings = project_batch_savings(&aggs, c);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].tag, "Background", "original case preserved");
}
#[test]
fn honors_configurable_tag_set() {
let c = catalog();
let aggs = vec![
agg(
"openai",
"gpt-5.5",
Some("nightly-evals"),
1_000_000,
0,
5.0,
1,
),
agg(
"openai",
"gpt-5.5",
Some("background"),
1_000_000,
0,
5.0,
1,
),
];
let findings = project_batch_savings_with_tags(&aggs, c, &["nightly-evals"]);
assert_eq!(findings.len(), 1, "only the custom tag matches");
assert_eq!(findings[0].tag, "nightly-evals");
}
#[test]
fn summary_is_grounded_and_human_readable() {
let f = BatchFinding {
tag: "nightly-evals".into(),
eligible_spend_usd: 40.0,
projected_batch_cost_usd: 20.0,
projected_savings_usd: 20.0,
share_of_spend_pct: 31.0,
request_count: 128,
};
let s = f.summary();
assert!(s.contains("tag=nightly-evals"), "{s}");
assert!(s.contains("31.0% of spend"), "{s}");
assert!(s.contains("$20.00"), "{s}");
assert!(s.contains("−50%"), "{s}");
assert!(s.contains("128 request"), "{s}");
}
#[test]
fn empty_aggregates_produce_no_findings() {
let c = catalog();
assert!(project_batch_savings(&[], c).is_empty());
}
#[test]
fn anthropic_eligible_segment_uses_catalog_batch_rate() {
let c = catalog();
let aggs = vec![agg(
"anthropic",
"claude-opus-4-8",
Some("offline"),
1_000_000,
1_000_000,
30.0,
7,
)];
let findings = project_batch_savings(&aggs, c);
assert_eq!(findings.len(), 1);
let f = &findings[0];
assert!((f.projected_batch_cost_usd - 15.0).abs() < 1e-9);
assert!((f.projected_savings_usd - 15.0).abs() < 1e-9);
}
}