yapi_core/agent_session/
stats.rs1use yapi_types::message::{ContentBlock, Message, StopReason};
4use yapi_types::session::FileEntry;
5
6use crate::compaction::{calculate_context_tokens, estimate_projected_context_tokens};
7use crate::session::build_projection;
8
9use super::AgentSession;
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub struct ContextUsage {
14 pub tokens: Option<u64>,
16 pub context_window: u64,
18}
19
20impl ContextUsage {
21 pub fn percent(&self) -> Option<f64> {
23 self.tokens
24 .map(|tokens| tokens as f64 / self.context_window as f64 * 100.0)
25 }
26}
27
28#[derive(Clone, Copy, Debug, Default, PartialEq)]
30pub struct UsageTotals {
31 pub input: u64,
33 pub output: u64,
35 pub cache_read: u64,
37 pub cache_write: u64,
39 pub cost: f64,
41 pub cache_hit_rate: Option<f64>,
43}
44
45#[derive(Clone, Debug, Default, PartialEq)]
47pub struct SessionStats {
48 pub total_messages: usize,
50 pub user_messages: usize,
52 pub assistant_messages: usize,
54 pub tool_calls: usize,
56 pub tool_results: usize,
58 pub breakdown: Vec<(String, f64, u64)>,
61}
62
63impl AgentSession {
64 pub fn context_usage(&self) -> Option<ContextUsage> {
67 let model = self.model()?;
68 let context_window = model.context_window;
69 if context_window == 0 {
70 return None;
71 }
72 self.with_session(|session| {
73 let branch = session.branch_path(None);
74 let projection = build_projection(&branch);
75 let latest_compaction = branch
76 .iter()
77 .rposition(|entry| matches!(entry, FileEntry::Compaction(_)));
78 if let Some(compaction) = latest_compaction {
79 let has_usage = |entry: &&FileEntry| {
80 let Some(id) = entry.meta().map(|meta| meta.id.as_str()) else {
81 return false;
82 };
83 projection.entries.iter().any(|projected| {
84 projected.source.meta().is_some_and(|meta| meta.id == id)
85 && projected.messages.iter().any(|message| {
86 matches!(message, Message::Assistant(assistant)
87 if !matches!(assistant.stop_reason, StopReason::Aborted | StopReason::Error)
88 && calculate_context_tokens(&assistant.usage) > 0)
89 })
90 })
91 };
92 if !branch[compaction + 1..].iter().any(has_usage) {
93 return Some(ContextUsage {
94 tokens: None,
95 context_window,
96 });
97 }
98 }
99 let tokens = estimate_projected_context_tokens(&projection, &branch).tokens;
100 Some(ContextUsage {
101 tokens: Some(tokens),
102 context_window,
103 })
104 })
105 }
106
107 pub fn usage_totals(&self) -> UsageTotals {
110 self.with_session(|session| {
111 let mut totals = UsageTotals::default();
112 for entry in session.entries() {
113 let usage = match entry {
114 FileEntry::Usage(entry) => Some(&entry.usage),
115 FileEntry::Message(entry) => match &entry.message {
116 Message::Assistant(assistant) => {
117 let usage = &assistant.usage;
118 let prompt = usage.input + usage.cache_read + usage.cache_write;
119 totals.cache_hit_rate = (prompt > 0)
120 .then(|| usage.cache_read as f64 / prompt as f64 * 100.0);
121 Some(usage)
122 }
123 Message::ToolResult(result) => result.usage.as_ref(),
124 _ => None,
125 },
126 FileEntry::Compaction(entry) => entry.usage.as_ref(),
127 FileEntry::BranchSummary(entry) => entry.usage.as_ref(),
128 _ => None,
129 };
130 if let Some(usage) = usage {
131 totals.input += usage.input;
132 totals.output += usage.output;
133 totals.cache_read += usage.cache_read;
134 totals.cache_write += usage.cache_write;
135 totals.cost += usage.cost.total;
136 }
137 }
138 totals
139 })
140 }
141
142 pub fn session_stats(&self) -> SessionStats {
145 self.with_session(|session| {
146 let mut stats = SessionStats::default();
147 let mut breakdown: Vec<(String, f64, u64)> = Vec::new();
148 let mut add = |key: String, usage: &yapi_types::message::Usage| {
149 let tokens = usage.input + usage.output + usage.cache_read + usage.cache_write;
150 match breakdown
151 .iter_mut()
152 .find(|(existing, _, _)| *existing == key)
153 {
154 Some((_, cost, total)) => {
155 *cost += usage.cost.total;
156 *total += tokens;
157 }
158 None => breakdown.push((key, usage.cost.total, tokens)),
159 }
160 };
161 for entry in session.entries() {
162 match entry {
163 FileEntry::Usage(entry) => {
164 add(format!("{}/{}", entry.provider, entry.model), &entry.usage);
165 }
166 FileEntry::Compaction(entry) => {
167 if let Some(usage) = &entry.usage {
168 add("Tools/summaries".into(), usage);
169 }
170 }
171 FileEntry::BranchSummary(entry) => {
172 if let Some(usage) = &entry.usage {
173 add("Tools/summaries".into(), usage);
174 }
175 }
176 FileEntry::Message(entry) => {
177 stats.total_messages += 1;
178 match &entry.message {
179 Message::User(_) => stats.user_messages += 1,
180 Message::ToolResult(result) => {
181 stats.tool_results += 1;
182 if let Some(usage) = &result.usage {
183 add("Tools/summaries".into(), usage);
184 }
185 }
186 Message::Assistant(assistant) => {
187 stats.assistant_messages += 1;
188 stats.tool_calls += assistant
189 .content
190 .iter()
191 .filter(|block| matches!(block, ContentBlock::ToolCall(_)))
192 .count();
193 let model = assistant
194 .response_model
195 .as_deref()
196 .unwrap_or(&assistant.model);
197 add(format!("{}/{model}", assistant.provider), &assistant.usage);
198 }
199 _ => {}
200 }
201 }
202 _ => {}
203 }
204 }
205 breakdown.retain(|(_, cost, tokens)| *cost > 0.0 || *tokens > 0);
206 breakdown.sort_by(|a, b| b.1.total_cmp(&a.1));
207 stats.breakdown = breakdown;
208 stats
209 })
210 }
211}