Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
288ade9
Add minicpm5 tool_parser for MiniCPM5 XML tool calls (prototype)
exzile Jun 29, 2026
0d45d35
minicpm5 parser: add streaming split-chunk and edge-case tests
exzile Jun 29, 2026
5c6e4a7
Merge branch 'main' into feature/minicpm5-tool-parser
exzile Jun 29, 2026
e9a9d86
Merge branch 'main' into feature/minicpm5-tool-parser
exzile Jun 30, 2026
869484c
Address review: share parser helpers, split state machine, separate r…
exzile Jun 30, 2026
b77edee
minicpm5: stream special tokens for tool tags + matching reasoning pa…
exzile Jun 30, 2026
0ceda28
test(minicpm5): clarify why the Qwen3-8B tokenizer is reused in tests
exzile Jul 1, 2026
e1bea89
minicpm5: implement native token-based reasoning parser
exzile Jul 1, 2026
2243ea4
Merge branch 'main' into feature/minicpm5-tool-parser
exzile Jul 1, 2026
7a3e1da
minicpm5: preserve special tokens when decoding content, not just rea…
exzile Jul 1, 2026
ff1694a
test(minicpm5): clarify streaming test scope, add multi-call delta co…
exzile Jul 1, 2026
b77dd79
Merge remote-tracking branch 'minifork/feature/minicpm5-tool-parser' …
przepeck Jul 3, 2026
d7a00ec
save
przepeck Jul 6, 2026
00c099a
fix content with reasoning and without tool calls
przepeck Jul 7, 2026
dc115a9
streaming tests
przepeck Jul 9, 2026
4376744
fixing array and object parsing
przepeck Jul 13, 2026
db2abda
styles
przepeck Jul 13, 2026
2f966cd
fix reasoning, tests and special tokens in content
przepeck Jul 14, 2026
9c4cf6c
reasoning fixes
przepeck Jul 14, 2026
941b157
styles
przepeck Jul 14, 2026
baf4831
Merge branch 'main' into przepeck/minicpm5
przepeck Jul 14, 2026
a60410e
copilot review
przepeck Jul 14, 2026
88bfa43
style
przepeck Jul 14, 2026
a02c8dd
typos
przepeck Jul 14, 2026
52706ea
copilot review
przepeck Jul 15, 2026
59ad38d
styles
przepeck Jul 15, 2026
053ff35
Merge branch 'main' into przepeck/minicpm5
przepeck Jul 16, 2026
645a29e
windows model download
przepeck Jul 16, 2026
24ca369
autodetection of tool and reasoning parser
przepeck Jul 16, 2026
3ee10e9
copilot's review
przepeck Jul 16, 2026
c2a2ece
review changes
przepeck Jul 17, 2026
93fc51f
style
przepeck Jul 17, 2026
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
15 changes: 14 additions & 1 deletion prepare_llm_models.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ DEVSTRAL_MODEL="unsloth/Devstral-Small-2507"
LFM2_MODEL="LiquidAI/LFM2-2.6B"
LFM25_MODEL="LiquidAI/LFM2.5-8B-A1B"
GEMMA4_MODEL="OpenVINO/gemma-4-E4B-it-int4-ov"
MINICPM5_MODEL="openbmb/MiniCPM5-1B"

if [ "$(python3 -c 'import sys; print(sys.version_info[1])')" -le "8" ]; then echo "Prepare models with python > 3.8."; exit 1 ; fi

Expand Down Expand Up @@ -230,6 +231,17 @@ if [ ! -f "$1/$GEMMA4_MODEL/$TOKENIZER_FILE" ]; then
exit 1
fi

if [ -f "$1/$MINICPM5_MODEL/$TOKENIZER_FILE" ]; then
echo "Models file $1/$MINICPM5_MODEL/$TOKENIZER_FILE exists. Skipping downloading models."
else
mkdir -p $1/$MINICPM5_MODEL
convert_tokenizer $MINICPM5_MODEL --with_detokenizer -o $1/$MINICPM5_MODEL
fi
Comment thread
przepeck marked this conversation as resolved.
if [ ! -f "$1/$MINICPM5_MODEL/$TOKENIZER_FILE" ]; then
echo "[ERROR] Models file $1/$MINICPM5_MODEL/$TOKENIZER_FILE does not exist."
exit 1
fi

