返回 CodeWhale
session_metrics.rs
根目录 / crates / tui / src / tui / session_metrics.rs
1 //! Session metrics: the shared accumulators behind the metrics line and
2 //! the detailed `turns · steps │ LLM · tools │ TTFT · avg tok/s │ cache │ in` ledger.
3 //!
4 //! Every number here is sourced from runtime evidence the engine already
5 //! emits — never from transcript timestamps or estimates:
6 //!
7 //! - **turns**: `Event::TurnStarted` count (`App::turn_counter`).
8 //! - **steps**: model calls (`Event::TurnUsage`) plus tool calls
9 //! (`Event::ToolCallComplete`) — the agent's step count.
10 //! - **LLM**: sum of model-call wall time. Uses `TurnUsage::request_ms`
11 //! (dispatch → usage receipt) when the engine measured dispatch, else the
12 //! stream duration it always reports.
13 //! - **tools**: sum of tool wall time from `ToolCallStarted` → `ToolCallComplete`
14 //! by tool id (the runtime's own clock, taken when the events drain).
15 //! - **TTFT avg**: mean of `TurnUsage::first_token_ms` over the model calls
16 //! that reported one.
17 //! - **avg tok/s**: sum of provider-reported output tokens divided by the sum
18 //! of measured request seconds (`request_ms`), across this loaded session's
19 //! completed usage receipts. This is effective request throughput, including
20 //! connection setup, time to first token, and pauses within the response;
21 //! it is not a decoder-speed measurement or a live text-token estimate.
22 //! Streaming and non-streaming calls use the same dispatch-to-receipt clock.
23 //! Tool execution and idle time between calls are excluded. A transparent
24 //! retry before any content uses the replacement request's clock; a billed
25 //! response with usage is counted even if a later retry is needed. Calls
26 //! without a positive measured request duration (including aggregate REPL
27 //! child receipts) are excluded from both numerator and denominator. The
28 //! normalized `Usage::output_tokens` receipt is canonical; separate reasoning
29 //! counts are not added again and streamed estimates never enter this average.
30 //! - **cache**: provider-reported prompt-cache hit tokens over hit + miss
31 //! (`SessionState::total_cache_hit_tokens` / `total_cache_miss_tokens`).
32 //! - **in**: provider-reported input tokens (`SessionState::total_input_tokens`).
33 //!
34 //! When a provider never reports a metric, or its evidence has not arrived
35 //! yet, the cell is omitted. Nothing here is estimated or captioned.
36 //!
37 //! The infoline and detailed ledger consume the same rate. The infoline keeps
38 //! the last measured session average while a request is in flight; it does not
39 //! divide a text estimate by a turn timer that also includes tools and waits.
40
41 use std::collections::HashMap;
42 use std::time::{Duration, Instant};
43
44 use codewhale_localization::{Locale, MessageId, tr};
45
46 /// Runtime accumulators behind the strip. Lives on [`crate::tui::app::App`],
47 /// resets with the token breakdown when a session is loaded, so the numbers
48 /// describe this runtime session — the same scope as the token ledger.
49 #[derive(Debug, Clone, Default)]
50 pub struct SessionMetrics {
51 /// Model calls that reported usage (`Event::TurnUsage`).
52 pub model_calls: u64,
53 /// Tool calls that completed (`Event::ToolCallComplete`).
54 pub tool_calls: u64,
55 /// Sum of model-call wall time.
56 pub llm_time: Duration,
57 /// Sum of tool wall time.
58 pub tool_time: Duration,
59 /// Sum of reported time-to-first-token values.
60 ttft_total: Duration,
61 /// How many model calls reported a time-to-first-token.
62 ttft_samples: u64,
63 /// Output tokens from calls that also reported a positive request duration.
64 rate_output_tokens: u64,
65 /// Dispatch-to-receipt time from exactly the same calls.
66 rate_request_time: Duration,
67 /// Tools currently running, keyed by tool id, with the instant their
68 /// start event drained.
69 tool_started: HashMap<String, Instant>,
70 }
71
72 impl SessionMetrics {
73 /// Fold one model-call usage receipt into the accumulators.
74 pub fn record_model_call(
75 &mut self,
76 output_tokens: u32,
77 duration_ms: u64,
78 first_token_ms: Option<u64>,
79 request_ms: Option<u64>,
80 ) {
81 self.model_calls = self.model_calls.saturating_add(1);
82 let call_ms = request_ms.unwrap_or(duration_ms);
83 self.llm_time = self.llm_time.saturating_add(Duration::from_millis(call_ms));
84 if let Some(ttft) = first_token_ms {
85 self.ttft_total = self.ttft_total.saturating_add(Duration::from_millis(ttft));
86 self.ttft_samples = self.ttft_samples.saturating_add(1);
87 }
88 if let Some(request_ms) = request_ms.filter(|millis| *millis > 0) {
89 self.rate_output_tokens = self
90 .rate_output_tokens
91 .saturating_add(u64::from(output_tokens));
92 self.rate_request_time = self
93 .rate_request_time
94 .saturating_add(Duration::from_millis(request_ms));
95 }
96 }
97
98 /// Note that a tool started; the matching completion closes the timer.
99 pub fn record_tool_started(&mut self, tool_id: &str) {
100 self.record_tool_started_at(tool_id, Instant::now());
101 }
102
103 fn record_tool_started_at(&mut self, tool_id: &str, at: Instant) {
104 self.tool_started.insert(tool_id.to_string(), at);
105 }
106
107 /// Note that a tool completed. Counts the call even when its start was
108 /// never seen (a replayed or foreign completion), but only accrues time
109 /// when the runtime saw both edges.
110 pub fn record_tool_completed(&mut self, tool_id: &str) {
111 self.record_tool_completed_at(tool_id, Instant::now());
112 }
113
114 fn record_tool_completed_at(&mut self, tool_id: &str, at: Instant) {
115 self.tool_calls = self.tool_calls.saturating_add(1);
116 if let Some(started) = self.tool_started.remove(tool_id) {
117 self.tool_time = self
118 .tool_time
119 .saturating_add(at.saturating_duration_since(started));
120 }
121 }
122
123 /// Drop in-flight tool timers (turn interrupted or failed): a tool that
124 /// never completed must not leak into the next turn's accounting.
125 pub fn clear_in_flight(&mut self) {
126 self.tool_started.clear();
127 }
128
129 /// Model calls plus tool calls.
130 #[must_use]
131 pub fn steps(&self) -> u64 {
132 self.model_calls.saturating_add(self.tool_calls)
133 }
134
135 /// Mean time-to-first-token, when at least one call reported it.
136 #[must_use]
137 pub fn ttft_average(&self) -> Option<Duration> {
138 if self.ttft_samples == 0 {
139 return None;
140 }
141 Some(self.ttft_total / u32::try_from(self.ttft_samples).unwrap_or(u32::MAX))
142 }
143
144 /// Session-average output tokens per measured request second. See the
145 /// module documentation for included time and receipt coverage.
146 #[must_use]
147 pub fn tokens_per_second(&self) -> Option<f64> {
148 let secs = self.rate_request_time.as_secs_f64();
149 if self.rate_output_tokens == 0 || !secs.is_finite() || secs <= 0.0 {
150 return None;
151 }
152 Some(self.rate_output_tokens as f64 / secs)
153 }
154 }
155
156 /// Everything the strip needs, decoupled from `App` so rendering can be
157 /// unit-tested without a full app.
158 #[derive(Debug, Clone, Copy, Default, PartialEq)]
159 pub struct MetricsSnapshot {
160 pub turns: u64,
161 pub steps: u64,
162 pub llm_time: Duration,
163 pub tool_time: Duration,
164 pub ttft_avg: Option<Duration>,
165 pub tokens_per_second: Option<f64>,
166 /// `None` when no provider reported prompt-cache classes this session.
167 pub cache_hit_percent: Option<u8>,
168 pub input_tokens: u64,
169 }
170
171 impl MetricsSnapshot {
172 /// True when there is nothing to say yet (fresh session).
173 #[must_use]
174 pub fn is_empty(&self) -> bool {
175 self.turns == 0 && self.steps == 0 && self.input_tokens == 0
176 }
177 }
178
179 /// One rendered cell: a value with its localized short label.
180 #[derive(Debug, Clone, PartialEq, Eq)]
181 pub struct MetricCell {
182 pub label: String,
183 pub value: String,
184 /// `label` first (`4 turns`) or value first (`LLM 11m46s`).
185 pub value_first: bool,
186 }
187 /// Group priority, highest kept first. When the row is too narrow, groups
188 /// are dropped from the end of this list; inside a group the second cell
189 /// (steps, tools, tok/s) is dropped before the group itself.
190 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
191 pub enum MetricGroup {
192 Input,
193 Cache,
194 Llm,
195 Turns,
196 Latency,
197 }
198
199 /// The DSH-style layout order, left to right.
200 const GROUP_ORDER: [MetricGroup; 5] = [
201 MetricGroup::Turns,
202 MetricGroup::Llm,
203 MetricGroup::Latency,
204 MetricGroup::Cache,
205 MetricGroup::Input,
206 ];
207
208 /// A group of one or two cells separated by ` · `.
209 #[derive(Debug, Clone, PartialEq, Eq)]
210 pub struct MetricGroupCells {
211 pub group: MetricGroup,
212 pub cells: Vec<MetricCell>,
213 }
214
215 /// Separators used between cells and between groups.
216 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
217 pub struct Separators {
218 pub cell: &'static str,
219 pub group: &'static str,
220 }
221
222 impl Separators {
223 /// Unicode: ` · ` inside a group, ` │ ` between groups.
224 pub const UNICODE: Self = Self {
225 cell: " · ",
226 group: " │ ",
227 };
228 /// ASCII-safe: ` . ` and ` | `.
229 pub const ASCII: Self = Self {
230 cell: " . ",
231 group: " | ",
232 };
233
234 #[must_use]
235 pub fn for_ascii(ascii_safe: bool) -> Self {
236 if ascii_safe {
237 Self::ASCII
238 } else {
239 Self::UNICODE
240 }
241 }
242 }
243
244 /// Format a duration the way the strip does: `11m46s`, `1h02m`, `1.5s`, `320ms`.
245 #[must_use]
246 pub fn format_duration(duration: Duration) -> String {
247 let ms = duration.as_millis();
248 if ms == 0 {
249 return "0s".to_string();
250 }
251 if ms < 1_000 {
252 return format!("{ms}ms");
253 }
254 let secs = duration.as_secs();
255 if secs < 60 {
256 let tenths = (ms + 50) / 100;
257 return format!("{}.{}s", tenths / 10, tenths % 10);
258 }
259 if secs < 3_600 {
260 return format!("{}m{:02}s", secs / 60, secs % 60);
261 }
262 format!("{}h{:02}m", secs / 3_600, (secs % 3_600) / 60)
263 }
264
265 /// Format a token count: `842`, `12.3K`, `9.3M`, `1.2B`.
266 #[must_use]
267 pub fn format_tokens(tokens: u64) -> String {
268 const UNITS: [(u64, &str); 3] = [(1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K")];
269 for (scale, suffix) in UNITS {
270 if tokens >= scale {
271 let scaled = tokens as f64 / scale as f64;
272 return if scaled >= 100.0 {
273 format!("{scaled:.0}{suffix}")
274 } else {
275 format!("{scaled:.1}{suffix}")
276 };
277 }
278 }
279 tokens.to_string()
280 }
281
282 /// Format an output rate: `120` or `7.5` (the label carries `tok/s`).
283 #[must_use]
284 pub fn format_rate(rate: f64) -> String {
285 if rate < 10.0 {
286 format!("{rate:.1}")
287 } else {
288 format!("{rate:.0}")
289 }
290 }
291
292 /// Build the cells for every group that has something truthful to show.
293 ///
294 /// A cell whose evidence has not arrived is omitted — never a placeholder:
295 /// `TTFT avg` / `tok/s` appear only once a model call reported them, `Cache
296 /// hit` only when a provider reported cache classes, `Input` only after the
297 /// first usage receipt. Turn cells are present once the session has started
298 /// (zero turns is a real count). Step cells wait for the first completed
299 /// model or tool call so `0 steps` cannot look like a stalled scoreboard.
300 #[must_use]
301 pub fn build_groups(snapshot: MetricsSnapshot, locale: Locale) -> Vec<MetricGroupCells> {
302 let label = |id: MessageId| tr(locale, id).into_owned();
303 let mut groups = Vec::new();
304 for group in GROUP_ORDER {
305 let cells = match group {
306 MetricGroup::Turns => {
307 if snapshot.turns == 0 && snapshot.steps == 0 {
308 continue;
309 }
310 let mut cells = Vec::new();
311 if snapshot.turns > 0 {
312 cells.push(MetricCell {
313 label: label(if snapshot.turns == 1 {
314 MessageId::SessionMetricsTurn
315 } else {
316 MessageId::SessionMetricsTurns
317 }),
318 value: snapshot.turns.to_string(),
319 value_first: true,
320 });
321 }
322 if snapshot.steps > 0 {
323 cells.push(MetricCell {
324 label: label(if snapshot.steps == 1 {
325 MessageId::SessionMetricsStep
326 } else {
327 MessageId::SessionMetricsSteps
328 }),
329 value: snapshot.steps.to_string(),
330 value_first: true,
331 });
332 }
333 if cells.is_empty() {
334 continue;
335 }
336 cells
337 }
338 MetricGroup::Llm => {
339 let mut cells = Vec::new();
340 if !snapshot.llm_time.is_zero() {
341 cells.push(MetricCell {
342 label: label(MessageId::SessionMetricsLlm),
343 value: format_duration(snapshot.llm_time),
344 value_first: false,
345 });
346 }
347 if !snapshot.tool_time.is_zero() {
348 cells.push(MetricCell {
349 label: label(MessageId::SessionMetricsTools),
350 value: format_duration(snapshot.tool_time),
351 value_first: false,
352 });
353 }
354 if cells.is_empty() {
355 continue;
356 }
357 cells
358 }
359 MetricGroup::Latency => {
360 let mut cells = Vec::new();
361 if let Some(ttft) = snapshot.ttft_avg {
362 cells.push(MetricCell {
363 label: label(MessageId::SessionMetricsTtft),
364 value: format_duration(ttft),
365 value_first: false,
366 });
367 }
368 if let Some(rate) = snapshot.tokens_per_second {
369 cells.push(MetricCell {
370 label: label(MessageId::SessionMetricsTokensPerSecond),
371 value: format_rate(rate),
372 value_first: true,
373 });
374 }
375 if cells.is_empty() {
376 continue;
377 }
378 cells
379 }
380 MetricGroup::Cache => {
381 let Some(pct) = snapshot.cache_hit_percent else {
382 continue;
383 };
384 vec![MetricCell {
385 label: label(MessageId::SessionMetricsCache),
386 value: format!("{pct}%"),
387 value_first: false,
388 }]
389 }
390 MetricGroup::Input => {
391 if snapshot.input_tokens == 0 {
392 continue;
393 }
394 vec![MetricCell {
395 label: label(MessageId::SessionMetricsInput),
396 value: format_tokens(snapshot.input_tokens),
397 value_first: false,
398 }]
399 }
400 };
401 groups.push(MetricGroupCells { group, cells });
402 }
403 groups
404 }
405
406 /// A rendered strip: the plain text (for tests, `/status`, and width math)
407 /// plus the cells that survived the budget, so the painter can style labels
408 /// and values differently.
409 #[derive(Debug, Clone, PartialEq, Eq)]
410 pub struct RenderedStrip {
411 pub groups: Vec<MetricGroupCells>,
412 pub separators: Separators,
413 }
414
415 impl RenderedStrip {
416 /// Plain-text form: `4 turns · 108 steps │ LLM 11m46s · tools 1m52s │ …`.
417 #[must_use]
418 pub fn text(&self) -> String {
419 let mut out = String::new();
420 for (index, group) in self.groups.iter().enumerate() {
421 if index > 0 {
422 out.push_str(self.separators.group);
423 }
424 for (cell_index, cell) in group.cells.iter().enumerate() {
425 if cell_index > 0 {
426 out.push_str(self.separators.cell);
427 }
428 if cell.value_first {
429 out.push_str(&cell.value);
430 out.push(' ');
431 out.push_str(&cell.label);
432 } else {
433 out.push_str(&cell.label);
434 out.push(' ');
435 out.push_str(&cell.value);
436 }
437 }
438 }
439 out
440 }
441 }
442
443 /// Snapshot the live app state into the strip's inputs.
444 #[must_use]
445 pub fn snapshot_from_app(app: &crate::tui::app::App) -> MetricsSnapshot {
446 let hit = u64::from(app.session.displayed_total_cache_hit_tokens());
447 let miss = u64::from(app.session.displayed_total_cache_miss_tokens());
448 let cache_hit_percent = (hit + miss > 0).then(|| {
449 // Widen before adding so saturated counters never exceed 100%.
450 u8::try_from((hit * 100 + (hit + miss) / 2) / (hit + miss)).unwrap_or(100)
451 });
452 MetricsSnapshot {
453 turns: app.turn_counter,
454 steps: app.session_metrics.steps(),
455 llm_time: app.session_metrics.llm_time,
456 tool_time: app.session_metrics.tool_time,
457 ttft_avg: app.session_metrics.ttft_average(),
458 tokens_per_second: app.session_metrics.tokens_per_second(),
459 cache_hit_percent,
460 input_tokens: u64::from(app.session.displayed_total_input_tokens()),
461 }
462 }
463
464 /// The complete, untrimmed strip text — what `/status` prints.
465 #[must_use]
466 pub fn full_text(snapshot: MetricsSnapshot, locale: Locale, ascii_safe: bool) -> String {
467 RenderedStrip {
468 groups: build_groups(snapshot, locale),
469 separators: Separators::for_ascii(ascii_safe),
470 }
471 .text()
472 }
473
474 #[cfg(test)]
475 mod tests {
476 use super::*;
477
478 fn sample() -> MetricsSnapshot {
479 MetricsSnapshot {
480 turns: 4,
481 steps: 108,
482 llm_time: Duration::from_secs(11 * 60 + 46),
483 tool_time: Duration::from_secs(60 + 52),
484 ttft_avg: Some(Duration::from_millis(1_500)),
485 tokens_per_second: Some(120.0),
486 cache_hit_percent: Some(99),
487 input_tokens: 9_300_000,
488 }
489 }
490
491 #[test]
492 fn durations_format_like_the_harness_strip() {
493 assert_eq!(format_duration(Duration::ZERO), "0s");
494 assert_eq!(format_duration(Duration::from_millis(320)), "320ms");
495 assert_eq!(format_duration(Duration::from_millis(1_500)), "1.5s");
496 assert_eq!(format_duration(Duration::from_millis(1_549)), "1.5s");
497 assert_eq!(format_duration(Duration::from_secs(59)), "59.0s");
498 assert_eq!(format_duration(Duration::from_secs(11 * 60 + 46)), "11m46s");
499 assert_eq!(format_duration(Duration::from_secs(3_600 + 120)), "1h02m");
500 }
501
502 #[test]
503 fn tokens_and_rates_format_compactly() {
504 assert_eq!(format_tokens(842), "842");
505 assert_eq!(format_tokens(12_345), "12.3K");
506 assert_eq!(format_tokens(128_000), "128K");
507 assert_eq!(format_tokens(9_300_000), "9.3M");
508 assert_eq!(format_tokens(1_200_000_000), "1.2B");
509 assert_eq!(format_rate(120.4), "120");
510 assert_eq!(format_rate(7.46), "7.5");
511 }
512
513 #[test]
514 fn full_strip_matches_the_reference_layout() {
515 let text = full_text(sample(), Locale::En, false);
516 assert_eq!(
517 text,
518 "4 turns · 108 steps │ LLM 11m46s · Tool call 1m52s │ TTFT avg 1.5s · 120 avg tok/s │ Cache hit 99% │ Input 9.3M"
519 );
520 let ascii = full_text(sample(), Locale::En, true);
521 assert!(ascii.is_ascii(), "{ascii}");
522 assert!(ascii.contains(" | LLM 11m46s . Tool call 1m52s | "));
523 }
524
525 #[test]
526 fn idle_snapshot_paints_nothing() {
527 let text = full_text(MetricsSnapshot::default(), Locale::En, false);
528 assert_eq!(text, "");
529 }
530
531 #[test]
532 fn absent_evidence_omits_the_cell_instead_of_a_placeholder() {
533 let mut snapshot = sample();
534 snapshot.cache_hit_percent = None;
535 snapshot.ttft_avg = None;
536 snapshot.tokens_per_second = None;
537 snapshot.input_tokens = 0;
538 let text = full_text(snapshot, Locale::En, false);
539 assert_eq!(text, "4 turns · 108 steps │ LLM 11m46s · Tool call 1m52s");
540 assert!(!text.contains('—'), "{text}");
541
542 // A partially reported latency group keeps only the reported cell.
543 snapshot.ttft_avg = Some(Duration::from_millis(900));
544 let text = full_text(snapshot, Locale::En, false);
545 assert!(text.ends_with("│ TTFT avg 900ms"), "{text}");
546 snapshot.ttft_avg = None;
547 snapshot.tokens_per_second = Some(88.0);
548 let text = full_text(snapshot, Locale::En, false);
549 assert!(text.ends_with("│ 88 avg tok/s"), "{text}");
550 }
551
552 #[test]
553 fn singular_labels_for_one_turn_and_one_step() {
554 let snapshot = MetricsSnapshot {
555 turns: 1,
556 steps: 1,
557 ..MetricsSnapshot::default()
558 };
559 let text = full_text(snapshot, Locale::En, false);
560 assert_eq!(text, "1 turn · 1 step", "{text}");
561 }
562
563 #[test]
564 fn every_shipped_locale_has_short_labels() {
565 for locale in Locale::shipped_complete() {
566 let text = full_text(sample(), *locale, false);
567 assert!(text.contains("4 "), "{}: {text}", locale.tag());
568 assert!(text.contains("11m46s"), "{}: {text}", locale.tag());
569 for group in build_groups(sample(), *locale) {
570 for cell in group.cells {
571 assert!(
572 cell.label.chars().count() <= 12,
573 "{}: label `{}` is too long for the strip",
574 locale.tag(),
575 cell.label
576 );
577 }
578 }
579 }
580 }
581
582 #[test]
583 fn accumulators_derive_ttft_and_rate_from_reported_calls() {
584 let mut metrics = SessionMetrics::default();
585 // 100 output tokens over a 2 s stream, first token after 500 ms, whole
586 // call 2.4 s including connection setup.
587 metrics.record_model_call(100, 2_000, Some(500), Some(2_400));
588 // A call that reported no first token (empty response) still counts
589 // for LLM time but not for TTFT.
590 metrics.record_model_call(20, 1_000, None, Some(1_100));
591 assert_eq!(metrics.model_calls, 2);
592 assert_eq!(metrics.llm_time, Duration::from_millis(3_500));
593 assert_eq!(metrics.ttft_average(), Some(Duration::from_millis(500)));
594 let rate = metrics.tokens_per_second().expect("rate");
595 assert!((rate - 120.0 / 3.5).abs() < 1e-9, "{rate}");
596
597 // Missing request_ms falls back to the stream duration.
598 metrics.record_model_call(0, 700, None, None);
599 assert_eq!(metrics.llm_time, Duration::from_millis(4_200));
600 // A duration without individual request timing cannot enter the rate.
601 assert!((metrics.tokens_per_second().unwrap() - 120.0 / 3.5).abs() < 1e-9);
602 }
603
604 #[test]
605 fn request_average_covers_ttft_stream_pauses_tools_and_non_streaming_calls() {
606 let mut metrics = SessionMetrics::default();
607 let t0 = Instant::now();
608 let connected = t0 + Duration::from_millis(200);
609 let first_token = t0 + Duration::from_secs(1);
610 let pause_started = t0 + Duration::from_secs(2);
611 let pause_finished = pause_started + Duration::from_secs(1);
612 let first_receipt = pause_finished + Duration::from_secs(2);
613 let millis = |duration: Duration| u64::try_from(duration.as_millis()).unwrap();
614 metrics.record_model_call(
615 120,
616 millis(first_receipt.duration_since(connected)),
617 Some(millis(first_token.duration_since(t0))),
618 Some(millis(first_receipt.duration_since(t0))),
619 );
620 assert_eq!(metrics.tokens_per_second(), Some(24.0));
621
622 // Thirty seconds of tool work and ten seconds idle are not model time.
623 metrics.record_tool_started_at("build", first_receipt);
624 let tool_finished = first_receipt + Duration::from_secs(30);
625 metrics.record_tool_completed_at("build", tool_finished);
626 let second_dispatch = tool_finished + Duration::from_secs(10);
627 assert_eq!(metrics.tokens_per_second(), Some(24.0));
628 let second_receipt = second_dispatch + Duration::from_secs(3);
629 metrics.record_model_call(
630 60,
631 2_800,
632 Some(500),
633 Some(millis(second_receipt.duration_since(second_dispatch))),
634 );
635
636 // A buffered/non-streaming call has a real request clock even though
637 // its adapter reports no stream duration or first-content timestamp.
638 let third_dispatch = second_receipt + Duration::from_secs(20);
639 let third_receipt = third_dispatch + Duration::from_secs(2);
640 metrics.record_model_call(
641 80,
642 0,
643 None,
644 Some(millis(third_receipt.duration_since(third_dispatch))),
645 );
646 assert_eq!(metrics.tokens_per_second(), Some(26.0)); // 260 / (5 + 3 + 2)
647 assert_eq!(metrics.ttft_average(), Some(Duration::from_millis(750)));
648 assert_eq!(metrics.tool_time, Duration::from_secs(30));
649
650 // Aggregate child or legacy receipts and zero-duration cache receipts
651 // cannot contribute tokens without their matching request denominator.
652 metrics.record_model_call(1_000, 9_000, None, None);
653 metrics.record_model_call(300, 0, None, Some(0));
654 assert_eq!(metrics.tokens_per_second(), Some(26.0));
655 // A measured, empty response consumes time and produces zero output.
656 metrics.record_model_call(0, 1_000, None, Some(1_000));
657 assert!((metrics.tokens_per_second().unwrap() - 260.0 / 11.0).abs() < 1e-9);
658 }
659
660 #[test]
661 fn tool_time_needs_both_edges_and_in_flight_timers_are_dropped() {
662 let mut metrics = SessionMetrics::default();
663 let t0 = Instant::now();
664 metrics.record_tool_started_at("a", t0);
665 metrics.record_tool_completed_at("a", t0 + Duration::from_millis(1_500));
666 // Completion without a seen start counts the call, not the time.
667 metrics.record_tool_completed_at("ghost", t0 + Duration::from_secs(9));
668 assert_eq!(metrics.tool_calls, 2);
669 assert_eq!(metrics.tool_time, Duration::from_millis(1_500));
670 assert_eq!(metrics.steps(), 2);
671
672 metrics.record_tool_started_at("b", t0);
673 metrics.clear_in_flight();
674 metrics.record_tool_completed_at("b", t0 + Duration::from_secs(5));
675 assert_eq!(metrics.tool_time, Duration::from_millis(1_500));
676 }
677 }
678
678 lines RUST