Skip to content
Open
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
3 changes: 3 additions & 0 deletions docs/llm/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,11 +299,14 @@ __Reasoning parsers:__
- `gemma4`
- `onyx`
- `minicpm5`
- `granite42` (pair with `qwen3coder` for its tool-call format)

#### Automatic parser detection

For most models with recognized chat templates, the server automatically detects the appropriate `tool_parser` and `reasoning_parser` at startup. This means you can deploy a model without explicitly specifying parsers — the server will analyze the chat template and select the correct one.

Granite 4.2 deployments should explicitly set `reasoning_parser` to `granite42`; its production parser has request-dependent promotion semantics that generic `<think>` detection cannot infer. Use `qwen3coder` as the companion `tool_parser` when the model emits Qwen3-Coder tool-call markup. OVMS's implementation follows IBM's [Granite 4.2 thinking parser](https://huggingface.co/ibm-granite/granite-4.2-8b/raw/f8de16cdcdbc6c779ca517604e050d82cc119e44/granite_thinking_parser.py).

If the auto-detected parser is not what you want, you can always override it by explicitly passing `--tool_parser` or `--reasoning_parser`.

To explicitly disable parser auto-detection and run without any parser, set the value to `none`:
Expand Down
4 changes: 2 additions & 2 deletions docs/parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ Task specific parameters for different tasks (text generation/image generation/e
| `--max_prompt_len` | `integer` | Sets NPU specific property for maximum number of tokens in the prompt. |
| `--kv_cache_precision` | `string` | Reduced kv cache precision to `u8` lowers the cache size consumption. Accepted values: `u8` or empty (default). |
| `--model_distribution_policy` | `string` | TENSOR_PARALLEL distributes tensor to multiple sockets/devices and processes it in parallel. PIPELINE_PARALLEL distributes different tensors to process by each device. Accepted values: `TENSOR_PARALLEL`, `PIPELINE_PARALLEL` or empty (default). |
| `--reasoning_parser` | `string` | Type of parser to use for reasoning content extraction from model output. Auto-detected from chat template if not specified. Use `none` to explicitly disable. Supported: [qwen3, gptoss, lfm2, gemma4, onyx] |
| `--reasoning_parser` | `string` | Type of parser to use for reasoning content extraction from model output. Auto-detected from chat template if not specified. Use `none` to explicitly disable. Supported: [qwen3, gptoss, lfm2, gemma4, onyx, granite42] |
| `--tool_parser` | `string` | Type of parser to use for tool calls extraction from model output. Auto-detected from chat template if not specified. Use `none` to explicitly disable. Supported: [llama3, phi4, hermes3, mistral, qwen3coder, gptoss, devstral, lfm2, gemma4, onyx] |
| `--enable_tool_guided_generation` | `bool` | Enables enforcing tool schema during generation. Requires setting response parser. Default: false. |
| `--cache_interval_multiplier` | `integer` | Multiplier for the KV cache block interval. Controls the granularity of cache allocation. Default: adaptive for the model. |
Expand Down Expand Up @@ -236,4 +236,4 @@ The `--target_device` option defaults to auto-detected based on available GPU de
- If no discrete GPUs but integrated GPUs exist, the first integrated GPU is recommended.
- Falls back to `CPU` if no suitable GPU is found.

> **Note:** Auto-detection does not select `NPU`. To use NPU, set `--target_device NPU` explicitly.
> **Note:** Auto-detection does not select `NPU`. To use NPU, set `--target_device NPU` explicitly.
12 changes: 12 additions & 0 deletions prepare_llm_models.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ STT_MODEL="openai/whisper-tiny"

# Models for tools testing. Only tokenizers are downloaded.
QWEN3_MODEL="Qwen/Qwen3-8B"
GRANITE42_MODEL="ibm-granite/granite-4.2-8b"
LLAMA3_MODEL="unsloth/Llama-3.1-8B-Instruct"
HERMES3_MODEL="NousResearch/Hermes-3-Llama-3.1-8B"
PHI4_MODEL="microsoft/Phi-4-mini-instruct"
Expand Down Expand Up @@ -134,6 +135,17 @@ if [ ! -f "$1/$QWEN3_MODEL/$TOKENIZER_FILE" ]; then
exit 1
fi

if [ -f "$1/$GRANITE42_MODEL/$TOKENIZER_FILE" ]; then
echo "Models file $1/$GRANITE42_MODEL/$TOKENIZER_FILE exists. Skipping downloading models."
else
mkdir -p "$1/$GRANITE42_MODEL"
convert_tokenizer "$GRANITE42_MODEL" --with_detokenizer -o "$1/$GRANITE42_MODEL"
fi
if [ ! -f "$1/$GRANITE42_MODEL/$TOKENIZER_FILE" ]; then
echo "[ERROR] Models file $1/$GRANITE42_MODEL/$TOKENIZER_FILE does not exist."
exit 1
fi

if [ -f "$1/$LLAMA3_MODEL/$TOKENIZER_FILE" ]; then
echo "Models file $1/$LLAMA3_MODEL/$TOKENIZER_FILE exists. Skipping downloading models."
else
Expand Down
26 changes: 25 additions & 1 deletion src/llm/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,28 @@ ovms_cc_library(
visibility = ["//visibility:public"],
)

ovms_cc_library(
name = "io_processing_granite42_content_parser",
hdrs = ["io_processing/granite42/granite42_content_parser.hpp"],
srcs = ["io_processing/granite42/granite42_content_parser.cpp"],
deps = [
":io_processing_base_output_parser",
"//third_party:genai",
],
visibility = ["//visibility:public"],
)

ovms_cc_library(
name = "io_processing_granite42_reasoning_parser",
hdrs = ["io_processing/granite42/granite42_reasoning_parser.hpp"],
srcs = ["io_processing/granite42/granite42_reasoning_parser.cpp"],
deps = [
":io_processing_base_output_parser",
"//third_party:genai",
],
visibility = ["//visibility:public"],
)

ovms_cc_library(
name = "io_processing_onyx_content_parser",
hdrs = ["io_processing/onyx/onyx_content_parser.hpp"],
Expand Down Expand Up @@ -533,6 +555,8 @@ ovms_cc_library( # TODO split further so we don't have to recompile everything w
":io_processing_onyx_content_parser",
":io_processing_default_content_parser",
":io_processing_lfm25_reasoning_parser",
":io_processing_granite42_content_parser",
":io_processing_granite42_reasoning_parser",
":io_processing_utils",
":apis_tool_schema_wrapper",
],
Expand Down Expand Up @@ -575,7 +599,7 @@ ovms_cc_library(
ovms_cc_library(
name = "genai_servables",
hdrs = ["servable.hpp",
"servable_initializer.hpp",
"servable_initializer.hpp",
"language_model/continuous_batching/servable.hpp",
"language_model/continuous_batching/llm_executor.hpp",
"language_model/continuous_batching/servable_initializer.hpp",
Expand Down
31 changes: 30 additions & 1 deletion src/llm/apis/openai_api_handler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,31 @@ namespace ovms {

constexpr size_t DEFAULT_MAX_STOP_WORDS = 16; // same as deep-seek

namespace {

// granite_thinking_parser.py checks Python identity (is False / is True), so
// accept only JSON booleans here. This runs after applyReasoningEffort() has
// merged its effective enable_thinking value into chat_template_kwargs.
bool shouldPromoteGraniteReasoningToContent(const Document& doc) {
const auto kwargsIt = doc.FindMember("chat_template_kwargs");
if (kwargsIt == doc.MemberEnd() || !kwargsIt->value.IsObject()) {
return false;
}

const auto& kwargs = kwargsIt->value;
const auto enableThinkingIt = kwargs.FindMember("enable_thinking");
const bool thinkingDisabled = enableThinkingIt != kwargs.MemberEnd() &&
enableThinkingIt->value.IsBool() &&
!enableThinkingIt->value.GetBool();
const auto forceContentIt = kwargs.FindMember("force_nonempty_content");
const bool forceNonemptyContent = forceContentIt != kwargs.MemberEnd() &&
forceContentIt->value.IsBool() &&
forceContentIt->value.GetBool();
return thinkingDisabled || forceNonemptyContent;
}

} // namespace

ov::genai::JsonContainer rapidJsonValueToJsonContainer(const rapidjson::Value& value) {
if (value.IsNull()) {
return ov::genai::JsonContainer(nullptr);
Expand Down Expand Up @@ -309,7 +334,11 @@ absl::Status OpenAIApiHandler::parseRequest(std::optional<uint32_t> maxTokensLim
void OpenAIApiHandler::initOutputParser() {
if (toolParserName.empty() && reasoningParserName.empty())
return;
outputParser = std::make_shared<OutputParser>(tokenizer, toolParserName, reasoningParserName, request.toolNameSchemaMap);
const bool granitePromoteReasoningToContent =
reasoningParserName == "granite42" &&
shouldPromoteGraniteReasoningToContent(doc);
outputParser = std::make_shared<OutputParser>(tokenizer, toolParserName, reasoningParserName,
request.toolNameSchemaMap, granitePromoteReasoningToContent);
}

absl::StatusOr<std::optional<ov::genai::JsonContainer>> OpenAIApiHandler::parseToolsToJsonContainer() {
Expand Down
6 changes: 6 additions & 0 deletions src/llm/io_processing/base_output_parser.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ class BaseOutputParser {

virtual void resetState() {}

// Gives a format-specific reasoning parser one narrow unary finalization
// hook. finalContentWasPresent describes raw bytes following the completed
// reasoning segment before any content/tool parser consumed them. It is
// per-generation internal state, never a response/API field.
virtual void finalizeUnaryDeltas(std::vector<Delta>& /*deltas*/, bool /*finalContentWasPresent*/) const {}

void setImplicitStart(bool value) { implicitStart = value; }
bool isImplicitStart() const { return implicitStart; }

Expand Down
50 changes: 50 additions & 0 deletions src/llm/io_processing/granite42/granite42_content_parser.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//*****************************************************************************
// 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 <string>
#include <utility>
#include <vector>

#include "granite42_content_parser.hpp"

namespace ovms {

std::optional<Delta> Granite42ContentParser::parseChunk(
const std::string& chunk,
const std::vector<int64_t>& /*tokens*/,
ov::genai::GenerationFinishReason /*finishReason*/) {
// No generated content segment exists. Returning nullopt keeps this distinct
// from a non-empty segment which becomes empty after leading-newline removal.
if (chunk.empty()) {
return std::nullopt;
}

if (contentStarted_) {
return ContentDelta{chunk};
}

const size_t firstNonNewline = chunk.find_first_not_of('\n');
if (firstNonNewline == std::string::npos) {
// vLLM turns this into content=None and leaves its first-content state
// unset. OutputParser suppresses this empty delta after draining it.
return ContentDelta{""};
}

contentStarted_ = true;
return ContentDelta{chunk.substr(firstNonNewline)};
}

} // namespace ovms
47 changes: 47 additions & 0 deletions src/llm/io_processing/granite42/granite42_content_parser.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//*****************************************************************************
// 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 <optional>
#include <string>
#include <vector>

#include <openvino/genai/tokenizer.hpp>

#include "../base_output_parser.hpp"

namespace ovms {

// Implements the streaming content normalization in IBM's
// granite_thinking_parser.py: strip '\n' only from the first non-empty final
// content delta. An all-newline first segment drains as an empty delta while
// retaining contentStarted_ == false, matching the vLLM streaming wrapper.
class Granite42ContentParser final : public BaseOutputParser {
bool contentStarted_ = false;

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

void resetState() override { contentStarted_ = false; }

std::optional<Delta> parseChunk(const std::string& chunk,
const std::vector<int64_t>& tokens,
ov::genai::GenerationFinishReason finishReason) override;
};

} // namespace ovms
Loading