@@ -2325,6 +2325,179 @@ static common_chat_params common_chat_params_init_deepseek_v3_2(const common_cha
23252325 return data;
23262326}
23272327
2328+ // Kimi K3 - XTML tagged format, built by open_tag/close_tag macros:
2329+ // open_tag(t, attrs) = <|open|>t k="v"...<|sep|> close_tag(t) = <|close|>t<|sep|>
2330+ // assistant := [think] [response] [tools] close_tag(message) <|end_of_msg|>
2331+ // the generation prompt already opens the think (or response) section, so the
2332+ // section opener is optional here - same as Kimi K2 Thinking
2333+ static common_chat_params common_chat_params_init_kimi_k3 (const common_chat_template & tmpl,
2334+ const autoparser::generation_params & inputs) {
2335+ common_chat_params data;
2336+
2337+ data.prompt = common_chat_template_direct_apply_impl (tmpl, inputs);
2338+ data.generation_prompt = common_chat_template_generation_prompt_impl (tmpl, inputs);
2339+ data.format = COMMON_CHAT_FORMAT_PEG_NATIVE ;
2340+ data.supports_thinking = true ;
2341+
2342+ const std::string SEP = " <|sep|>" ;
2343+ const std::string MSG_START = " <|open|>message role=\" assistant\" <|sep|>" ;
2344+ const std::string THINK_START = " <|open|>think<|sep|>" ;
2345+ const std::string THINK_END = " <|close|>think<|sep|>" ;
2346+ const std::string RESP_START = " <|open|>response<|sep|>" ;
2347+ const std::string RESP_END = " <|close|>response<|sep|>" ;
2348+ const std::string TOOLS_START = " <|open|>tools<|sep|>" ;
2349+ const std::string TOOLS_END = " <|close|>tools<|sep|>" ;
2350+ const std::string CALL_START = " <|open|>call tool=\" " ;
2351+ const std::string CALL_END = " <|close|>call<|sep|>" ;
2352+ const std::string ARG_START = " <|open|>argument key=\" " ;
2353+ const std::string ARG_END = " <|close|>argument<|sep|>" ;
2354+ const std::string MSG_END = " <|close|>message<|sep|>" ;
2355+ const std::string EOM_TOKEN = " <|end_of_msg|>" ;
2356+
2357+ // only the markers are special tokens. tag names ("think", "response", ...) are
2358+ // normal tokens and must not be preserved, or prose with those words is broken
2359+ data.preserved_tokens = {
2360+ " <|open|>" ,
2361+ " <|close|>" ,
2362+ " <|sep|>" ,
2363+ " <|end_of_msg|>" ,
2364+ };
2365+
2366+ data.thinking_start_tag = THINK_START ;
2367+ data.thinking_end_tags = { THINK_END };
2368+
2369+ // per-role message-start delimiters. user/assistant messages only have the role
2370+ // attribute, so the full opener is used. system and tool messages have more
2371+ // attributes, so those delimiters stop after the closing quote of the role
2372+ data.message_delimiters = {
2373+ { COMMON_CHAT_ROLE_ASSISTANT , " <|open|>message role=\" assistant\" <|sep|>" },
2374+ { COMMON_CHAT_ROLE_USER , " <|open|>message role=\" user\" <|sep|>" },
2375+ { COMMON_CHAT_ROLE_TOOL , " <|open|>message role=\" tool\" " },
2376+ { COMMON_CHAT_ROLE_SYSTEM , " <|open|>message role=\" system\" " },
2377+ };
2378+
2379+ auto has_tools = inputs.tools .is_array () && !inputs.tools .empty ();
2380+ auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE ;
2381+ auto include_grammar = has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE ;
2382+
2383+ if (inputs.has_continuation ()) {
2384+ const auto & msg = inputs.continue_msg ;
2385+
2386+ data.generation_prompt = MSG_START + THINK_START + msg.reasoning_content ;
2387+ if (inputs.continue_final_message == COMMON_CHAT_CONTINUATION_CONTENT ) {
2388+ data.generation_prompt += THINK_END + RESP_START + msg.render_content ();
2389+ }
2390+
2391+ data.prompt += data.generation_prompt ;
2392+ }
2393+
2394+ auto parser = build_chat_peg_parser ([&](common_chat_peg_builder & p) {
2395+ auto end = p.end ();
2396+
2397+ auto start = p.optional (p.literal (MSG_START ));
2398+
2399+ // the think section is always consumed, even with reasoning extraction off:
2400+ // the generation prompt ends with open_tag('think'), so it is always present.
2401+ // reasoning stops at its own closer, or at the response opener if the model
2402+ // skips the closer
2403+ auto think_body = extract_reasoning ? p.reasoning (p.until_one_of ({ THINK_END , RESP_START })) :
2404+ p.content (p.until_one_of ({ THINK_END , RESP_START }));
2405+
2406+ auto reasoning = p.optional (p.optional (p.literal (THINK_START )) + think_body +
2407+ p.optional (p.literal (THINK_END )));
2408+
2409+ // content runs to the response closer, or to the next section if truncated
2410+ auto response = p.optional (p.literal (RESP_START )) +
2411+ p.content (p.until_one_of ({ RESP_END , TOOLS_START , MSG_END })) +
2412+ p.optional (p.literal (RESP_END ));
2413+
2414+ // the EOG token after the message closer reaches the parser as text,
2415+ // so it must be consumed or the parse stays incomplete
2416+ auto trailer = p.optional (p.literal (MSG_END )) + p.optional (p.literal (EOM_TOKEN ));
2417+
2418+ if (!has_tools || inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_NONE ) {
2419+ return start + reasoning + response + trailer + end;
2420+ }
2421+
2422+ auto tool_choices = p.choice ();
2423+ foreach_function (inputs.tools , [&](const json & tool) {
2424+ const auto & function = tool.at (" function" );
2425+ std::string name = function.at (" name" );
2426+ const json schema = function.contains (" parameters" ) ? function.at (" parameters" ) : json::object ();
2427+
2428+ // arguments come one tag per key, with the JSON type in a type="..."
2429+ // attribute. the type is taken from the tool schema instead, as it tells
2430+ // us if the value is JSON or a literal string
2431+ auto args = p.eps ();
2432+ if (schema.contains (" properties" ) && !schema.at (" properties" ).empty ()) {
2433+ auto arg_choices = p.choice ();
2434+ for (const auto & prop : schema.at (" properties" ).items ()) {
2435+ const std::string & key = prop.key ();
2436+
2437+ std::string type = " string" ;
2438+ if (prop.value ().is_object () && prop.value ().contains (" type" ) &&
2439+ prop.value ().at (" type" ).is_string ()) {
2440+ type = prop.value ().at (" type" ).get <std::string>();
2441+ }
2442+
2443+ auto value = type == " string" ? p.tool_arg_string_value (p.until (ARG_END )) :
2444+ p.tool_arg_value (p.until (ARG_END ));
2445+
2446+ // skip the trailing type="..." attribute: anything up to <|sep|>
2447+ arg_choices |= p.rule (" kimi-k3-arg-" + name + " -" + key,
2448+ p.tool_arg (p.tool_arg_open (p.literal (ARG_START )) +
2449+ p.tool_arg_name (p.literal (key)) + p.literal (" \" " ) +
2450+ p.until (SEP ) + p.literal (SEP ) + value +
2451+ p.tool_arg_close (p.literal (ARG_END ))));
2452+ }
2453+ args = p.zero_or_more (arg_choices);
2454+ }
2455+
2456+ // skip the trailing index="N" attribute the same way
2457+ auto call = p.tool (p.tool_open (p.literal (CALL_START ) + p.tool_name (p.literal (name)) + p.literal (" \" " ) +
2458+ p.until (SEP ) + p.literal (SEP )) +
2459+ p.tool_args (args) + p.tool_close (p.literal (CALL_END )));
2460+
2461+ tool_choices |= p.rule (" kimi-k3-tool-" + name, call);
2462+ });
2463+
2464+ // all calls go inside one tools section, then the message is closed. the
2465+ // message closer is part of the trigger rule, or else the lazy grammar
2466+ // rejects it once tool calls have started
2467+ auto tools_section =
2468+ p.trigger_rule (" kimi-k3-tool-call" , p.literal (TOOLS_START ) + p.one_or_more (tool_choices) +
2469+ p.literal (TOOLS_END ) + p.optional (p.literal (MSG_END )) +
2470+ p.optional (p.literal (EOM_TOKEN )));
2471+
2472+ auto tools = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED ? tools_section :
2473+ p.optional (tools_section);
2474+
2475+ return start + reasoning + response + tools + trailer + end;
2476+ });
2477+
2478+ data.parser = parser.save ();
2479+
2480+ if (include_grammar) {
2481+ data.grammar_lazy = inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_REQUIRED ;
2482+ data.grammar = build_grammar ([&](const common_grammar_builder & builder) {
2483+ foreach_function (inputs.tools , [&](const json & tool) {
2484+ const auto & function = tool.at (" function" );
2485+ if (function.contains (" parameters" )) {
2486+ auto schema = function.at (" parameters" );
2487+ builder.resolve_refs (schema);
2488+ }
2489+ });
2490+ parser.build_grammar (builder, data.grammar_lazy );
2491+ });
2492+
2493+ data.grammar_triggers = {
2494+ { COMMON_GRAMMAR_TRIGGER_TYPE_WORD , TOOLS_START },
2495+ };
2496+ }
2497+
2498+ return data;
2499+ }
2500+
23282501// Cohere2 MoE (a.k.a. "North Code") parser.
23292502//
23302503// The assistant turn is fully marker-wrapped:
@@ -3293,6 +3466,13 @@ std::optional<common_chat_params> common_chat_try_specialized_template(
32933466 return common_chat_params_init_kimi_k2 (tmpl, params);
32943467 }
32953468
3469+ // Kimi K3 - the <|open|>/<|close|>/<|end_of_msg|> markers are unique to it
3470+ if (src.find (" <|open|>" ) != std::string::npos && src.find (" <|close|>" ) != std::string::npos &&
3471+ src.find (" <|end_of_msg|>" ) != std::string::npos) {
3472+ LOG_DBG (" Using specialized template: Kimi K3\n " );
3473+ return common_chat_params_init_kimi_k3 (tmpl, params);
3474+ }
3475+
32963476 // Cohere2 MoE / North Code - marker-wrapped format with <|START_TEXT|> content and
32973477 // <|START_ACTION|> JSON tool calls. <|START_TEXT|> is unique to this template (the older
32983478 // Command-R templates use <|START_RESPONSE|>).
0 commit comments