diff --git a/src/server/rest_handler.cpp b/src/server/rest_handler.cpp index 7890a4b5..267fdb0f 100644 --- a/src/server/rest_handler.cpp +++ b/src/server/rest_handler.cpp @@ -542,6 +542,51 @@ 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); + 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; + 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; }