[Rust Frontend] Support continuous_usage_stats stream option (#43965)

Co-authored-by: Bugen Zhao <i@bugenzhao.com>
Signed-off-by: RickyChen / 陳昭儒 <ricky.chen@infinirc.com>
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
This commit is contained in:
Chao-Ju Chen
2026-06-11 17:50:59 +00:00
committed by GitHub
co-authored by Bugen Zhao
parent b8142294b7
commit 3b03a2cf47
9 changed files with 335 additions and 51 deletions
@@ -37,6 +37,7 @@ use crate::routes::openai::utils::logprobs::{
use crate::routes::openai::utils::types::{
ChatLogProbs, FunctionCallDelta, FunctionCallResponse, ToolCall, ToolCallDelta, Usage,
};
use crate::routes::openai::utils::usage::ContinuousUsage;
use crate::routes::openai::utils::validated_json::ValidatedJson;
use crate::state::AppState;
use crate::utils::{resolve_request_context, unix_timestamp};
@@ -129,6 +130,8 @@ async fn collect_chat_completion(
ResponseOptions {
// Ignored: non-streaming responses always include usage.
include_usage: _,
// Ignored: non-streaming responses are collected before usage is attached.
include_continuous_usage: _,
requested_logprobs,
include_prompt_logprobs,
include_reasoning,
@@ -249,6 +252,7 @@ async fn chat_completion_chunk_stream(
}: ApiServerOptions,
ResponseOptions {
include_usage,
include_continuous_usage,
requested_logprobs,
// Ignored: chat streaming prompt logprobs are rejected for Python parity.
include_prompt_logprobs: _,
@@ -265,33 +269,47 @@ async fn chat_completion_chunk_stream(
// starts or ends, omit its token metadata as well as its visible delta.
let mut inside_hidden_reasoning = false;
let mut suppress_current_update_metadata = false;
let mut continuous_usage = ContinuousUsage::default();
/// Yield a chunk with optional continuous usage attached.
macro_rules! yield_chunk {
($chunk:expr) => {{
let mut chunk = $chunk;
if include_continuous_usage {
chunk.usage = Some(continuous_usage.to_usage());
}
y.yield_ok(chunk).await;
}};
}
// If the client requested logprobs or token_ids, we need to buffer chunks until
// we receive the separate `LogprobsDelta` event, so that we can emit one
// combined chunk with both the semantic delta and its per-update metadata.
let mut pending_chunk =
(requested_logprobs || return_token_ids).then(PendingChatChunk::default);
// Continuous usage also buffers so the token count from `LogprobsDelta` can
// be attached to the matching semantic chunk.
let mut pending_chunk = (requested_logprobs || return_token_ids || include_continuous_usage)
.then(PendingChatChunk::default);
while let Some(next) = stream.next().await {
match next {
Ok(ChatEvent::Start {
prompt_token_ids, ..
}) => {
continuous_usage.set_prompt_tokens(prompt_token_ids.len());
let mut chunk = start_chunk(&request_id, &response_model, created);
if return_token_ids {
chunk.prompt_token_ids = Some(prompt_token_ids.to_vec());
}
y.yield_ok(chunk).await;
yield_chunk!(chunk);
// When echo=true, emit the last assistant message content as a delta chunk.
if let Some(echo_text) = &echo {
y.yield_ok(block_delta_chunk(
yield_chunk!(block_delta_chunk(
&request_id,
&response_model,
created,
AssistantBlockKind::Text,
echo_text.clone(),
))
.await;
));
}
}
Ok(ChatEvent::BlockDelta { kind, delta, .. }) => {
@@ -301,14 +319,13 @@ async fn chat_completion_chunk_stream(
if let Some(pending_chunk) = pending_chunk.as_mut() {
pending_chunk.push_block_delta(kind, delta);
} else {
y.yield_ok(block_delta_chunk(
yield_chunk!(block_delta_chunk(
&request_id,
&response_model,
created,
kind,
delta,
))
.await;
));
}
} else {
suppress_current_update_metadata = true;
@@ -318,6 +335,8 @@ async fn chat_completion_chunk_stream(
logprobs,
token_ids,
}) => {
let delta_token_count = token_ids.len();
continuous_usage.add_output_tokens(delta_token_count);
let include_metadata =
!suppress_current_update_metadata && !inside_hidden_reasoning;
suppress_current_update_metadata = false;
@@ -339,16 +358,15 @@ async fn chat_completion_chunk_stream(
if let Some(chunk) =
pending_chunk.take_chunk(&request_id, &response_model, created)
{
y.yield_ok(chunk).await;
yield_chunk!(chunk);
}
} else if let Some(logprobs) = openai_logprobs {
y.yield_ok(logprobs_only_chunk(
yield_chunk!(logprobs_only_chunk(
&request_id,
&response_model,
created,
logprobs,
))
.await;
));
}
}
Ok(ChatEvent::BlockStart { kind, .. }) => {
@@ -376,15 +394,14 @@ async fn chat_completion_chunk_stream(
if let Some(pending_chunk) = pending_chunk.as_mut() {
pending_chunk.push_tool_call_start(tool_index, id, name);
} else {
y.yield_ok(tool_call_start_chunk(
yield_chunk!(tool_call_start_chunk(
&request_id,
&response_model,
created,
tool_index,
id,
name,
))
.await;
));
}
}
Ok(ChatEvent::ToolCallArgumentsDelta { index, delta }) => {
@@ -392,21 +409,20 @@ async fn chat_completion_chunk_stream(
if let Some(pending_chunk) = pending_chunk.as_mut() {
pending_chunk.push_tool_call_arguments(tool_index, delta);
} else {
y.yield_ok(tool_call_arguments_chunk(
yield_chunk!(tool_call_arguments_chunk(
&request_id,
&response_model,
created,
tool_index,
delta,
))
.await;
));
}
}
Ok(ChatEvent::ToolCallEnd { .. }) => {
debug!("ending current tool call");
}
Ok(ChatEvent::Done {
usage,
usage: final_usage,
finish_reason,
..
}) => {
@@ -414,18 +430,23 @@ async fn chat_completion_chunk_stream(
info!(
stream = true,
model = %response_model,
prompt_tokens = usage.prompt_token_count,
output_tokens = usage.output_token_count,
prompt_tokens = final_usage.prompt_token_count,
output_tokens = final_usage.output_token_count,
finish_reason = finish_reason.as_str(),
"chat completion finished"
);
}
continuous_usage.set_final_counts(
final_usage.prompt_token_count,
final_usage.output_token_count,
);
if let Some(pending_chunk) = pending_chunk.as_mut()
&& let Some(chunk) =
pending_chunk.take_chunk(&request_id, &response_model, created)
{
y.yield_ok(chunk).await;
yield_chunk!(chunk);
}
match final_chunk(
@@ -435,7 +456,7 @@ async fn chat_completion_chunk_stream(
finish_reason,
saw_tool_calls,
) {
Ok(chunk) => y.yield_ok(chunk).await,
Ok(chunk) => yield_chunk!(chunk),
Err(error) => {
error!(
error = %error.to_error_response().error.message,
@@ -450,7 +471,7 @@ async fn chat_completion_chunk_stream(
&request_id,
&response_model,
created,
Usage::from_token_usage(usage, enable_prompt_tokens_details),
Usage::from_token_usage(final_usage, enable_prompt_tokens_details),
))
.await;
}
@@ -33,6 +33,8 @@ pub(super) struct PreparedRequest {
pub(super) struct ResponseOptions {
/// Whether the caller asked for the final streamed usage chunk.
pub include_usage: bool,
/// Whether every streamed chunk should carry cumulative usage.
pub include_continuous_usage: bool,
/// Whether the caller requested output logprobs on chat choices.
pub requested_logprobs: bool,
/// Whether the caller requested top-level prompt logprobs.
@@ -82,6 +84,12 @@ pub(super) fn prepare_chat_request(
let include_usage = (request.stream_options.as_ref())
.and_then(|options| options.include_usage)
.unwrap_or(false);
let include_continuous_usage = include_usage
&& request
.stream_options
.as_ref()
.and_then(|options| options.continuous_usage_stats)
.unwrap_or(false);
let requested_logprobs = request.logprobs;
// Auto-enable prompt logprobs for non-streaming echo, matching Python vLLM's
@@ -154,6 +162,7 @@ pub(super) fn prepare_chat_request(
response_model,
options: ResponseOptions {
include_usage,
include_continuous_usage,
requested_logprobs,
include_prompt_logprobs,
include_reasoning,
@@ -375,8 +384,8 @@ mod tests {
AssistantRole, ChatCompletionMessage, ChatCompletionRequest,
};
use crate::routes::openai::utils::types::{
ChatMessage, ContentPart, Function, FunctionCallResponse, ImageUrl, MessageContent, Tool,
ToolCall, ToolChoice, ToolChoiceValue, VideoUrl,
ChatMessage, ContentPart, Function, FunctionCallResponse, ImageUrl, MessageContent,
StreamOptions, Tool, ToolCall, ToolChoice, ToolChoiceValue, VideoUrl,
};
use crate::utils::{ResolvedRequestContext, resolve_request_context};
@@ -456,6 +465,46 @@ mod tests {
assert_eq!(prepared.chat_request.tool_choice, ChatToolChoice::Auto);
}
#[test]
fn prepare_chat_request_maps_stream_usage_and_token_format_options() {
let mut request = base_request();
request.return_tokens_as_token_ids = Some(true);
request.stream_options = Some(StreamOptions {
include_usage: Some(true),
continuous_usage_stats: Some(true),
});
let prepared = prepare_chat_request(
request,
&served(&["Qwen/Qwen1.5-0.5B-Chat"]),
ResolvedRequestContext::default(),
)
.expect("request is valid");
assert!(prepared.options.include_usage);
assert!(prepared.options.include_continuous_usage);
assert!(prepared.options.return_tokens_as_token_ids);
}
#[test]
fn prepare_chat_request_gates_continuous_usage_on_include_usage() {
let mut request = base_request();
request.stream_options = Some(StreamOptions {
include_usage: None,
continuous_usage_stats: Some(true),
});
let prepared = prepare_chat_request(
request,
&served(&["Qwen/Qwen1.5-0.5B-Chat"]),
ResolvedRequestContext::default(),
)
.expect("request is valid");
assert!(!prepared.options.include_usage);
assert!(!prepared.options.include_continuous_usage);
}
#[test]
fn prepare_chat_request_keeps_optional_sampling_fields_unset() {
let prepared = prepare_chat_request(
@@ -137,15 +137,6 @@ pub(super) fn validate_request_compat(
"repetition_detection is not supported.",
)?;
if let Some(options) = &request.stream_options
&& options.continuous_usage_stats.is_some()
{
bail_invalid_request!(
param = "stream_options",
"continuous_usage_stats is not supported."
);
}
Ok(())
}
@@ -31,6 +31,7 @@ use crate::routes::openai::completions::types::{
CompletionStreamChoice, CompletionStreamResponse,
};
use crate::routes::openai::utils::types::LogProbs;
use crate::routes::openai::utils::usage::ContinuousUsage;
use crate::routes::openai::utils::validated_json::ValidatedJson;
use crate::state::AppState;
use crate::utils::{resolve_request_context, unix_timestamp};
@@ -127,6 +128,8 @@ async fn collect_completion(
ResponseOptions {
// Ignored: non-streaming responses always include usage.
include_usage: _,
// Ignored: non-streaming responses are collected before usage is attached.
include_continuous_usage: _,
echo,
requested_logprobs,
include_prompt_logprobs,
@@ -218,6 +221,7 @@ async fn completion_chunk_stream(
}: ApiServerOptions,
ResponseOptions {
include_usage,
include_continuous_usage,
echo,
requested_logprobs,
// Ignored: streaming prompt logprobs are rejected for Python parity.
@@ -230,6 +234,18 @@ async fn completion_chunk_stream(
pin_mut!(stream);
let mut visible_text_len = 0_u32;
let mut first_chunk = true;
let mut continuous_usage = ContinuousUsage::default();
/// Yield a chunk with optional continuous usage attached.
macro_rules! yield_chunk {
($chunk:expr) => {{
let mut chunk = $chunk;
if include_continuous_usage {
chunk.usage = Some(continuous_usage.to_usage());
}
y.yield_ok(CompletionSseChunk::Chunk(chunk)).await;
}};
}
while let Some(next) = stream.next().await {
match next {
@@ -237,6 +253,7 @@ async fn completion_chunk_stream(
prompt_token_ids, ..
}) => {
debug!("completion stream started");
continuous_usage.set_prompt_tokens(prompt_token_ids.len());
if let Some(prompt) = echo.as_ref() {
visible_text_len = text_len(prompt);
let mut chunk =
@@ -247,7 +264,7 @@ async fn completion_chunk_stream(
}
first_chunk = false;
}
y.yield_ok(CompletionSseChunk::Chunk(chunk)).await;
yield_chunk!(chunk);
} else if return_token_ids {
// Emit a chunk with prompt_token_ids in the first streaming response
let mut chunk =
@@ -256,7 +273,7 @@ async fn completion_chunk_stream(
choice.prompt_token_ids = Some(prompt_token_ids.to_vec());
}
first_chunk = false;
y.yield_ok(CompletionSseChunk::Chunk(chunk)).await;
yield_chunk!(chunk);
}
}
Ok(DecodedTextEvent::TextDelta {
@@ -281,10 +298,12 @@ async fn completion_chunk_stream(
None
};
let mut chunk = delta_chunk(&request_id, &response_model, created, delta, logprobs);
let delta_token_count = token_ids.len();
continuous_usage.add_output_tokens(delta_token_count);
if return_token_ids && let Some(choice) = chunk.choices.first_mut() {
choice.token_ids = Some(token_ids);
}
y.yield_ok(CompletionSseChunk::Chunk(chunk)).await;
yield_chunk!(chunk);
visible_text_len = visible_text_len.saturating_add(delta_text_len);
if let Some(finished) = finished {
@@ -298,13 +317,17 @@ async fn completion_chunk_stream(
"completion finished"
);
}
y.yield_ok(CompletionSseChunk::Chunk(final_chunk(
continuous_usage.set_final_counts(
finished.usage.prompt_token_count,
finished.usage.output_token_count,
);
let final_chunk = final_chunk(
&request_id,
&response_model,
created,
finished.finish_reason,
)?))
.await;
)?;
yield_chunk!(final_chunk);
if include_usage {
y.yield_ok(CompletionSseChunk::Usage(usage_chunk(
@@ -25,6 +25,8 @@ pub(super) struct PreparedRequest {
pub(super) struct ResponseOptions {
/// Whether the caller asked for the final streamed usage chunk.
pub include_usage: bool,
/// Whether every streamed chunk should carry cumulative usage.
pub include_continuous_usage: bool,
/// Original text prompt that should be echoed back northbound when
/// `echo=true`.
pub echo: Option<String>,
@@ -74,6 +76,12 @@ pub(super) fn prepare_completion_request(
let include_usage = (request.stream_options.as_ref())
.and_then(|options| options.include_usage)
.unwrap_or(false);
let include_continuous_usage = include_usage
&& request
.stream_options
.as_ref()
.and_then(|options| options.continuous_usage_stats)
.unwrap_or(false);
let include_prompt_logprobs = prompt_logprobs.is_some();
let echo = request.echo.then(|| request.prompt.as_text().cloned()).flatten();
@@ -129,6 +137,7 @@ pub(super) fn prepare_completion_request(
response_model,
options: ResponseOptions {
include_usage,
include_continuous_usage,
echo,
requested_logprobs: request.logprobs,
include_prompt_logprobs,
@@ -247,6 +256,55 @@ mod tests {
assert!(!prepared.text_request.decode_options.skip_special_tokens);
}
#[test]
fn prepare_completion_request_maps_stream_usage_and_token_format_options() {
let request: CompletionRequest = serde_json::from_value(json!({
"model": "Qwen/Qwen1.5-0.5B-Chat",
"prompt": "hello",
"stream": true,
"stream_options": {
"include_usage": true,
"continuous_usage_stats": true
},
"return_tokens_as_token_ids": true
}))
.expect("parse request");
let prepared = prepare_completion_request(
request,
&served(&["Qwen/Qwen1.5-0.5B-Chat"]),
ResolvedRequestContext::default(),
)
.expect("prepare");
assert!(prepared.options.include_usage);
assert!(prepared.options.include_continuous_usage);
assert!(prepared.options.return_tokens_as_token_ids);
}
#[test]
fn prepare_completion_request_gates_continuous_usage_on_include_usage() {
let request: CompletionRequest = serde_json::from_value(json!({
"model": "Qwen/Qwen1.5-0.5B-Chat",
"prompt": "hello",
"stream": true,
"stream_options": {
"continuous_usage_stats": true
}
}))
.expect("parse request");
let prepared = prepare_completion_request(
request,
&served(&["Qwen/Qwen1.5-0.5B-Chat"]),
ResolvedRequestContext::default(),
)
.expect("prepare");
assert!(!prepared.options.include_usage);
assert!(!prepared.options.include_continuous_usage);
}
#[test]
fn prepare_completion_request_accepts_text_echo() {
let request: CompletionRequest = serde_json::from_value(json!({
@@ -95,15 +95,6 @@ pub(super) fn validate_request_compat(
);
}
if let Some(options) = &request.stream_options
&& options.continuous_usage_stats.is_some()
{
bail_invalid_request!(
param = "stream_options",
"continuous_usage_stats is not supported."
);
}
Ok(())
}
@@ -2,4 +2,5 @@ pub mod logprobs;
pub mod structured_outputs;
pub mod token_ids;
pub mod types;
pub mod usage;
pub mod validated_json;
@@ -0,0 +1,35 @@
use super::types::Usage;
/// Tracks cumulative token counts for OpenAI streaming chunks.
///
/// This helper is intentionally only a counter. Callers decide whether to
/// attach `counts()` to each streamed data chunk, while final usage-only chunks
/// should still be built from the authoritative terminal `TokenUsage`.
#[derive(Debug, Clone, Default)]
pub(crate) struct ContinuousUsage {
prompt_tokens: usize,
output_tokens: usize,
}
impl ContinuousUsage {
/// Record the prompt-token count reported when a stream starts.
pub(crate) fn set_prompt_tokens(&mut self, prompt_tokens: usize) {
self.prompt_tokens = prompt_tokens;
}
/// Add newly decoded output tokens to the running completion count.
pub(crate) fn add_output_tokens(&mut self, output_tokens: usize) {
self.output_tokens = self.output_tokens.saturating_add(output_tokens);
}
/// Replace the running counts with the final counts reported by generation.
pub(crate) fn set_final_counts(&mut self, prompt_tokens: usize, output_tokens: usize) {
self.prompt_tokens = prompt_tokens;
self.output_tokens = output_tokens;
}
/// Build a streaming usage snapshot without prompt cache details.
pub(crate) fn to_usage(&self) -> Usage {
Usage::from_counts(self.prompt_tokens, self.output_tokens, None)
}
}
+115
View File
@@ -151,6 +151,14 @@ fn sse_data_payloads(text: &str) -> Vec<&str> {
text.lines().filter_map(|line| line.strip_prefix("data: ")).collect()
}
fn sse_json_payloads(text: &str) -> Vec<serde_json::Value> {
sse_data_payloads(text)
.into_iter()
.filter(|payload| *payload != "[DONE]")
.map(|payload| serde_json::from_str(payload).expect("sse json payload"))
.collect()
}
type TestFuture<'a> = Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
fn boxed_test_future<'a>(future: impl Future<Output = ()> + Send + 'a) -> TestFuture<'a> {
@@ -2341,6 +2349,60 @@ async fn include_usage_adds_final_usage_chunk_before_done() {
assert_eq!(usage_chunk["usage"]["total_tokens"], 25);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn stream_continuous_usage_stats_adds_usage_to_chat_chunks() {
let (app, engine_task) = test_app_with_stream_output_specs(default_stream_output_specs()).await;
let response = app
.clone()
.call(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "Qwen/Qwen1.5-0.5B-Chat",
"stream": true,
"stream_options": {
"include_usage": true,
"continuous_usage_stats": true
},
"messages": [{"role": "user", "content": "hello"}]
})
.to_string(),
))
.expect("build request"),
)
.await
.expect("call app");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
engine_task.await.expect("mock engine task");
let text = String::from_utf8(body.to_vec()).expect("utf8 body");
let payloads = sse_json_payloads(&text);
assert!(
payloads.iter().all(|payload| payload.get("usage").is_some()),
"{text}"
);
assert!(
payloads.iter().any(|payload| {
payload["choices"].as_array().is_some_and(|choices| !choices.is_empty())
&& payload["usage"]["completion_tokens"] == json!(1)
}),
"{text}"
);
let usage_chunk = payloads
.iter()
.find(|payload| payload["choices"] == json!([]))
.expect("final usage chunk");
assert_eq!(usage_chunk["usage"]["prompt_tokens"], 22);
assert_eq!(usage_chunk["usage"]["completion_tokens"], 3);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn stream_without_include_usage_keeps_existing_shape() {
@@ -3434,6 +3496,59 @@ async fn completions_happy_path_returns_sse_stream() {
assert_eq!(usage_chunk["usage"]["completion_tokens"], 3);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn completions_stream_continuous_usage_stats_adds_usage_to_chunks() {
let (app, engine_task) = test_app_with_stream_output_specs(default_stream_output_specs()).await;
let response = app
.clone()
.call(
Request::builder()
.method("POST")
.uri("/v1/completions")
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "Qwen/Qwen1.5-0.5B-Chat",
"prompt": "hello",
"stream": true,
"stream_options": {
"include_usage": true,
"continuous_usage_stats": true
}
})
.to_string(),
))
.expect("build request"),
)
.await
.expect("call app");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
engine_task.await.expect("mock engine task");
let text = String::from_utf8(body.to_vec()).expect("utf8 body");
let payloads = sse_json_payloads(&text);
assert!(
payloads.iter().all(|payload| payload.get("usage").is_some()),
"{text}"
);
assert!(
payloads.iter().any(|payload| {
payload["choices"].as_array().is_some_and(|choices| !choices.is_empty())
&& payload["usage"]["completion_tokens"] == json!(1)
}),
"{text}"
);
let usage_chunk = payloads
.iter()
.find(|payload| payload["choices"] == json!([]))
.expect("final usage chunk");
assert_eq!(usage_chunk["usage"]["completion_tokens"], 3);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn completions_echo_stream_emits_separate_prompt_chunk() {