Skip to content

Commit 7858489

Browse files
committed
fix(stargate-bench): report output usage from native mock streams
Honor include_usage with final generated-token counts and preserve the existing stream shape when usage is not requested. Verify the real mock-to-driver path with a Bazel test using declared executable inputs. Refs: #1817
1 parent 507cf3c commit 7858489

4 files changed

Lines changed: 246 additions & 32 deletions

File tree

‎src/libraries/rust/stargate/crates/mock-dynamo/src/openai.rs‎

Lines changed: 67 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,19 @@ const CANARY_ANSWER: &str = "2";
7272
#[derive(Deserialize)]
7373
pub(crate) struct ChatRequest {
7474
pub(crate) stream: Option<bool>,
75+
stream_options: Option<ChatStreamOptions>,
7576
pub(crate) model: Option<String>,
7677
pub(crate) max_tokens: Option<usize>,
7778
#[serde(default)]
7879
pub(crate) messages: Vec<serde_json::Value>,
7980
}
8081

82+
#[derive(Deserialize)]
83+
struct ChatStreamOptions {
84+
#[serde(default)]
85+
include_usage: bool,
86+
}
87+
8188
#[derive(Deserialize)]
8289
pub(crate) struct ResponsesRequest {
8390
pub(crate) stream: Option<bool>,
@@ -105,7 +112,10 @@ struct ChatCompletionChunk<'a> {
105112
id: &'a str,
106113
object: &'static str,
107114
model: &'a str,
108-
choices: [ChunkChoice<'a>; 1],
115+
choices: &'a [ChunkChoice<'a>],
116+
// Omitted unless requested, then null until the final usage chunk.
117+
#[serde(skip_serializing_if = "Option::is_none")]
118+
usage: Option<Option<ChatUsage>>,
109119
}
110120

111121
#[derive(Serialize)]
@@ -174,7 +184,7 @@ struct StreamResponseConfig {
174184

175185
#[derive(Clone, Copy, PartialEq, Eq)]
176186
enum StreamKind {
177-
Chat { canary: bool },
187+
Chat { canary: bool, include_usage: bool },
178188
Responses { created_at: u64 },
179189
}
180190

@@ -251,7 +261,13 @@ pub(crate) async fn chat_completions(
251261
output_tokens,
252262
kv_cache_access,
253263
request_slot,
254-
kind: StreamKind::Chat { canary },
264+
kind: StreamKind::Chat {
265+
canary,
266+
include_usage: req
267+
.stream_options
268+
.as_ref()
269+
.is_some_and(|options| options.include_usage),
270+
},
255271
});
256272
}
257273

@@ -495,29 +511,52 @@ pub(crate) enum ChatStreamChunk<'a> {
495511
Role,
496512
Content(&'a str),
497513
Stop,
498-
}
499-
500-
pub(crate) fn chat_chunk_json(id: &str, model: &str, chunk: ChatStreamChunk<'_>) -> String {
501-
let (role, content, finish_reason) = match chunk {
502-
ChatStreamChunk::Role => (Some("assistant"), None, None),
503-
ChatStreamChunk::Content(content) => (None, Some(content), None),
504-
ChatStreamChunk::Stop => (None, None, Some("stop")),
505-
};
514+
Usage {
515+
input_tokens: usize,
516+
output_tokens: usize,
517+
},
518+
}
519+
520+
pub(crate) fn chat_chunk_json(
521+
id: &str,
522+
model: &str,
523+
chunk: ChatStreamChunk<'_>,
524+
include_usage: bool,
525+
) -> String {
526+
let mut usage = include_usage.then_some(None);
527+
let choice = match chunk {
528+
ChatStreamChunk::Role => Some((Some("assistant"), None, None)),
529+
ChatStreamChunk::Content(content) => Some((None, Some(content), None)),
530+
ChatStreamChunk::Stop => Some((None, None, Some("stop"))),
531+
ChatStreamChunk::Usage {
532+
input_tokens,
533+
output_tokens,
534+
} => {
535+
usage = Some(Some(ChatUsage {
536+
prompt_tokens: input_tokens,
537+
completion_tokens: output_tokens,
538+
total_tokens: input_tokens.saturating_add(output_tokens),
539+
}));
540+
None
541+
}
542+
}
543+
.map(|(role, content, finish_reason)| ChunkChoice {
544+
index: 0,
545+
delta: Delta { role, content },
546+
finish_reason,
547+
});
506548
serde_json::to_string(&ChatCompletionChunk {
507549
id,
508550
object: "chat.completion.chunk",
509551
model,
510-
choices: [ChunkChoice {
511-
index: 0,
512-
delta: Delta { role, content },
513-
finish_reason,
514-
}],
552+
choices: choice.as_slice(),
553+
usage,
515554
})
516555
.expect("chat stream event should serialize")
517556
}
518557

