Skip to content
Open
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
45 changes: 45 additions & 0 deletions src/server/rest_handler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <think> 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 = "<think>";
static const std::string think_close = "</think>";
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;
}
Expand Down