[Rust Frontend] Fix finish reason for named tool choices (#49496)

Signed-off-by: reidliu41 <reid201711@gmail.com>

Reid committed Jul 28, 2026 at 18:53 UTC bf9f23003c67698b973a674b54e4ca77aae1adff
3 files changed +88 -17
rust/src/server/src/routes/openai/chat_completions.rs
+10 -6
@@ -131,6 +131,7 @@ async fn collect_chat_completion(
131 echo,
132 return_token_ids,
133 return_tokens_as_token_ids,
134 + is_named_tool_choice,
135 }: ResponseOptions,
136 ) -> Result<ChatCompletionResponse, ApiError> {
137 let collected = stream.collect_message().await.map_err(|error| {
@@ -157,7 +158,9 @@ async fn collect_chat_completion(
158 // When reasoning is hidden, omit them rather than leaking hidden reasoning
159 // tokens through per-token metadata.
160 let include_output_metadata = include_reasoning || reasoning.is_none();
160 - let finish_reason = chat_finish_reason_to_openai(&finish_reason, saw_tool_calls)?.to_string();
161 + let finish_reason =
162 + chat_finish_reason_to_openai(&finish_reason, saw_tool_calls && !is_named_tool_choice)?
163 + .to_string();
164 let tool_calls = message
165 .tool_calls()
166 .map(|call| ToolCall {
@@ -254,6 +257,7 @@ async fn chat_completion_chunk_stream(
257 echo,
258 return_token_ids,
259 return_tokens_as_token_ids,
260 + is_named_tool_choice,
261 }: ResponseOptions,
262 mut y: TryYielder<ChatCompletionStreamResponse, ApiError>,
263 ) -> Result<(), ApiError> {
@@ -454,7 +458,7 @@ async fn chat_completion_chunk_stream(
458 &response_model,
459 created,
460 finish_reason,
457 - saw_tool_calls,
461 + saw_tool_calls && !is_named_tool_choice,
462 ) {
463 Ok(chunk) => yield_chunk!(chunk),
464 Err(error) => {
@@ -787,10 +791,10 @@ fn final_chunk(
791 response_model: &str,
792 created: u64,
793 finish_reason: FinishReason,
790 - saw_tool_calls: bool,
794 + use_tool_calls_finish_reason: bool,
795 ) -> Result<ChatCompletionStreamResponse, ApiError> {
796 let stop_reason = finish_reason.as_stop_reason().map(stop_reason_to_json);
793 - let finish_reason = chat_finish_reason_to_openai(&finish_reason, saw_tool_calls)?;
797 + let finish_reason = chat_finish_reason_to_openai(&finish_reason, use_tool_calls_finish_reason)?;
798
799 debug!(
800 finish_reason = %finish_reason,
@@ -809,10 +813,10 @@ fn final_chunk(
813
814 fn chat_finish_reason_to_openai(
815 finish_reason: &FinishReason,
812 - saw_tool_calls: bool,
816 + use_tool_calls_finish_reason: bool,
817 ) -> Result<&'static str, ApiError> {
818 match finish_reason {
815 - FinishReason::Stop(_) if saw_tool_calls => Ok("tool_calls"),
819 + FinishReason::Stop(_) if use_tool_calls_finish_reason => Ok("tool_calls"),
820 FinishReason::Stop(_) => Ok("stop"),
821 FinishReason::Length => Ok("length"),
822 FinishReason::Abort => Ok("abort"),
rust/src/server/src/routes/openai/chat_completions/convert.rs
+6
@@ -54,6 +54,8 @@ pub(super) struct ResponseOptions {
54 pub return_token_ids: bool,
55 /// Whether to format logprob tokens as `token_id:{id}`.
56 pub return_tokens_as_token_ids: bool,
57 + /// Whether the request forces one named function tool.
58 + pub is_named_tool_choice: bool,
59 }
60
61 /// Validate and lower one OpenAI chat completion request into the internal chat
@@ -98,6 +100,7 @@ pub(super) fn prepare_chat_request(
100 .and_then(|options| options.continuous_usage_stats)
101 .unwrap_or(false);
102 let requested_logprobs = request.logprobs;
103 + let is_named_tool_choice = matches!(&request.tool_choice, Some(ToolChoice::Function { .. }));
104
105 // Auto-enable prompt logprobs for non-streaming echo, matching Python vLLM's
106 // behavior.
@@ -180,6 +183,7 @@ pub(super) fn prepare_chat_request(
183 echo,
184 return_token_ids: request.return_token_ids.unwrap_or(false),
185 return_tokens_as_token_ids: request.return_tokens_as_token_ids.unwrap_or(false),
186 + is_named_tool_choice,
187 },
188 chat_request,
189 })
@@ -1068,6 +1072,7 @@ mod tests {
1072 .expect("request is valid");
1073
1074 assert_eq!(prepared.chat_request.tool_choice, ChatToolChoice::Required);
1075 + assert!(!prepared.options.is_named_tool_choice);
1076 }
1077
1078 #[test]
@@ -1107,6 +1112,7 @@ mod tests {
1112 name: "get_weather".to_string(),
1113 }
1114 );
1115 + assert!(prepared.options.is_named_tool_choice);
1116 }
1117
1118 #[test]
rust/src/server/src/routes/tests.rs
+72 -11
@@ -153,6 +153,20 @@ fn default_stream_output_specs() -> Vec<(Vec<u32>, Option<EngineCoreFinishReason
153 ]
154 }
155
156 +fn weather_tool_call_output_specs() -> Vec<(Vec<u32>, Option<EngineCoreFinishReason>)> {
157 + vec![
158 + (bytes_to_token_ids(b"<think>Need tool.</think>"), None),
159 + (
160 + bytes_to_token_ids(b"<tool_call>\n{\"name\":\"get_weather\", "),
161 + None,
162 + ),
163 + (
164 + bytes_to_token_ids(b"\"arguments\":{\"city\":\"Paris\"}}\n</tool_call>"),
165 + Some(EngineCoreFinishReason::Stop),
166 + ),
167 + ]
168 +}
169 +
170 fn assert_adapter_a_lora_request(request: &EngineCoreRequest) {
171 let lora = request.lora_request.as_ref().expect("lora request");
172 assert_eq!(lora.lora_name, "adapter-a");
@@ -4554,17 +4568,7 @@ async fn include_reasoning_false_suppresses_non_stream_output_metadata() {
4568 async fn tool_calls_are_mapped_to_tool_call_sse_chunks() {
4569 let (app, engine_task) = test_app_with_backend_and_stream_output_specs(
4570 Arc::new(FakeChatBackend::with_model_id("Qwen/Qwen3-0.6B")),
4557 - vec![
4558 - (bytes_to_token_ids(b"<think>Need tool.</think>"), None),
4559 - (
4560 - bytes_to_token_ids(b"<tool_call>\n{\"name\":\"get_weather\", "),
4561 - None,
4562 - ),
4563 - (
4564 - bytes_to_token_ids(b"\"arguments\":{\"city\":\"Paris\"}}\n</tool_call>"),
4565 - Some(EngineCoreFinishReason::Stop),
4566 - ),
4567 - ],
4571 + weather_tool_call_output_specs(),
4572 )
4573 .await;
4574
@@ -4613,6 +4617,63 @@ async fn tool_calls_are_mapped_to_tool_call_sse_chunks() {
4617 assert!(text.contains("\"finish_reason\":\"tool_calls\""), "{text}");
4618 }
4619
4620 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4621 +#[serial]
4622 +async fn named_tool_choice_uses_stop_finish_reason() {
4623 + for stream in [false, true] {
4624 + let (app, engine_task) = test_app_with_backend_and_stream_output_specs(
4625 + Arc::new(FakeChatBackend::with_model_id("Qwen/Qwen3-0.6B")),
4626 + weather_tool_call_output_specs(),
4627 + )
4628 + .await;
4629 +
4630 + let response = app
4631 + .clone()
4632 + .call(
4633 + Request::builder()
4634 + .method("POST")
4635 + .uri("/v1/chat/completions")
4636 + .header("content-type", "application/json")
4637 + .body(Body::from(
4638 + json!({
4639 + "model": "Qwen/Qwen1.5-0.5B-Chat",
4640 + "stream": stream,
4641 + "messages": [{"role": "user", "content": "hello"}],
4642 + "tools": [{
4643 + "type": "function",
4644 + "function": {
4645 + "name": "get_weather",
4646 + "description": "Get weather",
4647 + "parameters": {
4648 + "type": "object",
4649 + "properties": {"city": {"type": "string"}}
4650 + }
4651 + }
4652 + }],
4653 + "tool_choice": {
4654 + "type": "function",
4655 + "function": {"name": "get_weather"}
4656 + }
4657 + })
4658 + .to_string(),
4659 + ))
4660 + .expect("build request"),
4661 + )
4662 + .await
4663 + .expect("call app");
4664 +
4665 + assert_eq!(response.status(), StatusCode::OK);
4666 + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
4667 + engine_task.await.expect("mock engine task");
4668 + let text = String::from_utf8(body.to_vec()).expect("utf8 body");
4669 +
4670 + assert!(text.contains("\"tool_calls\":"), "{text}");
4671 + assert!(text.contains("\"name\":\"get_weather\""), "{text}");
4672 + assert!(text.contains("\"finish_reason\":\"stop\""), "{text}");
4673 + assert!(!text.contains("\"finish_reason\":\"tool_calls\""), "{text}");
4674 + }
4675 +}
4676 +
4677 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4678 #[serial]
4679 async fn tool_call_sse_chunks_can_carry_logprobs() {