519-
fn chat_sse_event(id: &str, model: &str, chunk: ChatStreamChunk<'_>) -> Event {
520-
Event::default().data(chat_chunk_json(id, model, chunk))
558+
fn chat_sse_event(id: &str, model: &str, chunk: ChatStreamChunk<'_>, include_usage: bool) -> Event {
559+
Event::default().data(chat_chunk_json(id, model, chunk, include_usage))
521560
}
522561

523562
fn stream_response(config: StreamResponseConfig) -> Response {
@@ -557,22 +596,22 @@ fn stream_response(config: StreamResponseConfig) -> Response {
557596

558597
state.emit_counters(&request_id, &model, input_tokens, 0, false);
559598

560-
if matches!(kind, StreamKind::Chat { .. }) {
561-
yield Ok(chat_sse_event(&id, &model, ChatStreamChunk::Role));
599+
if let StreamKind::Chat { include_usage, .. } = kind {
600+
yield Ok(chat_sse_event(&id, &model, ChatStreamChunk::Role, include_usage));
562601
}
563602

564603
for i in 0..output_tokens {
565604
if i > 0 {
566605
tokio::time::sleep(token_delay(&state, &request_id, i)).await;
567606
}
568-
let token = if matches!(kind, StreamKind::Chat { canary: true }) {
607+
let token = if matches!(kind, StreamKind::Chat { canary: true, .. }) {
569608
CANARY_ANSWER
570609
} else {
571610
DUMMY_TOKENS[i % DUMMY_TOKENS.len()]
572611
};
573612
let event = match kind {
574-
StreamKind::Chat { .. } => {
575-
chat_sse_event(&id, &model, ChatStreamChunk::Content(token))
613+
StreamKind::Chat { include_usage, .. } => {
614+
chat_sse_event(&id, &model, ChatStreamChunk::Content(token), include_usage)
576615
}
577616
StreamKind::Responses { .. } => {
578617
output_text.push_str(token);
@@ -593,7 +632,7 @@ fn stream_response(config: StreamResponseConfig) -> Response {
593632
}
594633

595634
let completed = match kind {
596-
StreamKind::Chat { .. } => chat_sse_event(&id, &model, ChatStreamChunk::Stop),
635+
StreamKind::Chat { include_usage, .. } => chat_sse_event(&id, &model, ChatStreamChunk::Stop, include_usage),
597636
StreamKind::Responses { created_at } => responses_sse_event(
598637
"response.completed",
599638
&serde_json::json!({
@@ -628,7 +667,10 @@ fn stream_response(config: StreamResponseConfig) -> Response {
628667

629668
state.emit_counters(&request_id, &model, input_tokens, output_tokens, true);
630669

631-
if matches!(kind, StreamKind::Chat { .. }) {
670+
if let StreamKind::Chat { include_usage, .. } = kind {
671+
if include_usage {
672+
yield Ok(chat_sse_event(&id, &model, ChatStreamChunk::Usage { input_tokens, output_tokens }, true));
673+
}
632674
yield Ok(Event::default().data("[DONE]"));
633675
}
634676
};

‎src/libraries/rust/stargate/crates/mock-dynamo/src/tests.rs‎

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,10 @@ use axum::routing::{get, post, put};
2727
use tokio::io::{AsyncReadExt, AsyncWriteExt};
2828

2929
fn request() -> ChatRequest {
30-
ChatRequest {
31-
stream: Some(true),
32-
model: Some("dummy-model".to_string()),
33-
max_tokens: Some(1),
34-
messages: Vec::new(),
35-
}
30+
serde_json::from_value(serde_json::json!({
31+
"stream": true, "model": "dummy-model", "max_tokens": 1, "messages": []
32+
}))
33+
.unwrap()
3634
}
3735

3836
fn test_stats_events() -> broadcast::Sender<StatsStreamEvent> {
@@ -582,13 +580,80 @@ fn chat_stream_chunks_preserve_delta_and_finish_shapes() {
582580
),
583581
] {
584582
let value: serde_json::Value =
585-
serde_json::from_str(&chat_chunk_json("id", "model", chunk)).unwrap();
583+
serde_json::from_str(&chat_chunk_json("id", "model", chunk, false)).unwrap();
586584
assert_eq!(value["choices"][0]["delta"], delta);
587585
assert_eq!(value["choices"][0]["finish_reason"], finish_reason);
588586
assert!(value.get("usage").is_none());
589587
}
590588
}
591589

590+
#[tokio::test]
591+
async fn streaming_chat_usage_reports_actual_output_only_when_requested() {
592+
let state = AppState {
593+
output_tokens: OutputTokenConfig {
594+
min: 100,
595+
max: 100,
596+
distribution: OutputTokenDistribution::Uniform,
597+
},
598+
context_length_tokens: 5,
599+
..test_state()
600+
};
601+
let app = Router::new()
602+
.route("/v1/chat/completions", post(chat_completions))
603+
.with_state(state);
604+
let (address, server) = spawn_test_app(app).await;
605+
for include_usage in [None, Some(false), Some(true)] {
606+
let mut body = serde_json::json!({
607+
"model": "dummy-model", "messages": [], "stream": true, "max_tokens": 100
608+
});
609+
if let Some(include_usage) = include_usage {
610+
body["stream_options"] = serde_json::json!({"include_usage": include_usage});
611+
}
612+
let response = json_response(
613+
address,
614+
"POST",
615+
"/v1/chat/completions",
616+
"connection: close\r\nx-input-tokens: 2\r\nx-output-tokens: 100",
617+
&body.to_string(),
618+
)
619+
.await;
620+
assert!(response.starts_with("HTTP/1.1 200 OK"));
621+
let data: Vec<_> = response
622+
.lines()
623+
.filter_map(|line| line.strip_prefix("data: "))
624+
.collect();
625+
assert_eq!(data.last(), Some(&"[DONE]"));
626+
let events: Vec<serde_json::Value> = data[..data.len() - 1]
627+
.iter()
628+
.map(|data| serde_json::from_str(data).unwrap())
629+
.collect();
630+
assert_eq!(
631+
events
632+
.iter()
633+
.filter(|event| event["choices"][0]["delta"]["content"].is_string())
634+
.count(),
635+
3
636+
);
637+
if include_usage == Some(true) {
638+
let (usage, output_events) = events.split_last().unwrap();
639+
assert_eq!(usage["choices"], serde_json::json!([]));
640+
assert_eq!(
641+
usage["usage"],
642+
serde_json::json!({"prompt_tokens": 2, "completion_tokens": 3, "total_tokens": 5})
643+
);
644+
assert!(
645+
output_events
646+
.iter()
647+
.all(|event| event.get("usage") == Some(&serde_json::Value::Null))
648+
);
649+
} else {
650+
assert!(events.iter().all(|event| event.get("usage").is_none()));
651+
}
652+
}
653+
server.abort();
654+
let _ = server.await;
655+
}
656+
592657
#[tokio::test]
593658
async fn embeddings_endpoint_returns_json_without_stream() {
594659
let state = test_state();

‎src/libraries/rust/stargate/crates/stargate-bench/BUILD.bazel‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Benchmark harness. Not packaged into an OCI image.
55

66
load("@stargate_crates//:defs.bzl", "aliases", "all_crate_deps")
7+
load("@rules_python//python:defs.bzl", "py_test")
78
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_test")
89

910
# Workspace-local Cargo deps (own-workspace `workspace = true`).
@@ -39,3 +40,19 @@ rust_test(
3940
},
4041
deps = all_crate_deps(normal_dev = True),
4142
)
43+
44+
py_test(
45+
name = "benchmark_usage_test",
46+
srcs = ["benchmark_usage_test.py"],
47+
main = "benchmark_usage_test.py",
48+
args = [
49+
"$(rootpath //src/libraries/rust/stargate/crates/mock-dynamo:mock-dynamo)",
50+
"$(rootpath :stargate-bench)",
51+
],
52+
data = [
53+
"//src/libraries/rust/stargate/crates/mock-dynamo:mock-dynamo",
54+
":stargate-bench",
55+
],
56+
python_version = "3.11",
57+
size = "small",
58+
)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Exercise the real benchmark driver against its built-in mock backend."""
5+
6+
import json
7+
import os
8+
from pathlib import Path
9+
import re
10+
import subprocess
11+
import sys
12+
import tempfile
13+
import time
14+
15+
16+
def test_usage(mock_binary, bench_binary):
17+
with tempfile.TemporaryDirectory(dir=os.environ.get("TEST_TMPDIR")) as directory:
18+
root = Path(directory)
19+
server_log = root / "mock.log"
20+
with server_log.open("w") as log:
21+
server = subprocess.Popen(
22+
[
23+
str(mock_binary), "--http-listen-addr", "127.0.0.1:0",
24+
"--model-name", "model-a", "--num-tokens", "100",
25+
"--context-length-tokens", "5", "--token-delay-ms", "1",
26+
],
27+
env=dict(os.environ, RUST_LOG="info"),
28+
stdout=log,
29+
stderr=subprocess.STDOUT,
30+
)
31+
try:
32+
deadline = time.monotonic() + 5
33+
while True:
34+
output = server_log.read_text()
35+
address = re.search(r"http://127\.0\.0\.1:\d+/v1/chat/completions", output)
36+
if address:
37+
break
38+
if server.poll() is not None or time.monotonic() >= deadline:
39+
raise AssertionError(f"mock did not become ready:\n{output}")
40+
time.sleep(0.01)
41+
42+
manifest = root / "manifest.json"
43+
manifest.write_text(json.dumps({
44+
"manifest_version": 1,
45+
"benchmark_name": "streamed-usage",
46+
"metadata": {},
47+
"model": "model-a",
48+
"seed": 1,
49+
"request_count": 1,
50+
"max_concurrency": 1,
51+
"stargate_count": 1,
52+
"backend_count": 1,
53+
"requests": [{
54+
"request_index": 0,
55+
"request_id": "usage-request",
56+
"scheduled_offset_ms": 0,
57+
"routing_key": None,
58+
"cache_affinity_key": None,
59+
"input_tokens": 2,
60+
"output_tokens": 100,
61+
"backend_behavior_class": "uniform",
62+
}],
63+
}))
64+
results = root / "results.jsonl"
65+
driven = subprocess.run(
66+
[str(bench_binary), "drive", "--manifest", str(manifest),
67+
"--endpoint", address.group(), "--output", str(results)],
68+
capture_output=True, text=True, timeout=10,
69+
)
70+
assert driven.returncode == 0, driven.stdout + driven.stderr
71+
result = json.loads(results.read_text())
72+
assert result["ok"], result
73+
assert result["output_tokens"] == 100, result
74+
# The five-token context leaves three generated tokens after the prompt.
75+
assert result["observed_output_tokens"] == 3, result
76+
assert result["first_output_ms"] is not None, result
77+
finally:
78+
if server.poll() is None:
79+
server.terminate()
80+
try:
81+
server.wait(timeout=5)
82+
except subprocess.TimeoutExpired:
83+
server.kill()
84+
server.wait()
85+
86+
87+
if __name__ == "__main__":
88+
if len(sys.argv) != 3:
89+
raise SystemExit("usage: benchmark_usage_test.py MOCK_BINARY BENCH_BINARY")
90+
test_usage(*(Path(argument).resolve() for argument in sys.argv[1:]))

0 commit comments

Comments
 (0)