Skip to content

Commit 99f3dc3

Browse files
bernardladenthinXuan Son Nguyen
andauthored
server: honour per-request reasoning_budget_tokens in chat completions (ggml-org#23116)
* server: honour per-request reasoning_budget_tokens in chat completions The reasoning-budget block in oaicompat_chat_params_parse read only the server-level default (opt.reasoning_budget, typically -1) and the Anthropic-style alias thinking_budget_tokens, but never the canonical reasoning_budget_tokens field from the request body. Because the key was then written into llama_params before the generic body-copy loop ran, the copy loop found the key already present and silently skipped the caller-supplied value. Any per-request override (e.g. 0 to suppress thinking entirely) was therefore discarded. Fix: read reasoning_budget_tokens from the request body first, so the value that reaches the sampling layer is the one the caller intended. Add a unit test in test-chat.cpp that exercises this path via oaicompat_chat_params_parse with a Qwen3 template (which the autoparser detects as a thinking-capable model) and asserts the returned llama_params carries reasoning_budget_tokens == 0. * server: honour per-request reasoning_budget_message in chat completions The reasoning-budget block in oaicompat_chat_params_parse wrote reasoning_budget_message into llama_params straight from the server-level default (opt.reasoning_budget_message) and never read the canonical reasoning_budget_message field from the request body. Because the key was written before the generic body-copy loop ran, that loop found the key already present and silently skipped the caller-supplied value. Any per-request override of the message injected before the end tag when the budget is exhausted was therefore discarded, even though server-task.cpp already reads reasoning_budget_message from that data. This mirrors the reasoning_budget_tokens bug fixed in the previous commit. Fix: read reasoning_budget_message from the request body first, falling back to the server default, so the value that reaches the sampling layer is the one the caller intended. While here, collapse the adjacent reasoning_budget_tokens override to a single json_value() call; json_value already falls back to the default on a missing/null/wrong-type key, so the explicit body.contains() guard was redundant. No behavioral change. Add a unit test in test-chat.cpp that exercises this path via oaicompat_chat_params_parse with a Qwen3 template (which the autoparser detects as a thinking-capable model) and asserts the returned llama_params carries the per-request reasoning_budget_message rather than the server default. * cleanup --------- Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
1 parent 3455882 commit 99f3dc3

2 files changed

Lines changed: 70 additions & 2 deletions

File tree

tests/test-chat.cpp

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5911,6 +5911,71 @@ static void test_developer_role_to_system_workaround() {
59115911
}
59125912
}
59135913

