Skip to main content

ferrox_api/
usage.rs

1//! OpenAI-convention token accounting plus llama.cpp-style timings.
2//!
3//! Counted from the exact token ids the generation loop processed
4//! (prompt after BOS insertion, and every generated id), not
5//! re-tokenized after the fact -- re-tokenizing decoded text is not
6//! guaranteed to round-trip to the same count.
7//!
8//! Why the server reports timings at all, when a client can hold a
9//! stopwatch: the client's stopwatch measures the network, the proxy's
10//! buffer and its own event loop. More importantly it cannot separate
11//! **prefill from decode**, and a UI that divides total tokens by total
12//! wall time reports a 50 tok/s model as 5 tok/s whenever the prompt is
13//! long. Every downstream number built on that is then wrong in the
14//! same direction. So the phases are reported separately and the client
15//! is never asked to infer one from the other.
16//!
17//! Every timing is optional: a cached response, a batched decode, or an
18//! engine path that does not time itself must be able to answer
19//! honestly rather than emit a plausible zero.
20
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct Usage {
25    pub prompt_tokens: usize,
26    pub completion_tokens: usize,
27    pub total_tokens: usize,
28    /// Prefill throughput (prompt tokens / prefill seconds), when timed.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub prompt_per_second: Option<f64>,
31    /// Decode throughput (completion tokens / decode seconds), when timed.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub predicted_per_second: Option<f64>,
34    /// Wall time spent processing the prompt, in milliseconds.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub prompt_eval_duration_ms: Option<f64>,
37    /// Wall time spent in the decode loop, in milliseconds. Kept
38    /// separate from `prompt_eval_duration_ms` on purpose (see the
39    /// module docs).
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub generation_duration_ms: Option<f64>,
42    /// Time to first token: from the start of prefill to the moment the
43    /// first token was produced. `None` when no token was produced at
44    /// all (an immediate EOS), because a zero there would read as an
45    /// instantaneous response.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub time_to_first_token_ms: Option<f64>,
48    /// Prompt tokens served from the KV prefix cache instead of being
49    /// recomputed. `Some(0)` means "the cache was consulted and missed";
50    /// `None` means "no prefix cache is configured" -- a distinction the
51    /// UI needs to decide whether to show the row at all.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub cached_tokens: Option<usize>,
54    /// Completion tokens per verification step when speculative
55    /// decoding ran: the published *acceptance length*. `None` means
56    /// speculation did not run, which is not the same as an acceptance
57    /// length of 1.0 (speculation ran and never helped).
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub acceptance_length: Option<f64>,
60    /// Draft tokens the target actually evaluated. Positions after a
61    /// rejection are not counted, so the ratio below tracks the
62    /// drafter's accuracy rather than its block size.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub draft_tokens: Option<usize>,
65    /// Draft tokens accepted.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub accepted_draft_tokens: Option<usize>,
68    /// Accept rate at each position within the draft block, each
69    /// conditional on that position having been reached.
70    ///
71    /// Reported alongside the mean and not folded into it: a drafter
72    /// that is right at position 0 and useless by position 7 has the
73    /// same mean as one that is uniformly mediocre, and the two want
74    /// opposite block sizes. Suffix decay is only visible per position.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub draft_accept_rate_per_position: Option<Vec<f64>>,
77}
78
79impl Usage {
80    pub fn new(prompt_tokens: usize, completion_tokens: usize) -> Self {
81        Usage {
82            prompt_tokens,
83            completion_tokens,
84            total_tokens: prompt_tokens + completion_tokens,
85            prompt_per_second: None,
86            predicted_per_second: None,
87            prompt_eval_duration_ms: None,
88            generation_duration_ms: None,
89            time_to_first_token_ms: None,
90            cached_tokens: None,
91            acceptance_length: None,
92            draft_tokens: None,
93            accepted_draft_tokens: None,
94            draft_accept_rate_per_position: None,
95        }
96    }
97
98    /// Records the two phase durations, in seconds, and the rates they
99    /// imply. A zero-length phase leaves the rate unset rather than
100    /// dividing by zero into infinity.
101    pub fn with_timings(mut self, prompt_secs: f64, predicted_secs: f64) -> Self {
102        self.prompt_eval_duration_ms = Some(prompt_secs * 1000.0);
103        self.generation_duration_ms = Some(predicted_secs * 1000.0);
104        if prompt_secs > 0.0 && self.prompt_tokens > 0 {
105            self.prompt_per_second = Some(self.prompt_tokens as f64 / prompt_secs);
106        }
107        if predicted_secs > 0.0 && self.completion_tokens > 0 {
108            self.predicted_per_second = Some(self.completion_tokens as f64 / predicted_secs);
109        }
110        self
111    }
112
113    /// Time-to-first-token, in seconds, measured from the start of
114    /// prefill. Ignored when no token was generated.
115    pub fn with_ttft(mut self, secs: f64) -> Self {
116        if self.completion_tokens > 0 {
117            self.time_to_first_token_ms = Some(secs * 1000.0);
118        }
119        self
120    }
121
122    /// Prompt tokens that came from the prefix cache. Call this only
123    /// when a prefix cache actually exists (see `cached_tokens`).
124    pub fn with_cached_tokens(mut self, cached: usize) -> Self {
125        self.cached_tokens = Some(cached);
126        self
127    }
128
129    /// Records what speculative decoding actually achieved for this
130    /// request. Call this only when speculation ran: leaving the fields
131    /// unset is how a non-speculative request says so, and a zero would
132    /// read as "speculation ran and failed".
133    ///
134    /// `accepted` and `drafted` are token counts, `per_position` the
135    /// accept rate at each position inside the draft block. A zero
136    /// `verification_steps` leaves `acceptance_length` unset rather
137    /// than dividing by zero.
138    pub fn with_speculation(
139        mut self,
140        verification_steps: usize,
141        accepted: usize,
142        drafted: usize,
143        per_position: Vec<f64>,
144    ) -> Self {
145        if verification_steps > 0 {
146            self.acceptance_length =
147                Some(self.completion_tokens as f64 / verification_steps as f64);
148        }
149        self.accepted_draft_tokens = Some(accepted);
150        self.draft_tokens = Some(drafted);
151        self.draft_accept_rate_per_position = Some(per_position);
152        self
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn totals_are_the_sum_of_the_two_phases() {
162        let usage = Usage::new(7, 3);
163        assert_eq!(usage.total_tokens, 10);
164    }
165
166    #[test]
167    fn phase_durations_stay_separate() {
168        // 100 prompt tokens in 1s, 10 generated in 1s. A client that
169        // conflated the phases would report 110/2 = 55 tok/s for both.
170        let usage = Usage::new(100, 10).with_timings(1.0, 1.0);
171        assert_eq!(usage.prompt_per_second, Some(100.0));
172        assert_eq!(usage.predicted_per_second, Some(10.0));
173        assert_eq!(usage.prompt_eval_duration_ms, Some(1000.0));
174        assert_eq!(usage.generation_duration_ms, Some(1000.0));
175    }
176
177    #[test]
178    fn zero_length_phases_do_not_become_infinite_rates() {
179        let usage = Usage::new(5, 5).with_timings(0.0, 0.0);
180        assert_eq!(usage.prompt_per_second, None);
181        assert_eq!(usage.predicted_per_second, None);
182        assert_eq!(usage.prompt_eval_duration_ms, Some(0.0));
183    }
184
185    #[test]
186    fn ttft_is_unset_when_nothing_was_generated() {
187        let usage = Usage::new(5, 0).with_ttft(0.25);
188        assert_eq!(usage.time_to_first_token_ms, None);
189        assert_eq!(
190            Usage::new(5, 1).with_ttft(0.25).time_to_first_token_ms,
191            Some(250.0)
192        );
193    }
194
195    #[test]
196    fn untimed_usage_serializes_to_the_plain_openai_shape() {
197        // Older clients must not start seeing null-valued extras.
198        let json = serde_json::to_string(&Usage::new(2, 3)).unwrap();
199        assert_eq!(
200            json,
201            "{\"prompt_tokens\":2,\"completion_tokens\":3,\"total_tokens\":5}"
202        );
203    }
204
205    #[test]
206    fn a_non_speculative_request_reports_no_acceptance_length_at_all() {
207        // `None` and `1.0` mean different things: "speculation did not
208        // run" and "speculation ran and never helped". A UI that saw a
209        // zero or a one on every plain request would report a
210        // speculative decoder that does not exist.
211        let plain = Usage::new(10, 5);
212        assert_eq!(plain.acceptance_length, None);
213        assert_eq!(plain.draft_tokens, None);
214        let json = serde_json::to_value(&plain).unwrap();
215        assert!(json.get("acceptance_length").is_none());
216        assert!(json.get("draft_accept_rate_per_position").is_none());
217    }
218
219    #[test]
220    fn acceptance_length_is_completion_tokens_per_verification_step() {
221        // 12 tokens out of 5 verification steps is an acceptance length
222        // of 2.4 -- the published metric. Dividing by forward passes
223        // including prefill, or by drafted tokens, gives a different
224        // and incomparable number.
225        let usage = Usage::new(20, 12).with_speculation(5, 7, 10, vec![0.9, 0.6, 0.2]);
226        assert_eq!(usage.acceptance_length, Some(2.4));
227        assert_eq!(usage.accepted_draft_tokens, Some(7));
228        assert_eq!(usage.draft_tokens, Some(10));
229        assert_eq!(
230            usage.draft_accept_rate_per_position,
231            Some(vec![0.9, 0.6, 0.2])
232        );
233    }
234
235    #[test]
236    fn speculation_that_verified_nothing_reports_no_length_rather_than_infinity() {
237        let usage = Usage::new(20, 0).with_speculation(0, 0, 0, Vec::new());
238        assert_eq!(usage.acceptance_length, None);
239        // The counters are still reported: speculation was configured,
240        // it just never got to verify anything.
241        assert_eq!(usage.draft_tokens, Some(0));
242    }
243
244    #[test]
245    fn a_prefix_cache_miss_is_distinguishable_from_no_prefix_cache() {
246        assert_eq!(Usage::new(2, 3).cached_tokens, None);
247        assert_eq!(
248            Usage::new(2, 3).with_cached_tokens(0).cached_tokens,
249            Some(0)
250        );
251    }
252}