if [ -f "$1/$VLM_MODEL/$TOKENIZER_FILE" ]; then
echo "Model file $1/$VLM_MODEL/$TOKENIZER_FILE exists. Skipping downloading models."
else
Expand All @@ -240,4 +252,5 @@ fi
if [ ! -f "$1/$VLM_MODEL/$TOKENIZER_FILE" ]; then
echo "[ERROR] Model file $1/$VLM_MODEL/$TOKENIZER_FILE does not exist."
exit 1
fi
fi

25 changes: 25 additions & 0 deletions src/llm/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ ovms_cc_library(
"//src/port:rapidjson_writer",
"//src/port:rapidjson_document",
":io_processing_utils",
":apis_tool_schema_wrapper",
"//third_party:genai",
],
)
Expand Down Expand Up @@ -360,6 +361,29 @@ ovms_cc_library(
visibility = ["//visibility:public"],
)

ovms_cc_library(
name = "io_processing_minicpm5_tool_parser",
hdrs = [
"io_processing/minicpm5/minicpm5_tool_parser.hpp",
"io_processing/minicpm5/minicpm5_reasoning_parser.hpp",
],
srcs = [
"io_processing/minicpm5/minicpm5_tool_parser.cpp",
"io_processing/minicpm5/minicpm5_reasoning_parser.cpp",
],
deps = [
"@com_github_tencent_rapidjson//:rapidjson",
"//src:libovmslogging",
"//src:libovmsstatus",
"//src/utils:rapidjson_utils",
":io_processing_utils",
":io_processing_base_output_parser",
":apis_tool_schema_wrapper",
"//third_party:genai",
],
visibility = ["//visibility:public"],
)

