121 lines
4.8 KiB
Rust
121 lines
4.8 KiB
Rust
|
|
//! MCP token-usage reporting tool (`tool_get_token_usage`).
|
||
|
|
|
||
|
|
use serde_json::{Value, json};
|
||
|
|
|
||
|
|
use crate::http::context::AppContext;
|
||
|
|
|
||
|
|
pub(crate) fn tool_get_token_usage(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||
|
|
let root = ctx.state.get_project_root()?;
|
||
|
|
let filter_story = args.get("story_id").and_then(|v| v.as_str());
|
||
|
|
|
||
|
|
let all_records = crate::agents::token_usage::read_all(&root)?;
|
||
|
|
let records: Vec<_> = all_records
|
||
|
|
.into_iter()
|
||
|
|
.filter(|r| filter_story.is_none_or(|s| r.story_id == s))
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
let total_cost: f64 = records.iter().map(|r| r.usage.total_cost_usd).sum();
|
||
|
|
let total_input: u64 = records.iter().map(|r| r.usage.input_tokens).sum();
|
||
|
|
let total_output: u64 = records.iter().map(|r| r.usage.output_tokens).sum();
|
||
|
|
let total_cache_create: u64 = records
|
||
|
|
.iter()
|
||
|
|
.map(|r| r.usage.cache_creation_input_tokens)
|
||
|
|
.sum();
|
||
|
|
let total_cache_read: u64 = records
|
||
|
|
.iter()
|
||
|
|
.map(|r| r.usage.cache_read_input_tokens)
|
||
|
|
.sum();
|
||
|
|
|
||
|
|
serde_json::to_string_pretty(&json!({
|
||
|
|
"records": records.iter().map(|r| json!({
|
||
|
|
"story_id": r.story_id,
|
||
|
|
"agent_name": r.agent_name,
|
||
|
|
"timestamp": r.timestamp,
|
||
|
|
"input_tokens": r.usage.input_tokens,
|
||
|
|
"output_tokens": r.usage.output_tokens,
|
||
|
|
"cache_creation_input_tokens": r.usage.cache_creation_input_tokens,
|
||
|
|
"cache_read_input_tokens": r.usage.cache_read_input_tokens,
|
||
|
|
"total_cost_usd": r.usage.total_cost_usd,
|
||
|
|
})).collect::<Vec<_>>(),
|
||
|
|
"totals": {
|
||
|
|
"records": records.len(),
|
||
|
|
"input_tokens": total_input,
|
||
|
|
"output_tokens": total_output,
|
||
|
|
"cache_creation_input_tokens": total_cache_create,
|
||
|
|
"cache_read_input_tokens": total_cache_read,
|
||
|
|
"total_cost_usd": total_cost,
|
||
|
|
}
|
||
|
|
}))
|
||
|
|
.map_err(|e| format!("Serialization error: {e}"))
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
use crate::http::test_helpers::test_ctx;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn tool_get_token_usage_empty_returns_zero_totals() {
|
||
|
|
let tmp = tempfile::tempdir().unwrap();
|
||
|
|
let ctx = test_ctx(tmp.path());
|
||
|
|
let result = tool_get_token_usage(&json!({}), &ctx).unwrap();
|
||
|
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||
|
|
assert_eq!(parsed["records"].as_array().unwrap().len(), 0);
|
||
|
|
assert_eq!(parsed["totals"]["records"], 0);
|
||
|
|
assert_eq!(parsed["totals"]["total_cost_usd"], 0.0);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn tool_get_token_usage_returns_written_records() {
|
||
|
|
let tmp = tempfile::tempdir().unwrap();
|
||
|
|
let root = tmp.path();
|
||
|
|
let ctx = test_ctx(root);
|
||
|
|
|
||
|
|
let usage = crate::agents::TokenUsage {
|
||
|
|
input_tokens: 100,
|
||
|
|
output_tokens: 200,
|
||
|
|
cache_creation_input_tokens: 5000,
|
||
|
|
cache_read_input_tokens: 10000,
|
||
|
|
total_cost_usd: 1.57,
|
||
|
|
};
|
||
|
|
let record =
|
||
|
|
crate::agents::token_usage::build_record("42_story_foo", "coder-1", None, usage);
|
||
|
|
crate::agents::token_usage::append_record(root, &record).unwrap();
|
||
|
|
|
||
|
|
let result = tool_get_token_usage(&json!({}), &ctx).unwrap();
|
||
|
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||
|
|
assert_eq!(parsed["records"].as_array().unwrap().len(), 1);
|
||
|
|
assert_eq!(parsed["records"][0]["story_id"], "42_story_foo");
|
||
|
|
assert_eq!(parsed["records"][0]["agent_name"], "coder-1");
|
||
|
|
assert_eq!(parsed["records"][0]["input_tokens"], 100);
|
||
|
|
assert_eq!(parsed["totals"]["records"], 1);
|
||
|
|
assert!((parsed["totals"]["total_cost_usd"].as_f64().unwrap() - 1.57).abs() < f64::EPSILON);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn tool_get_token_usage_filters_by_story_id() {
|
||
|
|
let tmp = tempfile::tempdir().unwrap();
|
||
|
|
let root = tmp.path();
|
||
|
|
let ctx = test_ctx(root);
|
||
|
|
|
||
|
|
let usage = crate::agents::TokenUsage {
|
||
|
|
input_tokens: 50,
|
||
|
|
output_tokens: 60,
|
||
|
|
cache_creation_input_tokens: 0,
|
||
|
|
cache_read_input_tokens: 0,
|
||
|
|
total_cost_usd: 0.5,
|
||
|
|
};
|
||
|
|
let r1 =
|
||
|
|
crate::agents::token_usage::build_record("10_story_a", "coder-1", None, usage.clone());
|
||
|
|
let r2 = crate::agents::token_usage::build_record("20_story_b", "coder-2", None, usage);
|
||
|
|
crate::agents::token_usage::append_record(root, &r1).unwrap();
|
||
|
|
crate::agents::token_usage::append_record(root, &r2).unwrap();
|
||
|
|
|
||
|
|
let result = tool_get_token_usage(&json!({"story_id": "10_story_a"}), &ctx).unwrap();
|
||
|
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||
|
|
assert_eq!(parsed["records"].as_array().unwrap().len(), 1);
|
||
|
|
assert_eq!(parsed["records"][0]["story_id"], "10_story_a");
|
||
|
|
assert_eq!(parsed["totals"]["records"], 1);
|
||
|
|
}
|
||
|
|
}
|