-
Notifications
You must be signed in to change notification settings - Fork 24
Prebuilt: carry Kimi-K3 on top of TML Inkling for the nightly PR set #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e010660
1703700
506b7e9
cf11c4c
06eec9f
ddb76c2
483579e
cf67f0d
47c5bbd
efc8bc3
a90e4ba
2b4c6d0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2123,6 +2123,189 @@ static common_chat_params common_chat_params_init_deepseek_v3_2(const common_cha | |
| return data; | ||
| } | ||
|
|
||
| // Kimi K3 - XTML-ish tagged format from the model's own template: | ||
| // open_tag(t, attrs) = <|open|>t k="v"...<|sep|> close_tag(t) = <|close|>t<|sep|> | ||
| // assistant := [think] [response] [tools] close_tag(message) <|end_of_msg|> | ||
| // Note the generation prompt already opens the think (or response) section, so | ||
| // the section opener is optional here - same situation as Kimi K2 Thinking. | ||
| static common_chat_params common_chat_params_init_kimi_k3(const common_chat_template & tmpl, | ||
| const autoparser::generation_params & inputs) { | ||
| common_chat_params data; | ||
|
|
||
| data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs); | ||
| data.generation_prompt = common_chat_template_generation_prompt_impl(tmpl, inputs); | ||
| data.format = COMMON_CHAT_FORMAT_PEG_NATIVE; | ||
| data.supports_thinking = true; | ||
|
|
||
| const std::string SEP = "<|sep|>"; | ||
| const std::string MSG_START = "<|open|>message role=\"assistant\"<|sep|>"; | ||
| const std::string THINK_START = "<|open|>think<|sep|>"; | ||
| const std::string THINK_END = "<|close|>think<|sep|>"; | ||
| const std::string RESP_START = "<|open|>response<|sep|>"; | ||
| const std::string RESP_END = "<|close|>response<|sep|>"; | ||
| const std::string TOOLS_START = "<|open|>tools<|sep|>"; | ||
| const std::string TOOLS_END = "<|close|>tools<|sep|>"; | ||
| const std::string CALL_START = "<|open|>call tool=\""; | ||
| const std::string CALL_END = "<|close|>call<|sep|>"; | ||
| const std::string ARG_START = "<|open|>argument key=\""; | ||
| const std::string ARG_END = "<|close|>argument<|sep|>"; | ||
| const std::string MSG_END = "<|close|>message<|sep|>"; | ||
| const std::string EOM_TOKEN = "<|end_of_msg|>"; | ||
|
|
||
| // The four markers are the only special tokens; tag names ("think", | ||
| // "response", "message") are ordinary tokens and must NOT be preserved, | ||
| // or ordinary prose containing those words would be mangled. | ||
| data.preserved_tokens = { | ||
| "<|open|>", | ||
| "<|close|>", | ||
| "<|sep|>", | ||
| "<|end_of_msg|>", | ||
| }; | ||
|
|
||
| data.thinking_start_tag = THINK_START; | ||
| data.thinking_end_tags = { THINK_END }; | ||
|
|
||
| // Per-role message-start delimiters. User/assistant messages carry only the | ||
| // role attribute, so their full opener (through <|sep|>) is used. System and | ||
| // tool messages continue with more attributes (type=/tool=/index=), so those | ||
| // delimiters stop after the role's closing quote - verified against the K3 | ||
| // tokenizer that the quote is always its own token and never merges with the | ||
| // following attribute text, keeping the token-level prefix match exact. | ||
| data.message_delimiters = { | ||
| { COMMON_CHAT_ROLE_ASSISTANT, "<|open|>message role=\"assistant\"<|sep|>" }, | ||
| { COMMON_CHAT_ROLE_USER, "<|open|>message role=\"user\"<|sep|>" }, | ||
| { COMMON_CHAT_ROLE_TOOL, "<|open|>message role=\"tool\"" }, | ||
| { COMMON_CHAT_ROLE_SYSTEM, "<|open|>message role=\"system\"" }, | ||
| }; | ||
|
|
||
| auto has_tools = inputs.tools.is_array() && !inputs.tools.empty(); | ||
| auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE; | ||
| auto include_grammar = has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE; | ||
|
|
||
| if (inputs.has_continuation()) { | ||
| const auto & msg = inputs.continue_msg; | ||
|
|
||
| data.generation_prompt = MSG_START + THINK_START + msg.reasoning_content; | ||
| if (inputs.continue_final_message == COMMON_CHAT_CONTINUATION_CONTENT) { | ||
| data.generation_prompt += THINK_END + RESP_START + msg.render_content(); | ||
| } | ||
|
|
||
| data.prompt += data.generation_prompt; | ||
| } | ||
|
|
||
| auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) { | ||
| auto end = p.end(); | ||
|
|
||
| auto start = p.optional(p.literal(MSG_START)); | ||
|
|
||
| // The think section is ALWAYS consumed, even when reasoning extraction | ||
| // is off: K3's generation prompt ends with open_tag('think'), so the | ||
| // opener is present on every request and would otherwise leak into | ||
| // content. With extraction off the thoughts fall into content, matching | ||
| // how the other reasoning models behave. | ||
| // Reasoning stops at its own closer, or at the response opener if the | ||
| // model skips the closer entirely (seen on short answers). | ||
| auto think_body = extract_reasoning ? p.reasoning(p.until_one_of({ THINK_END, RESP_START })) : | ||
| p.content(p.until_one_of({ THINK_END, RESP_START })); | ||
|
|
||
| auto reasoning = p.optional(p.optional(p.literal(THINK_START)) + think_body + | ||
| p.optional(p.literal(THINK_END))); | ||
|
|
||
| // Content runs to the response closer, or to whatever comes next if a | ||
| // truncated generation never emits one. | ||
| auto response = p.optional(p.literal(RESP_START)) + | ||
| p.content(p.until_one_of({ RESP_END, TOOLS_START, MSG_END })) + | ||
| p.optional(p.literal(RESP_END)); | ||
|
|
||
| // The message closer is followed by the EOG token, which reaches the | ||
| // parser as text and must be consumed or the parse is left incomplete. | ||
| auto trailer = p.optional(p.literal(MSG_END)) + p.optional(p.literal(EOM_TOKEN)); | ||
|
|
||
| if (!has_tools || inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_NONE) { | ||
| return start + reasoning + response + trailer + end; | ||
| } | ||
|
|
||
| auto tool_choices = p.choice(); | ||
| foreach_function(inputs.tools, [&](const json & tool) { | ||
| const auto & function = tool.at("function"); | ||
| std::string name = function.at("name"); | ||
| const json schema = function.contains("parameters") ? function.at("parameters") : json::object(); | ||
|
|
||
| // Arguments arrive one tag per key, with the JSON type carried in a | ||
| // type="..." attribute. We take the type from the tool schema | ||
| // instead - it is authoritative, and it tells us whether the value | ||
| // should be parsed as JSON or kept as a literal string. | ||
| auto args = p.eps(); | ||
| if (schema.contains("properties") && !schema.at("properties").empty()) { | ||
| auto arg_choices = p.choice(); | ||
| for (const auto & prop : schema.at("properties").items()) { | ||
| const std::string & key = prop.key(); | ||
|
|
||
| std::string type = "string"; | ||
| if (prop.value().is_object() && prop.value().contains("type") && | ||
| prop.value().at("type").is_string()) { | ||
| type = prop.value().at("type").get<std::string>(); | ||
| } | ||
|
|
||
| auto value = type == "string" ? p.tool_arg_string_value(p.until(ARG_END)) : | ||
| p.tool_arg_value(p.until(ARG_END)); | ||
|
Comment on lines
+2250
to
+2251
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With tools enabled, both parser branches accept arbitrary text through Useful? React with 👍 / 👎. |
||
|
|
||
| // skip the trailing type="..." attribute: anything up to <|sep|> | ||
| arg_choices |= p.rule("kimi-k3-arg-" + name + "-" + key, | ||
| p.tool_arg(p.tool_arg_open(p.literal(ARG_START)) + | ||
| p.tool_arg_name(p.literal(key)) + p.literal("\"") + | ||
| p.until(SEP) + p.literal(SEP) + value + | ||
| p.tool_arg_close(p.literal(ARG_END)))); | ||
| } | ||
| args = p.zero_or_more(arg_choices); | ||
| } | ||
|
|
||
| // skip the trailing index="N" attribute the same way | ||
| auto call = p.tool(p.tool_open(p.literal(CALL_START) + p.tool_name(p.literal(name)) + p.literal("\"") + | ||
| p.until(SEP) + p.literal(SEP)) + | ||
| p.tool_args(args) + p.tool_close(p.literal(CALL_END))); | ||
|
|
||
| tool_choices |= p.rule("kimi-k3-tool-" + name, call); | ||
| }); | ||
|
|
||
| // K3 emits every call inside one <|open|>tools<|sep|> section, then | ||
| // closes the message. The message closer is part of the trigger rule so | ||
| // that the lazy grammar still permits it once tool calls have started - | ||
| // otherwise constrained decoding rejects the model's own closing tag. | ||
| auto tools_section = | ||
| p.trigger_rule("kimi-k3-tool-call", p.literal(TOOLS_START) + p.one_or_more(tool_choices) + | ||
| p.literal(TOOLS_END) + p.optional(p.literal(MSG_END)) + | ||
| p.optional(p.literal(EOM_TOKEN))); | ||
|
|
||
| auto tools = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED ? tools_section : | ||
| p.optional(tools_section); | ||
|
|
||
| return start + reasoning + response + tools + trailer + end; | ||
| }); | ||
|
|
||
| data.parser = parser.save(); | ||
|
|
||
| if (include_grammar) { | ||
| data.grammar_lazy = inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_REQUIRED; | ||
| data.grammar = build_grammar([&](const common_grammar_builder & builder) { | ||
| foreach_function(inputs.tools, [&](const json & tool) { | ||
| const auto & function = tool.at("function"); | ||
| if (function.contains("parameters")) { | ||
| auto schema = function.at("parameters"); | ||
| builder.resolve_refs(schema); | ||
| } | ||
| }); | ||
| parser.build_grammar(builder, data.grammar_lazy); | ||
| }); | ||
|
|
||
| data.grammar_triggers = { | ||
| { COMMON_GRAMMAR_TRIGGER_TYPE_WORD, TOOLS_START }, | ||
| }; | ||
| } | ||
|
|
||
| return data; | ||
| } | ||
|
|
||
| // Cohere2 MoE (a.k.a. "North Code") parser. | ||
| // | ||
| // The assistant turn is fully marker-wrapped: | ||
|
|
@@ -3057,6 +3240,14 @@ std::optional<common_chat_params> common_chat_try_specialized_template( | |
| return common_chat_params_init_kimi_k2(tmpl, params); | ||
| } | ||
|
|
||
| // Kimi K3 - XTML-ish tagged format built from open_tag/close_tag macros. | ||
| // Detection: the <|open|>/<|close|>/<|sep|> marker trio is unique to K3. | ||
| if (src.find("<|open|>") != std::string::npos && src.find("<|close|>") != std::string::npos && | ||
| src.find("<|end_of_msg|>") != std::string::npos) { | ||
| LOG_DBG("Using specialized template: Kimi K3\n"); | ||
| return common_chat_params_init_kimi_k3(tmpl, params); | ||
| } | ||
|
|
||
| // Cohere2 MoE / North Code - marker-wrapped format with <|START_TEXT|> content and | ||
| // <|START_ACTION|> JSON tool calls. <|START_TEXT|> is unique to this template (the older | ||
| // Command-R templates use <|START_RESPONSE|>). | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a user or assistant message has a
name, the new template insertsname="..."before<|sep|>, so these full delimiters never occur in the rendered prompt. Since server message-span checkpointing uses exact token matches, named user messages are not recognized as user boundaries; use the same role-only prefix form already used for system and tool messages.Useful? React with 👍 / 👎.