Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Menu
Open sidebar
OpenDAS
dynamo
Commits
04f7579b
Unverified
Commit
04f7579b
authored
Nov 07, 2025
by
Ayush Agarwal
Committed by
GitHub
Nov 08, 2025
Browse files
fix: no more multiple finish reasons in stream (#4154)
Signed-off-by:
ayushag
<
ayushag@nvidia.com
>
parent
d3b5e9f2
Changes
6
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
376 additions
and
100 deletions
+376
-100
lib/llm/src/preprocessor.rs
lib/llm/src/preprocessor.rs
+1
-1
lib/llm/src/protocols/openai/chat_completions/jail.rs
lib/llm/src/protocols/openai/chat_completions/jail.rs
+69
-12
lib/llm/tests/data/vllm/qwen3-0.6B/chat_completion_incomplete_tool.json
...data/vllm/qwen3-0.6B/chat_completion_incomplete_tool.json
+21
-0
lib/llm/tests/data/vllm/qwen3-0.6B/chat_completion_stream_finish_length.json
...vllm/qwen3-0.6B/chat_completion_stream_finish_length.json
+20
-0
lib/llm/tests/test_jail.rs
lib/llm/tests/test_jail.rs
+110
-86
lib/llm/tests/test_streaming_tool_parsers.rs
lib/llm/tests/test_streaming_tool_parsers.rs
+155
-1
No files found.
lib/llm/src/preprocessor.rs
View file @
04f7579b
...
@@ -764,7 +764,7 @@ impl OpenAIPreprocessor {
...
@@ -764,7 +764,7 @@ impl OpenAIPreprocessor {
let
jail
=
JailedStream
::
builder
()
let
jail
=
JailedStream
::
builder
()
.tool_call_parser
(
tool_call_parser
)
.tool_call_parser
(
tool_call_parser
)
.build
();
.build
();
jail
.apply
(
stream
)
jail
.apply
_with_finish_reason
(
stream
)
}
}
// Motivation: Each transformation on the stream should be a separate step to allow for more flexibility
// Motivation: Each transformation on the stream should be a separate step to allow for more flexibility
...
...
lib/llm/src/protocols/openai/chat_completions/jail.rs
View file @
04f7579b
...
@@ -13,6 +13,7 @@ use dynamo_parsers::tool_calling::{
...
@@ -13,6 +13,7 @@ use dynamo_parsers::tool_calling::{
};
};
use
dynamo_runtime
::
protocols
::
annotated
::
Annotated
;
use
dynamo_runtime
::
protocols
::
annotated
::
Annotated
;
use
futures
::{
Stream
,
StreamExt
};
use
futures
::{
Stream
,
StreamExt
};
use
std
::
collections
::
HashMap
;
use
crate
::
utils
::{
MarkerMatcher
,
MatchResult
};
use
crate
::
utils
::{
MarkerMatcher
,
MatchResult
};
...
@@ -72,6 +73,8 @@ struct ChoiceJailState {
...
@@ -72,6 +73,8 @@ struct ChoiceJailState {
accumulated_content
:
String
,
accumulated_content
:
String
,
/// Buffer for partial marker matches across chunks
/// Buffer for partial marker matches across chunks
partial_match_buffer
:
String
,
partial_match_buffer
:
String
,
/// Stream finish reason
stream_finish_reason
:
Option
<
FinishReason
>
,
}
}
fn
create_choice_stream
(
fn
create_choice_stream
(
...
@@ -106,6 +109,7 @@ impl ChoiceJailState {
...
@@ -106,6 +109,7 @@ impl ChoiceJailState {
is_jailed
:
false
,
is_jailed
:
false
,
accumulated_content
:
String
::
new
(),
accumulated_content
:
String
::
new
(),
partial_match_buffer
:
String
::
new
(),
partial_match_buffer
:
String
::
new
(),
stream_finish_reason
:
None
,
}
}
}
}
...
@@ -130,7 +134,6 @@ impl ChoiceJailState {
...
@@ -130,7 +134,6 @@ impl ChoiceJailState {
jail_stream
:
&
JailedStream
,
jail_stream
:
&
JailedStream
,
)
->
Vec
<
ChoiceEmission
>
{
)
->
Vec
<
ChoiceEmission
>
{
let
mut
emissions
=
Vec
::
new
();
let
mut
emissions
=
Vec
::
new
();
if
!
self
.is_jailed
{
if
!
self
.is_jailed
{
// Use the marker matcher to detect complete/partial markers
// Use the marker matcher to detect complete/partial markers
let
match_result
=
jail_stream
let
match_result
=
jail_stream
...
@@ -152,7 +155,7 @@ impl ChoiceJailState {
...
@@ -152,7 +155,7 @@ impl ChoiceJailState {
choice
.delta.role
,
choice
.delta.role
,
&
prefix
,
&
prefix
,
None
,
None
,
None
,
choice
.finish_reason
,
choice
.logprobs
.clone
(),
choice
.logprobs
.clone
(),
);
);
emissions
.push
(
ChoiceEmission
::
PassThrough
(
prefix_choice
));
emissions
.push
(
ChoiceEmission
::
PassThrough
(
prefix_choice
));
...
@@ -192,7 +195,7 @@ impl ChoiceJailState {
...
@@ -192,7 +195,7 @@ impl ChoiceJailState {
choice
.delta.role
,
choice
.delta.role
,
trailing_part
,
trailing_part
,
None
,
None
,
None
,
choice
.finish_reason
,
choice
.logprobs
.clone
(),
choice
.logprobs
.clone
(),
);
);
emissions
.push
(
ChoiceEmission
::
Trailing
(
trailing_choice
));
emissions
.push
(
ChoiceEmission
::
Trailing
(
trailing_choice
));
...
@@ -224,7 +227,7 @@ impl ChoiceJailState {
...
@@ -224,7 +227,7 @@ impl ChoiceJailState {
choice
.delta.role
,
choice
.delta.role
,
&
prefix
,
&
prefix
,
None
,
None
,
None
,
choice
.finish_reason
,
choice
.logprobs
.clone
(),
choice
.logprobs
.clone
(),
);
);
emissions
.push
(
ChoiceEmission
::
PassThrough
(
prefix_choice
));
emissions
.push
(
ChoiceEmission
::
PassThrough
(
prefix_choice
));
...
@@ -267,7 +270,7 @@ impl ChoiceJailState {
...
@@ -267,7 +270,7 @@ impl ChoiceJailState {
choice
.delta.role
,
choice
.delta.role
,
&
content
,
&
content
,
None
,
None
,
None
,
choice
.finish_reason
,
choice
.logprobs
.clone
(),
choice
.logprobs
.clone
(),
);
);
emissions
.push
(
ChoiceEmission
::
PassThrough
(
pass_through_choice
));
emissions
.push
(
ChoiceEmission
::
PassThrough
(
pass_through_choice
));
...
@@ -312,7 +315,7 @@ impl ChoiceJailState {
...
@@ -312,7 +315,7 @@ impl ChoiceJailState {
choice
.delta.role
,
choice
.delta.role
,
trailing_part
,
trailing_part
,
None
,
None
,
None
,
choice
.finish_reason
,
choice
.logprobs
.clone
(),
choice
.logprobs
.clone
(),
);
);
emissions
.push
(
ChoiceEmission
::
Trailing
(
trailing_choice
));
emissions
.push
(
ChoiceEmission
::
Trailing
(
trailing_choice
));
...
@@ -323,7 +326,6 @@ impl ChoiceJailState {
...
@@ -323,7 +326,6 @@ impl ChoiceJailState {
}
}
// If not unjailing, don't emit anything (still accumulating)
// If not unjailing, don't emit anything (still accumulating)
}
}
emissions
emissions
}
}
...
@@ -342,7 +344,7 @@ impl ChoiceJailState {
...
@@ -342,7 +344,7 @@ impl ChoiceJailState {
Some
(
Role
::
Assistant
),
Some
(
Role
::
Assistant
),
&
self
.accumulated_content
,
&
self
.accumulated_content
,
None
,
None
,
None
,
self
.stream_finish_reason
,
// For the accumulated content, assign the original stream finish reason, otherwise it will get lost
None
,
None
,
);
);
...
@@ -428,6 +430,19 @@ impl JailedStream {
...
@@ -428,6 +430,19 @@ impl JailedStream {
JailedStreamBuilder
::
new
()
JailedStreamBuilder
::
new
()
}
}
/// Apply jail stream transformation with finish_reason fix
/// This is a convenience method that applies both apply() and fix_finish_reason()
pub
fn
apply_with_finish_reason
<
S
>
(
self
,
stream
:
S
,
)
->
impl
Stream
<
Item
=
Annotated
<
NvCreateChatCompletionStreamResponse
>>
+
Send
where
S
:
Stream
<
Item
=
Annotated
<
NvCreateChatCompletionStreamResponse
>>
+
Send
+
'static
,
{
let
jailed_stream
=
self
.apply
(
stream
);
JailedStream
::
fix_finish_reason
(
jailed_stream
)
}
/// Apply the jail transformation to a stream of chat completion responses
/// Apply the jail transformation to a stream of chat completion responses
/// Consumes self and returns the transformed stream
/// Consumes self and returns the transformed stream
pub
fn
apply
<
S
>
(
pub
fn
apply
<
S
>
(
...
@@ -449,6 +464,7 @@ impl JailedStream {
...
@@ -449,6 +464,7 @@ impl JailedStream {
// Pin the stream for iteration (stack pinning is more efficient)
// Pin the stream for iteration (stack pinning is more efficient)
tokio
::
pin!
(
stream
);
tokio
::
pin!
(
stream
);
// Process each item in the stream
// Process each item in the stream
while
let
Some
(
response
)
=
stream
.next
()
.await
{
while
let
Some
(
response
)
=
stream
.next
()
.await
{
if
let
Some
(
chat_response
)
=
response
.data
.as_ref
()
{
if
let
Some
(
chat_response
)
=
response
.data
.as_ref
()
{
...
@@ -467,6 +483,9 @@ impl JailedStream {
...
@@ -467,6 +483,9 @@ impl JailedStream {
last_annotated_comment
=
response
.comment
.clone
();
last_annotated_comment
=
response
.comment
.clone
();
}
}
// Track actual stream finish reason in the choice state
choice_state
.stream_finish_reason
=
choice
.finish_reason
;
// Process this choice and get emissions
// Process this choice and get emissions
let
emissions
=
choice_state
.process_content
(
choice
,
content
,
&
self
)
.await
;
let
emissions
=
choice_state
.process_content
(
choice
,
content
,
&
self
)
.await
;
all_emissions
.extend
(
emissions
);
all_emissions
.extend
(
emissions
);
...
@@ -707,16 +726,16 @@ impl JailedStream {
...
@@ -707,16 +726,16 @@ impl JailedStream {
}),
}),
})
})
.collect
();
.collect
();
// Create choice with tool calls
// Create choice with tool calls
r
et
urn
create_choice_stream
(
l
et
choice
=
create_choice_stream
(
choice_index
,
choice_index
,
Some
(
Role
::
Assistant
),
Some
(
Role
::
Assistant
),
normal_text
.as_deref
()
.unwrap_or
(
""
),
normal_text
.as_deref
()
.unwrap_or
(
""
),
Some
(
tool_call_chunks
),
Some
(
tool_call_chunks
),
Some
(
FinishReason
::
ToolCalls
)
,
None
,
None
,
None
,
);
);
return
choice
;
}
}
// No tool calls found or parsing failed, return content choice
// No tool calls found or parsing failed, return content choice
...
@@ -725,7 +744,7 @@ impl JailedStream {
...
@@ -725,7 +744,7 @@ impl JailedStream {
Some
(
Role
::
Assistant
),
Some
(
Role
::
Assistant
),
accumulated_content
,
accumulated_content
,
None
,
None
,
None
,
base_choice
.finish_reason
,
base_choice
.logprobs
.clone
(),
base_choice
.logprobs
.clone
(),
)
)
}
}
...
@@ -745,6 +764,44 @@ impl JailedStream {
...
@@ -745,6 +764,44 @@ impl JailedStream {
}
}
false
false
}
}
/// Post-processor that sets finish_reason to ToolCalls when tool calls were emitted
/// This should be called after apply() to fix the finish_reason for tool call chunks
pub
fn
fix_finish_reason
<
S
>
(
input_stream
:
S
,
)
->
impl
Stream
<
Item
=
Annotated
<
NvCreateChatCompletionStreamResponse
>>
+
Send
where
S
:
Stream
<
Item
=
Annotated
<
NvCreateChatCompletionStreamResponse
>>
+
Send
+
'static
,
{
stream!
{
tokio
::
pin!
(
input_stream
);
let
mut
has_tool_calls_per_choice
:
HashMap
<
u32
,
bool
>
=
HashMap
::
new
();
while
let
Some
(
mut
response
)
=
input_stream
.next
()
.await
{
// Track if any choice emitted tool calls
if
let
Some
(
ref
data
)
=
response
.data
{
for
choice
in
&
data
.choices
{
if
choice
.delta.tool_calls
.is_some
()
{
has_tool_calls_per_choice
.insert
(
choice
.index
,
true
);
}
}
}
// If this chunk has finish_reason and the choice had tool calls, override to ToolCalls
if
let
Some
(
ref
mut
data
)
=
response
.data
{
for
choice
in
&
mut
data
.choices
{
if
choice
.finish_reason
.is_some
()
&&
choice
.finish_reason
==
Some
(
FinishReason
::
Stop
)
&&
has_tool_calls_per_choice
.get
(
&
choice
.index
)
.copied
()
.unwrap_or
(
false
)
{
choice
.finish_reason
=
Some
(
FinishReason
::
ToolCalls
);
}
}
}
yield
response
;
}
}
}
}
}
/// Builder for configuring a JailedStream
/// Builder for configuring a JailedStream
...
...
lib/llm/tests/data/vllm/qwen3-0.6B/chat_completion_incomplete_tool.json
0 → 100644
View file @
04f7579b
{
"request_id"
:
"8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"expected_output"
:
{
"normal_content"
:
" the requested format.
\n
</think>
\n\n
<tool_call>
\n\n
{
\"
name
\"
:
\"
get"
},
"input_stream"
:
[
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
" the"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
" requested"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
" format"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
".
\n
"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
"</think>"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
"
\n\n
"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
"<tool_call>"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
"
\n
"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
"{
\"
"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
"name"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
"
\"
:"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
"
\"
"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
"get"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
},
"finish_reason"
:
"length"
}]}}
]
}
lib/llm/tests/data/vllm/qwen3-0.6B/chat_completion_stream_finish_length.json
0 → 100644
View file @
04f7579b
{
"request_id"
:
"8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"expected_output"
:
{
"normal_content"
:
"<think>
\n
Okay, the user is asking for the weather in San Francisco in"
},
"input_stream"
:
[
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
"<think>"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
"
\n
"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
"Okay"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
","
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
" the"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
" user"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
" is"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
" asking"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
" for"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
" the"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
" weather"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
}}]}},
{
"data"
:{
"id"
:
"chatcmpl-8f33c28b-cb52-4272-9ac5-0cb9f80386d3"
,
"choices"
:[{
"index"
:
0
,
"delta"
:{
"content"
:
" in"
,
"function_call"
:
null
,
"tool_calls"
:
null
,
"role"
:
"assistant"
,
"refusal"
:
null
,
"reasoning_content"
:
null
},
"finish_reason"
:
"length"
}]}}
]
}
lib/llm/tests/test_jail.rs
View file @
04f7579b
This diff is collapsed.
Click to expand it.
lib/llm/tests/test_streaming_tool_parsers.rs
View file @
04f7579b
...
@@ -26,7 +26,7 @@ across backends.
...
@@ -26,7 +26,7 @@ across backends.
*/
*/
use
dynamo_async_openai
::
types
::
ChatChoiceStream
;
use
dynamo_async_openai
::
types
::
{
ChatChoiceStream
,
FinishReason
}
;
use
dynamo_llm
::
preprocessor
::
OpenAIPreprocessor
;
use
dynamo_llm
::
preprocessor
::
OpenAIPreprocessor
;
use
dynamo_llm
::
protocols
::
openai
::
chat_completions
::
NvCreateChatCompletionStreamResponse
;
use
dynamo_llm
::
protocols
::
openai
::
chat_completions
::
NvCreateChatCompletionStreamResponse
;
use
dynamo_runtime
::
protocols
::
annotated
::
Annotated
;
use
dynamo_runtime
::
protocols
::
annotated
::
Annotated
;
...
@@ -251,6 +251,71 @@ fn aggregate_content_from_chunks(
...
@@ -251,6 +251,71 @@ fn aggregate_content_from_chunks(
}
}
}
}
/// Helper function to validate finish_reason in the stream
/// Returns true if:
/// 1. There is exactly one finish_reason in the entire stream
/// 2. The finish_reason is in the last chunk
/// 3. The finish_reason matches the expected value
fn
validate_finish_reason
(
chunks
:
&
[
Annotated
<
NvCreateChatCompletionStreamResponse
>
],
expected_finish_reason
:
FinishReason
,
)
->
bool
{
let
mut
finish_reason_count
=
0
;
let
mut
last_chunk_index
=
None
;
let
mut
finish_reason_value
=
None
;
// Count finish_reason occurrences and track position
for
(
idx
,
chunk
)
in
chunks
.iter
()
.enumerate
()
{
if
let
Some
(
ref
response_data
)
=
chunk
.data
{
for
choice
in
&
response_data
.choices
{
if
let
Some
(
reason
)
=
choice
.finish_reason
{
finish_reason_count
+=
1
;
last_chunk_index
=
Some
(
idx
);
finish_reason_value
=
Some
(
reason
);
}
}
}
}
// Validate:
// 1. Exactly one finish_reason in the stream
if
finish_reason_count
!=
1
{
eprintln!
(
"Expected exactly 1 finish_reason, but found {}"
,
finish_reason_count
);
return
false
;
}
// 2. finish_reason is in the last chunk
if
let
Some
(
idx
)
=
last_chunk_index
{
if
idx
!=
chunks
.len
()
-
1
{
eprintln!
(
"Expected finish_reason in last chunk (index {}), but found at index {}"
,
chunks
.len
()
-
1
,
idx
);
return
false
;
}
}
else
{
eprintln!
(
"No finish_reason found in stream"
);
return
false
;
}
// 3. finish_reason matches expected value
if
let
Some
(
reason
)
=
finish_reason_value
&&
reason
!=
expected_finish_reason
{
eprintln!
(
"Expected finish_reason {:?}, but found {:?}"
,
expected_finish_reason
,
reason
);
return
false
;
}
true
}
#[cfg(test)]
#[cfg(test)]
mod
tests
{
mod
tests
{
use
super
::
*
;
use
super
::
*
;
...
@@ -304,6 +369,12 @@ mod tests {
...
@@ -304,6 +369,12 @@ mod tests {
aggregated
.has_tool_calls
,
expected_has_tool_calls
,
aggregated
.has_tool_calls
,
expected_has_tool_calls
,
"Tool calls presence should match expected value"
"Tool calls presence should match expected value"
);
);
// Verify finish_reason is valid: exactly one occurrence, in last chunk, and is Stop
assert
!
(
validate_finish_reason
(
&
output_chunks
,
FinishReason
::
Stop
),
"finish_reason validation failed for non-tool call case"
);
}
}
#[tokio::test]
#[tokio::test]
...
@@ -360,6 +431,12 @@ mod tests {
...
@@ -360,6 +431,12 @@ mod tests {
// Verify tool calls
// Verify tool calls
assert_tool_calls
(
&
aggregated
.tool_calls
,
&
test_data
.expected_tool_calls
);
assert_tool_calls
(
&
aggregated
.tool_calls
,
&
test_data
.expected_tool_calls
);
// Verify finish_reason is valid: exactly one occurrence, in last chunk, and is ToolCalls
assert
!
(
validate_finish_reason
(
&
output_chunks
,
FinishReason
::
ToolCalls
),
"finish_reason validation failed for tool call case"
);
}
}
#[tokio::test]
#[tokio::test]
...
@@ -403,6 +480,12 @@ mod tests {
...
@@ -403,6 +480,12 @@ mod tests {
aggregated
.has_tool_calls
,
expected_has_tool_calls
,
aggregated
.has_tool_calls
,
expected_has_tool_calls
,
"Tool calls presence should match expected value"
"Tool calls presence should match expected value"
);
);
// Verify finish_reason is valid: exactly one occurrence, in last chunk, and is Stop
assert
!
(
validate_finish_reason
(
&
output_chunks
,
FinishReason
::
Stop
),
"finish_reason validation failed for non-tool call case"
);
}
}
#[tokio::test]
#[tokio::test]
...
@@ -455,6 +538,12 @@ mod tests {
...
@@ -455,6 +538,12 @@ mod tests {
// Verify tool calls
// Verify tool calls
assert_tool_calls
(
&
aggregated
.tool_calls
,
&
test_data
.expected_tool_calls
);
assert_tool_calls
(
&
aggregated
.tool_calls
,
&
test_data
.expected_tool_calls
);
// Verify finish_reason is valid: exactly one occurrence, in last chunk, and is ToolCalls
assert
!
(
validate_finish_reason
(
&
output_chunks
,
FinishReason
::
ToolCalls
),
"finish_reason validation failed for tool call case"
);
}
}
#[tokio::test]
#[tokio::test]
...
@@ -511,6 +600,12 @@ mod tests {
...
@@ -511,6 +600,12 @@ mod tests {
);
);
assert_tool_calls
(
&
aggregated
.tool_calls
,
&
test_data
.expected_tool_calls
);
assert_tool_calls
(
&
aggregated
.tool_calls
,
&
test_data
.expected_tool_calls
);
// Verify finish_reason is valid: exactly one occurrence, in last chunk, and is Stop
assert
!
(
validate_finish_reason
(
&
output_chunks
,
FinishReason
::
Stop
),
"finish_reason validation failed for non-tool call case"
);
}
}
#[tokio::test]
#[tokio::test]
...
@@ -567,6 +662,12 @@ mod tests {
...
@@ -567,6 +662,12 @@ mod tests {
);
);
assert_tool_calls
(
&
aggregated
.tool_calls
,
&
test_data
.expected_tool_calls
);
assert_tool_calls
(
&
aggregated
.tool_calls
,
&
test_data
.expected_tool_calls
);
// Verify finish_reason is valid: exactly one occurrence, in last chunk, and is ToolCalls
assert
!
(
validate_finish_reason
(
&
output_chunks
,
FinishReason
::
ToolCalls
),
"finish_reason validation failed for tool call case"
);
}
}
#[tokio::test]
#[tokio::test]
...
@@ -620,6 +721,12 @@ mod tests {
...
@@ -620,6 +721,12 @@ mod tests {
);
);
assert_tool_calls
(
&
aggregated
.tool_calls
,
&
test_data
.expected_tool_calls
);
assert_tool_calls
(
&
aggregated
.tool_calls
,
&
test_data
.expected_tool_calls
);
// Verify finish_reason is valid: exactly one occurrence, in last chunk, and is Stop
assert
!
(
validate_finish_reason
(
&
output_chunks
,
FinishReason
::
Stop
),
"finish_reason validation failed for non-tool call case"
);
}
}
#[tokio::test]
#[tokio::test]
...
@@ -674,6 +781,12 @@ mod tests {
...
@@ -674,6 +781,12 @@ mod tests {
"Tool calls presence should match expected value"
"Tool calls presence should match expected value"
);
);
assert_tool_calls
(
&
aggregated
.tool_calls
,
&
test_data
.expected_tool_calls
);
assert_tool_calls
(
&
aggregated
.tool_calls
,
&
test_data
.expected_tool_calls
);
// Verify finish_reason is valid: exactly one occurrence, in last chunk, and is ToolCalls
assert
!
(
validate_finish_reason
(
&
output_chunks
,
FinishReason
::
ToolCalls
),
"finish_reason validation failed for tool call case"
);
}
}
#[tokio::test]
#[tokio::test]
...
@@ -726,5 +839,46 @@ mod tests {
...
@@ -726,5 +839,46 @@ mod tests {
// Verify tool calls
// Verify tool calls
assert_tool_calls
(
&
aggregated
.tool_calls
,
&
test_data
.expected_tool_calls
);
assert_tool_calls
(
&
aggregated
.tool_calls
,
&
test_data
.expected_tool_calls
);
// Verify finish_reason is valid: exactly one occurrence, in last chunk, and is ToolCalls
assert
!
(
validate_finish_reason
(
&
output_chunks
,
FinishReason
::
ToolCalls
),
"finish_reason validation failed for tool call case"
);
}
#[tokio::test]
async
fn
test_qwen_finish_reason_length_vllm
()
{
let
file_paths
=
vec!
[
format!
(
"{}/vllm/qwen3-0.6B/chat_completion_stream_finish_length.json"
,
DATA_ROOT_PATH
),
format!
(
"{}/vllm/qwen3-0.6B/chat_completion_incomplete_tool.json"
,
DATA_ROOT_PATH
),
];
for
file_path
in
file_paths
{
let
test_data
=
load_test_data
(
&
file_path
);
// Create a stream from the mock chunks
let
input_stream
=
stream
::
iter
(
test_data
.stream_chunks
);
// Parse the response stream with tool parsing enabled
let
output_chunks
=
parse_response_stream
(
input_stream
,
true
,
false
,
Some
(
"hermes"
.to_string
()),
None
)
.await
;
// Verify we got output chunks
assert
!
(
!
output_chunks
.is_empty
(),
"Should have output chunks"
);
// Verify finish_reason is valid: exactly one occurrence, in last chunk, and is Length
assert
!
(
validate_finish_reason
(
&
output_chunks
,
FinishReason
::
Length
),
"finish_reason validation failed for length finish case"
);
}
}
}
}
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment