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/// OpenAI's `usage.completion_tokens_details`.
24///
25/// Only the one field is carried: the others in OpenAI's object
26/// (`audio_tokens`, `accepted_prediction_tokens`) describe features
27/// this server does not implement, and inventing zeroes for them would
28/// be the same lie this type exists to stop telling.
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30pub struct CompletionTokensDetails {
31 pub reasoning_tokens: usize,
32}
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct Usage {
36 pub prompt_tokens: usize,
37 pub completion_tokens: usize,
38 pub total_tokens: usize,
39 /// OpenAI's nested completion breakdown. Absent unless the
40 /// reasoning split actually ran, because a zero here is a claim
41 /// about the model and not about this server.
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub completion_tokens_details: Option<CompletionTokensDetails>,
44 /// Prefill throughput (prompt tokens / prefill seconds), when timed.
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub prompt_per_second: Option<f64>,
47 /// Decode throughput (completion tokens / decode seconds), when timed.
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub predicted_per_second: Option<f64>,
50 /// Wall time spent processing the prompt, in milliseconds.
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub prompt_eval_duration_ms: Option<f64>,
53 /// Wall time spent in the decode loop, in milliseconds. Kept
54 /// separate from `prompt_eval_duration_ms` on purpose (see the
55 /// module docs).
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub generation_duration_ms: Option<f64>,
58 /// Time to first token: from the start of prefill to the moment the
59 /// first token was produced. `None` when no token was produced at
60 /// all (an immediate EOS), because a zero there would read as an
61 /// instantaneous response.
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub time_to_first_token_ms: Option<f64>,
64 /// Prompt tokens served from the KV prefix cache instead of being
65 /// recomputed. `Some(0)` means "the cache was consulted and missed";
66 /// `None` means "no prefix cache is configured" -- a distinction the
67 /// UI needs to decide whether to show the row at all.
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub cached_tokens: Option<usize>,
70 /// Completion tokens per verification step when speculative
71 /// decoding ran: the published *acceptance length*. `None` means
72 /// speculation did not run, which is not the same as an acceptance
73 /// length of 1.0 (speculation ran and never helped).
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub acceptance_length: Option<f64>,
76 /// Draft tokens the target actually evaluated. Positions after a
77 /// rejection are not counted, so the ratio below tracks the
78 /// drafter's accuracy rather than its block size.
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub draft_tokens: Option<usize>,
81 /// Draft tokens accepted.
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub accepted_draft_tokens: Option<usize>,
84 /// Accept rate at each position within the draft block, each
85 /// conditional on that position having been reached.
86 ///
87 /// Reported alongside the mean and not folded into it: a drafter
88 /// that is right at position 0 and useless by position 7 has the
89 /// same mean as one that is uniformly mediocre, and the two want
90 /// opposite block sizes. Suffix decay is only visible per position.
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub draft_accept_rate_per_position: Option<Vec<f64>>,
93}
94
95impl Usage {
96 pub fn new(prompt_tokens: usize, completion_tokens: usize) -> Self {
97 Usage {
98 prompt_tokens,
99 completion_tokens,
100 total_tokens: prompt_tokens + completion_tokens,
101 completion_tokens_details: None,
102 prompt_per_second: None,
103 predicted_per_second: None,
104 prompt_eval_duration_ms: None,
105 generation_duration_ms: None,
106 time_to_first_token_ms: None,
107 cached_tokens: None,
108 acceptance_length: None,
109 draft_tokens: None,
110 accepted_draft_tokens: None,
111 draft_accept_rate_per_position: None,
112 }
113 }
114
115 /// Records the two phase durations, in seconds, and the rates they
116 /// imply. A zero-length phase leaves the rate unset rather than
117 /// dividing by zero into infinity.
118 pub fn with_timings(mut self, prompt_secs: f64, predicted_secs: f64) -> Self {
119 self.prompt_eval_duration_ms = Some(prompt_secs * 1000.0);
120 self.generation_duration_ms = Some(predicted_secs * 1000.0);
121 if prompt_secs > 0.0 && self.prompt_tokens > 0 {
122 self.prompt_per_second = Some(self.prompt_tokens as f64 / prompt_secs);
123 }
124 if predicted_secs > 0.0 && self.completion_tokens > 0 {
125 self.predicted_per_second = Some(self.completion_tokens as f64 / predicted_secs);
126 }
127 self
128 }
129
130 /// Time-to-first-token, in seconds, measured from the start of
131 /// prefill. Ignored when no token was generated.
132 pub fn with_ttft(mut self, secs: f64) -> Self {
133 if self.completion_tokens > 0 {
134 self.time_to_first_token_ms = Some(secs * 1000.0);
135 }
136 self
137 }
138
139 /// How many of the completion's tokens were spent reasoning.
140 ///
141 /// OpenAI's own field, and the only part of a reasoning model's
142 /// accounting that IS in their spec -- `reasoning_content` is a
143 /// DeepSeek convention this server also speaks, but the token
144 /// count is standard, and it is how a caller prices or budgets a
145 /// thinking model.
146 ///
147 /// `None`, not zero, when this build cannot know: a checkpoint
148 /// whose family emits no reasoning at all, and any path that did
149 /// not run the split. `/v1/responses` used to report a hardcoded
150 /// `0` here, which reads as "this model did not think" rather than
151 /// "nobody counted" -- the exact confusion this module's header
152 /// rules out for timings.
153 pub fn with_reasoning_tokens(mut self, reasoning: usize) -> Self {
154 self.completion_tokens_details = Some(CompletionTokensDetails {
155 reasoning_tokens: reasoning,
156 });
157 self
158 }
159
160 /// Prompt tokens that came from the prefix cache. Call this only
161 /// when a prefix cache actually exists (see `cached_tokens`).
162 pub fn with_cached_tokens(mut self, cached: usize) -> Self {
163 self.cached_tokens = Some(cached);
164 self
165 }
166
167 /// Records what speculative decoding actually achieved for this
168 /// request. Call this only when speculation ran: leaving the fields
169 /// unset is how a non-speculative request says so, and a zero would
170 /// read as "speculation ran and failed".
171 ///
172 /// `accepted` and `drafted` are token counts, `per_position` the
173 /// accept rate at each position inside the draft block. A zero
174 /// `verification_steps` leaves `acceptance_length` unset rather
175 /// than dividing by zero.
176 pub fn with_speculation(
177 mut self,
178 verification_steps: usize,
179 accepted: usize,
180 drafted: usize,
181 per_position: Vec<f64>,
182 ) -> Self {
183 if verification_steps > 0 {
184 self.acceptance_length =
185 Some(self.completion_tokens as f64 / verification_steps as f64);
186 }
187 self.accepted_draft_tokens = Some(accepted);
188 self.draft_tokens = Some(drafted);
189 self.draft_accept_rate_per_position = Some(per_position);
190 self
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn totals_are_the_sum_of_the_two_phases() {
200 let usage = Usage::new(7, 3);
201 assert_eq!(usage.total_tokens, 10);
202 }
203
204 #[test]
205 fn phase_durations_stay_separate() {
206 // 100 prompt tokens in 1s, 10 generated in 1s. A client that
207 // conflated the phases would report 110/2 = 55 tok/s for both.
208 let usage = Usage::new(100, 10).with_timings(1.0, 1.0);
209 assert_eq!(usage.prompt_per_second, Some(100.0));
210 assert_eq!(usage.predicted_per_second, Some(10.0));
211 assert_eq!(usage.prompt_eval_duration_ms, Some(1000.0));
212 assert_eq!(usage.generation_duration_ms, Some(1000.0));
213 }
214
215 #[test]
216 fn zero_length_phases_do_not_become_infinite_rates() {
217 let usage = Usage::new(5, 5).with_timings(0.0, 0.0);
218 assert_eq!(usage.prompt_per_second, None);
219 assert_eq!(usage.predicted_per_second, None);
220 assert_eq!(usage.prompt_eval_duration_ms, Some(0.0));
221 }
222
223 #[test]
224 fn ttft_is_unset_when_nothing_was_generated() {
225 let usage = Usage::new(5, 0).with_ttft(0.25);
226 assert_eq!(usage.time_to_first_token_ms, None);
227 assert_eq!(
228 Usage::new(5, 1).with_ttft(0.25).time_to_first_token_ms,
229 Some(250.0)
230 );
231 }
232
233 #[test]
234 fn untimed_usage_serializes_to_the_plain_openai_shape() {
235 // Older clients must not start seeing null-valued extras.
236 let json = serde_json::to_string(&Usage::new(2, 3)).unwrap();
237 assert_eq!(
238 json,
239 "{\"prompt_tokens\":2,\"completion_tokens\":3,\"total_tokens\":5}"
240 );
241 }
242
243 #[test]
244 fn a_non_speculative_request_reports_no_acceptance_length_at_all() {
245 // `None` and `1.0` mean different things: "speculation did not
246 // run" and "speculation ran and never helped". A UI that saw a
247 // zero or a one on every plain request would report a
248 // speculative decoder that does not exist.
249 let plain = Usage::new(10, 5);
250 assert_eq!(plain.acceptance_length, None);
251 assert_eq!(plain.draft_tokens, None);
252 let json = serde_json::to_value(&plain).unwrap();
253 assert!(json.get("acceptance_length").is_none());
254 assert!(json.get("draft_accept_rate_per_position").is_none());
255 }
256
257 #[test]
258 fn acceptance_length_is_completion_tokens_per_verification_step() {
259 // 12 tokens out of 5 verification steps is an acceptance length
260 // of 2.4 -- the published metric. Dividing by forward passes
261 // including prefill, or by drafted tokens, gives a different
262 // and incomparable number.
263 let usage = Usage::new(20, 12).with_speculation(5, 7, 10, vec![0.9, 0.6, 0.2]);
264 assert_eq!(usage.acceptance_length, Some(2.4));
265 assert_eq!(usage.accepted_draft_tokens, Some(7));
266 assert_eq!(usage.draft_tokens, Some(10));
267 assert_eq!(
268 usage.draft_accept_rate_per_position,
269 Some(vec![0.9, 0.6, 0.2])
270 );
271 }
272
273 #[test]
274 fn speculation_that_verified_nothing_reports_no_length_rather_than_infinity() {
275 let usage = Usage::new(20, 0).with_speculation(0, 0, 0, Vec::new());
276 assert_eq!(usage.acceptance_length, None);
277 // The counters are still reported: speculation was configured,
278 // it just never got to verify anything.
279 assert_eq!(usage.draft_tokens, Some(0));
280 }
281
282 #[test]
283 fn a_prefix_cache_miss_is_distinguishable_from_no_prefix_cache() {
284 assert_eq!(Usage::new(2, 3).cached_tokens, None);
285 assert_eq!(
286 Usage::new(2, 3).with_cached_tokens(0).cached_tokens,
287 Some(0)
288 );
289 }
290}