5914+
static void test_reasoning_budget_tokens_per_request() {
5915+
LOG_DBG("%s\n", __func__);
5916+
// Use Qwen3 template which has <think>...</think> reasoning markers.
5917+
// The autoparser detects them and sets thinking_start/end_tag, which enables
5918+
// the reasoning-budget code path in oaicompat_chat_params_parse.
5919+
auto tmpls = read_templates("models/templates/Qwen-Qwen3-0.6B.jinja");
5920+
5921+
server_chat_params opt;
5922+
opt.tmpls = std::move(tmpls);
5923+
opt.use_jinja = true;
5924+
opt.enable_thinking = true;
5925+
opt.reasoning_budget = -1;
5926+
opt.reasoning_format = COMMON_REASONING_FORMAT_NONE;
5927+
5928+
// Body with per-request reasoning_budget_tokens=0 (suppress thinking).
5929+
json body = {
5930+
{"messages", json::array({json{{"role", "user"}, {"content", "hello"}}})},
5931+
{"reasoning_budget_tokens", 0},
5932+
};
5933+
std::vector<raw_buffer> out_files;
5934+
auto llama_params = oaicompat_chat_params_parse(body, opt, out_files);
5935+
5936+
// The per-request value must win over the server default (-1).
5937+
if (!llama_params.contains("reasoning_budget_tokens")) {
5938+
throw std::runtime_error("reasoning_budget_tokens missing from llama_params (thinking_end_tag may be empty for this template)");
5939+
}
5940+
int got = llama_params["reasoning_budget_tokens"].get<int>();
5941+
if (got != 0) {
5942+
throw std::runtime_error(std::string("Expected reasoning_budget_tokens=0, got ") + std::to_string(got));
5943+
}
5944+
}
5945+
5946+
static void test_reasoning_budget_message_per_request() {
5947+
LOG_DBG("%s\n", __func__);
5948+
// Same code path as test_reasoning_budget_tokens_per_request: the Qwen3 template's
5949+
// <think>...</think> markers enable the reasoning-budget block in oaicompat_chat_params_parse.
5950+
auto tmpls = read_templates("models/templates/Qwen-Qwen3-0.6B.jinja");
5951+
5952+
server_chat_params opt;
5953+
opt.tmpls = std::move(tmpls);
5954+
opt.use_jinja = true;
5955+
opt.enable_thinking = true;
5956+
opt.reasoning_budget = -1;
5957+
opt.reasoning_format = COMMON_REASONING_FORMAT_NONE;
5958+
opt.reasoning_budget_message = "server default";
5959+
5960+
// Body with a per-request reasoning_budget_message override.
5961+
const std::string per_request_message = "per-request message";
5962+
json body = {
5963+
{"messages", json::array({json{{"role", "user"}, {"content", "hello"}}})},
5964+
{"reasoning_budget_message", per_request_message},
5965+
};
5966+
std::vector<raw_buffer> out_files;
5967+
auto llama_params = oaicompat_chat_params_parse(body, opt, out_files);
5968+
5969+
// The per-request value must win over the server default.
5970+
if (!llama_params.contains("reasoning_budget_message")) {
5971+
throw std::runtime_error("reasoning_budget_message missing from llama_params (thinking_end_tag may be empty for this template)");
5972+
}
5973+
std::string got = llama_params["reasoning_budget_message"].get<std::string>();
5974+
if (got != per_request_message) {
5975+
throw std::runtime_error("Expected reasoning_budget_message='" + per_request_message + "', got '" + got + "'");
5976+
}
5977+
}
5978+
59145979
static void test_msg_diffs_compute() {
59155980
LOG_DBG("%s\n", __func__);
59165981
{
@@ -6068,6 +6133,8 @@ int main(int argc, char ** argv) {
60686133
test_convert_responses_to_chatcmpl();
60696134
test_developer_role_to_system_workaround();
60706135
test_template_generation_prompt();
6136+
test_reasoning_budget_tokens_per_request();
6137+
test_reasoning_budget_message_per_request();
60716138
test_template_output_peg_parsers(detailed_debug);
60726139
std::cout << "\n[chat] All tests passed!" << '\n';
60736140
}

tools/server/server-common.cpp

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,7 +1117,8 @@ json oaicompat_chat_params_parse(
11171117

11181118
// Reasoning budget: pass parameters through to sampling layer
11191119
{
1120-
int reasoning_budget = json_value(body, "thinking_budget_tokens", -1);
1120+
int reasoning_budget = json_value(body, "reasoning_budget_tokens",
1121+
json_value(body, "thinking_budget_tokens", -1));
11211122
if (reasoning_budget == -1) {
11221123
reasoning_budget = opt.reasoning_budget;
11231124
}
@@ -1126,7 +1127,7 @@ json oaicompat_chat_params_parse(
11261127
llama_params["reasoning_budget_tokens"] = reasoning_budget;
11271128
llama_params["reasoning_budget_start_tag"] = chat_params.thinking_start_tag;
11281129
llama_params["reasoning_budget_end_tag"] = chat_params.thinking_end_tag;
1129-
llama_params["reasoning_budget_message"] = opt.reasoning_budget_message;
1130+
llama_params["reasoning_budget_message"] = json_value(body, "reasoning_budget_message", opt.reasoning_budget_message);
11301131
llama_params["reasoning_control"] = json_value(body, "reasoning_control", false);
11311132
}
11321133
}

0 commit comments

Comments
 (0)