[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() {