@@ -596,29 +596,124 @@ async def _predict(self, request, context, streaming=False):
596596
597597 # Stream the results
598598 generated_text = ""
599+ generated_token_ids : list [int ] = []
599600 last_output = None
600- # When a tool parser is active for a tool-enabled request, the model's
601- # raw tool-call markup (e.g. <tool_call>...) must not be streamed as
602- # delta.content — clients would otherwise see the unparsed syntax. Buffer
603- # the text; the final chat_delta below carries the parsed tool_calls (or
604- # the cleaned content), which the Go side converts to SSE deltas.
601+
602+ # Tool-parsing strategy decision (made once, before the loop):
603+ #
604+ # When a tool parser is active, the model's raw tool-call markup
605+ # (e.g. <tool_call>...) must not be streamed verbatim as delta.content
606+ # — clients would see the unparsed syntax. Two paths:
607+ #
608+ # (A) native streaming via parser.extract_tool_calls_streaming. All
609+ # concrete tool parsers shipped with vLLM 0.23+ implement this
610+ # (Granite4, Qwen3Coder, DeepSeekV31, Jamba, Ernie45, Hermes,
611+ # llama3_json, mistral, …). The parser decides per-delta whether
612+ # to emit content or suppress tool-call markup, and emits a
613+ # structured DeltaMessage(tool_calls=[...]) when a call is ready.
614+ # (B) buffer fallback — used only when the parser surprisingly lacks
615+ # the streaming method or it raises mid-stream. The post-loop
616+ # extract_tool_calls assembles the final chat_delta. Same correctness
617+ # guarantee as a non-streaming response, at the cost of a delayed
618+ # final chunk.
605619 has_tool_parser = bool (self .tool_parser_cls and request .Tools )
620+ tp_instance = None
621+ tp_request = None
622+ native_streaming = False
623+ native_streaming_error = False
624+ if has_tool_parser :
625+ try :
626+ tools_for_parser = json .loads (request .Tools )
627+ except json .JSONDecodeError :
628+ tools_for_parser = []
629+ try :
630+ tp_instance = self .tool_parser_cls (self .tokenizer , tools = tools_for_parser )
631+ except TypeError :
632+ tp_instance = self .tool_parser_cls (self .tokenizer )
633+ # Build a minimal ChatCompletionRequest so the streaming method
634+ # sees the tools list. We do not need any other request fields —
635+ # parsers only read .tools (and sometimes .tool_choice, which we
636+ # leave at default).
637+ try :
638+ from vllm .entrypoints .openai .chat_completion .protocol import (
639+ ChatCompletionRequest as _CCR ,
640+ )
641+ tp_request = _CCR (
642+ model = "local" ,
643+ messages = [{"role" : "user" , "content" : "" }],
644+ tools = tools_for_parser or None ,
645+ )
646+ except Exception as e :
647+ print (f"Could not build ChatCompletionRequest for streaming parser: { e } " ,
648+ file = sys .stderr )
649+ tp_request = None
650+ native_streaming = (
651+ tp_request is not None
652+ and hasattr (tp_instance , "extract_tool_calls_streaming" )
653+ )
654+
606655 try :
607656 async for request_output in outputs :
608657 iteration_text = request_output .outputs [0 ].text
609658 last_output = request_output
610659
611- if streaming and not has_tool_parser :
612- # Remove text already sent as vllm concatenates the text from previous yields
660+ if streaming :
613661 delta_iteration_text = iteration_text .removeprefix (generated_text )
614- # Send the partial result
615- yield backend_pb2 .Reply (
616- message = bytes (delta_iteration_text , encoding = 'utf-8' ),
617- chat_deltas = [backend_pb2 .ChatDelta (content = delta_iteration_text )],
618- )
619-
620- # Keep track of text generated
662+ new_token_ids = list (request_output .outputs [0 ].token_ids )
663+ delta_token_ids = new_token_ids [len (generated_token_ids ):]
664+
665+ if not has_tool_parser :
666+ # Plain streaming — unchanged from pre-tool-parser path.
667+ yield backend_pb2 .Reply (
668+ message = bytes (delta_iteration_text , encoding = 'utf-8' ),
669+ chat_deltas = [backend_pb2 .ChatDelta (content = delta_iteration_text )],
670+ )
671+ elif native_streaming and not native_streaming_error :
672+ # (A) Native vLLM extract_tool_calls_streaming.
673+ try :
674+ msg = tp_instance .extract_tool_calls_streaming (
675+ previous_text = generated_text ,
676+ current_text = iteration_text ,
677+ delta_text = delta_iteration_text ,
678+ previous_token_ids = generated_token_ids ,
679+ current_token_ids = new_token_ids ,
680+ delta_token_ids = delta_token_ids ,
681+ request = tp_request ,
682+ )
683+ except Exception as e :
684+ print (f"Streaming tool parser error (falling back to "
685+ f"buffer for the rest of the stream): { e } " ,
686+ file = sys .stderr )
687+ native_streaming_error = True
688+ msg = None
689+ if msg is not None :
690+ tc_protos = []
691+ for tc in (msg .tool_calls or []):
692+ fn = tc .function or None
693+ tc_protos .append (backend_pb2 .ToolCallDelta (
694+ index = tc .index ,
695+ id = tc .id or "" ,
696+ name = (fn .name if fn and fn .name else "" ) or "" ,
697+ arguments = (fn .arguments if fn and fn .arguments else "" ) or "" ,
698+ ))
699+ cd_kwargs = {}
700+ if msg .content :
701+ cd_kwargs ["content" ] = msg .content
702+ if msg .reasoning :
703+ cd_kwargs ["reasoning_content" ] = msg .reasoning
704+ if tc_protos :
705+ cd_kwargs ["tool_calls" ] = tc_protos
706+ if cd_kwargs :
707+ yield backend_pb2 .Reply (
708+ message = bytes (msg .content or "" , encoding = 'utf-8' ),
709+ chat_deltas = [backend_pb2 .ChatDelta (** cd_kwargs )],
710+ )
711+ # (B) buffer fallback — emit nothing during the stream.
712+ # The post-loop extract_tool_calls block builds the final chunk.
713+
714+ # Keep track of text + token_ids generated
621715 generated_text = iteration_text
716+ generated_token_ids = list (request_output .outputs [0 ].token_ids )
622717 finally :
623718 await outputs .aclose ()
624719
@@ -643,16 +738,19 @@ async def _predict(self, request, context, streaming=False):
643738 except Exception as e :
644739 print (f"Reasoning parser error: { e } " , file = sys .stderr )
645740
646- if self .tool_parser_cls and request .Tools :
741+ # When (A) native streaming ran cleanly, per-delta yields above already
742+ # delivered everything — do NOT extract again on the full text or we'd
743+ # duplicate content/tool_calls into the final chunk.
744+ if has_tool_parser and not (native_streaming and not native_streaming_error ):
647745 try :
648- tools = json . loads ( request . Tools )
649- # Some concrete parsers only accept the tokenizer; only the
650- # abstract base declares the tools kwarg. Try with tools first,
651- # fall back to tokenizer-only.
652- try :
653- tp = self .tool_parser_cls (self .tokenizer , tools = tools )
654- except TypeError :
655- tp = self .tool_parser_cls (self .tokenizer )
746+ tp = tp_instance
747+ if tp is None :
748+ # Defensive: tp_instance build failed earlier; reconstruct.
749+ tools = json . loads ( request . Tools )
750+ try :
751+ tp = self .tool_parser_cls (self .tokenizer , tools = tools )
752+ except TypeError :
753+ tp = self .tool_parser_cls (self .tokenizer )
656754 info = tp .extract_tool_calls (content , request = None )
657755 if info .tools_called :
658756 content = info .content or ""
@@ -665,6 +763,10 @@ async def _predict(self, request, context, streaming=False):
665763 ))
666764 except Exception as e :
667765 print (f"Tool parser error: { e } " , file = sys .stderr )
766+ elif native_streaming and not native_streaming_error :
767+ # Per-delta path already emitted content + tool_calls; the final
768+ # chat_delta should carry only metadata (token counts, logprobs).
769+ content = ""
668770
669771 # Extract token counts
670772 prompt_tokens = 0
@@ -704,7 +806,26 @@ async def _predict(self, request, context, streaming=False):
704806 )
705807
706808 if streaming :
707- # Final chunk with structured data
809+ # Final chunk with structured data.
810+ #
811+ # If we used the buffer fallback (has_tool_parser=True AND native
812+ # streaming did NOT run cleanly) and the parser found no tool call,
813+ # flush the buffered content as ONE content delta — and clear the
814+ # final chat_delta's content so the metadata chunk does not repeat
815+ # what we just sent. This is the plain-text-with-tool-parser path.
816+ buffered_fallback = (
817+ has_tool_parser
818+ and not (native_streaming and not native_streaming_error )
819+ )
820+ if buffered_fallback and not tool_calls_proto and content :
821+ yield backend_pb2 .Reply (
822+ message = bytes (content , encoding = 'utf-8' ),
823+ chat_deltas = [backend_pb2 .ChatDelta (content = content )],
824+ )
825+ chat_delta = backend_pb2 .ChatDelta (
826+ reasoning_content = reasoning_content ,
827+ tool_calls = tool_calls_proto ,
828+ )
708829 yield backend_pb2 .Reply (
709830 message = b"" ,
710831 prompt_tokens = prompt_tokens ,
0 commit comments