Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/llm/io_processing/gemma4/gemma4_tool_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,9 @@ std::optional<Delta> Gemma4ToolParser::parseChunk(const std::string& chunk, cons
return ToolCallDelta{toolCallIndex, generateRandomId(), this->toolCall.name, ""};
}
if (this->currentState == State::ToolCallEnded) {
return wrapDeltaArgs(this->toolCall.arguments, toolCallIndex);
auto delta = wrapDeltaArgs(this->toolCall.arguments, toolCallIndex);
this->toolCall = ToolCall{};
return delta;
}
if (this->currentState == State::Content) {
size_t contentEnd = this->streamingContent.find(TOOL_CALL_START_TAG, this->streamingPosition);
Expand Down
4 changes: 3 additions & 1 deletion src/llm/io_processing/lfm2/lfm2_tool_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,9 @@ std::optional<Delta> Lfm2ToolParser::parseChunk(const std::string& chunk,
return ToolCallDelta{this->toolCallIndex, generateRandomId(), this->toolCall.name, ""};
}
if (this->currentState == Lfm2ParseState::ToolCallEnded) {
return wrapDeltaArgs(this->toolCall.arguments, this->toolCallIndex);
auto delta = wrapDeltaArgs(this->toolCall.arguments, this->toolCallIndex);
this->toolCall = ToolCall{};
return delta;
}
if (this->currentState == Lfm2ParseState::Content) {
const std::string& startTag = parsingConfig.startTags[0];
Expand Down
60 changes: 57 additions & 3 deletions src/llm/io_processing/minicpm5/minicpm5_tool_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ void Minicpm5ToolParserImpl::addParameterToCurrentFunctionDoc(std::string& param
}

Status Minicpm5ToolParserImpl::removeToolCallsFromContentIfNeeded(std::string& outContent) {
// Generation can be truncated mid-tool-call (max_tokens hit, or eos suppressed) so an opening
// "<function" is recorded with no matching "</function>" close. That leaves begin with more
// entries than end. The unterminated call is always the most recent one (top of the begin
// stack), so drop it -- erasing from its start to end-of-content -- rather than bailing and
// leaving every (including completed) block in the content returned to the user.
while (toolCallPositions.begin.size() > toolCallPositions.end.size()) {
auto posBegin = toolCallPositions.begin.top();
toolCallPositions.begin.pop();
if (posBegin <= outContent.size()) {
SPDLOG_TRACE("Minicpm5: removing unterminated tool call from outContent begin:{} to end", posBegin);
outContent.erase(posBegin);
}
}
if (toolCallPositions.begin.size() != toolCallPositions.end.size()) {
SPDLOG_DEBUG("Minicpm5: mismatched tool tags, begin: {}, end: {}",
toolCallPositions.begin.size(), toolCallPositions.end.size());
Expand Down Expand Up @@ -283,6 +296,38 @@ std::optional<ToolCalls_t> Minicpm5ToolParserImpl::parseChunk(const std::string&
return std::nullopt;
}

std::optional<ToolCalls_t> Minicpm5ToolParserImpl::finalizeOnGenerationEnd() {
if (this->currentState == State::Content ||
this->currentState == State::InsideFunctionName) {
// No usable function name was ever captured -- nothing to recover. Still clear the
// dangling partial state so it doesn't look like a call is still in flight.
resetParsingState();
return std::nullopt;
}
Comment on lines +300 to +306
if (this->currentState == State::InsideParamName) {
// Drop the incomplete parameter name; close the function with whatever was captured before it.
this->currentState = State::InsideFunction;
}
if (this->currentState == State::InsideParam) {
this->streamContent += Minicpm5ToolParser::PARAM_END_TAG;
}
if (this->currentState == State::InsideParam || this->currentState == State::InsideFunction) {
this->streamContent += Minicpm5ToolParser::FUNCTION_END_TAG;
}

ToolCalls_t toolCalls;
while (parseUntilStateChange(toolCalls)) {
}
// Generation has ended: nothing more will ever be parsed from streamContent, so leave the
// parser in the same clean state a normal completion would (toolCallPositions is kept --
// removeToolCallsFromContentIfNeeded() still needs it afterward).
resetParsingState();
if (!toolCalls.empty()) {
return std::move(toolCalls);
}
return std::nullopt;
}

std::optional<std::string> Minicpm5ToolParserImpl::getCurrentFunctionName() const {
if (this->currentFunction.name.empty())
return std::nullopt;
Expand Down Expand Up @@ -361,11 +406,20 @@ std::optional<Delta> Minicpm5ToolParser::sendFirstDeltaIfNeeded(const std::strin
std::optional<Delta> Minicpm5ToolParser::parseChunk(
const std::string& newChunk,
const std::vector<int64_t>& /*tokens*/,
ov::genai::GenerationFinishReason /*finishReason*/) {
ov::genai::GenerationFinishReason finishReason) {
SPDLOG_DEBUG("Minicpm5ToolParser: chunk: '{}'", newChunk);
if (newChunk.empty())
if (newChunk.empty() && finishReason == ov::genai::GenerationFinishReason::NONE)
return std::nullopt;
auto toolCallsOpt = this->streamParser.parseChunk(newChunk);
std::optional<ToolCalls_t> toolCallsOpt;
if (!newChunk.empty()) {
toolCallsOpt = this->streamParser.parseChunk(newChunk);
}

// If no complete tool calls were returned yet and generation has ended, finalize the current
// tool call in progress to recover any remaining data (for example if arguments were not closed properly).
if (!toolCallsOpt.has_value() && finishReason != ov::genai::GenerationFinishReason::NONE) {
toolCallsOpt = this->streamParser.finalizeOnGenerationEnd();
}
if (toolCallsOpt.has_value()) {
return this->sendFullDelta(toolCallsOpt.value());
}
Expand Down
21 changes: 16 additions & 5 deletions src/llm/io_processing/minicpm5/minicpm5_tool_parser.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -81,16 +81,18 @@ struct Minicpm5ToolParserImpl {
*/
std::optional<ToolCalls_t> parseChunk(const std::string& chunk);

// Called once generation has stopped and parseChunk() produced nothing new: synthesizes
// the closing tags still missing for whatever tool call is in flight (using only data
// already captured) so it can be recovered instead of silently dropped. An incomplete
// name/attribute can't be recovered and returns nullopt.
std::optional<ToolCalls_t> finalizeOnGenerationEnd();

std::optional<std::string> getCurrentFunctionName() const;

Status removeToolCallsFromContentIfNeeded(std::string& outContent);

void reset() {
currentState = State::Content;
currentFunction.clear();
currentParameterName.clear();
streamContent.clear();
lastProcessedPosition = 0;
resetParsingState();
toolCallPositions = ToolCallPositions{};
}

Expand All @@ -100,6 +102,15 @@ struct Minicpm5ToolParserImpl {
private:
const ToolsParameterTypeMap_t& toolsParametersTypeMap;
const bool removeNewlineAroundParameters = true;
// Resets everything except toolCallPositions, which removeToolCallsFromContentIfNeeded()
// still needs to consult after generation ends.
void resetParsingState() {
currentState = State::Content;
currentFunction.clear();
currentParameterName.clear();
streamContent.clear();
lastProcessedPosition = 0;
}
State currentState = State::Content;
Minicpm5Functool currentFunction;
std::string currentParameterName;
Expand Down
48 changes: 46 additions & 2 deletions src/llm/io_processing/onyx/onyx_tool_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,40 @@ std::optional<ToolCalls_t> OnyxToolParserImpl::parseChunk(const std::string& chu
return std::nullopt;
}

std::optional<ToolCalls_t> OnyxToolParserImpl::finalizeOnGenerationEnd() {
if (this->currentState == State::Content ||
this->currentState == State::InsideToolCall ||
this->currentState == State::InsideFunctionName) {
// No usable function name was ever captured -- nothing to recover. Still clear the
// dangling partial state so it doesn't look like a call is still in flight.
resetParsingState();
return std::nullopt;
}
if (this->currentState == State::InsideParameterName) {
// Drop the incomplete parameter name; close the function with whatever was captured before it.
this->currentState = State::InsideFunction;
}
if (this->currentState == State::InsideParameter) {
this->streamContent += OnyxToolParser::PARAMETER_END_TAG;
}
if (this->currentState == State::InsideParameter || this->currentState == State::InsideFunction) {
this->streamContent += OnyxToolParser::FUNCTION_END_TAG;
}
this->streamContent += OnyxToolParser::TOOL_END_TAG;

ToolCalls_t toolCalls;
while (parseUntilStateChange(toolCalls)) {
}
// Generation has ended: nothing more will ever be parsed from streamContent, so leave the
// parser in the same clean state a normal completion would (toolCallPositions is kept --
// removeToolCallsFromContentIfNeeded() still needs it afterward).
resetParsingState();
if (!toolCalls.empty()) {
return std::move(toolCalls);
}
return std::nullopt;
}

std::optional<std::string> OnyxToolParserImpl::getCurrentFunctionName() const {
if (this->currentFunction.name.empty()) {
return std::nullopt;
Expand Down Expand Up @@ -296,10 +330,20 @@ std::optional<Delta> OnyxToolParser::parseChunk(const std::string& newChunk, con
// streamParser returns assembled toolCalls once a call closes ("</atem:function_calls>");
// until then, if the function name is already known, send its first delta once.
SPDLOG_DEBUG("Chunk: '{}', finishReason: {}", newChunk, static_cast<int>(finishReason));
if (newChunk.empty()) {
if (newChunk.empty() && finishReason == ov::genai::GenerationFinishReason::NONE) {
return std::nullopt;
}
auto toolCallsOpt = this->streamParser.parseChunk(newChunk);
std::optional<ToolCalls_t> toolCallsOpt;
if (!newChunk.empty()) {
toolCallsOpt = this->streamParser.parseChunk(newChunk);
}

// If no complete tool calls were returned yet and generation has ended, finalize the current
// tool call in progress to recover any remaining data (for example if arguments were not closed properly).
if (!toolCallsOpt.has_value() && finishReason != ov::genai::GenerationFinishReason::NONE) {
toolCallsOpt = this->streamParser.finalizeOnGenerationEnd();
}

if (toolCallsOpt.has_value()) {
return this->sendFullDelta(toolCallsOpt.value());
}
Expand Down
20 changes: 15 additions & 5 deletions src/llm/io_processing/onyx/onyx_tool_parser.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,15 @@ struct OnyxToolParserImpl {
// Return all tool calls fully closed ("</atem:function_calls>" seen) in the aggregated
// content so far that were not returned before -- nullopt if none completed yet.
std::optional<ToolCalls_t> parseChunk(const std::string& chunk);
// Called once generation has stopped and parseChunk() produced nothing new: synthesizes
// the closing tags still missing for whatever tool call is in flight (using only data
// already captured) so it can be recovered instead of silently dropped. An incomplete
// name/attribute can't be recovered and returns nullopt.
std::optional<ToolCalls_t> finalizeOnGenerationEnd();
std::optional<std::string> getCurrentFunctionName() const;
Status removeToolCallsFromContentIfNeeded(std::string& outContent);
void reset() {
currentState = State::Content;
currentFunction.clear();
currentParameterName.clear();
streamContent.clear();
lastProcessedPosition = 0;
resetParsingState();
toolCallPositions = ToolCallPositions{};
}
State getCurrentState() const {
Expand All @@ -112,6 +113,15 @@ struct OnyxToolParserImpl {
// Onyx renders parameter values tight ("...\">VALUE</atem:parameter>"), so unlike
// qwen3coder there is no surrounding-newline convention to trim.
const bool removeNewlineAroundParameters = false;
// Resets everything except toolCallPositions, which removeToolCallsFromContentIfNeeded()
// still needs to consult after generation ends.
void resetParsingState() {
currentState = State::Content;
currentFunction.clear();
currentParameterName.clear();
streamContent.clear();
lastProcessedPosition = 0;
}
State currentState = State::Content;
OnyxFunctool currentFunction;
std::string currentParameterName;
Expand Down
60 changes: 58 additions & 2 deletions src/llm/io_processing/qwen3coder/qwen3coder_tool_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ const std::string Qwen3CoderToolParser::FUNCTION_END_TAG = "</function>";
const std::string Qwen3CoderToolParser::TOOL_END_TAG = "</tool_call>";

Status Qwen3CoderToolParserImpl::removeToolCallsFromContentIfNeeded(std::string& outContent) {
// Generation can be truncated mid-tool-call (max_tokens hit, or eos suppressed) so an opening
// "<tool_call>"/"<function=" is recorded with no matching close. That leaves begin with more
// entries than end. The unterminated call is always the most recent one (top of the begin
// stack), so drop it -- erasing from its start to end-of-content -- rather than bailing and
// leaving every (including completed) block in the content returned to the user.
while (toolCallPositions.begin.size() > toolCallPositions.end.size()) {
auto posBegin = toolCallPositions.begin.top();
toolCallPositions.begin.pop();
if (posBegin <= outContent.size()) {
SPDLOG_TRACE("Removing unterminated tool call from outContent begin:{} to end", posBegin);
outContent.erase(posBegin);
}
}
if (toolCallPositions.begin.size() != toolCallPositions.end.size()) {
SPDLOG_DEBUG("Mismatched tool tags, begin: {}, end: {}", toolCallPositions.begin.size(), toolCallPositions.end.size());
return Status(StatusCode::INTERNAL_ERROR, "Mismatched tool tags");
Expand Down Expand Up @@ -213,6 +226,40 @@ std::optional<ToolCalls_t> Qwen3CoderToolParserImpl::parseChunk(const std::strin
return std::nullopt;
}

std::optional<ToolCalls_t> Qwen3CoderToolParserImpl::finalizeOnGenerationEnd() {
if (this->currentState == State::Content ||
this->currentState == State::InsideToolCall ||
this->currentState == State::InsideFunctionName) {
// No usable function name was ever captured -- nothing to recover. Still clear the
// dangling partial state so it doesn't look like a call is still in flight.
resetParsingState();
return std::nullopt;
}
Comment on lines +230 to +237
if (this->currentState == State::InsideParameterName) {
// Drop the incomplete parameter name; close the function with whatever was captured before it.
this->currentState = State::InsideFunction;
}
if (this->currentState == State::InsideParameter) {
this->streamContent += Qwen3CoderToolParser::PARAMETER_END_TAG;
}
if (this->currentState == State::InsideParameter || this->currentState == State::InsideFunction) {
this->streamContent += Qwen3CoderToolParser::FUNCTION_END_TAG;
}
this->streamContent += Qwen3CoderToolParser::TOOL_END_TAG;

ToolCalls_t toolCalls;
while (parseUntilStateChange(toolCalls)) {
}
// Generation has ended: nothing more will ever be parsed from streamContent, so leave the
// parser in the same clean state a normal completion would (toolCallPositions is kept --
// removeToolCallsFromContentIfNeeded() still needs it afterward).
resetParsingState();
if (!toolCalls.empty()) {
return std::move(toolCalls);
}
return std::nullopt;
}

Qwen3CoderToolParser::Qwen3CoderToolParser(ov::genai::Tokenizer& tokenizer, const ToolsSchemas_t& toolSchemas,
std::optional<OutputParsingConfig> configOverride) :
BaseOutputParser(tokenizer, [&]() {
Expand Down Expand Up @@ -262,10 +309,19 @@ std::optional<Delta> Qwen3CoderToolParser::parseChunk(const std::string& newChun
// if toolCalls is not returned, but we are insideFunction state, we need to return the first delta with function name once
// otherwise nullopt
SPDLOG_DEBUG("Chunk: '{}', finishReason: {}", newChunk, static_cast<int>(finishReason));
if (newChunk.empty()) {
if (newChunk.empty() && finishReason == ov::genai::GenerationFinishReason::NONE) {
return std::nullopt;
}
auto toolCallsOpt = this->streamParser.parseChunk(newChunk);
std::optional<ToolCalls_t> toolCallsOpt;
if (!newChunk.empty()) {
toolCallsOpt = this->streamParser.parseChunk(newChunk);
}

// If no complete tool calls were returned yet and generation has ended, finalize the current
// tool call in progress to recover any remaining data (for example if arguments were not closed properly).
if (!toolCallsOpt.has_value() && finishReason != ov::genai::GenerationFinishReason::NONE) {
toolCallsOpt = this->streamParser.finalizeOnGenerationEnd();
}
if (toolCallsOpt.has_value()) {
return this->sendFullDelta(toolCallsOpt.value());
}
Expand Down
20 changes: 15 additions & 5 deletions src/llm/io_processing/qwen3coder/qwen3coder_tool_parser.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,15 @@ C->ITC->IFN->IF->IPN->IP->AF->C
* that were not returned before
*/
std::optional<ToolCalls_t> parseChunk(const std::string& chunk);
// Called once generation has stopped and parseChunk() produced nothing new: synthesizes
// the closing tags still missing for whatever tool call is in flight (using only data
// already captured) so it can be recovered instead of silently dropped. An incomplete
// name/attribute can't be recovered and returns nullopt.
std::optional<ToolCalls_t> finalizeOnGenerationEnd();
std::optional<std::string> getCurrentFunctionName() const;
Status removeToolCallsFromContentIfNeeded(std::string& outContent);
void reset() {
currentState = State::Content;
currentFunction.clear();
currentParameterName.clear();
streamContent.clear();
lastProcessedPosition = 0;
resetParsingState();
toolCallPositions = ToolCallPositions{};
}
State getCurrentState() const {
Expand All @@ -106,6 +107,15 @@ C->ITC->IFN->IF->IPN->IP->AF->C
private:
const ToolsParameterTypeMap_t& toolsParametersTypeMap;
const bool removeNewlineAroundParameters = true;
// Resets everything except toolCallPositions, which removeToolCallsFromContentIfNeeded()
// still needs to consult after generation ends.
void resetParsingState() {
currentState = State::Content;
currentFunction.clear();
currentParameterName.clear();
streamContent.clear();
lastProcessedPosition = 0;
}
State currentState = State::Content;
Functool currentFunction;
std::string currentParameterName;
Expand Down
17 changes: 17 additions & 0 deletions src/test/llm/output_parsers/gemma4_output_parser_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,23 @@ TEST_F(Gemma4OutputParserTest, StreamingWithToolResponseTokenAtTheEndOfGeneratio
assertStreamingVec(chunkToDeltaVec);
}

// Model omits the "<tool_call|>" end tag entirely and jumps straight to a stray
// "<|tool_response>" before stopping. Arguments must be emitted exactly once
// (regression test: the finish-flush fallback used to blindly re-emit them).
TEST_F(Gemma4OutputParserTest, StreamingWithMissingEndTagBeforeStop) {
std::vector<std::tuple<std::string, ov::genai::GenerationFinishReason, std::optional<std::string>>> chunkToDeltaVec{
{"<|tool_call>", ov::genai::GenerationFinishReason::NONE, std::nullopt},
{"call:", ov::genai::GenerationFinishReason::NONE, std::nullopt},
{"ls", ov::genai::GenerationFinishReason::NONE, std::nullopt},
{"{a:", ov::genai::GenerationFinishReason::NONE, R"({"delta":{"tool_calls":[{"id":"XXXXXXXXX","type":"function","index":0,"function":{"name":"ls"}}]}})"},
{"true}", ov::genai::GenerationFinishReason::NONE, R"({"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"a\":true}"}}]}})"},
{"<|tool_response>", ov::genai::GenerationFinishReason::NONE, std::nullopt},
{"", ov::genai::GenerationFinishReason::STOP, std::nullopt},
};

assertStreamingVec(chunkToDeltaVec);
}

TEST_F(Gemma4OutputParserTest, StreamingContentWithTurnTokenAtTheEndOfGeneration) {
std::vector<std::tuple<std::string, ov::genai::GenerationFinishReason, std::optional<std::string>>> chunkToDeltaVec{
{"This is", ov::genai::GenerationFinishReason::NONE, R"({"delta":{"content":"This is"}})"},
Expand Down
Loading