server: fix checkpoints creation (#22929)
* common : add common_chat_split_by_role * cont : fix spans to reach end of message * server: fix checkpoints creation - extract message_spans from chat templates - find the prompt token position before the latest user message - split prompt batching at that position - create a context checkpoint before the latest user input - avoid periodic mid-prompt checkpoints when that position is known - handle multimodal prompts when mapping text/template positions to server prompt tokens - add --checkpoint-min-step to control minimum spacing between checkpoints * cont : clean-up * Support autoparser detection for message barriers * server: fix message span delimiter and update docs --------- Co-authored-by: Alde Rojas <hello@alde.dev> Co-authored-by: Georgi Gerganov <ggerganov@gmail.com> Co-authored-by: Piotr Wilkin <piotr.wilkin@syndatis.com>
This commit is contained in:
co-authored by
Alde Rojas
Georgi Gerganov
Piotr Wilkin
parent
6d57c26ef8
commit
e2ef8fe42c
@@ -81,6 +81,8 @@ static void test_normalize_quotes_with_embedded_quotes(testing & t);
|
||||
// TAG_WITH_TAGGED argument parsing tests
|
||||
static void test_tagged_args_with_embedded_quotes(testing & t);
|
||||
|
||||
static void test_role_markers_all_templates(testing & t);
|
||||
|
||||
int main(int argc, char * argv[]) {
|
||||
testing t(std::cout);
|
||||
t.verbose = true;
|
||||
@@ -103,6 +105,7 @@ int main(int argc, char * argv[]) {
|
||||
t.test("standard_json_tools", test_standard_json_tools_formats);
|
||||
t.test("normalize_quotes_to_json", test_normalize_quotes_to_json);
|
||||
t.test("tagged_args_embedded_quotes", test_tagged_args_with_embedded_quotes);
|
||||
t.test("role_markers_all_templates", test_role_markers_all_templates);
|
||||
|
||||
return t.summary();
|
||||
}
|
||||
@@ -714,7 +717,7 @@ static void test_compare_variants_both_modifiers(testing & t) {
|
||||
static void test_compare_variants_template_failure(testing & t) {
|
||||
// Test with template that causes failure during application (not construction)
|
||||
// We use a valid template syntax but one that will fail during application
|
||||
common_chat_template tmpl("{{ messages[0]['nonexistent_field'] }}", "", "");
|
||||
common_chat_template tmpl("{{ messages.cahoot()[0]['nonexistent_field'] }}", "", "");
|
||||
|
||||
template_params params;
|
||||
params.messages = json::array({
|
||||
@@ -1848,6 +1851,128 @@ static json build_edit_tool() {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Role marker detection tests for all autoparser-handled templates
|
||||
//
|
||||
// Verifies that detect_user_start_marker / detect_assistant_start_marker
|
||||
// return the correct boundary text between turns for every template that
|
||||
// falls through to the differential autoparser (i.e. is not handled by a
|
||||
// dedicated specialized template in common_chat_try_specialized_template).
|
||||
//
|
||||
// Markers were deduced manually from the jinja sources in models/templates/.
|
||||
// ============================================================================
|
||||
struct role_marker_case {
|
||||
std::string template_file;
|
||||
std::string expected_user_start;
|
||||
std::string expected_assistant_start;
|
||||
};
|
||||
|
||||
static void test_role_markers_all_templates(testing & t) {
|
||||
// Each entry is { template filename, user_start, assistant_start } as
|
||||
// produced when rendering the standard chatml-like sequences. The values
|
||||
// come from reading each jinja template and tracing what text precedes
|
||||
// a user/assistant message body once the autoparser strips any reasoning
|
||||
// markers it detected first.
|
||||
const std::vector<role_marker_case> cases = {
|
||||
// ChatML family: <|im_start|>{role} ... <|im_end|>
|
||||
{ "Bielik-11B-v3.0-Instruct.jinja", "<|im_start|>user", "<|im_start|>assistant" },
|
||||
{ "HuggingFaceTB-SmolLM3-3B.jinja", "<|im_start|>user", "<|im_start|>assistant" },
|
||||
{ "MiMo-VL.jinja", "<|im_start|>user", "<|im_start|>assistant" },
|
||||
{ "NousResearch-Hermes-2-Pro-Llama-3-8B-tool_use.jinja", "<|im_start|>user", "<|im_start|>assistant" },
|
||||
{ "NousResearch-Hermes-3-Llama-3.1-8B-tool_use.jinja", "<|im_start|>user", "<|im_start|>assistant" },
|
||||
{ "NVIDIA-Nemotron-3-Nano-30B-A3B-BF16.jinja", "<|im_start|>user", "<|im_start|>assistant" },
|
||||
{ "Qwen3.5-4B.jinja", "<|im_start|>user", "<|im_start|>assistant" },
|
||||
{ "Qwen3-Coder.jinja", "<|im_start|>user", "<|im_start|>assistant" },
|
||||
{ "Qwen-Qwen2.5-7B-Instruct.jinja", "<|im_start|>user", "<|im_start|>assistant" },
|
||||
{ "Qwen-Qwen3-0.6B.jinja", "<|im_start|>user", "<|im_start|>assistant" },
|
||||
{ "Qwen-QwQ-32B.jinja", "<|im_start|>user", "<|im_start|>assistant" },
|
||||
{ "StepFun3.5-Flash.jinja", "<|im_start|>user", "<|im_start|>assistant" },
|
||||
{ "stepfun-ai-Step-3.5-Flash.jinja", "<|im_start|>user", "<|im_start|>assistant" },
|
||||
|
||||
// DeepSeek family
|
||||
{ "deepseek-ai-DeepSeek-R1-Distill-Llama-8B.jinja", "<|User|>", "<|Assistant|>" },
|
||||
{ "deepseek-ai-DeepSeek-R1-Distill-Qwen-32B.jinja", "<|User|>", "<|Assistant|>" },
|
||||
{ "deepseek-ai-DeepSeek-V3.1.jinja", "<|User|>", "<|Assistant|>" },
|
||||
{ "llama-cpp-deepseek-r1.jinja", "<|User|>", "<|Assistant|>" },
|
||||
|
||||
// Llama 3 header family
|
||||
{ "meetkai-functionary-medium-v3.1.jinja", "<|start_header_id|>user<|end_header_id|>", "<|start_header_id|>assistant<|end_header_id|>" },
|
||||
{ "meta-llama-Llama-3.1-8B-Instruct.jinja", "<|start_header_id|>user<|end_header_id|>", "<|start_header_id|>assistant<|end_header_id|>" },
|
||||
{ "meta-llama-Llama-3.2-3B-Instruct.jinja", "<|start_header_id|>user<|end_header_id|>", "<|start_header_id|>assistant<|end_header_id|>" },
|
||||
{ "meta-llama-Llama-3.3-70B-Instruct.jinja", "<|start_header_id|>user<|end_header_id|>", "<|start_header_id|>assistant<|end_header_id|>" },
|
||||
// fireworks-ai forces a trailing assistant header even without add_generation_prompt,
|
||||
// so the marker is absorbed into the common suffix and assistant_start is detected as empty.
|
||||
{ "fireworks-ai-llama-3-firefunction-v2.jinja", "<|start_header_id|>user<|end_header_id|>", "<|start_header_id|>assistant<|end_header_id|>" },
|
||||
|
||||
// Phi/GLM/Apriel-style: <|user|> / <|assistant|>
|
||||
{ "microsoft-Phi-3.5-mini-instruct.jinja", "<|user|>", "<|assistant|>" },
|
||||
{ "GLM-4.6.jinja", "<|user|>", "<|assistant|>" },
|
||||
{ "unsloth-Apriel-1.5.jinja", "<|user|>", "<|assistant|>" },
|
||||
{ "GLM-4.7-Flash.jinja", "<|user|>", "<|assistant|>" },
|
||||
|
||||
// Gemma 2: <start_of_turn>{user|model}
|
||||
{ "google-gemma-2-2b-it.jinja", "<start_of_turn>user", "<start_of_turn>model" },
|
||||
|
||||
// IBM Granite
|
||||
{ "ibm-granite-granite-3.3-2B-Instruct.jinja", "<|start_of_role|>user<|end_of_role|>", "<|start_of_role|>assistant<|end_of_role|>" },
|
||||
{ "ibm-granite-granite-4.0.jinja", "<|start_of_role|>user<|end_of_role|>", "<|start_of_role|>assistant<|end_of_role|>" },
|
||||
|
||||
// Cohere R-series
|
||||
{ "CohereForAI-c4ai-command-r7b-12-2024-tool_use.jinja",
|
||||
"<|START_OF_TURN_TOKEN|><|USER_TOKEN|>", "<|START_RESPONSE|>" },
|
||||
{ "CohereForAI-c4ai-command-r-plus-tool_use.jinja",
|
||||
"<|START_OF_TURN_TOKEN|><|USER_TOKEN|>", "<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>" },
|
||||
|
||||
// Mistral: assistant content follows [/INST] immediately, no header
|
||||
{ "mistralai-Mistral-Nemo-Instruct-2407.jinja", "[INST]", "" },
|
||||
{ "Mistral-Small-3.2-24B-Instruct-2506.jinja", "[INST]", "" },
|
||||
|
||||
// Apertus uses <|user_start|> / <|assistant_start|> but the user diff
|
||||
// carries the preceding <|assistant_end|> from the previous turn.
|
||||
{ "Apertus-8B-Instruct.jinja", "<|user_start|>", "<|assistant_start|>" },
|
||||
|
||||
// Apriel 1.6 wraps the assistant body with <|begin_assistant|>, but
|
||||
// <|begin_assistant|> is also the detected reasoning start, so the
|
||||
// assistant_start is trimmed back to the preceding newline.
|
||||
{ "Apriel-1.6-15b-Thinker-fixed.jinja", "<|begin_user|>", "<|begin_assistant|>" },
|
||||
|
||||
// ByteDance Seed-OSS: <seed:bos>{role}
|
||||
{ "ByteDance-Seed-OSS.jinja", "<seed:bos>user", "<seed:bos>assistant" },
|
||||
|
||||
// GigaChat 3.1: {role}<|role_sep|>
|
||||
{ "GigaChat3.1-10B-A1.8B.jinja", "user<|role_sep|>", "assistant<|role_sep|>" },
|
||||
|
||||
// MiniMax M2: ]~b]{user|ai}
|
||||
{ "MiniMax-M2.jinja", "]~b]user", "]~b]ai" },
|
||||
|
||||
// Nemotron Nano v2: <SPECIAL_11>{User|Assistant}; assistant marker
|
||||
// is followed by a prefilled <think> block that gets included.
|
||||
{ "NVIDIA-Nemotron-Nano-v2.jinja", "<SPECIAL_11>User", "<SPECIAL_11>Assistant" },
|
||||
|
||||
// Reka Edge: "human: " / "assistant: " — but the rendered preamble
|
||||
// depends on enable_thinking, which currently confuses the user-start
|
||||
// diff and trims the marker down. Lock in the observed value.
|
||||
{ "Reka-Edge.jinja", "human:", "assistant:" },
|
||||
|
||||
// RWKV-world chat preset: "User: " / "Assistant: "
|
||||
{ "llama-cpp-rwkv-world.jinja", "User:", "Assistant:" },
|
||||
|
||||
// Upstage Solar 100B: <|begin|>{role}... but reasoning marker absorbs
|
||||
// the "<|begin|>assistant" prefix from assistant_start.
|
||||
{ "upstage-Solar-Open-100B.jinja", "<|begin|>user<|content|>", "<|begin|>assistant" },
|
||||
};
|
||||
|
||||
for (const auto & c : cases) {
|
||||
t.test(c.template_file, [&](testing & t) {
|
||||
common_chat_template tmpl = load_template(t, "models/templates/" + c.template_file);
|
||||
struct autoparser ap;
|
||||
ap.analyze_template(tmpl);
|
||||
t.assert_equal("user_start", c.expected_user_start, ap.user_start);
|
||||
t.assert_equal("assistant_start", c.expected_assistant_start, ap.assistant_start);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Test that reproduces the Seed-OSS template issue with embedded quotes
|
||||
static void test_tagged_args_with_embedded_quotes(testing & t) {
|
||||
json tools = build_edit_tool();
|
||||
|
||||
+39
-1
@@ -1548,6 +1548,40 @@ static void test_msgs_oaicompat_json_conversion() {
|
||||
}
|
||||
}
|
||||
|
||||
static void test_split_by_role() {
|
||||
LOG_DBG("%s\n", __func__);
|
||||
|
||||
// Empty inputs
|
||||
assert_equals<size_t>(0, common_chat_split_by_role("", {}).size());
|
||||
assert_equals<size_t>(0, common_chat_split_by_role("hello", {}).size());
|
||||
assert_equals<size_t>(0, common_chat_split_by_role("", { { "user", "<|user|>" } }).size());
|
||||
|
||||
// Multi-role conversation, no leading/trailing content
|
||||
{
|
||||
const std::string prompt = "<|user|>Hi<|assistant|>Hello<|user|>Bye";
|
||||
const auto splits = common_chat_split_by_role(prompt, {
|
||||
{ "user", "<|user|>" },
|
||||
{ "assistant", "<|assistant|>" },
|
||||
});
|
||||
assert_equals<size_t>(3, splits.size());
|
||||
|
||||
assert_equals<std::string>("user", splits[0].role);
|
||||
assert_equals<size_t>(0, splits[0].pos);
|
||||
assert_equals<size_t>(10, splits[0].len);
|
||||
assert_equals<std::string>("<|user|>Hi", prompt.substr(splits[0].pos, splits[0].len));
|
||||
|
||||
assert_equals<std::string>("assistant", splits[1].role);
|
||||
assert_equals<size_t>(10, splits[1].pos);
|
||||
assert_equals<size_t>(18, splits[1].len);
|
||||
assert_equals<std::string>("<|assistant|>Hello", prompt.substr(splits[1].pos, splits[1].len));
|
||||
|
||||
assert_equals<std::string>("user", splits[2].role);
|
||||
assert_equals<size_t>(28, splits[2].pos);
|
||||
assert_equals<size_t>(11, splits[2].len);
|
||||
assert_equals<std::string>("<|user|>Bye", prompt.substr(splits[2].pos, splits[2].len));
|
||||
}
|
||||
}
|
||||
|
||||
static void test_tools_oaicompat_json_conversion() {
|
||||
LOG_DBG("%s\n", __func__);
|
||||
std::vector<common_chat_tool> tools{
|
||||
@@ -4338,16 +4372,19 @@ static void test_template_output_peg_parsers(bool detailed_debug) {
|
||||
// Format: <TOOLCALL>[{"name": "func", "arguments": {...}}]</TOOLCALL>
|
||||
{
|
||||
auto tst = peg_tester("models/templates/NVIDIA-Nemotron-Nano-v2.jinja", detailed_debug);
|
||||
tst.test("<TOOLCALL>[{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}]</TOOLCALL><SPECIAL_12>")
|
||||
tst.test("<TOOLCALL>[{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}]</TOOLCALL>")
|
||||
.tools({ special_function_tool })
|
||||
.expect(message_assist_call)
|
||||
.run();
|
||||
|
||||
// Continuation tests
|
||||
tst.test("world!\nWhat's up?")
|
||||
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
|
||||
.enable_thinking(true)
|
||||
.messages({ message_user, message_assist_prefill_content })
|
||||
.add_generation_prompt(false)
|
||||
.continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
|
||||
.expect_reasoning("I'm thinking")
|
||||
.expect_content("Hello, world!\nWhat's up?")
|
||||
.run();
|
||||
}
|
||||
@@ -5593,6 +5630,7 @@ int main(int argc, char ** argv) {
|
||||
{
|
||||
test_msg_diffs_compute();
|
||||
test_msgs_oaicompat_json_conversion();
|
||||
test_split_by_role();
|
||||
test_tools_oaicompat_json_conversion();
|
||||
test_convert_responses_to_chatcmpl();
|
||||
test_developer_role_to_system_workaround();
|
||||
|
||||
Reference in New Issue
Block a user