Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
0d96944
Add DeepSeek V3.1 thinking mode support
openhands-agent Aug 22, 2025
3912fd3
Another attempt by V3.1 non-thinking
createthis Aug 22, 2025
bac6c99
Fix test, but it's not asserting anything.
createthis Aug 22, 2025
fe86282
Ignore vim swap files in tests dir
createthis Aug 22, 2025
3d00d62
Update the test
createthis Aug 22, 2025
c50d887
Try using try_find_literal instead of regex
createthis Aug 23, 2025
3f319aa
passing test
createthis Aug 23, 2025
79f7ca3
Revert "Try using try_find_literal instead of regex"
createthis Aug 23, 2025
0d959ba
Remove unnecessary change
createthis Aug 23, 2025
6223c1c
Remove comment
createthis Aug 23, 2025
0d372f4
Add code to handle non-thinking mode.
createthis Aug 23, 2025
f0da116
Try to set message['prefix'] when thinking is enabled.
createthis Aug 23, 2025
56f7e38
This fixes reasoning, but breaks normal content. We need state in the
createthis Aug 23, 2025
f4f0ddb
DeepSeek V3.1 thinking is now the default. Disable with `--reasoning-…
createthis Aug 23, 2025
f7d2ee9
Simplify (DeepSeek V3.1 reasoning)
createthis Aug 24, 2025
7ac92ca
Fix sign inversion bug
createthis Aug 25, 2025
be0b2b8
Add some tool calling code (not working).
createthis Aug 25, 2025
776d95b
Tool calls working in non-reasoning mode.
createthis Aug 25, 2025
a32cad1
Attempt a unit test for tool call parsing.
createthis Aug 25, 2025
52d5488
Passing test
createthis Aug 25, 2025
a839be7
Add tests for both happy path and broken fenced DeepSeek V3.1 tool ca…
createthis Aug 25, 2025
6ade60e
Passing DeepSeek V3.1 tool call tests, but model is not working.
createthis Aug 25, 2025
79d4812
Revert assistance response prefill change. Not my monkeys.
createthis Aug 26, 2025
36b047c
Add fenced_thinking unit test variant. Passes, but thinking tool calling
createthis Aug 26, 2025
bdfa87f
Tests pass in reasoning mode. Also e2e tool test passes.
createthis Aug 26, 2025
0e36761
Make a copy of the parse_json_tool_calls function for deepseek-v3.1 so
createthis Aug 26, 2025
4cb47f3
Merge pull request #7 from createthis/deepseek_3_1_thinking_mode_danger
createthis Aug 26, 2025
ab22c76
Fix thinking_forced_open logic. tool calling broken. Need to add another
createthis Aug 26, 2025
4a2d17d
That's what I get for cargo culting a newline.
createthis Aug 26, 2025
b2d57ce
Add multi tool call test for deepseek v3.1 non-reasoning
createthis Aug 27, 2025
f422fe7
Merge branch 'master' into deepseek_3_1_thinking_mode
createthis Aug 27, 2025
7dc19e8
Move test, remove .gitignore change
createthis Aug 29, 2025
380146e
Place deepseek-v3.1 reasoning test directly into existing reasoning
createthis Aug 29, 2025
9056707
Address whitespace CI failure.
createthis Aug 29, 2025
a406d6a
Merge two assert_equals per CISC's request.
createthis Aug 30, 2025
ec984da
Add DeepSeek-V3.1 tests to tests/test-chat.cpp per CISC's request.
createthis Aug 30, 2025
92003d7
Merge branch 'master' into deepseek_3_1_thinking_mode
createthis Aug 30, 2025
f661dbe
Merge deepseek V3.1 and regular parse_json_tool_calls() function
createthis Aug 30, 2025
12b013f
Update tests/test-chat-parser.cpp
createthis Aug 31, 2025
800af00
Update tests/test-chat-parser.cpp
createthis Aug 31, 2025
80a7e1c
Update tests/test-chat-parser.cpp
createthis Aug 31, 2025
155852a
Update tests/test-chat-parser.cpp
createthis Aug 31, 2025
e587808
Update tests/test-chat-parser.cpp
createthis Aug 31, 2025
ac6ed1e
Update tests/test-chat-parser.cpp
createthis Aug 31, 2025
3843d94
Update tests/test-chat-parser.cpp
createthis Aug 31, 2025
6773708
Update tests/test-chat-parser.cpp
createthis Aug 31, 2025
befa31c
Update tests/test-chat-parser.cpp
createthis Aug 31, 2025
7795594
DeepSeek V3.1 fix reasoning_format none
createthis Sep 1, 2025
3dee75a
Merge branch 'master' into deepseek_3_1_thinking_mode
createthis Sep 3, 2025
a7316b0
Strip grammar down to strictly what we expect based on model card. Throw
createthis Sep 3, 2025
707cde1
Merge pull request #8 from createthis/deepseek_3_1_thinking_mode_expe…
createthis Sep 3, 2025
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
156 changes: 153 additions & 3 deletions common/chat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,7 @@ const char * common_chat_format_name(common_chat_format format) {
case COMMON_CHAT_FORMAT_FIREFUNCTION_V2: return "FireFunction v2";
case COMMON_CHAT_FORMAT_FUNCTIONARY_V3_2: return "Functionary v3.2";
case COMMON_CHAT_FORMAT_FUNCTIONARY_V3_1_LLAMA_3_1: return "Functionary v3.1 Llama 3.1";
case COMMON_CHAT_FORMAT_DEEPSEEK_V3_1: return "DeepSeek V3.1";
case COMMON_CHAT_FORMAT_HERMES_2_PRO: return "Hermes 2 Pro";
case COMMON_CHAT_FORMAT_COMMAND_R7B: return "Command R7B";
case COMMON_CHAT_FORMAT_GRANITE: return "Granite";
Expand Down Expand Up @@ -678,7 +679,8 @@ static void parse_json_tool_calls(
const common_regex & close_regex,
const std::optional<common_regex> & block_close,
bool allow_raw_python = false,
const std::function<std::string(const common_chat_msg_parser::find_regex_result & fres)> & get_function_name = nullptr) {
const std::function<std::string(const common_chat_msg_parser::find_regex_result & fres)> & get_function_name = nullptr,
bool update_cursor = false) {

auto parse_tool_calls = [&]() {
size_t from = std::string::npos;
Expand All @@ -689,6 +691,7 @@ static void parse_json_tool_calls(
: function_regex
? builder.try_find_regex(*function_regex, from)
: std::nullopt;

if (res) {
std::string name;
if (get_function_name) {
Expand All @@ -703,7 +706,12 @@ static void parse_json_tool_calls(
from = res->groups[0].begin + 1;
continue;
}
from = std::string::npos;
if (update_cursor) {
builder.move_to(res->groups[0].end);
from = builder.pos();
} else {
from = std::string::npos;
}

auto maybe_raw_python = name == "python" && allow_raw_python;
if (builder.input()[builder.pos()] == '{' || !maybe_raw_python) {
Expand All @@ -712,8 +720,16 @@ static void parse_json_tool_calls(
throw common_chat_msg_partial_exception("incomplete tool call");
}
builder.consume_regex(close_regex);
if (update_cursor) {
from = builder.pos(); // continue after this call
continue;
}
}
if (update_cursor) {
throw common_chat_msg_partial_exception("incomplete tool call");
} else {
continue;
}
continue;
}
if (maybe_raw_python) {
auto arguments = wrap_code_as_arguments(builder, builder.consume_rest());
Expand All @@ -727,13 +743,18 @@ static void parse_json_tool_calls(
break;
}
if (block_close) {
if (update_cursor) {
// ensure we’re right after the last call header/close
if (from != std::string::npos) builder.move_to(from);
}
builder.consume_regex(*block_close);
}
builder.consume_spaces();
builder.add_content(builder.consume_rest());
};
if (block_open) {
if (auto res = builder.try_find_regex(*block_open)) {
if (update_cursor) builder.move_to(res->groups[0].end); // consume opener
parse_tool_calls();
} else {
builder.add_content(builder.consume_rest());
Expand Down Expand Up @@ -1313,6 +1334,71 @@ static common_chat_params common_chat_params_init_deepseek_r1(const common_chat_
}
return data;
}

static common_chat_params common_chat_params_init_deepseek_v3_1(const common_chat_template & tmpl, const struct templates_params & inputs) {
common_chat_params data;

// Pass thinking context for DeepSeek V3.1 template
json additional_context = {
{"thinking", inputs.enable_thinking},
};

auto prompt = apply(tmpl, inputs,
/* messages_override= */ inputs.messages,
/* tools_override= */ std::nullopt,
additional_context);
data.prompt = prompt;
data.format = COMMON_CHAT_FORMAT_DEEPSEEK_V3_1;
if (string_ends_with(data.prompt, "<think>")) {
if (!inputs.enable_thinking) {
data.prompt += "</think>";
} else {
data.thinking_forced_open = true;
}
}
if (inputs.tools.is_array() && !inputs.tools.empty()) {
data.grammar_lazy = inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_REQUIRED && inputs.json_schema.is_null();
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
std::vector<std::string> tool_rules;
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
std::string name = function.at("name");
auto parameters = function.at("parameters");
builder.resolve_refs(parameters);
tool_rules.push_back(builder.add_rule(name + "-call",
"( \"<|tool▁call▁begin|>\" )? \"" + name + "<|tool▁sep|>"
"\" " + builder.add_schema(name + "-args", parameters) + " "
"\"<|tool▁call▁end|>\""));
});
// Distill Qwen 7B & 32B models seem confused re/ syntax of their tool call opening tag,
// so we accept common variants (then it's all constrained)
builder.add_rule("root",
std::string(data.thinking_forced_open ? "( \"</think>\" space )? " : "") +
"( \"<|tool▁calls▁begin|>\" | \"<|tool_calls_begin|>\" | \"<|tool calls begin|>\" | \"<|tool\\\\_calls\\\\_begin|>\" | \"<|tool▁calls|>\" ) "
"(" + string_join(tool_rules, " | ") + ")" + (inputs.parallel_tool_calls ? "*" : "") + " "
"\"<|tool▁calls▁end|>\""
" space");
data.grammar_triggers.push_back({
COMMON_GRAMMAR_TRIGGER_TYPE_PATTERN_FULL,
// If thinking_forced_open, then we capture the </think> tag in the grammar,
// (important for required tool choice) and in the trigger's first capture (decides what is sent to the grammar)
std::string(data.thinking_forced_open ? "[\\s\\S]*?(</think>\\s*)" : "(?:<think>[\\s\\S]*?</think>\\s*)?") +
"(<|tool▁calls▁begin|>|<|tool_calls_begin|>|<|tool calls begin|>|<|tool\\\\_calls\\\\_begin|>|<|tool▁calls|>)[\\s\\S]*"
});
data.preserved_tokens = {
"<think>",
"</think>",
"<|tool▁calls▁begin|>",
"<|tool▁call▁begin|>",
"<|tool▁sep|>",
"<|tool▁call▁end|>",
"<|tool▁calls▁end|>",
};
});
}
return data;
}

static void common_chat_parse_deepseek_r1(common_chat_msg_parser & builder) {
builder.try_parse_reasoning("<think>", "</think>");
if (!builder.syntax().parse_tool_calls) {
Expand All @@ -1334,6 +1420,61 @@ static void common_chat_parse_deepseek_r1(common_chat_msg_parser & builder) {
tool_calls_end);
}

static void common_chat_parse_deepseek_v3_1_content(common_chat_msg_parser & builder) {
static const common_regex function_regex("(?:<|tool▁call▁begin|>)?([^\\n<]+)(?:<|tool▁sep|>)");

static const common_regex close_regex("(?:[\\s]*)?<|tool▁call▁end|>");
static const common_regex tool_calls_begin("(?:<|tool▁calls▁begin|>|<|tool_calls_begin|>|<|tool calls begin|>|<|tool\\\\_calls\\\\_begin|>|<|tool▁calls|>)");
static const common_regex tool_calls_end("<|tool▁calls▁end|>");

if (!builder.syntax().parse_tool_calls) {
LOG_DBG("%s: not parse_tool_calls\n", __func__);
builder.add_content(builder.consume_rest());
return;
}

LOG_DBG("%s: parse_tool_calls\n", __func__);

parse_json_tool_calls(
builder,
/* block_open= */ tool_calls_begin,
/* function_regex_start_only= */ std::nullopt,
function_regex,
close_regex,
tool_calls_end,
false,
nullptr,
true);
}

static void common_chat_parse_deepseek_v3_1(common_chat_msg_parser & builder) {
// DeepSeek V3.1 outputs reasoning content between "<think>" and "</think>" tags, followed by regular content
// First try to parse using the standard reasoning parsing method
LOG_DBG("%s: thinking_forced_open: %s\n", __func__, std::to_string(builder.syntax().thinking_forced_open).c_str());

if (builder.try_parse_reasoning("<think>", "</think>")) {
// If reasoning was parsed successfully, the remaining content is regular content
LOG_DBG("%s: parsed reasoning, adding content\n", __func__);
// </think><|tool▁calls▁begin|><|tool▁call▁begin|>function<|tool▁sep|>NAME\n```json\nJSON\n```<|tool▁call▁end|><|tool▁calls▁end|>
common_chat_parse_deepseek_v3_1_content(builder);
} else {
if (builder.syntax().reasoning_format == COMMON_REASONING_FORMAT_NONE) {
LOG_DBG("%s: reasoning_format none, adding content\n", __func__);
common_chat_parse_deepseek_v3_1_content(builder);
return;
}
// If no reasoning tags found, check if we should treat everything as reasoning
if (builder.syntax().thinking_forced_open) {
// If thinking is forced open but no tags found, treat everything as reasoning
LOG_DBG("%s: thinking_forced_open, adding reasoning content\n", __func__);
builder.add_reasoning_content(builder.consume_rest());
} else {
// <|tool▁call▁begin|>NAME<|tool▁sep|>JSON<|tool▁call▁end|>
common_chat_parse_deepseek_v3_1_content(builder);
}
}
}

static common_chat_params common_chat_params_init_gpt_oss(const common_chat_template & tmpl, const struct templates_params & inputs) {
common_chat_params data;
auto prompt = apply(tmpl, inputs);
Expand Down Expand Up @@ -2263,6 +2404,12 @@ static common_chat_params common_chat_templates_apply_jinja(
}
}

// DeepSeek V3.1: detect based on specific patterns in the template
if (src.find("message['prefix'] is defined and message['prefix'] and thinking") != std::string::npos &&
params.json_schema.is_null()) {
return common_chat_params_init_deepseek_v3_1(tmpl, params);
}

// DeepSeek R1: use handler in all cases except json schema (thinking / tools).
if (src.find("<|tool▁calls▁begin|>") != std::string::npos && params.json_schema.is_null()) {
return common_chat_params_init_deepseek_r1(tmpl, params);
Expand Down Expand Up @@ -2430,6 +2577,9 @@ static void common_chat_parse(common_chat_msg_parser & builder) {
case COMMON_CHAT_FORMAT_DEEPSEEK_R1:
common_chat_parse_deepseek_r1(builder);
break;
case COMMON_CHAT_FORMAT_DEEPSEEK_V3_1:
common_chat_parse_deepseek_v3_1(builder);
break;
case COMMON_CHAT_FORMAT_FUNCTIONARY_V3_2:
common_chat_parse_functionary_v3_2(builder);
break;
Expand Down
1 change: 1 addition & 0 deletions common/chat.h
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ enum common_chat_format {
COMMON_CHAT_FORMAT_FIREFUNCTION_V2,
COMMON_CHAT_FORMAT_FUNCTIONARY_V3_2,
COMMON_CHAT_FORMAT_FUNCTIONARY_V3_1_LLAMA_3_1,
COMMON_CHAT_FORMAT_DEEPSEEK_V3_1,
COMMON_CHAT_FORMAT_HERMES_2_PRO,
COMMON_CHAT_FORMAT_COMMAND_R7B,
COMMON_CHAT_FORMAT_GRANITE,
Expand Down
1 change: 1 addition & 0 deletions models/templates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ These templates can be updated with the following commands:
./scripts/get_chat_template.py Qwen/QwQ-32B > models/templates/Qwen-QwQ-32B.jinja
./scripts/get_chat_template.py Qwen/Qwen3-0.6B > models/templates/Qwen-Qwen3-0.6B.jinja
./scripts/get_chat_template.py zai-org/GLM-4.5 > models/templates/zai-org-GLM-4.5.jinja
./scripts/get_chat_template.py deepseek-ai/DeepSeek-V3.1 > models/templates/deepseek-ai-DeepSeek-V3.1.jinja
```
3 changes: 3 additions & 0 deletions models/templates/deepseek-ai-DeepSeek-V3.1.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{% if not add_generation_prompt is defined %}{% set add_generation_prompt = false %}{% endif %}{% if not thinking is defined %}{% set thinking = false %}{% endif %}{% set ns = namespace(is_first=false, is_tool=false, system_prompt='', is_first_sp=true, is_last_user=false) %}{%- for message in messages %}{%- if message['role'] == 'system' %}{%- if ns.is_first_sp %}{% set ns.system_prompt = ns.system_prompt + message['content'] %}{% set ns.is_first_sp = false %}{%- else %}{% set ns.system_prompt = ns.system_prompt + '
' + message['content'] %}{%- endif %}{%- endif %}{%- endfor %}{{ bos_token }}{{ ns.system_prompt }}{%- for message in messages %}{%- if message['role'] == 'user' %}{%- set ns.is_tool = false -%}{%- set ns.is_first = false -%}{%- set ns.is_last_user = true -%}{{'<|User|>' + message['content']}}{%- endif %}{%- if message['role'] == 'assistant' and message['tool_calls'] is defined and message['tool_calls'] is not none %}{%- if ns.is_last_user %}{{'<|Assistant|></think>'}}{%- endif %}{%- set ns.is_last_user = false -%}{%- set ns.is_first = false %}{%- set ns.is_tool = false -%}{%- for tool in message['tool_calls'] %}{%- if not ns.is_first %}{%- if message['content'] is none %}{{'<|tool▁calls▁begin|><|tool▁call▁begin|>'+ tool['function']['name'] + '<|tool▁sep|>' + tool['function']['arguments'] + '<|tool▁call▁end|>'}}{%- else %}{{message['content'] + '<|tool▁calls▁begin|><|tool▁call▁begin|>' + tool['function']['name'] + '<|tool▁sep|>' + tool['function']['arguments'] + '<|tool▁call▁end|>'}}{%- endif %}{%- set ns.is_first = true -%}{%- else %}{{'<|tool▁call▁begin|>'+ tool['function']['name'] + '<|tool▁sep|>' + tool['function']['arguments'] + '<|tool▁call▁end|>'}}{%- endif %}{%- endfor %}{{'<|tool▁calls▁end|><|end▁of▁sentence|>'}}{%- endif %}{%- if message['role'] == 'assistant' and (message['tool_calls'] is not defined or message['tool_calls'] is none) %}{%- if ns.is_last_user %}{{'<|Assistant|>'}}{%- if message['prefix'] is defined and message['prefix'] and thinking %}{{'<think>'}} {%- else %}{{'</think>'}}{%- endif %}{%- endif %}{%- set ns.is_last_user = false -%}{%- if ns.is_tool %}{{message['content'] + '<|end▁of▁sentence|>'}}{%- set ns.is_tool = false -%}{%- else %}{%- set content = message['content'] -%}{%- if '</think>' in content %}{%- set content = content.split('</think>', 1)[1] -%}{%- endif %}{{content + '<|end▁of▁sentence|>'}}{%- endif %}{%- endif %}{%- if message['role'] == 'tool' %}{%- set ns.is_last_user = false -%}{%- set ns.is_tool = true -%}{{'<|tool▁output▁begin|>' + message['content'] + '<|tool▁output▁end|>'}}{%- endif %}{%- endfor -%}{%- if add_generation_prompt and ns.is_last_user and not ns.is_tool %}{{'<|Assistant|>'}}{%- if not thinking %}{{'</think>'}}{%- else %}{{'<think>'}}{%- endif %}{% endif %}
Loading
Loading