A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 4, 15:00 UTC
[Frontend]Responses API supports Tool/Function calling with streaming with required (#40700)
Signed-off-by: chaunceyjiang <chaunceyjiang@gmail.com>
Chauncey committed
Apr 28, 2026 at 10:36 UTC
146f44b77d079f5a16fe7094fa0dde6b1be95f38
6 files changed
+932
-718
tests/entrypoints/openai/responses/test_function_call.py
+156
-1
@@ -321,12 +321,100 @@ async def test_function_calling_with_streaming_expected_arguments(
321
322
@pytest.mark.asyncio
323
@pytest.mark.parametrize("model_name", [MODEL_NAME])
324
+@pytest.mark.parametrize(
325
+ "tool_choice",
326
+ ["auto", "required"],
327
+)
328
async def test_function_calling_with_streaming_types(
325
- client: openai.AsyncOpenAI, model_name: str
329
+ client: openai.AsyncOpenAI, model_name: str, tool_choice
330
):
331
# this links the "done" type with the "start" type
332
# so every "done" type should have a corresponding "start" type
333
# and every open block should be closed by the end of the stream
334
+ #
335
+ # stream of events for a response with function call could look like this:
336
+ # option1: reasoning -> content(option) -> function_call
337
+ # response.created
338
+ # -> response.in_progress
339
+ # -> response.output_item.added
340
+ # -> response.reasoning_part.added
341
+ # -> response.reasoning_text.delta
342
+ # ....
343
+ # -> response.reasoning_text.delta
344
+ # -> response.reasoning_text.done
345
+ # -> response.reasoning_part.done
346
+ # -> response.output_item.done
347
+ # -> response.output_item.added
348
+ # -> response.content_part.added
349
+ # -> response.output_text.delta
350
+ # ...
351
+ # -> response.output_text.delta
352
+ # -> response.output_text.done
353
+ # -> response.content_part.done
354
+ # -> response.output_item.done
355
+ # -> response.output_item.added
356
+ # -> response.function_call_arguments.delta
357
+ # ...
358
+ # -> response.function_call_arguments.delta
359
+ # -> response.function_call_arguments.done
360
+ # -> response.output_item.done
361
+ # -> response.completed
362
+ #
363
+ #
364
+ # option2: reasoning -> content
365
+ # response.created
366
+ # -> response.in_progress
367
+ # -> response.output_item.added
368
+ # -> response.reasoning_part.added
369
+ # -> response.reasoning_text.delta
370
+ # ....
371
+ # -> response.reasoning_text.delta
372
+ # -> response.reasoning_text.done
373
+ # -> response.reasoning_part.done
374
+ # -> response.output_item.done
375
+ # -> response.output_item.added
376
+ # -> response.content_part.added
377
+ # -> response.output_text.delta
378
+ # ..
379
+ # -> response.output_text.delta
380
+ # -> response.output_text.done
381
+ # -> response.content_part.done
382
+ # -> response.output_item.done
383
+ # -> response.completed
384
+ #
385
+ # option3: content
386
+ #
387
+ # response.created
388
+ # -> response.in_progress
389
+ # -> response.output_item.added
390
+ # -> response.content_part.added
391
+ # -> response.output_text.delta
392
+ # ...
393
+ # -> response.output_text.delta
394
+ # -> response.output_text.done
395
+ # -> response.content_part.done
396
+ # -> response.output_item.done
397
+ # -> response.completed
398
+ #
399
+ # option4: content -> function_call
400
+ # response.created
401
+ # -> response.in_progress
402
+ # -> response.output_item.added
403
+ # -> response.content_part.added
404
+ # -> response.output_text.delta
405
+ # ...
406
+ # -> response.output_text.delta
407
+ # -> response.output_text.done
408
+ # -> response.content_part.done
409
+ # -> response.output_item.done
410
+ # -> response.output_item.added
411
+ # -> response.function_call_arguments.delta
412
+ # ...
413
+ # -> response.function_call_arguments.delta
414
+ # -> response.function_call_arguments.done
415
+ # -> response.output_item.done
416
+ # -> response.completed
417
+
418
pairs_of_event_types = {
419
"response.completed": "response.created",
420
"response.output_item.done": "response.output_item.added",
@@ -347,6 +435,7 @@ async def test_function_calling_with_streaming_types(
435
model=model_name,
436
input=input_list,
437
tools=tools,
438
+ tool_choice=tool_choice,
439
stream=True,
440
)
441
@@ -367,3 +456,69 @@ async def test_function_calling_with_streaming_types(
456
assert stack_of_event_types[-1] == pairs_of_event_types[event.type]
457
stack_of_event_types.pop()
458
assert len(stack_of_event_types) == 0
459
+
460
+
461
+@pytest.mark.asyncio
462
+@pytest.mark.parametrize("model_name", [MODEL_NAME])
463
+@pytest.mark.parametrize(
464
+ "tool_choice",
465
+ ["required", "auto"],
466
+)
467
+async def test_function_calling_with_streaming_forced_tool_choice(
468
+ client: openai.AsyncOpenAI, model_name: str, tool_choice: str
469
+):
470
+ tools = [
471
+ {
472
+ "type": "function",
473
+ "name": "get_weather",
474
+ "description": "Get current temperature for provided location in celsius.",
475
+ "parameters": {
476
+ "type": "object",
477
+ "properties": {
478
+ "location": {"type": "string"},
479
+ },
480
+ "required": ["location"],
481
+ "additionalProperties": False,
482
+ },
483
+ "strict": True,
484
+ }
485
+ ]
486
+
487
+ stream_response = await client.responses.create(
488
+ model=model_name,
489
+ input="Call the get_weather function for Berlin and do not answer directly.",
490
+ tools=tools,
491
+ tool_choice=tool_choice,
492
+ stream=True,
493
+ )
494
+
495
+ tool_call_item = None
496
+ completed_event = None
497
+ text_deltas = []
498
+ async for event in stream_response:
499
+ if (
500
+ event.type == "response.output_item.added"
501
+ and event.item.type == "function_call"
502
+ ):
503
+ tool_call_item = event.item
504
+ elif event.type == "response.output_text.delta":
505
+ text_deltas.append(event.delta)
506
+ elif event.type == "response.function_call_arguments.delta" and tool_call_item:
507
+ tool_call_item.arguments += event.delta
508
+ elif (
509
+ event.type == "response.output_item.done"
510
+ and event.item.type == "function_call"
511
+ ):
512
+ completed_event = event
513
+
514
+ assert tool_call_item is not None
515
+ assert tool_call_item.type == "function_call"
516
+ assert tool_call_item.name == "get_weather"
517
+ assert completed_event is not None
518
+ assert tool_call_item.arguments == completed_event.item.arguments
519
+ assert tool_call_item.name == completed_event.item.name
520
+ args = json.loads(tool_call_item.arguments)
521
+ assert "location" in args
522
+ assert args["location"] is not None
523
+ # Forced tool choice should not leak tool-call JSON via output_text delta.
524
+ assert "".join(text_deltas).strip() == ""
vllm/entrypoints/openai/chat_completion/serving.py
+9
-134
@@ -9,10 +9,7 @@ from collections.abc import Sequence as GenericSequence
9
from http import HTTPStatus
10
from typing import TYPE_CHECKING, Any, Final
11
12
-import partial_json_parser
13
-import regex as re
12
from fastapi import Request
15
-from partial_json_parser.core.options import Allow
13
14
from vllm.engine.protocol import EngineClient
15
from vllm.entrypoints.chat_utils import (
@@ -73,7 +70,7 @@ from vllm.reasoning import ReasoningParser
70
from vllm.renderers import ChatParams
71
from vllm.sampling_params import BeamSearchParams, SamplingParams
72
from vllm.tokenizers import TokenizerLike
76
-from vllm.tool_parsers.utils import partial_json_loads
73
+from vllm.tool_parsers.streaming import extract_required_tool_call_streaming
74
from vllm.utils.collection_utils import as_list
75
from vllm.utils.mistral import is_mistral_tokenizer, is_mistral_tool_parser
76
@@ -383,45 +380,6 @@ class OpenAIServingChat(OpenAIServing):
380
return self.response_role
381
return request.messages[-1]["role"]
382
386
- @staticmethod
387
- def _bracket_level(s: str, opening="{", closing="}") -> int:
388
- """
389
- Calculate the current level of nested brackets in a given string.
390
- """
391
- level = 0
392
- for char in s:
393
- if char == opening:
394
- level += 1
395
- elif char == closing:
396
- level -= 1
397
- return level
398
-
399
- @staticmethod
400
- def _filter_delta_text(delta_text: str, previous_text: str) -> tuple[str, bool]:
401
- # remove last '},' of the tool definition stemming from the
402
- # "name"/"parameters" outer object or closing ']' of the tool list
403
- # count occurrences of opening and closing curly braces and
404
- # once level 0 is reached stop outputting text
405
- # if 0 is reached while parsing the delta_text we know the current
406
- # tool will finish in this current iteration
407
- bracket_level = OpenAIServingChat._bracket_level(previous_text)
408
- updated_delta, passed_zero = "", False
409
- for c in delta_text:
410
- if c == "{":
411
- bracket_level += 1
412
- passed_zero = bracket_level == 0
413
- elif c == "}":
414
- bracket_level -= 1
415
- passed_zero = bracket_level == 0
416
-
417
- if bracket_level != 0:
418
- updated_delta += c
419
- else:
420
- # if a comma is reached at level 0 we can stop
421
- if c == ",":
422
- break
423
- return updated_delta, passed_zero
424
-
383
def extract_tool_call_required_streaming(
384
self,
385
previous_text: str,
@@ -430,97 +388,14 @@ class OpenAIServingChat(OpenAIServing):
388
function_name_returned: bool,
389
tool_call_idx: int | None = None,
390
) -> tuple[DeltaMessage | None, bool]:
433
- if current_text is None or current_text == "":
434
- # if the current text is empty, we cannot parse it
435
- return None, function_name_returned
436
- try:
437
- flags = Allow.ALL
438
- obj, _ = partial_json_loads(current_text, flags)
439
- except (
440
- partial_json_parser.core.exceptions.MalformedJSON,
441
- json.JSONDecodeError,
442
- ):
443
- logger.debug("not enough tokens to parse into JSON yet")
444
- obj = None
445
-
446
- # check if the current text is a valid array
447
- # containing a partial tool calling object
448
- # if not repeat
449
- if obj is None or not isinstance(obj, list) or not len(obj) > 0:
450
- function_name_returned = False
451
- delta_message = None
452
- else:
453
- _, finishes_previous_tool = OpenAIServingChat._filter_delta_text(
454
- delta_text, previous_text
455
- )
456
- # take the last tool call from the generated list
457
- current_tool_call = obj[-1]
458
-
459
- # once parameters have been generated the name is complete as well
460
- if not finishes_previous_tool and (
461
- "name" not in current_tool_call or "parameters" not in current_tool_call
462
- ):
463
- function_name_returned = False
464
- delta_message = None
465
- else:
466
- if not function_name_returned:
467
- # get partly generated arguments from the latest tool call
468
- param_match = re.search(
469
- r'.*"parameters":\s*(.*)', current_text, re.DOTALL
470
- )
471
- arguments = param_match.group(1) if param_match else ""
472
- arguments, _ = OpenAIServingChat._filter_delta_text(
473
- arguments, previous_text
474
- )
475
-
476
- # if this iteration finishes a previous tool call but a
477
- # new incomplete tool is already generated, take the
478
- # previous from the list
479
- if finishes_previous_tool and "parameters" not in current_tool_call:
480
- current_tool_call = obj[-2]
481
-
482
- function_name_returned = True
483
- tool_call_id = make_tool_call_id(
484
- id_type=self.tool_call_id_type,
485
- func_name=current_tool_call["name"],
486
- idx=tool_call_idx,
487
- )
488
- delta_message = DeltaMessage(
489
- tool_calls=[
490
- DeltaToolCall(
491
- id=tool_call_id,
492
- function=DeltaFunctionCall(
493
- name=current_tool_call["name"], arguments=arguments
494
- ),
495
- index=len(obj) - 1,
496
- type="function",
497
- )
498
- ]
499
- )
500
-
501
- else:
502
- delta_text, _ = OpenAIServingChat._filter_delta_text(
503
- delta_text, previous_text
504
- )
505
-
506
- if delta_text != "":
507
- delta_message = DeltaMessage(
508
- tool_calls=[
509
- DeltaToolCall(
510
- function=DeltaFunctionCall(
511
- # OpenAI API returns None
512
- # instead of name every time
513
- name=None,
514
- arguments=delta_text,
515
- ),
516
- index=len(obj) - 1,
517
- )
518
- ]
519
- )
520
- else:
521
- delta_message = None
522
-
523
- return delta_message, function_name_returned
391
+ return extract_required_tool_call_streaming(
392
+ previous_text=previous_text,
393
+ current_text=current_text,
394
+ delta_text=delta_text,
395
+ function_name_returned=function_name_returned,
396
+ tool_call_idx=tool_call_idx,
397
+ tool_call_id_type=self.tool_call_id_type,
398
+ )
399
400
async def chat_completion_stream_generator(
401
self,
vllm/entrypoints/openai/responses/serving.py
+45
-573
@@ -3,7 +3,6 @@
3
4
import asyncio
5
import time
6
-import uuid
6
from collections import deque
7
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping, Sequence
8
from contextlib import AsyncExitStack
@@ -13,29 +12,14 @@ from typing import Any, Final
12
13
from fastapi import Request
14
from openai.types.responses import (
16
- ResponseContentPartAddedEvent,
17
- ResponseContentPartDoneEvent,
18
- ResponseFunctionCallArgumentsDeltaEvent,
19
- ResponseFunctionCallArgumentsDoneEvent,
15
ResponseFunctionToolCall,
21
- ResponseFunctionToolCallItem,
16
ResponseOutputItem,
23
- ResponseOutputItemAddedEvent,
24
- ResponseOutputItemDoneEvent,
17
ResponseOutputMessage,
18
ResponseOutputText,
27
- ResponseReasoningItem,
28
- ResponseReasoningTextDeltaEvent,
29
- ResponseReasoningTextDoneEvent,
19
ResponseStatus,
31
- ResponseTextDeltaEvent,
32
- ResponseTextDoneEvent,
20
response_text_delta_event,
21
)
22
from openai.types.responses.response_output_text import Logprob, LogprobTopLogprob
36
-from openai.types.responses.response_reasoning_item import (
37
- Content as ResponseReasoningTextContent,
38
-)
23
from openai.types.responses.tool import Mcp, Tool
24
from openai_harmony import Message as OpenAIHarmonyMessage
25
from pydantic import TypeAdapter
@@ -89,15 +73,15 @@ from vllm.entrypoints.openai.responses.protocol import (
73
ResponseInProgressEvent,
74
ResponseInputOutputItem,
75
ResponseInputOutputMessage,
92
- ResponseReasoningPartAddedEvent,
93
- ResponseReasoningPartDoneEvent,
76
ResponsesRequest,
77
ResponsesResponse,
78
ResponseUsage,
79
StreamingResponsesResponse,
80
)
81
from vllm.entrypoints.openai.responses.streaming_events import (
82
+ SimpleStreamingEventProcessor,
83
StreamingState,
84
+ _StateType,
85
emit_content_delta_events,
86
emit_previous_item_done_events,
87
emit_tool_action_events,
@@ -1353,571 +1337,59 @@ class OpenAIServingResponses(OpenAIServing):
1337
[StreamingResponsesResponse], StreamingResponsesResponse
1338
],
1339
) -> AsyncGenerator[StreamingResponsesResponse, None]:
1356
- current_content_index = 0
1357
- current_output_index = 0
1358
- current_item_id = ""
1359
- current_tool_call_index: int | None = None
1340
+ processor = SimpleStreamingEventProcessor()
1341
parser = self.parser(tokenizer, request.tools) if self.parser else None
1361
- first_delta_sent = False
1362
- previous_delta_messages: list[DeltaMessage] = []
1342
+
1343
+ def _get_logprobs(
1344
+ output: CompletionOutput,
1345
+ ) -> list[response_text_delta_event.Logprob]:
1346
+ if not request.is_include_output_logprobs():
1347
+ return []
1348
+ return self._create_stream_response_logprobs(
1349
+ token_ids=output.token_ids,
1350
+ logprobs=output.logprobs,
1351
+ tokenizer=tokenizer,
1352
+ top_logprobs=request.top_logprobs,
1353
+ )
1354
+
1355
async for ctx in result_generator:
1356
assert isinstance(ctx, SimpleContext)
1365
- if ctx.last_output is None:
1357
+ if ctx.last_output is None or not ctx.last_output.outputs:
1358
continue
1367
- if ctx.last_output.outputs:
1368
- output = ctx.last_output.outputs[0]
1369
- # finish_reason='error' indicates a retryable error
1370
- self._raise_if_error(output.finish_reason, request.request_id)
1371
- delta_text = output.text
1372
- delta_token_ids = as_list(output.token_ids)
1373
-
1374
- if parser:
1375
- delta_message = parser.parse_delta(
1376
- delta_text=delta_text,
1377
- delta_token_ids=delta_token_ids,
1378
- request=request,
1379
- prompt_token_ids=ctx.last_output.prompt_token_ids,
1380
- )
1381
- else:
1382
- delta_message = DeltaMessage(
1383
- content=output.text,
1384
- )
1385
- if not delta_message:
1386
- continue
1387
- tool_call_item_started = False
1388
- if not first_delta_sent:
1389
- current_item_id = random_uuid()
1390
- if delta_message.tool_calls:
1391
- current_tool_call_id = f"call_{random_uuid()}"
1392
- assert len(delta_message.tool_calls) == 1, (
1393
- "Multiple tool calls in one delta is not supported"
1394
- )
1395
- assert delta_message.tool_calls[0].function is not None, (
1396
- "Tool call without function is not supported"
1397
- )
1398
- assert delta_message.tool_calls[0].function.name is not None, (
1399
- "Tool call without function name is not supported"
1400
- )
1401
- current_tool_call_name = delta_message.tool_calls[
1402
- 0
1403
- ].function.name
1404
- current_tool_call_index = delta_message.tool_calls[0].index
1405
- yield _increment_sequence_number_and_return(
1406
- ResponseOutputItemAddedEvent(
1407
- type="response.output_item.added",
1408
- sequence_number=-1,
1409
- output_index=current_output_index,
1410
- item=ResponseFunctionToolCallItem(
1411
- type="function_call",
1412
- id=current_item_id,
1413
- call_id=current_tool_call_id,
1414
- name=current_tool_call_name,
1415
- arguments="",
1416
- status="in_progress",
1417
- ),
1418
- )
1419
- )
1420
- tool_call_item_started = True
1421
- elif delta_message.reasoning:
1422
- yield _increment_sequence_number_and_return(
1423
- ResponseOutputItemAddedEvent(
1424
- type="response.output_item.added",
1425
- sequence_number=-1,
1426
- output_index=current_output_index,
1427
- item=ResponseReasoningItem(
1428
- type="reasoning",
1429
- id=current_item_id,
1430
- summary=[],
1431
- status="in_progress",
1432
- ),
1433
- )
1434
- )
1435
- yield _increment_sequence_number_and_return(
1436
- ResponseReasoningPartAddedEvent(
1437
- type="response.reasoning_part.added",
1438
- sequence_number=-1,
1439
- output_index=current_output_index,
1440
- item_id=current_item_id,
1441
- content_index=current_content_index,
1442
- part=ResponseReasoningTextContent(
1443
- text="",
1444
- type="reasoning_text",
1445
- ),
1446
- )
1447
- )
1448
- elif not delta_message.tool_calls:
1449
- yield _increment_sequence_number_and_return(
1450
- ResponseOutputItemAddedEvent(
1451
- type="response.output_item.added",
1452
- sequence_number=-1,
1453
- output_index=current_output_index,
1454
- item=ResponseOutputMessage(
1455
- id=current_item_id,
1456
- type="message",
1457
- role="assistant",
1458
- content=[],
1459
- status="in_progress",
1460
- ),
1461
- )
1462
- )
1463
- yield _increment_sequence_number_and_return(
1464
- ResponseContentPartAddedEvent(
1465
- type="response.content_part.added",
1466
- sequence_number=-1,
1467
- output_index=current_output_index,
1468
- item_id=current_item_id,
1469
- content_index=current_content_index,
1470
- part=ResponseOutputText(
1471
- type="output_text",
1472
- text="",
1473
- annotations=[],
1474
- logprobs=[],
1475
- ),
1476
- )
1477
- )
1478
- first_delta_sent = True
1359
1480
- # check delta message and previous delta message are
1481
- # same as content or reasoning content
1482
- if (
1483
- previous_delta_messages
1484
- and previous_delta_messages[-1].reasoning is not None
1485
- and delta_message.content is not None
1486
- ):
1487
- # from reasoning to normal content, send done
1488
- # event for reasoning
1489
- reason_content = "".join(
1490
- pm.reasoning
1491
- for pm in previous_delta_messages
1492
- if pm.reasoning is not None
1493
- )
1360
+ output = ctx.last_output.outputs[0]
1361
+ self._raise_if_error(output.finish_reason, request.request_id)
1362
+ delta_text = output.text
1363
+ delta_token_ids = as_list(output.token_ids)
1364
+
1365
+ if parser:
1366
+ delta_message = parser.parse_delta(
1367
+ delta_text=delta_text,
1368
+ delta_token_ids=delta_token_ids,
1369
+ request=request,
1370
+ prompt_token_ids=ctx.last_output.prompt_token_ids,
1371
+ )
1372
+ else:
1373
+ delta_message = DeltaMessage(content=output.text)
1374
1495
- # delta message could have both reasoning and
1496
- # content. Include current delta's reasoning in the
1497
- # finalization since it may carry the tail end of
1498
- # reasoning text (e.g. when reasoning end and
1499
- # content start arrive in the same delta).
1500
- if delta_message.reasoning is not None:
1501
- yield _increment_sequence_number_and_return(
1502
- ResponseReasoningTextDeltaEvent(
1503
- type="response.reasoning_text.delta",
1504
- sequence_number=-1,
1505
- content_index=current_content_index,
1506
- output_index=current_output_index,
1507
- item_id=current_item_id,
1508
- delta=delta_message.reasoning,
1509
- )
1510
- )
1511
- reason_content += delta_message.reasoning
1512
- delta_message = DeltaMessage(content=delta_message.content)
1513
-
1514
- yield _increment_sequence_number_and_return(
1515
- ResponseReasoningTextDoneEvent(
1516
- type="response.reasoning_text.done",
1517
- item_id=current_item_id,
1518
- sequence_number=-1,
1519
- output_index=current_output_index,
1520
- content_index=current_content_index,
1521
- text=reason_content,
1522
- )
1523
- )
1524
- yield _increment_sequence_number_and_return(
1525
- ResponseReasoningPartDoneEvent(
1526
- type="response.reasoning_part.done",
1527
- sequence_number=-1,
1528
- item_id=current_item_id,
1529
- output_index=current_output_index,
1530
- content_index=current_content_index,
1531
- part=ResponseReasoningTextContent(
1532
- text=reason_content,
1533
- type="reasoning_text",
1534
- ),
1535
- )
1536
- )
1537
- current_content_index = 0
1538
- reasoning_item = ResponseReasoningItem(
1539
- type="reasoning",
1540
- content=[
1541
- ResponseReasoningTextContent(
1542
- text=reason_content,
1543
- type="reasoning_text",
1544
- ),
1545
- ],
1546
- status="completed",
1547
- id=current_item_id,
1548
- summary=[],
1549
- )
1550
- yield _increment_sequence_number_and_return(
1551
- ResponseOutputItemDoneEvent(
1552
- type="response.output_item.done",
1553
- sequence_number=-1,
1554
- output_index=current_output_index,
1555
- item=reasoning_item,
1556
- )
1557
- )
1558
- current_output_index += 1
1559
- current_item_id = str(uuid.uuid4())
1560
- yield _increment_sequence_number_and_return(
1561
- ResponseOutputItemAddedEvent(
1562
- type="response.output_item.added",
1563
- sequence_number=-1,
1564
- output_index=current_output_index,
1565
- item=ResponseOutputMessage(
1566
- id=current_item_id,
1567
- type="message",
1568
- role="assistant",
1569
- content=[],
1570
- status="in_progress",
1571
- ),
1572
- )
1573
- )
1574
- yield _increment_sequence_number_and_return(
1575
- ResponseContentPartAddedEvent(
1576
- type="response.content_part.added",
1577
- sequence_number=-1,
1578
- output_index=current_output_index,
1579
- item_id=current_item_id,
1580
- content_index=current_content_index,
1581
- part=ResponseOutputText(
1582
- type="output_text",
1583
- text="",
1584
- annotations=[],
1585
- logprobs=[],
1586
- ),
1587
- )
1588
- )
1589
- # reset previous delta messages
1590
- previous_delta_messages = []
1591
- if delta_message.tool_calls and delta_message.tool_calls[0].function:
1592
- tool_call = delta_message.tool_calls[0]
1593
- tool_call_function = tool_call.function
1594
- if (
1595
- current_tool_call_index is not None
1596
- and tool_call.index is not None
1597
- and tool_call.index != current_tool_call_index
1598
- and tool_call_function is not None
1599
- and tool_call_function.name is not None
1600
- ):
1601
- # From one tool call to another, finalize the previous
1602
- # function-call item before opening the next one.
1603
- parts = []
1604
- for pm in previous_delta_messages:
1605
- if pm.tool_calls:
1606
- previous_tool_call = pm.tool_calls[0]
1607
- if previous_tool_call.function is not None:
1608
- parts.append(
1609
- previous_tool_call.function.arguments or ""
1610
- )
1611
-
1612
- tool_call_arguments = "".join(parts)
1613
- yield _increment_sequence_number_and_return(
1614
- ResponseFunctionCallArgumentsDoneEvent(
1615
- type="response.function_call_arguments.done",
1616
- sequence_number=-1,
1617
- output_index=current_output_index,
1618
- item_id=current_item_id,
1619
- arguments=tool_call_arguments,
1620
- name=current_tool_call_name,
1621
- )
1622
- )
1623
- function_call_item = ResponseFunctionToolCall(
1624
- type="function_call",
1625
- name=current_tool_call_name,
1626
- arguments=tool_call_arguments,
1627
- status="completed",
1628
- id=current_item_id,
1629
- call_id=current_tool_call_id,
1630
- )
1631
- yield _increment_sequence_number_and_return(
1632
- ResponseOutputItemDoneEvent(
1633
- type="response.output_item.done",
1634
- sequence_number=-1,
1635
- output_index=current_output_index,
1636
- item=function_call_item,
1637
- )
1638
- )
1639
- # Reset previous delta messages so the next tool call
1640
- # does not reuse arguments from the completed item.
1641
- previous_delta_messages = []
1642
- current_output_index += 1
1643
- current_item_id = random_uuid()
1644
- current_tool_call_name = tool_call_function.name
1645
- current_tool_call_id = f"call_{random_uuid()}"
1646
- current_tool_call_index = tool_call.index
1647
- yield _increment_sequence_number_and_return(
1648
- ResponseOutputItemAddedEvent(
1649
- type="response.output_item.added",
1650
- sequence_number=-1,
1651
- output_index=current_output_index,
1652
- item=ResponseFunctionToolCallItem(
1653
- type="function_call",
1654
- id=current_item_id,
1655
- call_id=current_tool_call_id,
1656
- name=current_tool_call_name,
1657
- arguments="",
1658
- status="in_progress",
1659
- ),
1660
- )
1661
- )
1662
- current_content_index = 0
1663
- tool_call_item_started = True
1664
-
1665
- if delta_message.tool_calls[0].function.arguments:
1666
- yield _increment_sequence_number_and_return(
1667
- ResponseFunctionCallArgumentsDeltaEvent(
1668
- type="response.function_call_arguments.delta",
1669
- sequence_number=-1,
1670
- output_index=current_output_index,
1671
- item_id=current_item_id,
1672
- delta=delta_message.tool_calls[0].function.arguments,
1673
- )
1674
- )
1675
- # tool call initiated with no arguments
1676
- elif (
1677
- delta_message.tool_calls[0].function.name
1678
- and not tool_call_item_started
1679
- ):
1680
- # send done with current content part
1681
- # and add new function call item
1682
- yield _increment_sequence_number_and_return(
1683
- ResponseTextDoneEvent(
1684
- type="response.output_text.done",
1685
- sequence_number=-1,
1686
- output_index=current_output_index,
1687
- content_index=current_content_index,
1688
- text="",
1689
- logprobs=[],
1690
- item_id=current_item_id,
1691
- )
1692
- )
1693
- yield _increment_sequence_number_and_return(
1694
- ResponseContentPartDoneEvent(
1695
- type="response.content_part.done",
1696
- sequence_number=-1,
1697
- item_id=current_item_id,
1698
- output_index=current_output_index,
1699
- content_index=current_content_index,
1700
- part=ResponseOutputText(
1701
- type="output_text",
1702
- text="",
1703
- annotations=[],
1704
- logprobs=[],
1705
- ),
1706
- )
1707
- )
1708
- yield _increment_sequence_number_and_return(
1709
- ResponseOutputItemDoneEvent(
1710
- type="response.output_item.done",
1711
- sequence_number=-1,
1712
- output_index=current_output_index,
1713
- item=ResponseOutputMessage(
1714
- id=current_item_id,
1715
- type="message",
1716
- role="assistant",
1717
- content=[],
1718
- status="completed",
1719
- ),
1720
- )
1721
- )
1722
- current_output_index += 1
1723
- current_item_id = random_uuid()
1724
- current_tool_call_name = delta_message.tool_calls[
1725
- 0
1726
- ].function.name
1727
- current_tool_call_id = f"call_{random_uuid()}"
1728
- current_tool_call_index = delta_message.tool_calls[0].index
1729
- yield _increment_sequence_number_and_return(
1730
- ResponseOutputItemAddedEvent(
1731
- type="response.output_item.added",
1732
- sequence_number=-1,
1733
- output_index=current_output_index,
1734
- item=ResponseFunctionToolCallItem(
1735
- type="function_call",
1736
- id=current_item_id,
1737
- call_id=current_tool_call_id,
1738
- name=current_tool_call_name,
1739
- arguments="",
1740
- status="in_progress",
1741
- ),
1742
- )
1743
- )
1744
- # skip content part for tool call
1745
- current_content_index = 1
1746
- continue
1747
- elif delta_message.reasoning is not None:
1748
- yield _increment_sequence_number_and_return(
1749
- ResponseReasoningTextDeltaEvent(
1750
- type="response.reasoning_text.delta",
1751
- sequence_number=-1,
1752
- content_index=current_content_index,
1753
- output_index=current_output_index,
1754
- item_id=current_item_id,
1755
- delta=delta_message.reasoning,
1756
- )
1757
- )
1758
- elif delta_message.content:
1759
- yield _increment_sequence_number_and_return(
1760
- ResponseTextDeltaEvent(
1761
- type="response.output_text.delta",
1762
- sequence_number=-1,
1763
- content_index=current_content_index,
1764
- output_index=current_output_index,
1765
- item_id=current_item_id,
1766
- delta=delta_message.content,
1767
- logprobs=(
1768
- self._create_stream_response_logprobs(
1769
- token_ids=output.token_ids,
1770
- logprobs=output.logprobs,
1771
- tokenizer=tokenizer,
1772
- top_logprobs=request.top_logprobs,
1773
- )
1774
- if request.is_include_output_logprobs()
1775
- else []
1776
- ),
1777
- )
1778
- )
1375
+ if not delta_message:
1376
+ continue
1377
1780
- previous_delta_messages.append(delta_message)
1378
+ target_state, tool_call = processor.resolve_target_state(delta_message)
1379
+ if target_state == _StateType.NONE:
1380
+ continue
1381
1782
- if previous_delta_messages:
1783
- parts = []
1784
- for pm in previous_delta_messages:
1785
- if pm.tool_calls:
1786
- assert len(pm.tool_calls) == 1, (
1787
- "Multiple tool calls in one delta is not supported"
1788
- )
1789
- assert pm.tool_calls[0].function is not None, (
1790
- "Tool call without function is not supported"
1791
- )
1792
- parts.append(pm.tool_calls[0].function.arguments or "")
1382
+ if processor.needs_transition(target_state, tool_call):
1383
+ for event in processor.close_current():
1384
+ yield _increment_sequence_number_and_return(event)
1385
+ for event in processor.open(target_state, tool_call):
1386
+ yield _increment_sequence_number_and_return(event)
1387
1794
- tool_call_arguments = "".join(parts)
1795
- if tool_call_arguments:
1796
- yield _increment_sequence_number_and_return(
1797
- ResponseFunctionCallArgumentsDoneEvent(
1798
- type="response.function_call_arguments.done",
1799
- sequence_number=-1,
1800
- output_index=current_output_index,
1801
- item_id=current_item_id,
1802
- arguments=tool_call_arguments,
1803
- name=current_tool_call_name,
1804
- )
1805
- )
1806
- current_content_index = 0
1807
- function_call_item = ResponseFunctionToolCall(
1808
- type="function_call",
1809
- name=current_tool_call_name,
1810
- arguments=tool_call_arguments,
1811
- status="completed",
1812
- id=current_item_id,
1813
- call_id=current_tool_call_id,
1814
- )
1815
- yield _increment_sequence_number_and_return(
1816
- ResponseOutputItemDoneEvent(
1817
- type="response.output_item.done",
1818
- sequence_number=-1,
1819
- output_index=current_output_index,
1820
- item=function_call_item,
1821
- )
1822
- )
1388
+ for event in processor.emit_delta(delta_message, output, _get_logprobs):
1389
+ yield _increment_sequence_number_and_return(event)
1390
1824
- elif previous_delta_messages[-1].reasoning is not None:
1825
- reason_content = "".join(
1826
- pm.reasoning
1827
- for pm in previous_delta_messages
1828
- if pm.reasoning is not None
1829
- )
1830
- yield _increment_sequence_number_and_return(
1831
- ResponseReasoningTextDoneEvent(
1832
- type="response.reasoning_text.done",
1833
- item_id=current_item_id,
1834
- sequence_number=-1,
1835
- output_index=current_output_index,
1836
- content_index=current_content_index,
1837
- text=reason_content,
1838
- )
1839
- )
1840
- yield _increment_sequence_number_and_return(
1841
- ResponseReasoningPartDoneEvent(
1842
- type="response.reasoning_part.done",
1843
- sequence_number=-1,
1844
- item_id=current_item_id,
1845
- output_index=current_output_index,
1846
- content_index=current_content_index,
1847
- part=ResponseReasoningTextContent(
1848
- text=reason_content,
1849
- type="reasoning_text",
1850
- ),
1851
- )
1852
- )
1853
- reasoning_item = ResponseReasoningItem(
1854
- type="reasoning",
1855
- content=[
1856
- ResponseReasoningTextContent(
1857
- text=reason_content,
1858
- type="reasoning_text",
1859
- ),
1860
- ],
1861
- status="completed",
1862
- id=current_item_id,
1863
- summary=[],
1864
- )
1865
- yield _increment_sequence_number_and_return(
1866
- ResponseOutputItemDoneEvent(
1867
- type="response.output_item.done",
1868
- sequence_number=-1,
1869
- output_index=current_output_index,
1870
- item=reasoning_item,
1871
- )
1872
- )
1873
- elif previous_delta_messages[-1].content:
1874
- final_content = "".join(
1875
- pm.content for pm in previous_delta_messages if pm.content
1876
- )
1877
- yield _increment_sequence_number_and_return(
1878
- ResponseTextDoneEvent(
1879
- type="response.output_text.done",
1880
- sequence_number=-1,
1881
- output_index=current_output_index,
1882
- content_index=current_content_index,
1883
- text=final_content,
1884
- logprobs=[],
1885
- item_id=current_item_id,
1886
- )
1887
- )
1888
- part = ResponseOutputText(
1889
- text=final_content,
1890
- type="output_text",
1891
- annotations=[],
1892
- )
1893
- yield _increment_sequence_number_and_return(
1894
- ResponseContentPartDoneEvent(
1895
- type="response.content_part.done",
1896
- sequence_number=-1,
1897
- item_id=current_item_id,
1898
- output_index=current_output_index,
1899
- content_index=current_content_index,
1900
- part=part,
1901
- )
1902
- )
1903
- item = ResponseOutputMessage(
1904
- type="message",
1905
- role="assistant",
1906
- content=[
1907
- part,
1908
- ],
1909
- status="completed",
1910
- id=current_item_id,
1911
- summary=[],
1912
- )
1913
- yield _increment_sequence_number_and_return(
1914
- ResponseOutputItemDoneEvent(
1915
- type="response.output_item.done",
1916
- sequence_number=-1,
1917
- output_index=current_output_index,
1918
- item=item,
1919
- )
1920
- )
1391
+ for event in processor.close_current():
1392
+ yield _increment_sequence_number_and_return(event)
1393
1394
async def _process_harmony_streaming_events(
1395
self,
vllm/entrypoints/openai/responses/streaming_events.py
+462
-2
@@ -16,8 +16,10 @@ The file is organized as:
16
"""
17
18
import json
19
-from dataclasses import dataclass
20
-from typing import Final
19
+from collections.abc import Callable
20
+from dataclasses import dataclass, field
21
+from enum import Enum, auto
22
+from typing import Any, ClassVar, Final, NamedTuple
23
24
from openai.types.responses import (
25
ResponseCodeInterpreterCallCodeDeltaEvent,
@@ -31,6 +33,7 @@ from openai.types.responses import (
33
ResponseFunctionCallArgumentsDeltaEvent,
34
ResponseFunctionCallArgumentsDoneEvent,
35
ResponseFunctionToolCall,
36
+ ResponseFunctionToolCallItem,
37
ResponseFunctionWebSearch,
38
ResponseMcpCallArgumentsDeltaEvent,
39
ResponseMcpCallArgumentsDoneEvent,
@@ -49,6 +52,7 @@ from openai.types.responses import (
52
ResponseWebSearchCallInProgressEvent,
53
ResponseWebSearchCallSearchingEvent,
54
response_function_web_search,
55
+ response_text_delta_event,
56
)
57
from openai.types.responses.response_output_item import McpCall
58
from openai.types.responses.response_reasoning_item import (
@@ -57,12 +61,14 @@ from openai.types.responses.response_reasoning_item import (
61
from openai_harmony import Message as HarmonyMessage
62
63
from vllm.entrypoints.mcp.tool_server import ToolServer
64
+from vllm.entrypoints.openai.engine.protocol import DeltaMessage
65
from vllm.entrypoints.openai.responses.context import StreamingHarmonyContext
66
from vllm.entrypoints.openai.responses.protocol import (
67
ResponseReasoningPartAddedEvent,
68
ResponseReasoningPartDoneEvent,
69
StreamingResponsesResponse,
70
)
71
+from vllm.outputs import CompletionOutput
72
from vllm.utils import random_uuid
73
74
TOOL_NAME_TO_MCP_SERVER_LABEL: Final[dict[str, str]] = {
@@ -796,3 +802,457 @@ def emit_tool_action_events(
802
)
803
804
return events
805
+
806
+
807
+# =====================================================================
808
+# Simple streaming helpers
809
+# =====================================================================
810
+
811
+
812
+class _StateType(Enum):
813
+ NONE = auto()
814
+ CONTENT = auto()
815
+ REASONING = auto()
816
+ TOOL_CALL = auto()
817
+
818
+
819
+@dataclass
820
+class SimpleStreamingState:
821
+ output_index: int = 0
822
+ current_item_id: str = ""
823
+ content_index: int = 0
824
+ accumulated_text: str = ""
825
+ tool_call_id: str = ""
826
+ tool_call_name: str = ""
827
+ tool_call_index: int | None = None
828
+ has_emitted_tool_call_delta: bool = False
829
+ current_state: _StateType = field(default_factory=lambda: _StateType.NONE)
830
+
831
+
832
+def emit_simple_content_open(
833
+ state: SimpleStreamingState,
834
+) -> list[StreamingResponsesResponse]:
835
+ state.current_state = _StateType.CONTENT
836
+ state.current_item_id = random_uuid()
837
+ state.content_index = 0
838
+ state.accumulated_text = ""
839
+ return [
840
+ ResponseOutputItemAddedEvent(
841
+ type="response.output_item.added",
842
+ sequence_number=-1,
843
+ output_index=state.output_index,
844
+ item=ResponseOutputMessage(
845
+ id=state.current_item_id,
846
+ type="message",
847
+ role="assistant",
848
+ content=[],
849
+ status="in_progress",
850
+ ),
851
+ ),
852
+ ResponseContentPartAddedEvent(
853
+ type="response.content_part.added",
854
+ sequence_number=-1,
855
+ output_index=state.output_index,
856
+ item_id=state.current_item_id,
857
+ content_index=state.content_index,
858
+ part=ResponseOutputText(
859
+ type="output_text",
860
+ text="",
861
+ annotations=[],
862
+ logprobs=[],
863
+ ),
864
+ ),
865
+ ]
866
+
867
+
868
+def emit_simple_content_delta(
869
+ state: SimpleStreamingState,
870
+ delta: str,
871
+ logprobs: list[response_text_delta_event.Logprob] | None = None,
872
+) -> list[StreamingResponsesResponse]:
873
+ state.accumulated_text += delta
874
+ return [
875
+ ResponseTextDeltaEvent(
876
+ type="response.output_text.delta",
877
+ sequence_number=-1,
878
+ content_index=state.content_index,
879
+ output_index=state.output_index,
880
+ item_id=state.current_item_id,
881
+ delta=delta,
882
+ logprobs=logprobs or [],
883
+ )
884
+ ]
885
+
886
+
887
+def emit_simple_content_done(
888
+ state: SimpleStreamingState,
889
+) -> list[StreamingResponsesResponse]:
890
+ part = ResponseOutputText(
891
+ type="output_text",
892
+ text=state.accumulated_text,
893
+ annotations=[],
894
+ )
895
+ events: list[StreamingResponsesResponse] = [
896
+ ResponseTextDoneEvent(
897
+ type="response.output_text.done",
898
+ sequence_number=-1,
899
+ output_index=state.output_index,
900
+ content_index=state.content_index,
901
+ text=state.accumulated_text,
902
+ logprobs=[],
903
+ item_id=state.current_item_id,
904
+ ),
905
+ ResponseContentPartDoneEvent(
906
+ type="response.content_part.done",
907
+ sequence_number=-1,
908
+ item_id=state.current_item_id,
909
+ output_index=state.output_index,
910
+ content_index=state.content_index,
911
+ part=part,
912
+ ),
913
+ ResponseOutputItemDoneEvent(
914
+ type="response.output_item.done",
915
+ sequence_number=-1,
916
+ output_index=state.output_index,
917
+ item=ResponseOutputMessage(
918
+ id=state.current_item_id,
919
+ type="message",
920
+ role="assistant",
921
+ content=[part] if state.accumulated_text else [],
922
+ status="completed",
923
+ summary=[],
924
+ ),
925
+ ),
926
+ ]
927
+ state.output_index += 1
928
+ state.current_state = _StateType.NONE
929
+ return events
930
+
931
+
932
+def emit_simple_reasoning_open(
933
+ state: SimpleStreamingState,
934
+) -> list[StreamingResponsesResponse]:
935
+ state.current_state = _StateType.REASONING
936
+ state.current_item_id = random_uuid()
937
+ state.content_index = 0
938
+ state.accumulated_text = ""
939
+ return [
940
+ ResponseOutputItemAddedEvent(
941
+ type="response.output_item.added",
942
+ sequence_number=-1,
943
+ output_index=state.output_index,
944
+ item=ResponseReasoningItem(
945
+ type="reasoning",
946
+ id=state.current_item_id,
947
+ summary=[],
948
+ status="in_progress",
949
+ ),
950
+ ),
951
+ ResponseReasoningPartAddedEvent(
952
+ type="response.reasoning_part.added",
953
+ sequence_number=-1,
954
+ output_index=state.output_index,
955
+ item_id=state.current_item_id,
956
+ content_index=state.content_index,
957
+ part=ResponseReasoningTextContent(
958
+ text="",
959
+ type="reasoning_text",
960
+ ),
961
+ ),
962
+ ]
963
+
964
+
965
+def emit_simple_reasoning_delta(
966
+ state: SimpleStreamingState,
967
+ delta: str,
968
+) -> list[StreamingResponsesResponse]:
969
+ state.accumulated_text += delta
970
+ return [
971
+ ResponseReasoningTextDeltaEvent(
972
+ type="response.reasoning_text.delta",
973
+ item_id=state.current_item_id,
974
+ sequence_number=-1,
975
+ output_index=state.output_index,
976
+ content_index=state.content_index,
977
+ delta=delta,
978
+ )
979
+ ]
980
+
981
+
982
+def emit_simple_reasoning_done(
983
+ state: SimpleStreamingState,
984
+) -> list[StreamingResponsesResponse]:
985
+ part = ResponseReasoningTextContent(
986
+ text=state.accumulated_text,
987
+ type="reasoning_text",
988
+ )
989
+ events: list[StreamingResponsesResponse] = [
990
+ ResponseReasoningTextDoneEvent(
991
+ type="response.reasoning_text.done",
992
+ item_id=state.current_item_id,
993
+ sequence_number=-1,
994
+ output_index=state.output_index,
995
+ content_index=state.content_index,
996
+ text=state.accumulated_text,
997
+ ),
998
+ ResponseReasoningPartDoneEvent(
999
+ type="response.reasoning_part.done",
1000
+ sequence_number=-1,
1001
+ item_id=state.current_item_id,
1002
+ output_index=state.output_index,
1003
+ content_index=state.content_index,
1004
+ part=part,
1005
+ ),
1006
+ ResponseOutputItemDoneEvent(
1007
+ type="response.output_item.done",
1008
+ sequence_number=-1,
1009
+ output_index=state.output_index,
1010
+ item=ResponseReasoningItem(
1011
+ type="reasoning",
1012
+ content=[part],
1013
+ status="completed",
1014
+ id=state.current_item_id,
1015
+ summary=[],
1016
+ ),
1017
+ ),
1018
+ ]
1019
+ state.output_index += 1
1020
+ state.current_state = _StateType.NONE
1021
+ return events
1022
+
1023
+
1024
+def emit_simple_tool_call_open(
1025
+ state: SimpleStreamingState,
1026
+ name: str,
1027
+ index: int | None,
1028
+) -> list[StreamingResponsesResponse]:
1029
+ state.current_state = _StateType.TOOL_CALL
1030
+ state.current_item_id = random_uuid()
1031
+ state.tool_call_id = f"call_{random_uuid()}"
1032
+ state.tool_call_name = name
1033
+ state.tool_call_index = index
1034
+ state.accumulated_text = ""
1035
+ state.has_emitted_tool_call_delta = False
1036
+ return [
1037
+ ResponseOutputItemAddedEvent(
1038
+ type="response.output_item.added",
1039
+ sequence_number=-1,
1040
+ output_index=state.output_index,
1041
+ item=ResponseFunctionToolCallItem(
1042
+ type="function_call",
1043
+ id=state.current_item_id,
1044
+ call_id=state.tool_call_id,
1045
+ name=name,
1046
+ arguments="",
1047
+ status="in_progress",
1048
+ ),
1049
+ ),
1050
+ ]
1051
+
1052
+
1053
+def emit_simple_tool_call_delta(
1054
+ state: SimpleStreamingState,
1055
+ delta: str,
1056
+) -> list[StreamingResponsesResponse]:
1057
+ state.accumulated_text += delta
1058
+ state.has_emitted_tool_call_delta = True
1059
+ return [
1060
+ ResponseFunctionCallArgumentsDeltaEvent(
1061
+ type="response.function_call_arguments.delta",
1062
+ sequence_number=-1,
1063
+ output_index=state.output_index,
1064
+ item_id=state.current_item_id,
1065
+ delta=delta,
1066
+ )
1067
+ ]
1068
+
1069
+
1070
+def emit_simple_tool_call_done(
1071
+ state: SimpleStreamingState,
1072
+) -> list[StreamingResponsesResponse]:
1073
+ events: list[StreamingResponsesResponse] = []
1074
+ if state.has_emitted_tool_call_delta:
1075
+ events.append(
1076
+ ResponseFunctionCallArgumentsDoneEvent(
1077
+ type="response.function_call_arguments.done",
1078
+ sequence_number=-1,
1079
+ output_index=state.output_index,
1080
+ item_id=state.current_item_id,
1081
+ arguments=state.accumulated_text,
1082
+ name=state.tool_call_name,
1083
+ )
1084
+ )
1085
+ events.append(
1086
+ ResponseOutputItemDoneEvent(
1087
+ type="response.output_item.done",
1088
+ sequence_number=-1,
1089
+ output_index=state.output_index,
1090
+ item=ResponseFunctionToolCall(
1091
+ type="function_call",
1092
+ name=state.tool_call_name,
1093
+ arguments=state.accumulated_text,
1094
+ status="completed",
1095
+ id=state.current_item_id,
1096
+ call_id=state.tool_call_id,
1097
+ ),
1098
+ ),
1099
+ )
1100
+ state.output_index += 1
1101
+ state.current_state = _StateType.NONE
1102
+ return events
1103
+
1104
+
1105
+class _StateHandlers(NamedTuple):
1106
+ """Tuple for each state: open(start), delta(chunk), done(finish)."""
1107
+
1108
+ open_fn: Callable[..., list[StreamingResponsesResponse]]
1109
+ delta_fn: Callable[..., list[StreamingResponsesResponse]]
1110
+ done_fn: Callable[..., list[StreamingResponsesResponse]]
1111
+
1112
+
1113
+class SimpleStreamingEventProcessor:
1114
+ """
1115
+ State-machine processor for the simple (non-Harmony) streaming path.
1116
+
1117
+ Core flow:
1118
+ 1. Resolve the target state from the delta_message
1119
+ (CONTENT / REASONING / TOOL_CALL).
1120
+ 2. If the target state differs from the current one,
1121
+ close_current() then open() the new state.
1122
+ 3. emit_delta() produces the incremental events for the state.
1123
+
1124
+ State lifecycle:
1125
+ open() -> repeated emit_delta() -> close_current()
1126
+ """
1127
+
1128
+ _STATE_HANDLERS: ClassVar[dict[_StateType, _StateHandlers]] = {
1129
+ _StateType.CONTENT: _StateHandlers(
1130
+ emit_simple_content_open,
1131
+ emit_simple_content_delta,
1132
+ emit_simple_content_done,
1133
+ ),
1134
+ _StateType.REASONING: _StateHandlers(
1135
+ emit_simple_reasoning_open,
1136
+ emit_simple_reasoning_delta,
1137
+ emit_simple_reasoning_done,
1138
+ ),
1139
+ _StateType.TOOL_CALL: _StateHandlers(
1140
+ emit_simple_tool_call_open,
1141
+ emit_simple_tool_call_delta,
1142
+ emit_simple_tool_call_done,
1143
+ ),
1144
+ }
1145
+
1146
+ def __init__(self, state: SimpleStreamingState | None = None) -> None:
1147
+ self.state = state or SimpleStreamingState()
1148
+
1149
+ def resolve_target_state(
1150
+ self, delta_message: DeltaMessage
1151
+ ) -> tuple[_StateType, Any]:
1152
+ """
1153
+ Decide which state the next delta belongs to.
1154
+
1155
+ Priority: TOOL_CALL > REASONING > CONTENT, fallback to NONE.
1156
+ For TOOL_CALL the first tool_call object is also returned so
1157
+ callers can detect a switch between consecutive tools.
1158
+ """
1159
+ if (
1160
+ delta_message.tool_calls
1161
+ and delta_message.tool_calls[0].function is not None
1162
+ ):
1163
+ return _StateType.TOOL_CALL, delta_message.tool_calls[0]
1164
+ if delta_message.reasoning is not None:
1165
+ return _StateType.REASONING, None
1166
+ if delta_message.content:
1167
+ return _StateType.CONTENT, None
1168
+ return _StateType.NONE, None
1169
+
1170
+ def needs_transition(self, target_state: _StateType, tool_call: Any) -> bool:
1171
+ """
1172
+ Return True when we must close the current state and open a new one.
1173
+
1174
+ Two cases trigger a transition:
1175
+ 1. The target state differs from the current state
1176
+ (e.g. CONTENT -> TOOL_CALL).
1177
+ 2. We are already in TOOL_CALL but the next tool_call has a
1178
+ different index (multiple consecutive tool calls).
1179
+ """
1180
+ if self.state.current_state != target_state:
1181
+ return True
1182
+ return (
1183
+ target_state == _StateType.TOOL_CALL
1184
+ and tool_call is not None
1185
+ and self.state.tool_call_index is not None
1186
+ and tool_call.index is not None
1187
+ and self.state.tool_call_index != tool_call.index
1188
+ )
1189
+
1190
+ def close_current(self) -> list[StreamingResponsesResponse]:
1191
+ """Close the current state and emit its 'done' event sequence."""
1192
+ handlers = self._STATE_HANDLERS.get(self.state.current_state)
1193
+ if handlers is None:
1194
+ return []
1195
+ return handlers.done_fn(self.state)
1196
+
1197
+ def open(
1198
+ self, target_state: _StateType, tool_call: Any = None
1199
+ ) -> list[StreamingResponsesResponse]:
1200
+ """Open a new state and emit its 'added' / 'open' event sequence."""
1201
+ handlers = self._STATE_HANDLERS[target_state]
1202
+ if target_state == _StateType.TOOL_CALL:
1203
+ assert tool_call is not None
1204
+ return handlers.open_fn(
1205
+ self.state, tool_call.function.name, tool_call.index
1206
+ )
1207
+ return handlers.open_fn(self.state)
1208
+
1209
+ def emit_delta(
1210
+ self,
1211
+ delta_message: DeltaMessage,
1212
+ output: CompletionOutput,
1213
+ get_logprobs: Callable[
1214
+ [CompletionOutput], list[response_text_delta_event.Logprob]
1215
+ ]
1216
+ | None = None,
1217
+ ) -> list[StreamingResponsesResponse]:
1218
+ """
1219
+ Emit incremental events for the current state from the delta.
1220
+
1221
+ Special case: when already in REASONING and the same delta also
1222
+ carries content, we emit the reasoning delta, close reasoning,
1223
+ open content, and then emit the content delta.
1224
+ """
1225
+ handlers = self._STATE_HANDLERS[self.state.current_state]
1226
+ events: list[StreamingResponsesResponse] = []
1227
+
1228
+ # Special case: reasoning -> content inside a single delta.
1229
+ if (
1230
+ self.state.current_state == _StateType.REASONING
1231
+ and delta_message.reasoning is not None
1232
+ and delta_message.content is not None
1233
+ ):
1234
+ events.extend(handlers.delta_fn(self.state, delta_message.reasoning))
1235
+ events.extend(self.close_current())
1236
+ events.extend(self.open(_StateType.CONTENT))
1237
+ content_handlers = self._STATE_HANDLERS[_StateType.CONTENT]
1238
+ logprobs = get_logprobs(output) if get_logprobs else []
1239
+ events.extend(
1240
+ content_handlers.delta_fn(self.state, delta_message.content, logprobs)
1241
+ )
1242
+ return events
1243
+
1244
+ if self.state.current_state == _StateType.TOOL_CALL:
1245
+ assert delta_message.tool_calls is not None
1246
+ tool_call_function = delta_message.tool_calls[0].function
1247
+ assert tool_call_function is not None
1248
+ if tool_call_function.arguments:
1249
+ return handlers.delta_fn(self.state, tool_call_function.arguments)
1250
+ return []
1251
+ elif self.state.current_state == _StateType.REASONING:
1252
+ assert delta_message.reasoning is not None
1253
+ return handlers.delta_fn(self.state, delta_message.reasoning)
1254
+ elif self.state.current_state == _StateType.CONTENT:
1255
+ assert delta_message.content is not None
1256
+ logprobs = get_logprobs(output) if get_logprobs else []
1257
+ return handlers.delta_fn(self.state, delta_message.content, logprobs)
1258
+ return []
vllm/parser/abstract_parser.py
+61
-8
@@ -38,6 +38,7 @@ from vllm.logger import init_logger
38
from vllm.reasoning.abs_reasoning_parsers import ReasoningParser
39
from vllm.tokenizers import TokenizerLike
40
from vllm.tool_parsers.abstract_tool_parser import ToolParser
41
+from vllm.tool_parsers.streaming import extract_required_tool_call_streaming
42
from vllm.tool_parsers.utils import Tool
43
from vllm.utils import random_uuid
44
@@ -53,6 +54,11 @@ class StreamState:
54
prompt_reasoning_checked: bool = False
55
previous_text: str = ""
56
previous_token_ids: list[int] = field(default_factory=list)
57
+ history_tool_call_cnt: int = 0
58
+ tool_call_id_type: str = "random"
59
+ # only used for "required" and "named tool" choices,
60
+ # tracks whether function name has been fully returned in the stream yet
61
+ function_name_returned: bool = False
62
63
64
class Parser:
@@ -558,6 +564,47 @@ class DelegatingParser(Parser):
564
request,
565
)
566
567
+ def _extract_tool_calls_streaming(
568
+ self,
569
+ previous_text: str,
570
+ current_text: str,
571
+ delta_text: str,
572
+ previous_token_ids: Sequence[int],
573
+ current_token_ids: Sequence[int],
574
+ delta_token_ids: Sequence[int],
575
+ request: ChatCompletionRequest,
576
+ # The following parameters are used for "required" tool choice parsing and are
577
+ # tracked in StreamState for streaming parsing.
578
+ tool_call_idx: int | None = None,
579
+ tool_call_id_type: str = "random",
580
+ function_name_returned: bool = False,
581
+ ) -> tuple[DeltaMessage | None, bool]:
582
+ if request.tool_choice and isinstance(
583
+ request.tool_choice, ChatCompletionNamedToolChoiceParam
584
+ ):
585
+ return None, False
586
+ if request.tool_choice == "required":
587
+ delta_message, function_name_returned = (
588
+ extract_required_tool_call_streaming(
589
+ previous_text=previous_text,
590
+ current_text=current_text,
591
+ delta_text=delta_text,
592
+ function_name_returned=function_name_returned,
593
+ tool_call_idx=tool_call_idx,
594
+ tool_call_id_type=tool_call_id_type,
595
+ )
596
+ )
597
+ return delta_message, function_name_returned
598
+ return self.extract_tool_calls_streaming(
599
+ previous_text,
600
+ current_text,
601
+ delta_text,
602
+ previous_token_ids,
603
+ current_token_ids,
604
+ delta_token_ids,
605
+ request,
606
+ ), False
607
+
608
def is_reasoning_end(self, input_ids: list[int]) -> bool:
609
if self._reasoning_parser is None:
610
return False
@@ -628,14 +675,20 @@ class DelegatingParser(Parser):
675
state.previous_token_ids = []
676
delta_text = current_text
677
delta_token_ids = current_token_ids
631
- delta_message = self.extract_tool_calls_streaming(
632
- previous_text=state.previous_text,
633
- current_text=current_text,
634
- delta_text=delta_text,
635
- previous_token_ids=state.previous_token_ids,
636
- current_token_ids=current_token_ids,
637
- delta_token_ids=delta_token_ids,
638
- request=request, # type: ignore[arg-type]
678
+
679
+ delta_message, state.function_name_returned = (
680
+ self._extract_tool_calls_streaming(
681
+ previous_text=state.previous_text,
682
+ current_text=current_text,
683
+ delta_text=delta_text,
684
+ previous_token_ids=state.previous_token_ids,
685
+ current_token_ids=current_token_ids,
686
+ delta_token_ids=delta_token_ids,
687
+ request=request, # type: ignore[arg-type]
688
+ tool_call_idx=state.history_tool_call_cnt,
689
+ tool_call_id_type=state.tool_call_id_type,
690
+ function_name_returned=state.function_name_returned,
691
+ )
692
)
693
694
# No parsers: pass through as content
vllm/tool_parsers/streaming.py
new
+199
@@ -0,0 +1,199 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import json
5
+from typing import TYPE_CHECKING
6
+
7
+import partial_json_parser
8
+import regex as re
9
+from partial_json_parser.core.options import Allow
10
+
11
+from vllm.entrypoints.chat_utils import make_tool_call_id
12
+from vllm.entrypoints.openai.engine.protocol import (
13
+ DeltaFunctionCall,
14
+ DeltaMessage,
15
+ DeltaToolCall,
16
+)
17
+from vllm.tool_parsers.mistral_tool_parser import MistralToolCall
18
+from vllm.tool_parsers.utils import partial_json_loads
19
+from vllm.utils.mistral import is_mistral_tokenizer
20
+
21
+if TYPE_CHECKING:
22
+ from vllm.tokenizers import TokenizerLike
23
+else:
24
+ TokenizerLike = object
25
+
26
+
27
+def _bracket_level(s: str, opening: str = "{", closing: str = "}") -> int:
28
+ """Calculate the current level of nested brackets in a string."""
29
+ level = 0
30
+ for char in s:
31
+ if char == opening:
32
+ level += 1
33
+ elif char == closing:
34
+ level -= 1
35
+ return level
36
+
37
+
38
+def filter_delta_text(
39
+ delta_text: str,
40
+ previous_text: str,
41
+) -> tuple[str, bool]:
42
+ """Trim trailing tool-list delimiters from required-tool streaming text."""
43
+ bracket_level = _bracket_level(previous_text)
44
+ updated_delta = ""
45
+ passed_zero = False
46
+ for char in delta_text:
47
+ if char == "{":
48
+ bracket_level += 1
49
+ passed_zero = bracket_level == 0
50
+ elif char == "}":
51
+ bracket_level -= 1
52
+ passed_zero = bracket_level == 0
53
+
54
+ if bracket_level != 0:
55
+ updated_delta += char
56
+ else:
57
+ if char == ",":
58
+ break
59
+ return updated_delta, passed_zero
60
+
61
+
62
+def extract_named_tool_call_streaming(
63
+ *,
64
+ delta_text: str,
65
+ function_name: str,
66
+ function_name_returned: bool,
67
+ tool_call_idx: int | None,
68
+ tool_call_id_type: str,
69
+ tokenizer: "TokenizerLike",
70
+ tool_call_array_index: int,
71
+) -> tuple[DeltaMessage, bool, bool]:
72
+ """Build a streaming tool-call delta for forced named tool choice."""
73
+ created_new_tool_call = False
74
+ if function_name_returned:
75
+ delta_tool_call = DeltaToolCall(
76
+ function=DeltaFunctionCall(arguments=delta_text),
77
+ index=tool_call_array_index,
78
+ )
79
+ else:
80
+ if is_mistral_tokenizer(tokenizer):
81
+ tool_call_id = MistralToolCall.generate_random_id()
82
+ else:
83
+ tool_call_id = make_tool_call_id(
84
+ id_type=tool_call_id_type,
85
+ func_name=function_name,
86
+ idx=tool_call_idx,
87
+ )
88
+ delta_tool_call = DeltaToolCall(
89
+ id=tool_call_id,
90
+ type="function",
91
+ function=DeltaFunctionCall(
92
+ name=function_name,
93
+ arguments=delta_text,
94
+ ),
95
+ index=tool_call_array_index,
96
+ )
97
+ function_name_returned = True
98
+ created_new_tool_call = True
99
+
100
+ return (
101
+ DeltaMessage(tool_calls=[delta_tool_call]),
102
+ function_name_returned,
103
+ created_new_tool_call,
104
+ )
105
+
106
+
107
+def extract_required_tool_call_streaming(
108
+ *,
109
+ previous_text: str,
110
+ current_text: str | None,
111
+ delta_text: str,
112
+ function_name_returned: bool,
113
+ tool_call_idx: int | None,
114
+ tool_call_id_type: str,
115
+) -> tuple[DeltaMessage | None, bool]:
116
+ if current_text is None or current_text == "":
117
+ # if the current text is empty, we cannot parse it
118
+ return None, function_name_returned
119
+ try:
120
+ flags = Allow.ALL
121
+ obj, _ = partial_json_loads(current_text, flags)
122
+ except (
123
+ partial_json_parser.core.exceptions.MalformedJSON,
124
+ json.JSONDecodeError,
125
+ ):
126
+ obj = None
127
+
128
+ # check if the current text is a valid array
129
+ # containing a partial tool calling object
130
+ # if not repeat
131
+ if obj is None or not isinstance(obj, list) or not len(obj) > 0:
132
+ function_name_returned = False
133
+ delta_message = None
134
+ else:
135
+ _, finishes_previous_tool = filter_delta_text(delta_text, previous_text)
136
+ # take the last tool call from the generated list
137
+ current_tool_call = obj[-1]
138
+
139
+ # once parameters have been generated the name is complete as well
140
+ if not finishes_previous_tool and (
141
+ "name" not in current_tool_call or "parameters" not in current_tool_call
142
+ ):
143
+ function_name_returned = False
144
+ delta_message = None
145
+ else:
146
+ if not function_name_returned:
147
+ # get partly generated arguments from the latest tool call
148
+ param_match = re.search(
149
+ r'.*"parameters":\s*(.*)', current_text, re.DOTALL
150
+ )
151
+ arguments = param_match.group(1) if param_match else ""
152
+ arguments, _ = filter_delta_text(arguments, previous_text)
153
+
154
+ # if this iteration finishes a previous tool call but a
155
+ # new incomplete tool is already generated, take the
156
+ # previous from the list
157
+ if finishes_previous_tool and "parameters" not in current_tool_call:
158
+ current_tool_call = obj[-2]
159
+
160
+ function_name_returned = True
161
+ tool_call_id = make_tool_call_id(
162
+ id_type=tool_call_id_type,
163
+ func_name=current_tool_call["name"],
164
+ idx=tool_call_idx,
165
+ )
166
+ delta_message = DeltaMessage(
167
+ tool_calls=[
168
+ DeltaToolCall(
169
+ id=tool_call_id,
170
+ function=DeltaFunctionCall(
171
+ name=current_tool_call["name"], arguments=arguments
172
+ ),
173
+ index=len(obj) - 1,
174
+ type="function",
175
+ )
176
+ ]
177
+ )
178
+
179
+ else:
180
+ delta_text, _ = filter_delta_text(delta_text, previous_text)
181
+
182
+ if delta_text != "":
183
+ delta_message = DeltaMessage(
184
+ tool_calls=[
185
+ DeltaToolCall(
186
+ function=DeltaFunctionCall(
187
+ # OpenAI API returns None
188
+ # instead of name every time
189
+ name=None,
190
+ arguments=delta_text,
191
+ ),
192
+ index=len(obj) - 1,
193
+ )
194
+ ]
195
+ )
196
+ else:
197
+ delta_message = None
198
+
199
+ return delta_message, function_name_returned