Skip to content
Closed
Show file tree
Hide file tree
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
191 changes: 191 additions & 0 deletions common/chat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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));

// 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:
Expand Down Expand Up @@ -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|>).
Expand Down
1 change: 1 addition & 0 deletions conversion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
"JinaEmbeddingsV5Model": "bert",
"KORMoForCausalLM": "qwen",
"KimiK25ForConditionalGeneration": "deepseek",
"KimiK3ForConditionalGeneration": "kimi_k3",
"KimiLinearForCausalLM": "kimi_linear",
"KimiLinearModel": "kimi_linear",
"KimiVLForConditionalGeneration": "deepseek",
Expand Down
53 changes: 52 additions & 1 deletion conversion/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,54 @@ class ModelType(IntEnum):
MMPROJ = 2


def repack_mxfp4_blocks(packed: Tensor, scale: Tensor) -> np.ndarray:
"""
Repack 4-bit MX weights into ggml `block_mxfp4`. Lossless - this only moves
bits, it does not dequantize and requantize.

Source (compressed-tensors "mxfp4-pack-quantized", and DeepSeek-V4's
equivalent weight/scale pair):
packed uint8 [rows, cols/2] two 4-bit codes per byte, element 2i in the
low nibble and 2i+1 in the high nibble
scale uint8 [rows, cols/32] one E8M0 biased exponent per 32-element group

Destination, per 32-element group: one scale byte then 16 code bytes, where
byte j holds element j in the low nibble and element j+16 in the high nibble
(see dequantize_row_mxfp4 in ggml-quants.c).

The 4-bit codes themselves need no remapping: both sides use sign in bit 3
and a magnitude index into (0, .5, 1, 1.5, 2, 3, 4, 6), which is exactly
ggml's kvalues_mxfp4 order. ggml's kvalues are doubled and its scale is
halved (GGML_E8M0_TO_FP32_HALF), so the represented value is unchanged.
"""
p = packed.contiguous().view(torch.uint8)
s = scale.contiguous().view(torch.uint8)

rows, packed_cols = p.shape
cols = packed_cols * 2
if cols % 32 != 0:
raise ValueError(f"MXFP4 source row has {cols} values, expected a multiple of 32")

n_blocks = cols // 32
if tuple(s.shape) != (rows, n_blocks):
raise ValueError(f"MXFP4 scale shape {tuple(s.shape)} does not match {(rows, n_blocks)}")

# 0xff is the NaN E8M0 exponent; caught later without the tensor name
n_bad = int((s == 0xFF).sum())
if n_bad:
raise ValueError(f"invalid E8M0 scale byte 0xff in {n_bad} MXFP4 block(s)")

src = p.reshape(rows, n_blocks, 16)
lo = src & 0x0F # elements 0, 2, 4, ...
hi = (src >> 4) & 0x0F # elements 1, 3, 5, ...

vals = torch.stack((lo, hi), dim=-1).reshape(rows, n_blocks, 32)
qs = vals[:, :, :16] | (vals[:, :, 16:] << 4)

raw = torch.cat((s.unsqueeze(-1), qs.to(torch.uint8)), dim=-1)
return raw.reshape(rows, n_blocks * 17).cpu().numpy()


class ModelBase:
_model_classes: dict[ModelType, dict[str, type[ModelBase]]] = {
ModelType.TEXT: {},
Expand Down Expand Up @@ -2664,7 +2712,10 @@ def get_model_architecture(hparams: dict[str, Any], model_type: ModelType) -> st
# Step3-VL keeps text config under text_config but uses a custom top-level architecture.
# For text conversion we route to a dedicated text-only class.
# TODO: refactor this later to avoid adding exception here
if model_type == ModelType.TEXT and arch in ("StepVLForConditionalGeneration", "Sarashina2VisionForCausalLM", "Exaone4_5_ForConditionalGeneration", "Step3p7ForConditionalGeneration"):
# Kimi-K3's text_config reports "KimiLinearForCausalLM", which is the older
# Kimi-Linear-48B architecture and cannot load K3 (no attention residuals,
# latent MoE, situ, ...). Route on the top-level architecture instead.
if model_type == ModelType.TEXT and arch in ("StepVLForConditionalGeneration", "Sarashina2VisionForCausalLM", "Exaone4_5_ForConditionalGeneration", "Step3p7ForConditionalGeneration", "KimiK3ForConditionalGeneration"):
return arch

# if "architectures" is found in the sub-config, use that instead
Expand Down
27 changes: 2 additions & 25 deletions conversion/deepseek.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
if TYPE_CHECKING:
from torch import Tensor

from .base import LazyTorchTensor, MmprojModel, ModelBase, TextModel, gguf, logger
from .base import LazyTorchTensor, MmprojModel, ModelBase, TextModel, gguf, logger, repack_mxfp4_blocks

from .qwen import QwenModel

Expand Down Expand Up @@ -596,30 +596,7 @@ def dequant_fp8_weight(weight: Tensor, scale: Tensor) -> Tensor:
for name in tensors_to_remove:
del self.model_tensors[name]

@staticmethod
def _pack_mxfp4_blocks(weight: Tensor, scale: Tensor) -> np.ndarray:
packed = weight.contiguous().view(torch.uint8)
scale_u8 = scale.contiguous().view(torch.uint8)

out_features, packed_cols = packed.shape
logical_cols = packed_cols * 2
if logical_cols % 32 != 0:
raise ValueError(f"MXFP4 source row has {logical_cols} values, expected a multiple of 32")

n_blocks = logical_cols // 32
if tuple(scale_u8.shape) != (out_features, n_blocks):
raise ValueError(f"MXFP4 scale shape {tuple(scale_u8.shape)} does not match {(out_features, n_blocks)}")

src = packed.reshape(out_features, n_blocks, 16)
low = src & 0x0F
high = (src >> 4) & 0x0F

# The safetensors bytes store adjacent values as low/high nibbles.
# ggml MXFP4 blocks store values 0..15 in low nibbles and 16..31 in high nibbles.
vals = torch.stack((low, high), dim=-1).reshape(out_features, n_blocks, 32)
qs = vals[:, :, :16] | (vals[:, :, 16:] << 4)
raw = torch.cat((scale_u8.unsqueeze(-1), qs.to(torch.uint8)), dim=-1)
return raw.reshape(out_features, n_blocks * 17).cpu().numpy()
_pack_mxfp4_blocks = staticmethod(repack_mxfp4_blocks)

def _write_mxfp4_expert_tensor(self, bid: int, proj: str, tensor_key: gguf.MODEL_TENSOR) -> list[str]:
n_experts = self.hparams["n_routed_experts"]
Expand Down
Loading
Loading