use chio_core::capability::MonetaryAmount;
use serde::{Deserialize, Serialize};
pub const COST_METADATA_SCHEMA: &str = "chio.cost-metadata.v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "dimension", rename_all = "snake_case")]
pub enum CostDimension {
ComputeTime {
duration_ms: u64,
},
DataVolume {
bytes_read: u64,
bytes_written: u64,
},
ApiCost {
amount: MonetaryAmount,
provider: String,
},
WarehouseQuery {
bytes_scanned: u64,
estimated_cost_usd: String,
},
Custom {
name: String,
value: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
unit: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostMetadata {
pub schema: String,
pub receipt_id: String,
pub timestamp: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
pub agent_id: String,
pub tool_server: String,
pub tool_name: String,
pub dimensions: Vec<CostDimension>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_monetary_cost: Option<MonetaryAmount>,
}
impl CostMetadata {
pub fn new(
receipt_id: String,
timestamp: u64,
agent_id: String,
tool_server: String,
tool_name: String,
) -> Self {
Self {
schema: COST_METADATA_SCHEMA.to_string(),
receipt_id,
timestamp,
session_id: None,
agent_id,
tool_server,
tool_name,
dimensions: Vec::new(),
total_monetary_cost: None,
}
}
pub fn add_dimension(&mut self, dim: CostDimension) {
self.dimensions.push(dim);
}
pub fn compute_total_monetary_cost(&mut self) {
let mut total_units: u64 = 0;
let mut currency: Option<String> = None;
for dim in &self.dimensions {
if let CostDimension::ApiCost { amount, .. } = dim {
match ¤cy {
None => {
currency = Some(amount.currency.clone());
total_units = amount.units;
}
Some(c) if c == &amount.currency => {
total_units = total_units.saturating_add(amount.units);
}
_ => {
}
}
}
}
if let Some(cur) = currency {
self.total_monetary_cost = Some(MonetaryAmount {
units: total_units,
currency: cur,
});
}
}
#[must_use]
pub fn total_compute_time_ms(&self) -> u64 {
self.dimensions
.iter()
.filter_map(|d| match d {
CostDimension::ComputeTime { duration_ms } => Some(*duration_ms),
_ => None,
})
.fold(0u64, u64::saturating_add)
}
#[must_use]
pub fn total_data_bytes(&self) -> u64 {
self.dimensions
.iter()
.filter_map(|d| match d {
CostDimension::DataVolume {
bytes_read,
bytes_written,
} => Some(bytes_read.saturating_add(*bytes_written)),
_ => None,
})
.fold(0u64, u64::saturating_add)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cost_metadata_roundtrip() {
let mut meta = CostMetadata::new(
"r-1".to_string(),
1700000000,
"agent-1".to_string(),
"srv-1".to_string(),
"tool-a".to_string(),
);
meta.add_dimension(CostDimension::ComputeTime { duration_ms: 150 });
meta.add_dimension(CostDimension::DataVolume {
bytes_read: 1024,
bytes_written: 512,
});
meta.add_dimension(CostDimension::ApiCost {
amount: MonetaryAmount {
units: 50,
currency: "USD".to_string(),
},
provider: "openai".to_string(),
});
meta.compute_total_monetary_cost();
let json = serde_json::to_string(&meta).unwrap();
let deserialized: CostMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.receipt_id, "r-1");
assert_eq!(deserialized.dimensions.len(), 3);
assert_eq!(deserialized.total_monetary_cost.as_ref().unwrap().units, 50);
}
#[test]
fn compute_total_sums_same_currency() {
let mut meta = CostMetadata::new(
"r-2".to_string(),
1700000000,
"agent-1".to_string(),
"srv-1".to_string(),
"tool-a".to_string(),
);
meta.add_dimension(CostDimension::ApiCost {
amount: MonetaryAmount {
units: 100,
currency: "USD".to_string(),
},
provider: "a".to_string(),
});
meta.add_dimension(CostDimension::ApiCost {
amount: MonetaryAmount {
units: 200,
currency: "USD".to_string(),
},
provider: "b".to_string(),
});
meta.compute_total_monetary_cost();
assert_eq!(meta.total_monetary_cost.as_ref().unwrap().units, 300);
assert_eq!(meta.total_monetary_cost.as_ref().unwrap().currency, "USD");
}
#[test]
fn total_compute_time() {
let mut meta = CostMetadata::new(
"r-3".to_string(),
0,
"a".to_string(),
"s".to_string(),
"t".to_string(),
);
meta.add_dimension(CostDimension::ComputeTime { duration_ms: 100 });
meta.add_dimension(CostDimension::ComputeTime { duration_ms: 250 });
assert_eq!(meta.total_compute_time_ms(), 350);
}
#[test]
fn total_data_bytes() {
let mut meta = CostMetadata::new(
"r-4".to_string(),
0,
"a".to_string(),
"s".to_string(),
"t".to_string(),
);
meta.add_dimension(CostDimension::DataVolume {
bytes_read: 100,
bytes_written: 50,
});
meta.add_dimension(CostDimension::DataVolume {
bytes_read: 200,
bytes_written: 30,
});
assert_eq!(meta.total_data_bytes(), 380);
}
#[test]
fn compute_total_with_zero_costs() {
let mut meta = CostMetadata::new(
"r-zero".to_string(),
0,
"a".to_string(),
"s".to_string(),
"t".to_string(),
);
meta.add_dimension(CostDimension::ApiCost {
amount: MonetaryAmount {
units: 0,
currency: "USD".to_string(),
},
provider: "free".to_string(),
});
meta.compute_total_monetary_cost();
assert_eq!(meta.total_monetary_cost.as_ref().unwrap().units, 0);
}
#[test]
fn compute_total_with_mixed_currencies_takes_first() {
let mut meta = CostMetadata::new(
"r-mixed".to_string(),
0,
"a".to_string(),
"s".to_string(),
"t".to_string(),
);
meta.add_dimension(CostDimension::ApiCost {
amount: MonetaryAmount {
units: 100,
currency: "USD".to_string(),
},
provider: "a".to_string(),
});
meta.add_dimension(CostDimension::ApiCost {
amount: MonetaryAmount {
units: 200,
currency: "EUR".to_string(),
},
provider: "b".to_string(),
});
meta.compute_total_monetary_cost();
assert_eq!(meta.total_monetary_cost.as_ref().unwrap().units, 100);
assert_eq!(meta.total_monetary_cost.as_ref().unwrap().currency, "USD");
}
#[test]
fn no_api_costs_produces_no_total() {
let mut meta = CostMetadata::new(
"r-none".to_string(),
0,
"a".to_string(),
"s".to_string(),
"t".to_string(),
);
meta.add_dimension(CostDimension::ComputeTime { duration_ms: 500 });
meta.compute_total_monetary_cost();
assert!(meta.total_monetary_cost.is_none());
}
#[test]
fn empty_dimensions_produce_zero_totals() {
let meta = CostMetadata::new(
"r-empty".to_string(),
0,
"a".to_string(),
"s".to_string(),
"t".to_string(),
);
assert_eq!(meta.total_compute_time_ms(), 0);
assert_eq!(meta.total_data_bytes(), 0);
}
#[test]
fn custom_dimension_roundtrip() {
let dim = CostDimension::Custom {
name: "tokens".to_string(),
value: 4096,
unit: Some("tokens".to_string()),
};
let json = serde_json::to_string(&dim).unwrap();
let back: CostDimension = serde_json::from_str(&json).unwrap();
assert_eq!(back, dim);
}
#[test]
fn warehouse_query_dimension_roundtrip() {
let dim = CostDimension::WarehouseQuery {
bytes_scanned: 50 * 1024 * 1024 * 1024,
estimated_cost_usd: "0.25".to_string(),
};
let json = serde_json::to_string(&dim).unwrap();
let back: CostDimension = serde_json::from_str(&json).unwrap();
assert_eq!(back, dim);
assert!(json.contains("\"dimension\":\"warehouse_query\""));
assert!(json.contains("\"bytes_scanned\""));
assert!(json.contains("\"estimated_cost_usd\""));
}
}