Skip to content

Commit d093df4

Browse files
committed
feat: Add support for usage in the OpenAI frontend vLLM backend (#8264)
1 parent e2e2ae5 commit d093df4

8 files changed

Lines changed: 573 additions & 28 deletions

File tree

python/openai/README.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ curl -s http://localhost:9000/v1/chat/completions -H 'Content-Type: application/
9898

9999
```json
100100
{
101-
"id": "cmpl-6930b296-7ef8-11ef-bdd1-107c6149ca79",
101+
"id": "cmpl-0242093d-51ae-11f0-b339-e7480668bfbe",
102102
"choices": [
103103
{
104104
"finish_reason": "stop",
@@ -113,11 +113,15 @@ curl -s http://localhost:9000/v1/chat/completions -H 'Content-Type: application/
113113
"logprobs": null
114114
}
115115
],
116-
"created": 1727679085,
116+
"created": 1750846825,
117117
"model": "llama-3.1-8b-instruct",
118118
"system_fingerprint": null,
119119
"object": "chat.completion",
120-
"usage": null
120+
"usage": {
121+
"completion_tokens": 7,
122+
"prompt_tokens": 42,
123+
"total_tokens": 49
124+
}
121125
}
122126
```
123127

@@ -138,20 +142,24 @@ curl -s http://localhost:9000/v1/completions -H 'Content-Type: application/json'
138142

139143
```json
140144
{
141-
"id": "cmpl-d51df75c-7ef8-11ef-bdd1-107c6149ca79",
145+
"id": "cmpl-58fba3a0-51ae-11f0-859d-e7480668bfbe",
142146
"choices": [
143147
{
144148
"finish_reason": "stop",
145149
"index": 0,
146150
"logprobs": null,
147-
"text": " a field of computer science that focuses on developing algorithms that allow computers to learn from"
151+
"text": " an amazing field that can truly understand the hidden patterns that exist in the data,"
148152
}
149153
],
150-
"created": 1727679266,
154+
"created": 1750846970,
151155
"model": "llama-3.1-8b-instruct",
152156
"system_fingerprint": null,
153157
"object": "text_completion",
154-
"usage": null
158+
"usage": {
159+
"completion_tokens": 16,
160+
"prompt_tokens": 4,
161+
"total_tokens": 20
162+
}
155163
}
156164
```
157165

python/openai/openai_frontend/engine/triton_engine.py

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@
5151
_create_trtllm_inference_request,
5252
_create_vllm_inference_request,
5353
_get_output,
54+
_get_usage_from_response,
5455
_get_vllm_lora_names,
56+
_StreamingUsageAccumulator,
5557
_validate_triton_responses_non_streaming,
5658
)
5759
from schemas.openai import (
@@ -65,6 +67,7 @@
6567
ChatCompletionStreamResponseDelta,
6668
ChatCompletionToolChoiceOption1,
6769
Choice,
70+
CompletionUsage,
6871
CreateChatCompletionRequest,
6972
CreateChatCompletionResponse,
7073
CreateChatCompletionStreamResponse,
@@ -225,6 +228,8 @@ async def chat(
225228
backend=metadata.backend,
226229
)
227230

231+
usage = _get_usage_from_response(response, metadata.backend)
232+
228233
return CreateChatCompletionResponse(
229234
id=request_id,
230235
choices=[
@@ -239,6 +244,7 @@ async def chat(
239244
model=request.model,
240245
system_fingerprint=None,
241246
object=ObjectType.chat_completion,
247+
usage=usage,
242248
)
243249

244250
def _get_chat_completion_response_message(
@@ -311,7 +317,7 @@ async def completion(
311317
created = int(time.time())
312318
if request.stream:
313319
return self._streaming_completion_iterator(
314-
request_id, created, request.model, responses
320+
request_id, created, request, responses, metadata.backend
315321
)
316322

317323
# Response validation with decoupled models in mind
@@ -320,6 +326,8 @@ async def completion(
320326
response = responses[0]
321327
text = _get_output(response)
322328

329+
usage = _get_usage_from_response(response, metadata.backend)
330+
323331
choice = Choice(
324332
finish_reason=FinishReason.stop,
325333
index=0,
@@ -333,6 +341,7 @@ async def completion(
333341
object=ObjectType.text_completion,
334342
created=created,
335343
model=request.model,
344+
usage=usage,
336345
)
337346

338347
# TODO: This behavior should be tested further
@@ -413,6 +422,7 @@ def _get_streaming_chat_response_chunk(
413422
request_id: str,
414423
created: int,
415424
model: str,
425+
usage: Optional[CompletionUsage] = None,
416426
) -> CreateChatCompletionStreamResponse:
417427
return CreateChatCompletionStreamResponse(
418428
id=request_id,
@@ -421,6 +431,7 @@ def _get_streaming_chat_response_chunk(
421431
model=model,
422432
system_fingerprint=None,
423433
object=ObjectType.chat_completion_chunk,
434+
usage=usage,
424435
)
425436

426437
def _get_first_streaming_chat_response(
@@ -436,7 +447,7 @@ def _get_first_streaming_chat_response(
436447
finish_reason=None,
437448
)
438449
chunk = self._get_streaming_chat_response_chunk(
439-
choice, request_id, created, model
450+
choice, request_id, created, model, usage=None
440451
)
441452
return chunk
442453

@@ -462,6 +473,8 @@ async def _streaming_chat_iterator(
462473
)
463474

464475
previous_text = ""
476+
include_usage = request.stream_options and request.stream_options.include_usage
477+
usage_accumulator = _StreamingUsageAccumulator(backend)
465478

466479
chunk = self._get_first_streaming_chat_response(
467480
request_id, created, model, role
@@ -470,6 +483,8 @@ async def _streaming_chat_iterator(
470483

471484
async for response in responses:
472485
delta_text = _get_output(response)
486+
if include_usage:
487+
usage_accumulator.update(response)
473488

474489
(
475490
response_delta,
@@ -504,10 +519,25 @@ async def _streaming_chat_iterator(
504519
)
505520

506521
chunk = self._get_streaming_chat_response_chunk(
507-
choice, request_id, created, model
522+
choice, request_id, created, model, usage=None
508523
)
509524
yield f"data: {chunk.model_dump_json(exclude_unset=True)}\n\n"
510525

526+
# Send the final usage chunk if requested via stream_options.
527+
if include_usage:
528+
usage_payload = usage_accumulator.get_final_usage()
529+
if usage_payload:
530+
final_usage_chunk = CreateChatCompletionStreamResponse(
531+
id=request_id,
532+
choices=[],
533+
created=created,
534+
model=model,
535+
system_fingerprint=None,
536+
object=ObjectType.chat_completion_chunk,
537+
usage=usage_payload,
538+
)
539+
yield f"data: {final_usage_chunk.model_dump_json(exclude_unset=True)}\n\n"
540+
511541
yield "data: [DONE]\n\n"
512542

513543
def _get_streaming_response_delta(
@@ -654,6 +684,18 @@ def _validate_chat_request(
654684

655685
self._verify_chat_tool_call_settings(request=request)
656686

687+
if request.stream_options and not request.stream:
688+
raise Exception("`stream_options` can only be used when `stream` is True")
689+
690+
if (
691+
request.stream_options
692+
and request.stream_options.include_usage
693+
and metadata.backend != "vllm"
694+
):
695+
raise Exception(
696+
"`stream_options.include_usage` is currently only supported for the vLLM backend"
697+
)
698+
657699
def _verify_chat_tool_call_settings(self, request: CreateChatCompletionRequest):
658700
if (
659701
request.tool_choice
@@ -690,9 +732,21 @@ def _verify_chat_tool_call_settings(self, request: CreateChatCompletionRequest):
690732
)
691733

692734
async def _streaming_completion_iterator(
693-
self, request_id: str, created: int, model: str, responses: AsyncIterable
735+
self,
736+
request_id: str,
737+
created: int,
738+
request: CreateCompletionRequest,
739+
responses: AsyncIterable,
740+
backend: str,
694741
) -> AsyncIterator[str]:
742+
model = request.model
743+
include_usage = request.stream_options and request.stream_options.include_usage
744+
usage_accumulator = _StreamingUsageAccumulator(backend)
745+
695746
async for response in responses:
747+
if include_usage:
748+
usage_accumulator.update(response)
749+
696750
text = _get_output(response)
697751
choice = Choice(
698752
finish_reason=FinishReason.stop if response.final else None,
@@ -707,10 +761,26 @@ async def _streaming_completion_iterator(
707761
object=ObjectType.text_completion,
708762
created=created,
709763
model=model,
764+
usage=None,
710765
)
711766

712767
yield f"data: {chunk.model_dump_json(exclude_unset=True)}\n\n"
713768

769+
# Send the final usage chunk if requested via stream_options.
770+
if include_usage:
771+
usage_payload = usage_accumulator.get_final_usage()
772+
if usage_payload:
773+
final_usage_chunk = CreateCompletionResponse(
774+
id=request_id,
775+
choices=[],
776+
system_fingerprint=None,
777+
object=ObjectType.text_completion,
778+
created=created,
779+
model=model,
780+
usage=usage_payload,
781+
)
782+
yield f"data: {final_usage_chunk.model_dump_json(exclude_unset=True)}\n\n"
783+
714784
yield "data: [DONE]\n\n"
715785

716786
def _validate_completion_request(
@@ -763,6 +833,18 @@ def _validate_completion_request(
763833
if request.logit_bias is not None or request.logprobs is not None:
764834
raise Exception("logit bias and log probs not supported")
765835

836+
if request.stream_options and not request.stream:
837+
raise Exception("`stream_options` can only be used when `stream` is True")
838+
839+
if (
840+
request.stream_options
841+
and request.stream_options.include_usage
842+
and metadata.backend != "vllm"
843+
):
844+
raise Exception(
845+
"`stream_options.include_usage` is currently only supported for the vLLM backend"
846+
)
847+
766848
def _should_stream_with_auto_tool_parsing(
767849
self, request: CreateChatCompletionRequest
768850
):

python/openai/openai_frontend/engine/utils/triton.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
import json
2828
import os
2929
import re
30-
from dataclasses import asdict
30+
from dataclasses import asdict, dataclass, field
3131
from typing import Iterable, List, Optional, Union
3232

3333
import numpy as np
@@ -36,6 +36,7 @@
3636
from schemas.openai import (
3737
ChatCompletionNamedToolChoice,
3838
ChatCompletionToolChoiceOption1,
39+
CompletionUsage,
3940
CreateChatCompletionRequest,
4041
CreateCompletionRequest,
4142
)
@@ -100,6 +101,8 @@ def _create_vllm_inference_request(
100101
# Pass sampling_parameters as serialized JSON string input to support List
101102
# fields like 'stop' that aren't supported by TRITONSERVER_Parameters yet.
102103
inputs["sampling_parameters"] = [sampling_parameters]
104+
inputs["return_num_input_tokens"] = np.bool_([True])
105+
inputs["return_num_output_tokens"] = np.bool_([True])
103106
return model.create_request(inputs=inputs)
104107

105108

@@ -184,6 +187,85 @@ def _to_string(tensor: tritonserver.Tensor) -> str:
184187
return _construct_string_from_pointer(tensor.data_ptr + 4, tensor.size - 4)
185188

186189

190+
@dataclass
191+
class _StreamingUsageAccumulator:
192+
"""Helper class to accumulate token usage from a streaming response."""
193+
194+
backend: str
195+
prompt_tokens: int = 0
196+
completion_tokens: int = 0
197+
_prompt_tokens_set: bool = field(init=False, default=False)
198+
199+
def update(self, response: tritonserver.InferenceResponse):
200+
"""Extracts usage from a response and updates the token counts."""
201+
usage = _get_usage_from_response(response, self.backend)
202+
if usage:
203+
# The prompt_tokens is received with every chunk but should only be set once.
204+
if not self._prompt_tokens_set:
205+
self.prompt_tokens = usage.prompt_tokens
206+
self._prompt_tokens_set = True
207+
self.completion_tokens += usage.completion_tokens
208+
209+
def get_final_usage(self) -> Optional[CompletionUsage]:
210+
"""
211+
Returns the final populated CompletionUsage object if any tokens were tracked.
212+
"""
213+
# If _prompt_tokens_set is True, it means we have received and processed
214+
# at least one valid usage payload.
215+
if self._prompt_tokens_set:
216+
return CompletionUsage(
217+
prompt_tokens=self.prompt_tokens,
218+
completion_tokens=self.completion_tokens,
219+
total_tokens=self.prompt_tokens + self.completion_tokens,
220+
)
221+
return None
222+
223+
224+
def _get_usage_from_response(
225+
response: tritonserver._api._response.InferenceResponse,
226+
backend: str,
227+
) -> Optional[CompletionUsage]:
228+
"""
229+
Extracts token usage statistics from a Triton inference response.
230+
"""
231+
# TODO: Remove this check once TRT-LLM backend supports both "num_input_tokens"
232+
# and "num_output_tokens", and also update the test cases accordingly.
233+
if backend != "vllm":
234+
return None
235+
236+
prompt_tokens = None
237+
completion_tokens = None
238+
239+
if (
240+
"num_input_tokens" in response.outputs
241+
and "num_output_tokens" in response.outputs
242+
):
243+
input_token_tensor = response.outputs["num_input_tokens"]
244+
output_token_tensor = response.outputs["num_output_tokens"]
245+
246+
if input_token_tensor.data_type == tritonserver.DataType.UINT32:
247+
prompt_tokens_ptr = ctypes.cast(
248+
input_token_tensor.data_ptr, ctypes.POINTER(ctypes.c_uint32)
249+
)
250+
prompt_tokens = prompt_tokens_ptr[0]
251+
252+
if output_token_tensor.data_type == tritonserver.DataType.UINT32:
253+
completion_tokens_ptr = ctypes.cast(
254+
output_token_tensor.data_ptr, ctypes.POINTER(ctypes.c_uint32)
255+
)
256+
completion_tokens = completion_tokens_ptr[0]
257+
258+
if prompt_tokens is not None and completion_tokens is not None:
259+
total_tokens = prompt_tokens + completion_tokens
260+
return CompletionUsage(
261+
prompt_tokens=prompt_tokens,
262+
completion_tokens=completion_tokens,
263+
total_tokens=total_tokens,
264+
)
265+
266+
return None
267+
268+
187269
# TODO: Use tritonserver.InferenceResponse when support is published
188270
def _get_output(response: tritonserver._api._response.InferenceResponse) -> str:
189271
if "text_output" in response.outputs:

0 commit comments

Comments
 (0)