[Rust Frontend] Report reasoning tokens in chat completion usage (#54883)

Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Bugen Zhao committed Sep 4, 2026 at 09:47 UTC d6bce42983bc0b2095ad6422dbf1399e219ae572
15 files changed +735 -88
.buildkite/test_areas/rust_frontend.yaml
+6
@@ -15,7 +15,9 @@ steps:
15 - tests/utils.py
16 - tests/benchmarks/test_serve_cli.py
17 - tests/entrypoints/openai/chat_completion/test_chat_completion.py
18 + - tests/entrypoints/openai/chat_completion/test_include_reasoning.py
19 - tests/entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py
20 + - tests/entrypoints/openai/chat_completion/test_serving_chat.py
21
22 # - tests/entrypoints/openai/completion/test_prompt_validation.py
23 - tests/entrypoints/launchers/test_shutdown.py
@@ -27,7 +29,9 @@ steps:
29 - export VLLM_WORKER_MULTIPROC_METHOD=spawn
30 - pytest -v -s benchmarks/test_serve_cli.py -k "not insecure and not (test_bench_serve and not test_bench_serve_chat)"
31 - pytest -v -s entrypoints/openai/chat_completion/test_chat_completion.py -k "not test_invalid_json_schema and not test_invalid_regex and not test_kv_transfer_prompt_token_ids_round_trip and not test_kv_transfer_prompt_token_ids_streaming"
32 + - pytest -v -s entrypoints/openai/chat_completion/test_include_reasoning.py
33 - pytest -v -s entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py -k "not multiple"
34 + - pytest -v -s entrypoints/openai/chat_completion/test_serving_chat.py -k "test_chat_per_request_metrics_follow_server_flag or test_streaming_reasoning_usage_counts_across_deltas or test_completion_tokens_details"
35
36 # - pytest -v -s entrypoints/openai/completion/test_prompt_validation.py -k "not prompt_embeds"
37 - pytest -v -s entrypoints/launchers/test_shutdown.py -k "not engine_failure and not test_abort_timeout_exits_quickly"
@@ -53,7 +57,9 @@ steps:
57 - tests/utils.py
58 - tests/benchmarks/test_serve_cli.py
59 - tests/entrypoints/openai/chat_completion/test_chat_completion.py
60 + - tests/entrypoints/openai/chat_completion/test_include_reasoning.py
61 - tests/entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py
62 + - tests/entrypoints/openai/chat_completion/test_serving_chat.py
63 - tests/entrypoints/openai/completion/test_shutdown.py
64 - tests/entrypoints/openai/test_return_token_ids.py
65 - tests/entrypoints/openai/test_uds.py
rust/src/chat/src/event.rs
+34 -1
@@ -123,6 +123,37 @@ impl [AssistantContentBlock] {
123 }
124 }
125
126 +/// Chat-level token usage for one completed request.
127 +///
128 +/// Extends the engine-level [`TokenUsage`] with reasoning attribution measured
129 +/// by the chat output pipeline. [`TokenUsage`] itself stays engine-level and
130 +/// is not touched by parsing.
131 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
132 +pub struct ChatTokenUsage {
133 + /// Engine-level token usage.
134 + pub engine: TokenUsage,
135 + /// Number of generated tokens attributed to reasoning; 0 when the
136 + /// configured parser has no reasoning channel.
137 + pub reasoning_tokens: usize,
138 +}
139 +
140 +impl From<TokenUsage> for ChatTokenUsage {
141 + fn from(engine: TokenUsage) -> Self {
142 + Self {
143 + engine,
144 + reasoning_tokens: 0,
145 + }
146 + }
147 +}
148 +
149 +impl Deref for ChatTokenUsage {
150 + type Target = TokenUsage;
151 +
152 + fn deref(&self) -> &Self::Target {
153 + &self.engine
154 + }
155 +}
156 +
157 /// Final structured assistant message assembled from the event stream.
158 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
159 pub struct AssistantMessage {
@@ -172,6 +203,8 @@ pub enum ChatEvent {
203 index: usize,
204 kind: AssistantBlockKind,
205 delta: String,
206 + /// Number of generated tokens attributed to this delta, when available.
207 + token_count: Option<usize>,
208 },
209 /// Per-decoded-update sample metadata: logprobs and/or output token IDs.
210 LogprobsDelta {
@@ -201,7 +234,7 @@ pub enum ChatEvent {
234 /// metadata.
235 Done {
236 message: AssistantMessage,
204 - usage: TokenUsage,
237 + usage: ChatTokenUsage,
238 finish_reason: FinishReason,
239 /// Connector-specific KV transfer parameters for disaggregated serving.
240 kv_transfer_params: Option<serde_json::Value>,
rust/src/chat/src/lib.rs
+1 -1
@@ -18,7 +18,7 @@ pub use backend::{
18 pub use error::{Error, Result};
19 pub use event::{
20 AssistantBlockKind, AssistantContentBlock, AssistantMessage, AssistantMessageExt,
21 - AssistantToolCall, ChatEvent,
21 + AssistantToolCall, ChatEvent, ChatTokenUsage,
22 };
23 use futures::{StreamExt, TryStreamExt as _};
24 pub use llm_multimodal::MediaContentPart;
rust/src/chat/src/output/default/unified.rs
+214 -14
@@ -17,7 +17,7 @@ use vllm_text::output::{DecodedText, DecodedTextEvent, SampledDelta};
17
18 use crate::Result;
19 use crate::error::Error;
20 -use crate::event::AssistantBlockKind;
20 +use crate::event::{AssistantBlockKind, ChatTokenUsage};
21 use crate::output::{AssistantEvent, DecodedTextEventStream, generate_tool_call_id};
22
23 /// Per-stream unified parsing state.
@@ -31,6 +31,10 @@ struct UnifiedParserState {
31 /// Supported parsers currently emit at most one active tool call at a time.
32 /// Change this to an indexed map if a model needs interleaved calls later.
33 open_call_index: Option<usize>,
34 + /// Running count of tokens attributed to reasoning. Kept solely to
35 + /// produce the final count on `Done`; per-delta counts are carried by
36 + /// each `TextDelta` event itself.
37 + reasoning_tokens: usize,
38 }
39
40 impl UnifiedParserState {
@@ -40,6 +44,7 @@ impl UnifiedParserState {
44 parser,
45 parser_failed: false,
46 open_call_index: None,
47 + reasoning_tokens: 0,
48 }
49 }
50
@@ -69,10 +74,10 @@ impl UnifiedParserState {
74 return Ok(text_event(AssistantBlockKind::Text, delta.text).into_iter().collect());
75 }
76
72 - let mut output = UnifiedParserOutput::default();
77 // The parser consumes the delta; keep its text for the fallback path
78 // that re-emits it as plain text.
79 let fallback_text = delta.text.clone();
80 + let mut output = UnifiedParserOutput::default();
81 match self.parser.parse_into(delta, &mut output) {
82 Ok(()) => {
83 let mut events = Vec::new();
@@ -138,9 +143,13 @@ impl UnifiedParserState {
143 self.open_call_index = None;
144 push_text_delta(events, AssistantBlockKind::Text, delta);
145 }
141 - UnifiedParserEvent::Reasoning(delta) => {
146 + UnifiedParserEvent::Reasoning(piece) => {
147 self.open_call_index = None;
143 - push_text_delta(events, AssistantBlockKind::Reasoning, delta.text);
148 + // By construction this counts exactly the tokens emitted to
149 + // the client as reasoning, so the parser-failure fallback
150 + // needs no special handling.
151 + self.reasoning_tokens += piece.attributions.len();
152 + push_piece_delta(events, AssistantBlockKind::Reasoning, piece);
153 }
154 UnifiedParserEvent::ToolCall(item) => {
155 self.process_tool_item(item, events)?;
@@ -200,12 +209,17 @@ impl UnifiedParserState {
209 }
210 }
211
203 -/// Build one plain text event if `delta` is non-empty.
212 +/// Build one plain text event with no measured tokens, if `delta` is
213 +/// non-empty.
214 fn text_event(kind: AssistantBlockKind, delta: String) -> Option<AssistantEvent> {
215 if delta.is_empty() {
216 return None;
217 }
208 - Some(AssistantEvent::TextDelta { kind, delta })
218 + Some(AssistantEvent::TextDelta {
219 + kind,
220 + delta,
221 + token_count: None,
222 + })
223 }
224
225 /// Push one plain text delta if it is non-empty.
@@ -215,6 +229,22 @@ fn push_text_delta(events: &mut Vec<AssistantEvent>, kind: AssistantBlockKind, d
229 }
230 }
231
232 +/// Push one attributed delta, counting its tokens, if its text is non-empty.
233 +fn push_piece_delta(
234 + events: &mut Vec<AssistantEvent>,
235 + kind: AssistantBlockKind,
236 + piece: DecodedText,
237 +) {
238 + if piece.text.is_empty() {
239 + return;
240 + }
241 + events.push(AssistantEvent::TextDelta {
242 + kind,
243 + delta: piece.text,
244 + token_count: Some(piece.attributions.len()),
245 + });
246 +}
247 +
248 /// Wrap one decoded-text stream into the internal unified assistant stream.
249 #[try_stream]
250 pub(crate) async fn unified_event_stream(
@@ -263,7 +293,10 @@ pub(crate) async fn unified_event_stream(
293 y.yield_ok(next).await;
294 }
295 y.yield_ok(AssistantEvent::Done {
266 - usage: finished.usage,
296 + usage: ChatTokenUsage {
297 + engine: finished.usage,
298 + reasoning_tokens: state.reasoning_tokens,
299 + },
300 finish_reason: finished.finish_reason,
301 kv_transfer_params: finished.kv_transfer_params,
302 ec_transfer_params: finished.ec_transfer_params,
@@ -285,10 +318,12 @@ mod tests {
318 use vllm_parser::reasoning::ReasoningError;
319 use vllm_parser::tool::{Tool, ToolCallDelta};
320 use vllm_parser::unified::{Gemma4UnifiedParser, UnifiedParserError, UnifiedParserOutput};
321 + use vllm_text::DecodedText;
322 use vllm_tokenizer::test_utils::TestTokenizer;
323 + use vllm_tokenizer::{TokenAnchor, TokenAttribution};
324
290 - use super::{DecodedText, unified_event_stream};
291 - use crate::event::AssistantBlockKind;
325 + use super::unified_event_stream;
326 + use crate::event::{AssistantBlockKind, ChatTokenUsage};
327 use crate::output::AssistantEvent;
328
329 enum ScriptedStep {
@@ -303,6 +338,7 @@ mod tests {
338 steps: VecDeque<ScriptedStep>,
339 reset_text: String,
340 tool_call_id: Option<String>,
341 + finish_output: Option<UnifiedParserOutput>,
342 finish_error_reset_text: Option<String>,
343 }
344
@@ -312,10 +348,16 @@ mod tests {
348 steps: steps.into_iter().collect(),
349 reset_text: String::new(),
350 tool_call_id: Some("call_test".to_string()),
351 + finish_output: None,
352 finish_error_reset_text: None,
353 }
354 }
355
356 + fn with_finish_output(mut self, output: UnifiedParserOutput) -> Self {
357 + self.finish_output = Some(output);
358 + self
359 + }
360 +
361 fn with_finish_error(mut self, reset_text: &str) -> Self {
362 self.finish_error_reset_text = Some(reset_text.to_string());
363 self
@@ -371,7 +413,7 @@ mod tests {
413 },
414 ));
415 }
374 - Ok(UnifiedParserOutput::default())
416 + Ok(self.finish_output.take().unwrap_or_default())
417 }
418
419 fn reset(&mut self) -> String {
@@ -381,7 +423,7 @@ mod tests {
423
424 fn decoded_delta(delta: &str) -> vllm_text::output::DecodedTextEvent {
425 vllm_text::output::DecodedTextEvent::TextDelta {
384 - decoded: vllm_text::DecodedText::unattributed(delta),
426 + decoded: DecodedText::unattributed(delta),
427 sampled: vllm_text::SampledDelta::default(),
428 finished: None,
429 }
@@ -389,7 +431,7 @@ mod tests {
431
432 fn finished_delta(delta: &str) -> vllm_text::output::DecodedTextEvent {
433 vllm_text::output::DecodedTextEvent::TextDelta {
392 - decoded: vllm_text::DecodedText::unattributed(delta),
434 + decoded: DecodedText::unattributed(delta),
435 sampled: vllm_text::SampledDelta::default(),
436 finished: Some(Box::new(vllm_text::output::Finished {
437 usage: vllm_llm::TokenUsage::default(),
@@ -425,6 +467,22 @@ mod tests {
467 output
468 }
469
470 + /// One reasoning piece carrying `tokens` synthetic token attributions.
471 + fn attributed_reasoning(delta: &str, tokens: u32) -> UnifiedParserOutput {
472 + let attributions = (0..tokens)
473 + .map(|token_id| TokenAttribution {
474 + token_id,
475 + anchor: TokenAnchor::Visible { byte_offset: 0 },
476 + })
477 + .collect();
478 + let mut output = UnifiedParserOutput::default();
479 + output.push_reasoning(DecodedText {
480 + text: delta.to_string(),
481 + attributions,
482 + });
483 + output
484 + }
485 +
486 fn tool_call(name: &str, arguments: &str) -> UnifiedParserOutput {
487 UnifiedParserOutput {
488 events: vec![vllm_parser::unified::UnifiedParserEvent::ToolCall(
@@ -455,6 +513,15 @@ mod tests {
513 output
514 }
515
516 + /// Terminal usage with a default engine-level usage and the given final
517 + /// reasoning token count.
518 + fn done_usage(reasoning_tokens: usize) -> ChatTokenUsage {
519 + ChatTokenUsage {
520 + engine: vllm_llm::TokenUsage::default(),
521 + reasoning_tokens,
522 + }
523 + }
524 +
525 #[tokio::test]
526 async fn unified_stream_parses_formatted_tool_call_without_latch() {
527 use vllm_parser::tool::{HermesToolParser, ToolParser as _};
@@ -519,6 +586,7 @@ mod tests {
586 vec![AssistantEvent::TextDelta {
587 kind: AssistantBlockKind::Reasoning,
588 delta: "thinking".to_string(),
589 + token_count: Some(0),
590 }]
591 );
592 }
@@ -565,6 +633,7 @@ mod tests {
633 AssistantEvent::TextDelta {
634 kind: AssistantBlockKind::Reasoning,
635 delta: "thinking".to_string(),
636 + token_count: Some(0),
637 },
638 AssistantEvent::ToolCallStart {
639 id: "call_test".to_string(),
@@ -594,6 +663,7 @@ mod tests {
663 AssistantEvent::TextDelta {
664 kind: AssistantBlockKind::Text,
665 delta: "visible ".to_string(),
666 + token_count: None,
667 },
668 AssistantEvent::ToolCallStart {
669 id: "call_test".to_string(),
@@ -633,6 +703,7 @@ mod tests {
703 AssistantEvent::TextDelta {
704 kind: AssistantBlockKind::Text,
705 delta: " done".to_string(),
706 + token_count: None,
707 },
708 ]
709 );
@@ -655,14 +726,17 @@ mod tests {
726 AssistantEvent::TextDelta {
727 kind: AssistantBlockKind::Text,
728 delta: "committed".to_string(),
729 + token_count: None,
730 },
731 AssistantEvent::TextDelta {
732 kind: AssistantBlockKind::Text,
733 delta: "buffered".to_string(),
734 + token_count: None,
735 },
736 AssistantEvent::TextDelta {
737 kind: AssistantBlockKind::Text,
738 delta: "later".to_string(),
739 + token_count: None,
740 },
741 ]
742 );
@@ -683,9 +757,134 @@ mod tests {
757 AssistantEvent::TextDelta {
758 kind: AssistantBlockKind::Text,
759 delta: "buffered".to_string(),
760 + token_count: None,
761 + },
762 + AssistantEvent::Done {
763 + usage: done_usage(0),
764 + finish_reason: crate::FinishReason::Stop(None),
765 + kv_transfer_params: None,
766 + ec_transfer_params: None,
767 + },
768 + ]
769 + );
770 + }
771 +
772 + #[tokio::test]
773 + async fn unified_stream_counts_reasoning_tokens_across_deltas() {
774 + let events = collect(
775 + ScriptedParser::new([
776 + ScriptedStep::Output(attributed_reasoning("think", 2)),
777 + ScriptedStep::Output(combined(text("visible"), attributed_reasoning("more", 3))),
778 + ]),
779 + vec![decoded_delta("a"), finished_delta("b")],
780 + )
781 + .await;
782 +
783 + assert_eq!(
784 + events,
785 + vec![
786 + AssistantEvent::TextDelta {
787 + kind: AssistantBlockKind::Reasoning,
788 + delta: "think".to_string(),
789 + token_count: Some(2),
790 + },
791 + // Non-reasoning deltas carry no token count.
792 + AssistantEvent::TextDelta {
793 + kind: AssistantBlockKind::Text,
794 + delta: "visible".to_string(),
795 + token_count: None,
796 + },
797 + AssistantEvent::TextDelta {
798 + kind: AssistantBlockKind::Reasoning,
799 + delta: "more".to_string(),
800 + token_count: Some(3),
801 + },
802 + AssistantEvent::Done {
803 + usage: done_usage(5),
804 + finish_reason: crate::FinishReason::Stop(None),
805 + kv_transfer_params: None,
806 + ec_transfer_params: None,
807 + },
808 + ]
809 + );
810 + }
811 +
812 + #[tokio::test]
813 + async fn unified_stream_counts_reasoning_emitted_at_finish() {
814 + let events = collect(
815 + ScriptedParser::new([ScriptedStep::Output(text("visible"))])
816 + .with_finish_output(attributed_reasoning("tail", 2)),
817 + vec![finished_delta("raw")],
818 + )
819 + .await;
820 +
821 + assert_eq!(
822 + events,
823 + vec![
824 + AssistantEvent::TextDelta {
825 + kind: AssistantBlockKind::Text,
826 + delta: "visible".to_string(),
827 + token_count: None,
828 + },
829 + AssistantEvent::TextDelta {
830 + kind: AssistantBlockKind::Reasoning,
831 + delta: "tail".to_string(),
832 + token_count: Some(2),
833 + },
834 + AssistantEvent::Done {
835 + usage: done_usage(2),
836 + finish_reason: crate::FinishReason::Stop(None),
837 + kv_transfer_params: None,
838 + ec_transfer_params: None,
839 + },
840 + ]
841 + );
842 + }
843 +
844 + #[tokio::test]
845 + async fn unified_stream_fallback_keeps_emitted_reasoning_count() {
846 + let events = collect(
847 + ScriptedParser::new([
848 + ScriptedStep::Output(attributed_reasoning("think", 2)),
849 + ScriptedStep::Error {
850 + committed: attributed_reasoning("more", 3),
851 + reset_text: "buffered".to_string(),
852 + },
853 + ]),
854 + vec![
855 + decoded_delta("a"),
856 + decoded_delta("bad"),
857 + finished_delta("later"),
858 + ],
859 + )
860 + .await;
861 +
862 + assert_eq!(
863 + events,
864 + vec![
865 + AssistantEvent::TextDelta {
866 + kind: AssistantBlockKind::Reasoning,
867 + delta: "think".to_string(),
868 + token_count: Some(2),
869 + },
870 + // Committed reasoning still counts before the failure fallback.
871 + AssistantEvent::TextDelta {
872 + kind: AssistantBlockKind::Reasoning,
873 + delta: "more".to_string(),
874 + token_count: Some(3),
875 + },
876 + AssistantEvent::TextDelta {
877 + kind: AssistantBlockKind::Text,
878 + delta: "buffered".to_string(),
879 + token_count: None,
880 + },
881 + AssistantEvent::TextDelta {
882 + kind: AssistantBlockKind::Text,
883 + delta: "later".to_string(),
884 + token_count: None,
885 },
886 AssistantEvent::Done {
688 - usage: vllm_llm::TokenUsage::default(),
887 + usage: done_usage(5),
888 finish_reason: crate::FinishReason::Stop(None),
889 kv_transfer_params: None,
890 ec_transfer_params: None,
@@ -727,9 +926,10 @@ mod tests {
926 kind: AssistantBlockKind::Text,
927 delta: "<|tool_call>call:write_file{content:<|\"|>hello world<|\"|>"
928 .to_string(),
929 + token_count: None,
930 },
931 AssistantEvent::Done {
732 - usage: vllm_llm::TokenUsage::default(),
932 + usage: done_usage(0),
933 finish_reason: crate::FinishReason::Stop(None),
934 kv_transfer_params: None,
935 ec_transfer_params: None,
rust/src/chat/src/output/harmony/mod.rs
+7 -1
@@ -259,6 +259,9 @@ impl HarmonyState {
259 events.push(AssistantEvent::TextDelta {
260 kind,
261 delta: "\n".to_string(),
262 + // TODO: measure reasoning tokens from native
263 + // analysis-channel token counts.
264 + token_count: None,
265 });
266 }
267
@@ -268,6 +271,9 @@ impl HarmonyState {
271 events.push(AssistantEvent::TextDelta {
272 kind,
273 delta: group.text,
274 + // TODO: measure reasoning tokens from native
275 + // analysis-channel token counts.
276 + token_count: None,
277 });
278 return;
279 }
@@ -373,7 +379,7 @@ async fn harmony_assistant_event_stream(
379
380 if let Some(finished) = finished {
381 y.yield_ok(AssistantEvent::Done {
376 - usage: finished.usage,
382 + usage: finished.usage.into(),
383 finish_reason: finished.finish_reason,
384 kv_transfer_params: finished.kv_transfer_params,
385 ec_transfer_params: finished.ec_transfer_params,
rust/src/chat/src/output/harmony/tests.rs
+5 -5
@@ -13,7 +13,7 @@ use vllm_text::output::{
13 use super::*;
14 use crate::output::ChatOutputProcessor;
15 use crate::request::{ChatRequest, ChatTool, ChatToolChoice, ResolvedToolContext};
16 -use crate::{AssistantMessageExt, ChatEvent, FinishReason};
16 +use crate::{AssistantMessageExt, ChatEvent, ChatTokenUsage, FinishReason};
17
18 fn assistant_prefix() -> Vec<u32> {
19 harmony_encoding()
@@ -125,11 +125,11 @@ fn interrupted_final_message_is_preserved() {
125 text: "hello".to_string(),
126 }],
127 },
128 - usage: vllm_llm::TokenUsage {
128 + usage: ChatTokenUsage::from(vllm_llm::TokenUsage {
129 prompt_token_count: 0,
130 output_token_count: 0,
131 cached_token_count: 0,
132 - },
132 + }),
133 finish_reason: FinishReason::stop_eos(),
134 kv_transfer_params: None,
135 ec_transfer_params: None,
@@ -176,11 +176,11 @@ fn interrupted_analysis_message_is_preserved() {
176 text: "think".to_string(),
177 }],
178 },
179 - usage: vllm_llm::TokenUsage {
179 + usage: ChatTokenUsage::from(vllm_llm::TokenUsage {
180 prompt_token_count: 0,
181 output_token_count: 0,
182 cached_token_count: 0,
183 - },
183 + }),
184 finish_reason: FinishReason::stop_eos(),
185 kv_transfer_params: None,
186 ec_transfer_params: None,
rust/src/chat/src/output/mod.rs
+4 -3
@@ -7,12 +7,11 @@ use std::sync::Arc;
7 use futures::Stream;
8 use trait_set::trait_set;
9 use uuid::Uuid;
10 -use vllm_llm::TokenUsage;
10 use vllm_text::output::{DecodedLogprobs, DecodedPromptLogprobs, DecodedTextEvent};
11
12 use crate::FinishReason;
13 use crate::error::Result;
15 -use crate::event::{AssistantBlockKind, ChatEvent};
14 +use crate::event::{AssistantBlockKind, ChatEvent, ChatTokenUsage};
15
16 mod default;
17 mod harmony;
@@ -35,6 +34,8 @@ pub(crate) enum AssistantEvent {
34 TextDelta {
35 kind: AssistantBlockKind,
36 delta: String,
37 + /// Number of generated tokens attributed to this delta, when available.
38 + token_count: Option<usize>,
39 },
40 /// Per-decoded-update sample metadata: logprobs and/or output token IDs.
41 LogprobsDelta {
@@ -47,7 +48,7 @@ pub(crate) enum AssistantEvent {
48 /// `ToolCallStart`.
49 ToolCallArgumentsDelta { delta: String },
50 Done {
50 - usage: TokenUsage,
51 + usage: ChatTokenUsage,
52 finish_reason: FinishReason,
53 /// Connector-specific KV transfer parameters for disaggregated serving.
54 kv_transfer_params: Option<serde_json::Value>,
rust/src/chat/src/output/structured.rs
+81 -16
@@ -16,6 +16,7 @@ use super::{AssistantEvent, AssistantEventStream};
16 use crate::error::Error;
17 use crate::event::{
18 AssistantBlockKind, AssistantContentBlock, AssistantMessage, AssistantToolCall, ChatEvent,
19 + ChatTokenUsage,
20 };
21 use crate::{FinishReason, Result};
22
@@ -80,10 +81,11 @@ impl StructuredEventState {
81 &mut self,
82 kind: AssistantBlockKind,
83 delta: String,
84 + token_count: Option<usize>,
85 ) -> Result<Vec<ChatEvent>> {
86 let mut events = Vec::new();
87 self.close_open_tool_call(&mut events);
86 - self.push_text_delta(kind, delta, &mut events);
88 + self.push_text_delta(kind, delta, token_count, &mut events);
89 Ok(events)
90 }
91
@@ -146,7 +148,7 @@ impl StructuredEventState {
148 /// Close any open block and emit the terminal `Done` event.
149 fn finish(
150 &mut self,
149 - usage: vllm_llm::TokenUsage,
151 + usage: ChatTokenUsage,
152 finish_reason: FinishReason,
153 kv_transfer_params: Option<serde_json::Value>,
154 ec_transfer_params: Option<serde_json::Value>,
@@ -170,6 +172,7 @@ impl StructuredEventState {
172 &mut self,
173 kind: AssistantBlockKind,
174 delta: String,
175 + token_count: Option<usize>,
176 events: &mut Vec<ChatEvent>,
177 ) {
178 if delta.is_empty() {
@@ -184,6 +187,7 @@ impl StructuredEventState {
187 index: open_block.index,
188 kind,
189 delta,
190 + token_count,
191 });
192 }
193 // Otherwise, close the currently open block (if any) and start a
@@ -197,7 +201,12 @@ impl StructuredEventState {
201 text: delta.clone(),
202 });
203 events.push(ChatEvent::BlockStart { index, kind });
200 - events.push(ChatEvent::BlockDelta { index, kind, delta });
204 + events.push(ChatEvent::BlockDelta {
205 + index,
206 + kind,
207 + delta,
208 + token_count,
209 + });
210 }
211 }
212 }
@@ -274,8 +283,12 @@ pub(crate) async fn structured_chat_event_stream(
283 })
284 .await;
285 }
277 - AssistantEvent::TextDelta { kind, delta } => {
278 - for next in state.process_text_delta(kind, delta)? {
286 + AssistantEvent::TextDelta {
287 + kind,
288 + delta,
289 + token_count,
290 + } => {
291 + for next in state.process_text_delta(kind, delta, token_count)? {
292 y.yield_ok(next).await;
293 }
294 }
@@ -321,7 +334,7 @@ mod tests {
334 use super::structured_chat_event_stream;
335 use crate::FinishReason;
336 use crate::error::Error;
324 - use crate::event::{AssistantBlockKind, AssistantMessageExt as _, ChatEvent};
337 + use crate::event::{AssistantBlockKind, AssistantMessageExt as _, ChatEvent, ChatTokenUsage};
338 use crate::output::AssistantEvent;
339
340 #[tokio::test]
@@ -335,11 +348,11 @@ mod tests {
348 delta: r#"{"city":"Paris"}"#.to_string(),
349 }),
350 Ok(AssistantEvent::Done {
338 - usage: vllm_llm::TokenUsage {
351 + usage: ChatTokenUsage::from(vllm_llm::TokenUsage {
352 prompt_token_count: 1,
353 output_token_count: 1,
354 cached_token_count: 0,
342 - },
355 + }),
356 finish_reason: FinishReason::stop_eos(),
357 kv_transfer_params: None,
358 ec_transfer_params: None,
@@ -390,11 +403,11 @@ mod tests {
403 delta: r#"{"b":2}"#.to_string(),
404 }),
405 Ok(AssistantEvent::Done {
393 - usage: vllm_llm::TokenUsage {
406 + usage: ChatTokenUsage::from(vllm_llm::TokenUsage {
407 prompt_token_count: 1,
408 output_token_count: 1,
409 cached_token_count: 0,
397 - },
410 + }),
411 finish_reason: FinishReason::stop_eos(),
412 kv_transfer_params: None,
413 ec_transfer_params: None,
@@ -433,6 +446,7 @@ mod tests {
446 Ok(AssistantEvent::TextDelta {
447 kind: AssistantBlockKind::Text,
448 delta: "before".to_string(),
449 + token_count: None,
450 }),
451 Ok(AssistantEvent::ToolCallStart {
452 id: "call_1".to_string(),
@@ -442,11 +456,11 @@ mod tests {
456 delta: r#"{"city":"Paris"}"#.to_string(),
457 }),
458 Ok(AssistantEvent::Done {
445 - usage: vllm_llm::TokenUsage {
459 + usage: ChatTokenUsage::from(vllm_llm::TokenUsage {
460 prompt_token_count: 1,
461 output_token_count: 1,
462 cached_token_count: 0,
449 - },
463 + }),
464 finish_reason: FinishReason::stop_eos(),
465 kv_transfer_params: None,
466 ec_transfer_params: None,
@@ -492,13 +506,14 @@ mod tests {
506 Ok(AssistantEvent::TextDelta {
507 kind: AssistantBlockKind::Text,
508 delta: "done".to_string(),
509 + token_count: None,
510 }),
511 Ok(AssistantEvent::Done {
497 - usage: vllm_llm::TokenUsage {
512 + usage: ChatTokenUsage::from(vllm_llm::TokenUsage {
513 prompt_token_count: 1,
514 output_token_count: 1,
515 cached_token_count: 0,
501 - },
516 + }),
517 finish_reason: FinishReason::stop_eos(),
518 kv_transfer_params: None,
519 ec_transfer_params: None,
@@ -562,11 +577,11 @@ mod tests {
577 delta: r#"{"b":2}"#.to_string(),
578 }),
579 Ok(AssistantEvent::Done {
565 - usage: vllm_llm::TokenUsage {
580 + usage: ChatTokenUsage::from(vllm_llm::TokenUsage {
581 prompt_token_count: 1,
582 output_token_count: 1,
583 cached_token_count: 0,
569 - },
584 + }),
585 finish_reason: FinishReason::stop_eos(),
586 kv_transfer_params: None,
587 ec_transfer_params: None,
@@ -596,4 +611,54 @@ mod tests {
611 assert_eq!(tool_calls.len(), 1);
612 assert_eq!(tool_calls[0].name, "first");
613 }
614 +
615 + #[tokio::test]
616 + async fn structured_stream_forwards_reasoning_token_count() {
617 + let events = stream::iter(vec![
618 + Ok(AssistantEvent::Start {
619 + prompt_token_ids: vec![].into(),
620 + prompt_logprobs: None,
621 + }),
622 + Ok(AssistantEvent::TextDelta {
623 + kind: AssistantBlockKind::Reasoning,
624 + delta: "think".to_string(),
625 + token_count: Some(3),
626 + }),
627 + Ok(AssistantEvent::Done {
628 + usage: ChatTokenUsage {
629 + engine: vllm_llm::TokenUsage {
630 + prompt_token_count: 1,
631 + output_token_count: 4,
632 + cached_token_count: 0,
633 + },
634 + reasoning_tokens: 3,
635 + },
636 + finish_reason: FinishReason::stop_eos(),
637 + kv_transfer_params: None,
638 + ec_transfer_params: None,
639 + }),
640 + ]);
641 +
642 + let events = structured_chat_event_stream(events, true)
643 + .collect::<Vec<_>>()
644 + .await
645 + .into_iter()
646 + .collect::<crate::Result<Vec<_>>>()
647 + .unwrap();
648 +
649 + assert!(matches!(events[0], ChatEvent::Start { .. }));
650 + assert!(matches!(
651 + events[2],
652 + ChatEvent::BlockDelta {
653 + kind: AssistantBlockKind::Reasoning,
654 + token_count: Some(3),
655 + ..
656 + }
657 + ));
658 + let ChatEvent::Done { usage, .. } = &events[4] else {
659 + panic!("expected done");
660 + };
661 + assert_eq!(usage.reasoning_tokens, 3);
662 + assert_eq!(usage.output_token_count, 4);
663 + }
664 }
rust/src/chat/src/stream.rs
+7 -7
@@ -11,7 +11,7 @@ use vllm_text::{DecodedLogprobs, DecodedPositionLogprobs, DecodedPromptLogprobs}
11
12 use crate::FinishReason;
13 use crate::error::{Error, Result};
14 -use crate::event::{AssistantContentBlock, AssistantMessage, ChatEvent};
14 +use crate::event::{AssistantContentBlock, AssistantMessage, ChatEvent, ChatTokenUsage};
15
16 /// Final structured assistant message plus terminal stream metadata.
17 #[derive(Debug, Clone, PartialEq)]
@@ -21,7 +21,7 @@ pub struct CollectedAssistantMessage {
21 pub prompt_logprobs: Option<DecodedPromptLogprobs>,
22 pub logprobs: Option<DecodedLogprobs>,
23 pub token_ids: Vec<u32>,
24 - pub usage: vllm_llm::TokenUsage,
24 + pub usage: ChatTokenUsage,
25 pub finish_reason: FinishReason,
26 /// Connector-specific KV transfer parameters for disaggregated serving.
27 pub kv_transfer_params: Option<serde_json::Value>,
@@ -140,7 +140,7 @@ mod tests {
140
141 use super::{ChatEventStream, CollectedAssistantMessage};
142 use crate::error::Error;
143 - use crate::event::ChatEvent;
143 + use crate::event::{ChatEvent, ChatTokenUsage};
144
145 #[tokio::test]
146 async fn collect_message_requires_terminal_done_event() {
@@ -195,11 +195,11 @@ mod tests {
195 }),
196 Ok(ChatEvent::Done {
197 message: Default::default(),
198 - usage: vllm_llm::TokenUsage {
198 + usage: ChatTokenUsage::from(vllm_llm::TokenUsage {
199 prompt_token_count: 2,
200 output_token_count: 1,
201 cached_token_count: 0,
202 - },
202 + }),
203 finish_reason: FinishReason::stop_eos(),
204 kv_transfer_params: None,
205 ec_transfer_params: None,
@@ -236,11 +236,11 @@ mod tests {
236 }],
237 }),
238 token_ids: vec![],
239 - usage: vllm_llm::TokenUsage {
239 + usage: ChatTokenUsage::from(vllm_llm::TokenUsage {
240 prompt_token_count: 2,
241 output_token_count: 1,
242 cached_token_count: 0,
243 - },
243 + }),
244 finish_reason: FinishReason::stop_eos(),
245 kv_transfer_params: None,
246 ec_transfer_params: None,
rust/src/chat/tests/chat.rs
+15
@@ -393,6 +393,7 @@ async fn chat_streams_text_events() {
393 index: 0,
394 kind: AssistantBlockKind::Text,
395 delta: "H".to_string(),
396 + token_count: None,
397 }
398 );
399 assert_eq!(
@@ -401,6 +402,7 @@ async fn chat_streams_text_events() {
402 index: 0,
403 kind: AssistantBlockKind::Text,
404 delta: "i".to_string(),
405 + token_count: None,
406 }
407 );
408 assert_eq!(
@@ -500,6 +502,7 @@ async fn chat_stream_waits_for_complete_utf8_before_emitting() {
502 index: 0,
503 kind: AssistantBlockKind::Text,
504 delta: "你".to_string(),
505 + token_count: None,
506 }
507 );
508 assert_eq!(
@@ -586,6 +589,7 @@ async fn chat_stream_flushes_held_text_on_finish() {
589 index: 0,
590 kind: AssistantBlockKind::Text,
591 delta: "ok st".to_string(),
592 + token_count: None,
593 }
594 );
595 assert_eq!(
@@ -773,6 +777,7 @@ async fn chat_stream_preserves_terminal_stop_token_when_requested() {
777 index: 0,
778 kind: AssistantBlockKind::Text,
779 delta: "Hi!".to_string(),
780 + token_count: None,
781 }
782 );
783 assert_eq!(
@@ -880,6 +885,7 @@ async fn chat_stream_separates_reasoning_blocks_automatically() {
885 index: 0,
886 kind: AssistantBlockKind::Reasoning,
887 delta: "reason ".to_string(),
888 + token_count: Some(7),
889 }
890 );
891 assert_eq!(
@@ -888,6 +894,7 @@ async fn chat_stream_separates_reasoning_blocks_automatically() {
894 index: 0,
895 kind: AssistantBlockKind::Reasoning,
896 delta: "more".to_string(),
897 + token_count: Some(4),
898 }
899 );
900 assert_eq!(
@@ -912,6 +919,7 @@ async fn chat_stream_separates_reasoning_blocks_automatically() {
919 index: 1,
920 kind: AssistantBlockKind::Text,
921 delta: "answer".to_string(),
922 + token_count: None,
923 }
924 );
925 assert_eq!(
@@ -927,12 +935,15 @@ async fn chat_stream_separates_reasoning_blocks_automatically() {
935 match next_semantic(&mut stream).await {
936 Some(Ok(ChatEvent::Done {
937 message,
938 + usage,
939 finish_reason,
940 ..
941 })) => {
942 assert_eq!(message.reasoning().unwrap(), "reason more");
943 assert_eq!(message.text(), "answer");
944 assert_eq!(finish_reason, FinishReason::Length);
945 + // 7 tokens for "reason " plus 4 for "more"; markers are excluded.
946 + assert_eq!(usage.reasoning_tokens, 11);
947 }
948 other => panic!("unexpected final event: {other:?}"),
949 }
@@ -1000,6 +1011,8 @@ async fn chat_collectors_return_structured_message_and_visible_text() {
1011 message.usage.output_token_count,
1012 "<think>inner</think>outer".len()
1013 );
1014 + // 5 tokens for "inner"; markers are excluded.
1015 + assert_eq!(message.usage.reasoning_tokens, 5);
1016
1017 let _ = shutdown_tx.send(());
1018 engine_task.await.unwrap();
@@ -1079,6 +1092,7 @@ async fn chat_explicitly_disables_reasoning_parser() {
1092 assert_eq!(message.message.reasoning(), None);
1093 assert_eq!(message.message.text(), "<think>reason more</think>answer");
1094 assert_eq!(message.finish_reason, FinishReason::Length);
1095 + assert_eq!(message.usage.reasoning_tokens, 0);
1096
1097 let _ = shutdown_tx.send(());
1098 engine_task.await.unwrap();
@@ -1379,6 +1393,7 @@ async fn chat_stream_and_collect_preserve_prompt_and_sample_logprobs() {
1393 index: 0,
1394 kind: AssistantBlockKind::Text,
1395 delta: "H".to_string(),
1396 + token_count: None,
1397 }
1398 );
1399 assert_eq!(
rust/src/server/src/routes/openai/chat_completions.rs
+250 -34
@@ -324,7 +324,20 @@ async fn chat_completion_chunk_stream(
324 ));
325 }
326 }
327 - Ok(ChatEvent::BlockDelta { kind, delta, .. }) => {
327 + Ok(ChatEvent::BlockDelta {
328 + kind,
329 + delta,
330 + token_count,
331 + ..
332 + }) => {
333 + // The count is per-delta and follows the decoder clock; it is
334 + // fed even when the reasoning delta itself is hidden from the
335 + // client (`include_reasoning=false`).
336 + if matches!(kind, AssistantBlockKind::Reasoning)
337 + && let Some(token_count) = token_count
338 + {
339 + continuous_usage.add_reasoning_tokens(token_count);
340 + }
341 let include_delta =
342 include_reasoning || !matches!(kind, AssistantBlockKind::Reasoning);
343 if include_delta {
@@ -433,6 +446,9 @@ async fn chat_completion_chunk_stream(
446 final_usage.prompt_token_count,
447 final_usage.output_token_count,
448 );
449 + // Converge the running reasoning count onto the authoritative
450 + // terminal value.
451 + continuous_usage.set_reasoning_tokens(final_usage.reasoning_tokens);
452
453 if let Some(pending_chunk) = pending_chunk.as_mut()
454 && let Some(chunk) = pending_chunk.take_chunk(&envelope)
@@ -806,16 +822,31 @@ mod tests {
822 use futures::{StreamExt as _, stream};
823 use serde_json::json;
824 use vllm_chat::{
809 - AssistantBlockKind, AssistantContentBlock, AssistantToolCall, ChatEvent, FinishReason,
825 + AssistantBlockKind, AssistantContentBlock, AssistantToolCall, ChatEvent, ChatTokenUsage,
826 + FinishReason,
827 };
828 use vllm_engine_core_client::protocol::output::StopReason;
829 use vllm_text::{DecodedLogprobs, DecodedPositionLogprobs, DecodedTokenLogprob};
830
831 use super::{
815 - ApiServerOptions, ResponseOptions, StreamResponseEnvelope, block_delta_chunk,
816 - chat_completion_chunk_stream, final_chunk,
832 + ApiServerOptions, ChatCompletionStreamResponse, ResponseOptions, StreamResponseEnvelope,
833 + block_delta_chunk, chat_completion_chunk_stream, final_chunk,
834 };
835
836 + /// Terminal usage with the given engine-level counts and zero reasoning tokens.
837 + fn done_usage(
838 + prompt_tokens: usize,
839 + output_tokens: usize,
840 + cached_tokens: usize,
841 + ) -> ChatTokenUsage {
842 + vllm_llm::TokenUsage {
843 + prompt_token_count: prompt_tokens,
844 + output_token_count: output_tokens,
845 + cached_token_count: cached_tokens,
846 + }
847 + .into()
848 + }
849 +
850 fn stream_envelope() -> Arc<StreamResponseEnvelope> {
851 Arc::new(StreamResponseEnvelope::new(
852 "chatcmpl-1".to_string(),
@@ -913,6 +944,7 @@ mod tests {
944 index: 0,
945 kind: AssistantBlockKind::Text,
946 delta: "hi".to_string(),
947 + token_count: None,
948 }),
949 Ok(ChatEvent::LogprobsDelta {
950 logprobs: Some(DecodedLogprobs {
@@ -929,11 +961,7 @@ mod tests {
961 }),
962 Ok(ChatEvent::Done {
963 message: Default::default(),
932 - usage: vllm_llm::TokenUsage {
933 - prompt_token_count: 1,
934 - output_token_count: 1,
935 - cached_token_count: 1,
936 - },
964 + usage: done_usage(1, 1, 1),
965 finish_reason: FinishReason::stop_eos(),
966 kv_transfer_params: None,
967 ec_transfer_params: None,
@@ -995,6 +1023,7 @@ mod tests {
1023 index: 0,
1024 kind: AssistantBlockKind::Reasoning,
1025 delta: "think".to_string(),
1026 + token_count: None,
1027 }),
1028 Ok(ChatEvent::LogprobsDelta {
1029 logprobs: Some(DecodedLogprobs {
@@ -1011,11 +1040,7 @@ mod tests {
1040 }),
1041 Ok(ChatEvent::Done {
1042 message: Default::default(),
1014 - usage: vllm_llm::TokenUsage {
1015 - prompt_token_count: 1,
1016 - output_token_count: 1,
1017 - cached_token_count: 0,
1018 - },
1043 + usage: done_usage(1, 1, 0),
1044 finish_reason: FinishReason::stop_eos(),
1045 kv_transfer_params: None,
1046 ec_transfer_params: None,
@@ -1059,19 +1084,17 @@ mod tests {
1084 index: 0,
1085 kind: AssistantBlockKind::Reasoning,
1086 delta: "think".to_string(),
1087 + token_count: None,
1088 }),
1089 Ok(ChatEvent::BlockDelta {
1090 index: 1,
1091 kind: AssistantBlockKind::Text,
1092 delta: "answer".to_string(),
1093 + token_count: None,
1094 }),
1095 Ok(ChatEvent::Done {
1096 message: Default::default(),
1070 - usage: vllm_llm::TokenUsage {
1071 - prompt_token_count: 1,
1072 - output_token_count: 2,
1073 - cached_token_count: 0,
1074 - },
1097 + usage: done_usage(1, 2, 0),
1098 finish_reason: FinishReason::stop_eos(),
1099 kv_transfer_params: None,
1100 ec_transfer_params: None,
@@ -1115,6 +1138,7 @@ mod tests {
1138 index: 0,
1139 kind: AssistantBlockKind::Reasoning,
1140 delta: "think".to_string(),
1141 + token_count: None,
1142 }),
1143 Ok(ChatEvent::LogprobsDelta {
1144 logprobs: Some(DecodedLogprobs {
@@ -1133,6 +1157,7 @@ mod tests {
1157 index: 1,
1158 kind: AssistantBlockKind::Text,
1159 delta: "answer".to_string(),
1160 + token_count: None,
1161 }),
1162 Ok(ChatEvent::LogprobsDelta {
1163 logprobs: Some(DecodedLogprobs {
@@ -1149,11 +1174,7 @@ mod tests {
1174 }),
1175 Ok(ChatEvent::Done {
1176 message: Default::default(),
1152 - usage: vllm_llm::TokenUsage {
1153 - prompt_token_count: 1,
1154 - output_token_count: 2,
1155 - cached_token_count: 0,
1156 - },
1177 + usage: done_usage(1, 2, 0),
1178 finish_reason: FinishReason::stop_eos(),
1179 kv_transfer_params: None,
1180 ec_transfer_params: None,
@@ -1226,6 +1247,7 @@ mod tests {
1247 index: 0,
1248 kind: AssistantBlockKind::Reasoning,
1249 delta: "think".to_string(),
1250 + token_count: None,
1251 }),
1252 Ok(ChatEvent::LogprobsDelta {
1253 logprobs: Some(DecodedLogprobs {
@@ -1267,6 +1289,7 @@ mod tests {
1289 index: 1,
1290 kind: AssistantBlockKind::Text,
1291 delta: "answer".to_string(),
1292 + token_count: None,
1293 }),
1294 Ok(ChatEvent::LogprobsDelta {
1295 logprobs: Some(DecodedLogprobs {
@@ -1283,11 +1306,7 @@ mod tests {
1306 }),
1307 Ok(ChatEvent::Done {
1308 message: Default::default(),
1286 - usage: vllm_llm::TokenUsage {
1287 - prompt_token_count: 1,
1288 - output_token_count: 4,
1289 - cached_token_count: 0,
1290 - },
1309 + usage: done_usage(1, 4, 0),
1310 finish_reason: FinishReason::stop_eos(),
1311 kv_transfer_params: None,
1312 ec_transfer_params: None,
@@ -1365,11 +1384,7 @@ mod tests {
1384 }),
1385 Ok(ChatEvent::Done {
1386 message: Default::default(),
1368 - usage: vllm_llm::TokenUsage {
1369 - prompt_token_count: 1,
1370 - output_token_count: 1,
1371 - cached_token_count: 0,
1372 - },
1387 + usage: done_usage(1, 1, 0),
1388 finish_reason: FinishReason::stop_eos(),
1389 kv_transfer_params: None,
1390 ec_transfer_params: None,
@@ -1410,4 +1425,205 @@ mod tests {
1425 None
1426 );
1427 }
1428 +
1429 + /// Measured reasoning-token count of one chunk's continuous usage.
1430 + fn chunk_reasoning_tokens(chunk: &ChatCompletionStreamResponse) -> Option<usize> {
1431 + chunk
1432 + .usage
1433 + .as_ref()
1434 + .map(|usage| usage.completion_tokens_details.reasoning_tokens)
1435 + }
1436 +
1437 + #[tokio::test]
1438 + async fn chunk_stream_reports_reasoning_tokens_with_continuous_usage() {
1439 + let stream = stream::iter(vec![
1440 + Ok(ChatEvent::Start {
1441 + prompt_token_ids: vec![7, 8, 9].into(),
1442 + prompt_logprobs: None,
1443 + }),
1444 + Ok(ChatEvent::BlockDelta {
1445 + index: 0,
1446 + kind: AssistantBlockKind::Reasoning,
1447 + delta: "think".to_string(),
1448 + token_count: Some(2),
1449 + }),
1450 + Ok(ChatEvent::LogprobsDelta {
1451 + logprobs: None,
1452 + token_ids: vec![20, 21],
1453 + }),
1454 + Ok(ChatEvent::BlockDelta {
1455 + index: 1,
1456 + kind: AssistantBlockKind::Text,
1457 + delta: "answer".to_string(),
1458 + // A stray text-side count must not pollute reasoning usage.
1459 + token_count: None,
1460 + }),
1461 + Ok(ChatEvent::Done {
1462 + message: Default::default(),
1463 + usage: ChatTokenUsage {
1464 + engine: vllm_llm::TokenUsage {
1465 + prompt_token_count: 3,
1466 + output_token_count: 3,
1467 + cached_token_count: 0,
1468 + },
1469 + reasoning_tokens: 2,
1470 + },
1471 + finish_reason: FinishReason::stop_eos(),
1472 + kv_transfer_params: None,
1473 + ec_transfer_params: None,
1474 + }),
1475 + ]);
1476 +
1477 + let chunks = chat_completion_chunk_stream(
1478 + stream,
1479 + "chatcmpl-1".to_string(),
1480 + "model".to_string(),
1481 + 1,
1482 + ApiServerOptions::default(),
1483 + ResponseOptions {
1484 + include_usage: true,
1485 + include_continuous_usage: true,
1486 + include_reasoning: true,
1487 + ..Default::default()
1488 + },
1489 + )
1490 + .collect::<Vec<_>>()
1491 + .await
1492 + .into_iter()
1493 + .collect::<Result<Vec<_>, _>>()
1494 + .expect("stream chunks");
1495 +
1496 + // Role, reasoning delta, text delta, finish-reason chunk, usage chunk.
1497 + assert_eq!(chunks.len(), 5);
1498 + // The role chunk reports reasoning tokens from 0 onwards.
1499 + let role_usage = chunks[0].usage.as_ref().expect("role chunk usage");
1500 + assert_eq!(role_usage.prompt_tokens, 3);
1501 + assert_eq!(role_usage.completion_tokens, Some(0));
1502 + assert_eq!(chunk_reasoning_tokens(&chunks[0]), Some(0));
1503 + // The reasoning delta contributes its per-delta measured count, fed by
1504 + // the `BlockDelta` itself rather than the sampled-clock `LogprobsDelta`.
1505 + assert_eq!(chunk_reasoning_tokens(&chunks[1]), Some(2));
1506 + assert_eq!(
1507 + chunks[1].usage.as_ref().and_then(|usage| usage.completion_tokens),
1508 + Some(2)
1509 + );
1510 + // The text delta's own count does not pollute reasoning usage, and the
1511 + // terminal usage chunk converges onto the authoritative final counts.
1512 + assert_eq!(chunk_reasoning_tokens(&chunks[2]), Some(2));
1513 + let final_usage = chunks[4].usage.as_ref().expect("final usage chunk");
1514 + assert_eq!(final_usage.prompt_tokens, 3);
1515 + assert_eq!(final_usage.completion_tokens, Some(3));
1516 + assert_eq!(chunk_reasoning_tokens(&chunks[4]), Some(2));
1517 + }
1518 +
1519 + #[tokio::test]
1520 + async fn chunk_stream_reports_reasoning_tokens_when_reasoning_hidden() {
1521 + let stream = stream::iter(vec![
1522 + Ok(ChatEvent::Start {
1523 + prompt_token_ids: vec![].into(),
1524 + prompt_logprobs: None,
1525 + }),
1526 + Ok(ChatEvent::BlockDelta {
1527 + index: 0,
1528 + kind: AssistantBlockKind::Reasoning,
1529 + delta: "think".to_string(),
1530 + token_count: Some(2),
1531 + }),
1532 + Ok(ChatEvent::Done {
1533 + message: Default::default(),
1534 + usage: ChatTokenUsage {
1535 + engine: vllm_llm::TokenUsage {
1536 + prompt_token_count: 1,
1537 + output_token_count: 2,
1538 + cached_token_count: 0,
1539 + },
1540 + reasoning_tokens: 2,
1541 + },
1542 + finish_reason: FinishReason::stop_eos(),
1543 + kv_transfer_params: None,
1544 + ec_transfer_params: None,
1545 + }),
1546 + ]);
1547 +
1548 + let chunks = chat_completion_chunk_stream(
1549 + stream,
1550 + "chatcmpl-1".to_string(),
1551 + "model".to_string(),
1552 + 1,
1553 + ApiServerOptions::default(),
1554 + ResponseOptions {
1555 + include_usage: true,
1556 + include_continuous_usage: true,
1557 + ..Default::default()
1558 + },
1559 + )
1560 + .collect::<Vec<_>>()
1561 + .await
1562 + .into_iter()
1563 + .collect::<Result<Vec<_>, _>>()
1564 + .expect("stream chunks");
1565 +
1566 + // The reasoning text stays hidden...
1567 + assert!(
1568 + chunks
1569 + .iter()
1570 + .all(|chunk| chunk.choices.iter().all(|choice| choice.delta.reasoning.is_none()))
1571 + );
1572 + // ...but the count is still reported, from 0 on the role chunk up to
1573 + // the converged final usage.
1574 + assert_eq!(chunk_reasoning_tokens(&chunks[0]), Some(0));
1575 + let final_usage = chunks.last().unwrap().usage.as_ref().expect("final usage chunk");
1576 + assert_eq!(final_usage.completion_tokens, Some(2));
1577 + assert_eq!(final_usage.completion_tokens_details.reasoning_tokens, 2);
1578 + }
1579 +
1580 + #[tokio::test]
1581 + async fn chunk_stream_reports_zero_reasoning_tokens_without_reasoning() {
1582 + let stream = stream::iter(vec![
1583 + Ok(ChatEvent::Start {
1584 + prompt_token_ids: vec![].into(),
1585 + prompt_logprobs: None,
1586 + }),
1587 + Ok(ChatEvent::BlockDelta {
1588 + index: 0,
1589 + kind: AssistantBlockKind::Text,
1590 + delta: "answer".to_string(),
1591 + token_count: None,
1592 + }),
1593 + Ok(ChatEvent::Done {
1594 + message: Default::default(),
1595 + usage: done_usage(1, 1, 0),
1596 + finish_reason: FinishReason::stop_eos(),
1597 + kv_transfer_params: None,
1598 + ec_transfer_params: None,
1599 + }),
1600 + ]);
1601 +
1602 + let chunks = chat_completion_chunk_stream(
1603 + stream,
1604 + "chatcmpl-1".to_string(),
1605 + "model".to_string(),
1606 + 1,
1607 + ApiServerOptions::default(),
1608 + ResponseOptions {
1609 + include_usage: true,
1610 + include_continuous_usage: true,
1611 + ..Default::default()
1612 + },
1613 + )
1614 + .collect::<Vec<_>>()
1615 + .await
1616 + .into_iter()
1617 + .collect::<Result<Vec<_>, _>>()
1618 + .expect("stream chunks");
1619 +
1620 + // Reasoning-token details are always reported; a stream without
1621 + // reasoning simply reports 0 on every usage payload.
1622 + for chunk in &chunks {
1623 + let usage = chunk.usage.as_ref().expect("usage");
1624 + assert_eq!(usage.completion_tokens_details.reasoning_tokens, 0);
1625 + let json = serde_json::to_value(usage).expect("usage serializes");
1626 + assert_eq!(json["completion_tokens_details"]["reasoning_tokens"], 0);
1627 + }
1628 + }
1629 }
rust/src/server/src/routes/openai/utils/types.rs
+73 -3
@@ -7,7 +7,7 @@ use std::slice;
7 use llm_multimodal::ImageDetail;
8 use serde::{Deserialize, Deserializer, Serialize};
9 use serde_json::Value;
10 -use vllm_llm::TokenUsage;
10 +use vllm_chat::ChatTokenUsage;
11
12 // ============================================================================
13 // Constants
@@ -440,14 +440,18 @@ pub struct Usage {
440 pub total_tokens: usize,
441 pub completion_tokens: Option<usize>,
442 pub prompt_tokens_details: Option<PromptTokenUsageInfo>,
443 + /// Reasoning-token breakdown.
444 + /// Always present, 0 when the configured parser has no reasoning channel.
445 + pub completion_tokens_details: CompletionTokenUsageInfo,
446 }
447
448 impl Usage {
446 - /// Create a Usage with prompt-token cache details.
449 + /// Create a Usage with prompt-token cache and reasoning-token details.
450 pub fn from_counts(
451 prompt_tokens: usize,
452 completion_tokens: usize,
453 cached_tokens: Option<usize>,
454 + reasoning_tokens: usize,
455 ) -> Self {
456 Self {
457 prompt_tokens,
@@ -456,14 +460,20 @@ impl Usage {
460 prompt_tokens_details: cached_tokens
461 .filter(|&c| c > 0)
462 .map(|c| PromptTokenUsageInfo { cached_tokens: c }),
463 + completion_tokens_details: CompletionTokenUsageInfo { reasoning_tokens },
464 }
465 }
466
462 - pub fn from_token_usage(usage: TokenUsage, enable_prompt_tokens_details: bool) -> Self {
467 + pub fn from_token_usage(
468 + usage: impl Into<ChatTokenUsage>,
469 + enable_prompt_tokens_details: bool,
470 + ) -> Self {
471 + let usage = usage.into();
472 Self::from_counts(
473 usage.prompt_token_count,
474 usage.output_token_count,
475 enable_prompt_tokens_details.then_some(usage.cached_token_count),
476 + usage.reasoning_tokens,
477 )
478 }
479 }
@@ -474,8 +484,15 @@ pub struct PromptTokenUsageInfo {
484 pub cached_tokens: usize,
485 }
486
487 +/// Mirrors the Python vLLM `CompletionTokenUsageInfo` class.
488 +#[derive(Debug, Clone, Default, Serialize)]
489 +pub struct CompletionTokenUsageInfo {
490 + pub reasoning_tokens: usize,
491 +}
492 +
493 #[cfg(test)]
494 mod usage_tests {
495 + use vllm_chat::ChatTokenUsage;
496 use vllm_llm::TokenUsage;
497
498 use super::Usage;
@@ -512,6 +529,59 @@ mod usage_tests {
529 Some(3)
530 );
531 }
532 +
533 + #[test]
534 + fn token_usage_includes_reasoning_token_details() {
535 + let usage = Usage::from_token_usage(
536 + ChatTokenUsage {
537 + engine: TokenUsage {
538 + prompt_token_count: 5,
539 + output_token_count: 4,
540 + cached_token_count: 0,
541 + },
542 + reasoning_tokens: 3,
543 + },
544 + false,
545 + );
546 +
547 + assert_eq!(usage.completion_tokens_details.reasoning_tokens, 3);
548 + let json = serde_json::to_value(&usage).expect("usage serializes");
549 + assert_eq!(json["completion_tokens_details"]["reasoning_tokens"], 3);
550 + }
551 +
552 + #[test]
553 + fn token_usage_serializes_zero_reasoning_tokens() {
554 + let usage = Usage::from_token_usage(
555 + ChatTokenUsage {
556 + engine: TokenUsage {
557 + prompt_token_count: 5,
558 + output_token_count: 0,
559 + cached_token_count: 0,
560 + },
561 + reasoning_tokens: 0,
562 + },
563 + false,
564 + );
565 +
566 + let json = serde_json::to_value(&usage).expect("usage serializes");
567 + assert_eq!(json["completion_tokens_details"]["reasoning_tokens"], 0);
568 + }
569 +
570 + #[test]
571 + fn token_usage_reports_zero_reasoning_tokens_without_reasoning_parser() {
572 + let usage = Usage::from_token_usage(
573 + TokenUsage {
574 + prompt_token_count: 5,
575 + output_token_count: 2,
576 + cached_token_count: 0,
577 + },
578 + false,
579 + );
580 +
581 + assert_eq!(usage.completion_tokens_details.reasoning_tokens, 0);
582 + let json = serde_json::to_value(&usage).expect("usage serializes");
583 + assert_eq!(json["completion_tokens_details"]["reasoning_tokens"], 0);
584 + }
585 }
586
587 /// OpenAI completions-style logprobs.
rust/src/server/src/routes/openai/utils/usage.rs
+19 -1
@@ -12,6 +12,8 @@ use super::types::Usage;
12 pub(crate) struct ContinuousUsage {
13 prompt_tokens: usize,
14 output_tokens: usize,
15 + /// Running reasoning-token count, following the decoder clock.
16 + reasoning_tokens: usize,
17 }
18
19 impl ContinuousUsage {
@@ -25,6 +27,17 @@ impl ContinuousUsage {
27 self.output_tokens = self.output_tokens.saturating_add(output_tokens);
28 }
29
30 + /// Add the tokens attributed to one reasoning delta to the running count.
31 + pub(crate) fn add_reasoning_tokens(&mut self, token_count: usize) {
32 + self.reasoning_tokens = self.reasoning_tokens.saturating_add(token_count);
33 + }
34 +
35 + /// Converge the running reasoning count onto the authoritative terminal
36 + /// value reported at stream end.
37 + pub(crate) fn set_reasoning_tokens(&mut self, reasoning_tokens: usize) {
38 + self.reasoning_tokens = reasoning_tokens;
39 + }
40 +
41 /// Replace the running counts with the final counts reported by generation.
42 pub(crate) fn set_final_counts(&mut self, prompt_tokens: usize, output_tokens: usize) {
43 self.prompt_tokens = prompt_tokens;
@@ -33,6 +46,11 @@ impl ContinuousUsage {
46
47 /// Build a streaming usage snapshot without prompt cache details.
48 pub(crate) fn to_usage(&self) -> Usage {
36 - Usage::from_counts(self.prompt_tokens, self.output_tokens, None)
49 + Usage::from_counts(
50 + self.prompt_tokens,
51 + self.output_tokens,
52 + None,
53 + self.reasoning_tokens,
54 + )
55 }
56 }
tests/entrypoints/openai/chat_completion/test_include_reasoning.py
+17 -1
@@ -5,7 +5,7 @@
5
6 Verifies that reasoning content is included by default and suppressed
7 when ``include_reasoning=False``, for both streaming and non-streaming
8 -Chat Completions.
8 +Chat Completions, while reasoning tokens remain represented in usage.
9 """
10
11 import openai
@@ -77,6 +77,22 @@ async def test_include_reasoning_false_non_streaming(client: openai.AsyncOpenAI)
77 assert msg.content, "Expected content in response even without reasoning"
78
79
80 +@pytest.mark.asyncio
81 +async def test_reasoning_tokens_in_usage(client: openai.AsyncOpenAI):
82 + """Hidden reasoning still contributes to terminal usage details."""
83 + response = await client.chat.completions.create(
84 + model=MODEL_NAME,
85 + messages=MESSAGES,
86 + max_tokens=64,
87 + extra_body={"include_reasoning": False},
88 + )
89 +
90 + assert response.usage is not None
91 + details = response.usage.completion_tokens_details
92 + assert details is not None
93 + assert details.reasoning_tokens > 0
94 +
95 +
96 @pytest.mark.asyncio
97 async def test_include_reasoning_true_streaming(client: openai.AsyncOpenAI):
98 """Default: reasoning deltas appear in streaming response."""
tests/entrypoints/openai/chat_completion/test_serving_chat.py
+2 -1
@@ -736,7 +736,8 @@ async def test_chat_per_request_metrics_follow_server_flag():
736 request_metadata=RequestResponseMetadata(request_id="chatcmpl-test-id"),
737 )
738 assert disabled_response.metrics is None
739 - assert disabled_response.usage.completion_tokens_details is None
739 + details = disabled_response.usage.completion_tokens_details
740 + assert details is None or details.reasoning_tokens == 0
741
742 enabled_serving = _build_minimal_metrics_serving_chat(
743 enable_per_request_metrics=True