From 3f56eb47ef285601973ddfbf2254a22ba58e30cd Mon Sep 17 00:00:00 2001 From: Javinator9889 Date: Thu, 17 Sep 2026 18:03:08 +0200 Subject: [PATCH 1/2] fix: split the think block in the non-streaming response too Only some models separate reasoning in parse_nstream_content (gpt-oss, nanbeige); the rest leave the block inline in content. The split otherwise happens only in the streaming parser, so any buffered response -- which is what tool_choice=required and named produce -- reached the client as literal tags instead of reasoning_content. Observed on qwen3.5:9b through OpenCode, which renders the tags rather than a reasoning block. Split it once in build_nstream_response so every model reports reasoning the same way. No-op when the model already filled reasoning_content. Handles the generation prompt having opened the block, where only the closing tag appears in the generated text. Measured on qwen3.5:9b: tool_choice=required goes from a literal in content to reasoning_content with clean content; plain non-streaming likewise. Offline ctest passes, tool-policy suite unchanged at 7/8 (the one failure is the pre-existing local include_usage deviation), usage contract passes. --- src/server/rest_handler.cpp | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/server/rest_handler.cpp b/src/server/rest_handler.cpp index 7890a4b5..ed23a0e9 100644 --- a/src/server/rest_handler.cpp +++ b/src/server/rest_handler.cpp @@ -542,6 +542,36 @@ json RestHandler::build_nstream_response(std::string response_text, chat_meta_in bool is_reasoning = !result.reasoning_content.empty(); bool is_tool_call = !result.tool_calls_list.empty() || !result.tool_name.empty(); + // Only some models split the think block in parse_nstream_content (gpt-oss, + // nanbeige, ...). The rest leave it inline, and it then reaches the client + // as literal tags in content instead of reasoning_content -- which + // is what a buffered stream shows, since the split otherwise only happens + // in the streaming parser. Do it here so every model reports reasoning the + // same way. No-op when the model already populated reasoning_content. + if (!is_reasoning) { + static const std::string think_open = ""; + static const std::string think_close = ""; + const size_t close_pos = result.content.find(think_close); + if (close_pos != std::string::npos) { + // The generation prompt may already have opened the block, in which + // case only the closing tag appears in the generated text. + size_t begin = 0; + const size_t open_pos = result.content.find(think_open); + if (open_pos != std::string::npos && open_pos < close_pos) { + begin = open_pos + think_open.length(); + } + auto trim = [](std::string v) { + const char* ws = " \t\r\n"; + const size_t b = v.find_first_not_of(ws); + if (b == std::string::npos) return std::string(); + return v.substr(b, v.find_last_not_of(ws) - b + 1); + }; + result.reasoning_content = trim(result.content.substr(begin, close_pos - begin)); + result.content = trim(result.content.substr(close_pos + think_close.length())); + is_reasoning = !result.reasoning_content.empty(); + } + } + if (is_reasoning) { message["reasoning_content"] = result.reasoning_content; } From c64547d03a82e908bff2b85b2edf650474d726b1 Mon Sep 17 00:00:00 2001 From: Javinator9889 Date: Fri, 18 Sep 2026 10:57:03 +0200 Subject: [PATCH 2/2] fix: report a truncated thought as reasoning, not as the answer When generation hits the token limit inside the think block the closing never arrives, so the split could not fire and the partial thought was delivered as content. The client then shows "Thinking Process: 1. **Analyze the Request:** The user wants me to ..." as the reply, which reads like the prompt being echoed back. Measured on qwen3.5:9b at 81k context over 30 turns: 14 turns produced a wrong answer and every one of them had finish_reason "length" with empty reasoning_content. Not one turn that finished normally was wrong. The same conversation at 97k in a single turn answers perfectly, so this is not context-length degradation -- it is truncation surfacing as content. Route the unclosed remainder to reasoning_content and leave content empty, since there is no answer yet. The streaming path already handles this: its parser stays in REASONING mode when the block never closes. --- src/server/rest_handler.cpp | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/server/rest_handler.cpp b/src/server/rest_handler.cpp index ed23a0e9..267fdb0f 100644 --- a/src/server/rest_handler.cpp +++ b/src/server/rest_handler.cpp @@ -552,7 +552,22 @@ json RestHandler::build_nstream_response(std::string response_text, chat_meta_in static const std::string think_open = ""; static const std::string think_close = ""; const size_t close_pos = result.content.find(think_close); - if (close_pos != std::string::npos) { + const size_t open_only = result.content.find(think_open); + if (close_pos == std::string::npos && open_only != std::string::npos) { + // Generation was cut off inside the think block (finish_reason + // "length"), so the closing tag never arrived. Without this the + // partial thought is delivered as the answer, and the client shows + // "Thinking Process: 1. Analyze the Request: The user wants ...", + // which reads like the prompt being echoed back. Report it as + // reasoning and leave the answer empty, because there isn't one. + size_t b = result.content.find_first_not_of( + " \t\r\n", open_only + think_open.length()); + result.reasoning_content = (b == std::string::npos) + ? std::string() : result.content.substr(b); + result.content.clear(); + is_reasoning = !result.reasoning_content.empty(); + } + else if (close_pos != std::string::npos) { // The generation prompt may already have opened the block, in which // case only the closing tag appears in the generated text. size_t begin = 0;