server: expose the reasoning effort levels of the chat template (#31271)

* server: expose the reasoning effort levels of the chat template

The capability probe renders a user turn for each candidate level
(minimal, low, medium, high, xhigh, max) and for an unknown level. A
level is honored when it renders without raising and differently from
the unknown level, which shows the template fallback. Levels sharing a
render are aliases and the highest one is kept.

The caps serialize to JSON, and chat_template_caps in /props reports
the list as reasoning_effort_levels, from lowest to highest.

* jinja: keep the caps free of the json dependency

The caps keep reporting their flags as a map, and the effort levels
stay a plain member. common_chat_templates_get_caps() builds the JSON
the server reports from both, so jinja depends on json only for the
probe inputs.
This commit is contained in:
Pascal authored and GitHub committed 2026-10-27 22:39:37 +01:00
1 parent a919179942
commit 1b4554eff1
9 files changed
+90 -13

No files matched your search

+7 -6
View File
@@ -1940,12 +1940,13 @@ const common_chat_msg & common_chat_session::finish(const common_chat_input & ch
return result;
}
std::map<std::string, bool> common_chat_templates_get_caps(const common_chat_templates * chat_templates) {
common_json common_chat_templates_get_caps(const common_chat_templates * chat_templates) {
GGML_ASSERT(chat_templates != nullptr);
GGML_ASSERT(chat_templates->template_default != nullptr);
if (chat_templates->template_tool_use != nullptr) {
// take the more expressive template when available
return chat_templates->template_tool_use->caps.to_map();
}
return chat_templates->template_default->caps.to_map();
// take the more expressive template when available
const auto & caps = chat_templates->template_tool_use != nullptr ? chat_templates->template_tool_use->caps
: chat_templates->template_default->caps;
common_json result = caps.to_map();
result["reasoning_effort_levels"] = caps.reasoning_effort_levels;
return result;
}
+1 -1
View File
@@ -472,7 +472,7 @@ common_json common_chat_tools_to_json_oaicompat(const std::vector<common_chat_to
common_json common_chat_tool_parameters(const common_json & function);
// get template caps, useful for reporting to server /props endpoint
std::map<std::string, bool> common_chat_templates_get_caps(const common_chat_templates * chat_templates);
common_json common_chat_templates_get_caps(const common_chat_templates * chat_templates);
common_chat_prompt common_chat_template_direct_apply(
const common_chat_template & tmpl,
+54
View File
@@ -6,7 +6,9 @@
// we can remove it in the future when we figure out a better way to define inputs using jinja::value
#include "json.h"
#include <algorithm>
#include <functional>
#include <optional>
#include <sstream>
#define FILENAME "jinja-caps"
@@ -588,6 +590,58 @@ caps caps_get(jinja::program & prog) {
}
);
if (result.supports_reasoning_effort) {
JJ_DEBUG("%s\n", ">>> Running capability check: reasoning effort levels");
static const std::vector<std::string> candidates = { "minimal", "low", "medium", "high", "xhigh", "max" };
// renders a single user turn with the given effort, nothing when the template raises
const auto render = [&](const std::string & effort) {
std::optional<std::string> rendered;
caps_try_execute(
prog,
[&]() {
// messages
return json::array({
{
{"role", "user"},
{"content", "User message"}
},
});
},
[&](context & ctx) {
ctx.set_val("enable_thinking", mk_val<value_bool>(true));
caps_apply_reasoning_effort(ctx, effort);
},
nullptr, // tools_fn
[&](context &, bool success, value &, value &, const std::string & output) {
if (success) {
rendered = output;
}
}
);
return rendered;
};
// an unknown level shows the render of the template fallback, a level that renders the same is not honored
// levels sharing a render are aliases and the highest one is kept
const auto unknown = render("REASONING_EFFORT_UNKNOWN");
std::vector<std::string> renders;
for (const auto & level : candidates) {
const auto rendered = render(level);
if (!rendered || rendered == unknown) {
continue;
}
const auto it = std::find(renders.begin(), renders.end(), *rendered);
if (it != renders.end()) {
result.reasoning_effort_levels[it - renders.begin()] = level;
} else {
renders.push_back(*rendered);
result.reasoning_effort_levels.push_back(level);
}
}
}
JJ_DEBUG("%s\n", result.to_string().c_str());
return result;
+4
View File
@@ -4,6 +4,7 @@
#include <string>
#include <map>
#include <vector>
namespace jinja {
@@ -19,6 +20,9 @@ struct caps {
// supports reasoning effort levels
bool supports_reasoning_effort = false;
// reasoning effort levels the template renders distinctly, from lowest to highest
std::vector<std::string> reasoning_effort_levels;
// one of the 2 content capabilities must be true
bool supports_string_content = true;
bool supports_typed_content = false;
+20 -2
View File
@@ -7738,7 +7738,7 @@ static void test_template_generation_prompt() {
preserve_reasoning_inputs.chat_template_kwargs["preserve_reasoning"] = "true";
auto preserve_reasoning_params = common_chat_templates_apply(tmpls.get(), preserve_reasoning_inputs);
assert_contains(preserve_reasoning_params.prompt.str(), "<|Assistant|><think>Previous reasoning</think>Previous answer");
assert_equals(true, common_chat_templates_get_caps(tmpls.get()).at("supports_preserve_reasoning"));
assert_equals(true, common_chat_templates_get_caps(tmpls.get()).at("supports_preserve_reasoning").get<bool>());
auto no_preserve_reasoning_inputs = default_history_inputs;
no_preserve_reasoning_inputs.chat_template_kwargs["preserve_reasoning"] = "false";
@@ -8311,7 +8311,7 @@ static void test_reasoning_effort_caps() {
auto assert_supports_effort = [](const std::string & path, bool expected) {
auto tmpls = read_templates(path);
assert_equals(expected, common_chat_templates_get_caps(tmpls.get()).at("supports_reasoning_effort"));
assert_equals(expected, common_chat_templates_get_caps(tmpls.get()).at("supports_reasoning_effort").get<bool>());
};
assert_supports_effort("models/templates/deepseek-ai-DeepSeek-V4.jinja", true);
@@ -8322,6 +8322,24 @@ static void test_reasoning_effort_caps() {
assert_supports_effort("models/templates/Cohere2MoE.jinja", true);
assert_supports_effort("models/templates/meta-llama-Llama-3.1-8B-Instruct.jinja", false);
assert_supports_effort("models/templates/Qwen-Qwen3-0.6B.jinja", false);
auto assert_effort_levels = [](const std::string & path, const std::vector<std::string> & expected) {
auto tmpls = read_templates(path);
assert_equals(json(expected).dump(), common_chat_templates_get_caps(tmpls.get()).at("reasoning_effort_levels").dump());
};
const std::vector<std::string> all_levels = { "minimal", "low", "medium", "high", "xhigh", "max" };
assert_effort_levels("models/templates/deepseek-ai-DeepSeek-V4.jinja", { "max" });
assert_effort_levels("models/templates/deepseek-ai-DeepSeek-V4-Flash-0731.jinja", { "high", "max" });
assert_effort_levels("models/templates/IFM-K2-Horizon.jinja", { "low", "medium", "high" });
assert_effort_levels("models/templates/tencent-Hy3.jinja", { "low", "high" });
assert_effort_levels("models/templates/muse-spark.jinja", { "minimal", "low", "medium", "high" });
assert_effort_levels("models/templates/upstage-Solar-Open-100B.jinja", { "low" });
assert_effort_levels("models/templates/muse-glimmer.jinja", all_levels);
assert_effort_levels("models/templates/openai-gpt-oss-120b.jinja", all_levels);
assert_effort_levels("models/templates/Cohere2MoE.jinja", {});
assert_effort_levels("models/templates/Qwen-Qwen3-0.6B.jinja", {});
}
static void test_msg_diffs_compute() {
+1 -1
View File
@@ -1019,7 +1019,7 @@ By default, it is read-only. To make POST request to change global properties, y
- `total_slots` - the total number of slots for process requests (defined by `--parallel` option)
- `model_path` - the path to model file (same with `-m` argument)
- `chat_template` - the model's original Jinja2 prompt template
- `chat_template_caps` - capabilities of the chat template (see `common/jinja/caps.h` for more info)
- `chat_template_caps` - capabilities of the chat template (see `common/jinja/caps.h` for more info), `reasoning_effort_levels` lists the `reasoning_effort` values the template renders distinctly, from lowest to highest
- `modalities` - the list of supported modalities
- `is_sleeping` - sleeping status, see [Sleeping on idle](#sleeping-on-idle)
+1 -1
View File
@@ -1442,7 +1442,7 @@ json oaicompat_chat_params_parse(
inputs.json_schema = json_schema.is_null() ? "" : json_schema.dump();
inputs.grammar = grammar;
inputs.use_jinja = opt.use_jinja;
inputs.parallel_tool_calls = json_value(body, "parallel_tool_calls", caps["supports_parallel_tool_calls"]);
inputs.parallel_tool_calls = json_value(body, "parallel_tool_calls", caps.at("supports_parallel_tool_calls").get<bool>());
inputs.add_generation_prompt = json_value(body, "add_generation_prompt", true);
inputs.continue_final_message = body.contains("continue_final_message") ?
common_chat_continuation_parse(body.at("continue_final_message")) :
+1 -1
View File
@@ -1721,7 +1721,7 @@ private:
{
auto caps = common_chat_templates_get_caps(chat_params.tmpls.get());
auto it = params_base.default_template_kwargs.find("preserve_reasoning");
bool supported = caps.at("supports_preserve_reasoning");
bool supported = caps.at("supports_preserve_reasoning").get<bool>();
bool specified = params_base.preserve_reasoning_specified;
// note: the kwarg is enabled by default if not specified explicitly, so check the value
bool enabled = it != params_base.default_template_kwargs.end() && it->second == "true";
+1 -1
View File
@@ -33,7 +33,7 @@ struct server_context_meta {
// chat params
server_chat_params & chat_params;
std::map<std::string, bool> chat_template_caps;
json chat_template_caps;
// tokens
std::string bos_token_str;