ovms_cc_library(
name = "io_processing_qwen3_reasoning_parser",
hdrs = ["io_processing/qwen3/reasoning_parser.hpp"],
Expand Down Expand Up @@ -429,6 +453,7 @@ ovms_cc_library( # TODO split further so we don't have to recompile everything w
":io_processing_lfm2_tool_parser",
":io_processing_lfm25_tool_parser",
":io_processing_gemma4_tool_parser",
":io_processing_minicpm5_tool_parser",
":io_processing_qwen3_reasoning_parser",
":io_processing_lfm25_reasoning_parser",
":io_processing_utils",
Expand Down
44 changes: 44 additions & 0 deletions src/llm/io_processing/base_output_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,50 @@

namespace ovms {

ParametersTypeMap_t parseToolSchema(const rapidjson::Value& schema) {
// Map each declared parameter name to its ParameterType from the tool's JSON schema.
ParametersTypeMap_t result;
if (!schema.IsObject()) {
return result;
}
if (!schema.HasMember("properties") || !schema["properties"].IsObject()) {
return result;
}
const rapidjson::Value& properties = schema["properties"];
for (auto it = properties.MemberBegin(); it != properties.MemberEnd(); ++it) {
if (!it->value.IsObject()) {
continue;
}
if (!it->value.HasMember("type") || !it->value["type"].IsString()) {
continue;
}
std::string paramName = it->name.GetString();
std::string typeStr = it->value["type"].GetString();
ParameterType type = ParameterType::UNKNOWN;
if (typeStr == "string") {
type = ParameterType::STRING;
} else if (typeStr == "number" || typeStr == "integer") {
type = ParameterType::NUMBER;
} else if (typeStr == "boolean") {
type = ParameterType::BOOLEAN;
} else if (typeStr == "array") {
type = ParameterType::ARRAY;
} else if (typeStr == "object") {
type = ParameterType::OBJECT;
}
result.emplace(paramName, type);
}
return result;
}

ToolsParameterTypeMap_t createToolsParametersTypesMap(const ToolsSchemas_t& toolsSchemas) {
ToolsParameterTypeMap_t toolsParametersTypes;
for (const auto& [toolName, toolSchemaWrapper] : toolsSchemas) {
toolsParametersTypes.emplace(toolName, parseToolSchema(*toolSchemaWrapper.rapidjsonRepr));
}
return toolsParametersTypes;
}

rapidjson::Document BaseOutputParser::wrapFirstDelta(const std::string& functionName, int toolCallIndex) {
rapidjson::Document wrappedDelta;
wrappedDelta.SetObject();
Expand Down
8 changes: 8 additions & 0 deletions src/llm/io_processing/base_output_parser.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
#include "src/port/rapidjson_stringbuffer.hpp"
#include "src/port/rapidjson_writer.hpp"

#include "src/llm/apis/tool_schema_wrapper.hpp"

namespace ovms {
struct ToolCall {
std::string id;
Expand Down Expand Up @@ -58,6 +60,12 @@ enum class ParameterType {
using ParametersTypeMap_t = std::unordered_map<std::string, ParameterType>; // param name -> param type
using ToolsParameterTypeMap_t = std::unordered_map<std::string, ParametersTypeMap_t>; // tool name -> (param name -> param type)

// Tool-schema helpers shared between tag/attribute-style parsers (e.g. qwen3coder, minicpm5).
// Builds a parameter name -> ParameterType map from a single tool's JSON schema.
ParametersTypeMap_t parseToolSchema(const rapidjson::Value& schema);
// Builds a tool name -> (parameter name -> ParameterType) map from all tools' schemas.
ToolsParameterTypeMap_t createToolsParametersTypesMap(const ToolsSchemas_t& toolsSchemas);

class BaseOutputParser {
protected:
ov::genai::Tokenizer tokenizer;
Expand Down
9 changes: 9 additions & 0 deletions src/llm/io_processing/chat_template/analyzer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ ChatTemplateAnalysisResult ChatTemplateAnalyzer::analyze(const std::string& temp
return result;
}

// MiniCPM5 detection — uses <param name="..."> XML style (distinct from qwen3coder's <parameter=...>)
if (contains(templateSource, "<param name=\"")) {
result.detectedToolParser = "minicpm5";
result.caps.supportsToolCalls = true;
result.detectedReasoningParser = "minicpm5";

return result;
}

// LFM2 detection
if (contains(templateSource, "<|assistant_tool_call|>") || contains(templateSource, "<|tool_call_start|>") || contains(templateSource, "keep_past_thinking")) {
result.detectedToolParser = "lfm2";
Expand Down
1 change: 1 addition & 0 deletions src/llm/io_processing/chat_template/probe.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ static bool analyzeProbeToolArgumentResults(bool strOk, const std::string& strOu
return output.find("\"" + PROBE_NEEDLE + "\": \"") != std::string::npos ||
output.find("\"" + PROBE_NEEDLE + "\":\"") != std::string::npos ||
output.find("<parameter=" + PROBE_NEEDLE + ">") != std::string::npos ||
output.find("<param name=\"" + PROBE_NEEDLE + "\">") != std::string::npos ||
output.find(PROBE_NEEDLE + ":<|") != std::string::npos ||
output.find(PROBE_NEEDLE + "=") != std::string::npos;
};
Expand Down
81 changes: 81 additions & 0 deletions src/llm/io_processing/minicpm5/minicpm5_reasoning_parser.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//*****************************************************************************
// Copyright 2026 Intel Corporation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//*****************************************************************************
#include <openvino/genai/tokenizer.hpp>
#include <string>
#include <vector>

Comment thread
przepeck marked this conversation as resolved.
#include "src/port/rapidjson_document.hpp"

#include "src/logging.hpp"
#include "minicpm5_reasoning_parser.hpp"
#include "src/llm/io_processing/utils.hpp"

namespace ovms {
void Minicpm5ReasoningParser::parse(ParsedOutput& parsedOutput, const std::vector<int64_t>& generatedTokens) {
auto startReasoningIt = std::find(generatedTokens.begin(), generatedTokens.end(), reasoningStartTokenId);
auto endReasoningIt = std::find(generatedTokens.begin(), generatedTokens.end(), reasoningEndTokenId);

if ((startReasoningIt == generatedTokens.end() && endReasoningIt == generatedTokens.end())) {
SPDLOG_LOGGER_DEBUG(llm_calculator_logger, "Minicpm5ReasoningParser: Reasoning start or end token not found in the generated tokens. Start token found: {}, End token found: {}, Start position: {}, End position: {}",
startReasoningIt != generatedTokens.end(), endReasoningIt != generatedTokens.end(), std::distance(generatedTokens.begin(), startReasoningIt), std::distance(generatedTokens.begin(), endReasoningIt));
return;
}

auto startPos = 0;
if (startReasoningIt != generatedTokens.end()) {
startPos = std::distance(generatedTokens.begin(), startReasoningIt) + 1;
} else {
SPDLOG_LOGGER_DEBUG(llm_calculator_logger, "Minicpm5ReasoningParser: Reasoning start token not found in the generated tokens. Start position: {}", startPos);
}
auto endPos = std::distance(generatedTokens.begin(), endReasoningIt);

std::string reasoningContent = tokenizer.decode(std::vector<int64_t>(startPos + generatedTokens.begin(), endPos + generatedTokens.begin()), ov::genai::skip_special_tokens(true));

parsedOutput.reasoning = reasoningContent;

if (endReasoningIt != generatedTokens.end()) {
endPos += 1;
}

std::string contentWithoutReasoning = tokenizer.decode(std::vector<int64_t>(endPos + generatedTokens.begin(), generatedTokens.end()), ov::genai::skip_special_tokens(true));
parsedOutput.content = contentWithoutReasoning;
}
Comment thread
przepeck marked this conversation as resolved.

std::optional<rapidjson::Document> Minicpm5ReasoningParser::parseChunk(const std::string& chunk, const std::vector<int64_t>& tokens, ov::genai::GenerationFinishReason finishReason) {
if (tokens.empty()) {
SPDLOG_LOGGER_DEBUG(llm_calculator_logger, "Received empty tokens for Minicpm5ReasoningParser");
return std::nullopt;
}

if (std::find(tokens.begin(), tokens.end(), reasoningStartTokenId) != tokens.end() ||
std::find(tokens.begin(), tokens.end(), reasoningEndTokenId) != tokens.end()) {
return std::nullopt;
} else {
rapidjson::StringBuffer buffer;
rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
writer.StartObject();
writer.String("delta");
writer.StartObject();
writer.String("reasoning_content");
writer.String(chunk.c_str());
writer.EndObject();
writer.EndObject();
rapidjson::Document doc;
doc.Parse(buffer.GetString());
return doc;
}
}
} // namespace ovms
53 changes: 53 additions & 0 deletions src/llm/io_processing/minicpm5/minicpm5_reasoning_parser.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//*****************************************************************************
// Copyright 2026 Intel Corporation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//*****************************************************************************
#pragma once
#include "src/llm/io_processing/base_output_parser.hpp"
#include <vector>
#include <string>

namespace ovms {
class Minicpm5ReasoningParser : public BaseOutputParser {
public:
static inline const std::string reasoningStartTag = "<think>";
static inline const std::string reasoningEndTag = "</think>";

static constexpr int64_t reasoningStartTokenId = 8;
static constexpr int64_t reasoningEndTokenId = 9;

public:
Minicpm5ReasoningParser() = delete;
explicit Minicpm5ReasoningParser(ov::genai::Tokenizer& tokenizer) :
BaseOutputParser(tokenizer) {}

void parse(ParsedOutput& parsedOutput, const std::vector<int64_t>& generatedTokens) override;
std::optional<rapidjson::Document> parseChunk(const std::string& chunk, const std::vector<int64_t>& tokens, ov::genai::GenerationFinishReason finishReason) override;
const std::vector<std::string>& getParsingStartTags() const override {
static const std::vector<std::string> parsingStartTags{this->reasoningStartTag};
return parsingStartTags;
}
const std::vector<std::string>& getSpecialParsingStartTags() const override {
static const std::vector<std::string> specialParsingStartTags{};
return specialParsingStartTags;
}
const std::string& getParsingEndTag() const override {
return reasoningEndTag;
}

bool requiresStreamingWithSpecialTokens() const override {
return true;
}
};
} // namespace ovms
Loading