K2-Horizon public release artifacts: four template and documentation observations

#12
by Sahalk7 - opened

Technical note for the K2-Horizon team. Prepared by Sahal AlKubaisi from an independent review of public artifacts.

Summary

These four issues were independently reproduced on the public artifacts of the six base K2-Horizon model repositories.

The work used template rendering on CPU, plus reading the published files and public source code (vLLM, SGLang, the OpenAI Python SDK). No model weights were run. Model behaviour, serving stacks and deployed systems were not tested.

# Issue Artifact Current applicability
1 Distinct inputs render to byte-identical prompt text: tool-call argument values in the xml / xml_typed formats, and the container shape of tool-response content chat template Body A: CURRENT BY STATIC IDENTITY. 0.9B: CURRENT BY STATIC IDENTITY of the macros used (not re-rendered)
2 Schema information is lost in the markdown (default) and xml tool presentations: root additionalProperties / patternProperties are not shown, and a $ref inside propertyNames (as Pydantic emits it for enum-keyed dicts) is printed only as a pointer whose definition never appears chat template Body A: CURRENT BY STATIC IDENTITY. 0.9B: CURRENT BY STATIC IDENTITY of the macros used (not re-rendered)
3 A function tool that omits parameters makes the template raise an error chat template Body A: CURRENT BY STATIC IDENTITY. 0.9B: CURRENT BY STATIC IDENTITY of the macros used (not re-rendered)
4 The 0.9B appendix's vLLM example reads reasoning_content; vLLM defines the field as reasoning K2-Horizon-0.9B APPENDIX.md CURRENT

Labels used:

  • CURRENT: re-read on the current revision.
  • CURRENT BY STATIC IDENTITY:
    • body A: the template file is byte-identical to the version that was rendered;
    • 0.9B: the macros the issue depends on are byte-identical, and the changed top-level lines are shown, by static reading, not to alter these inputs' output. Not re-rendered.

Status words used:

  • FACT: measured, or read directly from a file.
  • INFERENCE: reasoned from facts, not measured.
  • NOT TESTED: not examined.

Scope and revisions

Template body A. The five repositories below carry byte-identical chat_template.jinja files: SHA-256 a892cd0b0195599f283a8c706787520d9a6747640efb2f4dec4144b0abb62590, 51,034 bytes.

Repository Revision
K2-Horizon-3.7B d6e5432c59637093c0e125e791d89ea4a0b53559
K2-Horizon-7B 3b6ae394c1b863c27a306d168b25555837955192
K2-Horizon-32B b89c0f9ec4fffb45ff6151cfade689ac21321554
K2-Horizon-MoVA-36B-A4B cca48b6631d03c338a0e1d8beb0c392af8becbd3
K2-Horizon-375B-A23B bd1606f5332f4b055e98993e383ba3fbe40198f3

K2-Horizon-0.9B, revision 383cb66347be4d361138e8157c2f8779b7440752.

  • Current files:
    • chat_template.jinja: SHA-256 a8b98f49368427a3163b8ec3828edb36d106a8b95ef076e9aad1ccce2614db60;
    • chat_template_generation.jinja: SHA-256 9ad093f5528e5449691b5c68c302316f3920a9c0fb8b8f251ce9ca65b8995a7d.
  • Earlier renders. Issues 1–3 were also reproduced on the 0.9B chat_template.jinja as it stood at revision 76db896cfa459b82c71998a7741e30b54b37ec7f (SHA-256 5415a881f1c4391f8463187d1ffe80f2227d15ea060a54ce4c73eb626943bf01).
  • Issues 2a and 3 were also reproduced on that revision's chat_template_generation.jinja (SHA-256 6864a24302198296f98072813b15e81226501d46b9427b48683c5ab307eaa3b9).
  • The 2026-10-06 update. Both files were updated on 2026-10-06. The update adds a macro that normalises user, assistant and system content, including the first system message:
    • strings pass through;
    • lists of text parts are concatenated;
    • missing assistant content becomes empty;
    • other shapes raise an error.

Tool messages are not affected.

  • Static comparison.
    • Every template macro that issues 1–3 depend on is byte-identical in the updated files.
    • For the inputs below, the changed lines do not change the rendered output: the new macro returns string user and assistant content unchanged, no input has a system message, and tool messages are still rendered from message.content.
    • The updated 0.9B templates were not re-rendered.
  • The repository's chat_template_asst_tool_gen.jinja is byte-identical to the earlier chat_template.jinja.

Not compared. The FP8, GGUF, Uno and NVFP4 derivative repositories.

Environment.

  • Python 3.12.14, transformers 5.17.0, jinja2 3.1.6, tokenizers 0.23.2; pydantic 2.13.5 for issue 2.
  • CPU only, network off.
  • Template line numbers refer to body A; section 4 cites lines of the 0.9B APPENDIX.md.

Common setup. Every reproduction below starts from this setup. The issue 2 snippet also imports enum and pydantic, and the issue 3 snippet imports jinja2. The SHA-256 values quoted below are for this setup (the K2-Horizon-7B tokenizer).

from transformers import AutoTokenizer

tok = AutoTokenizer.from_pretrained(
    "IFM/K2-Horizon-7B",
    revision="3b6ae394c1b863c27a306d168b25555837955192",
    trust_remote_code=False
)


def render(messages, tools=None, **kwargs):
    return tok.apply_chat_template(messages, tools=tools, tokenize=False, **kwargs)

1. Distinct inputs render to byte-identical prompt text

Claim (FACT). In the xml tool-call format (the template default) and in xml_typed, a string argument value is written verbatim between <ifm|arg_value> and </ifm|arg_value>. The code is render_argument_value (L808) inside render_tool_calls_block (L865–889).

A value that contains the template's own argument framing therefore renders byte-identically to two separate arguments. The same holds for a value that closes the call and opens a second call.

Reproduction 1a.

tools = [{
    "type": "function",
    "function": {
        "name": "echo",
        "description": "t",
        "parameters": {
            "type": "object",
            "properties": {
                "k1": {"type": "string"},
                "k2": {"type": "string"}
            }
        }
    }
}]


def call(arguments):
    return [
        {"role": "user", "content": "go"},
        {
            "role": "assistant",
            "content": "",
            "think": "r",
            "tool_calls": [{
                "type": "function",
                "function": {
                    "name": "echo",
                    "arguments": arguments
                }
            }]
        }
    ]


two_args = render(
    call({"k1": "V1", "k2": "V2"}),
    tools,
    tool_call_format="xml"
)

one_arg = render(
    call({
        "k1": "V1</ifm|arg_value>\n<ifm|arg_key>k2</ifm|arg_key>\n<ifm|arg_value>V2"
    }),
    tools,
    tool_call_format="xml"
)

assert two_args == one_arg

Expected: two different tool calls give two different prompts.

Observed: the two prompts are byte-identical (SHA-256 6696199ef8aa8a5e94a4a8d940696d0de981b7caf597094b9f49568429ad7306). Both contain this assistant turn:

<|ifm|im_start|>assistant
<ifm|think>
r</ifm|think><ifm|tool_calls>
<ifm|tool_call>echo
<ifm|arg_key>k1</ifm|arg_key>
<ifm|arg_value>V1</ifm|arg_value>
<ifm|arg_key>k2</ifm|arg_key>
<ifm|arg_value>V2</ifm|arg_value>
</ifm|tool_call>
</ifm|tool_calls><|ifm|im_end|>

Scope of the witnesses.

  • Four such pairs collide: two arguments, and two calls, each in xml and in xml_typed. They collide on body A, and on the 0.9B chat_template.jinja as it stood at revision 76db896c.
  • Every tool name in them matches ^[a-zA-Z0-9_-]{1,64}$.
  • The tools list is shared, and it admits both structures.

Reproduction 1b (same property, a different site). render_tool_response_messages (L893–904) writes three things into the same output, with no marker between the alternatives:

  • a tool message's string content;
  • the string items of a list;
  • the text of a text part.
as_string = render([{"role": "tool", "content": "a"}])
as_list = render([{"role": "tool", "content": ["a"]}])
assert as_string == as_list

Expected: different content shapes give different prompts.

Observed:

  • Identical (SHA-256 fe48c36e9aa1693d1f7eb4c218c29771fef39c7ae79e5d2d46e1cf8826fe95a8).
  • The same holds for ["a"] vs [{"type": "text", "text": "a"}], and for a text part with an extra key vs one without.
  • This may be intended normalisation. It is reported because the shape cannot be recovered from the prompt.

Controls.

assert render(
    call({"k1": "V1", "k2": "V2"}),
    tools,
    tool_call_format="json"
) != render(
    call({
        "k1": "V1</ifm|arg_value>\n<ifm|arg_key>k2</ifm|arg_key>\n<ifm|arg_value>V2"
    }),
    tools,
    tool_call_format="json"
)

assert render(
    [{"role": "tool", "content": "a"}]
) != render(
    [{"role": "tool", "content": "b"}]
)
  • With tool_call_format="json", argument values pass through tojson, and the same two calls render differently.
  • This is a control at the argument level only: the json format is not a clean control for the whole format.
  • Different text in the same shape renders differently.

Current applicability.

  • Body A: CURRENT BY STATIC IDENTITY. The files are byte-identical to the template that was rendered.
  • 0.9B: CURRENT BY STATIC IDENTITY of the macros used. The macros these pairs use are byte-identical, and the changed top-level lines do not alter their output. This is a static reading; the 0.9B templates were not re-rendered.

Status.

  • FACT: the byte identity of the two prompts in each pair.
  • INFERENCE: a deterministic parser that receives only these identical bytes, the same tools list and a fixed configuration cannot determine which of the two calls produced them. This is not generalised to serving stacks or to any other parser input.
  • NOT TESTED:
    • model behaviour;
    • any server, HTTP or streaming path;
    • whether any client sends such values.

Prior art. How chat templates handle caller text that resembles template markup has been discussed publicly; no specific report is cited here. No novelty is claimed.

Area worth inspecting.

  • How string argument values are emitted next to the <ifm|arg_value> framing in the two xml formats.
  • Whether tool-response content should carry its container shape.

Which approach fits — an encoding, a template change, or input restrictions — is an engineering judgement. No fix is proposed or validated in this note.

2. Schema information lost in the markdown and xml tool presentations

Claim (FACT). validate_tools (L263–266) treats root-level additionalProperties and patternProperties in a tool's parameters as recognised keys, so such schemas are not sent to the verbatim-JSON fallback. Yet the markdown (default) and xml root renderers do not render them:

  • render_tools_markdown, L725–765;
  • render_tools_xml, L439–472.

They render only properties, or a root oneOf / anyOf / items. The json presentation keeps everything. A tool defined only through these keys is shown with no parameters.

Reproduction 2a.

def tool(parameters):
    return [{
        "type": "function",
        "function": {
            "name": "set_labels",
            "description": "Set labels.",
            "parameters": parameters
        }
    }]


msgs = [{"role": "user", "content": "Set the labels."}]

extra = {
    "type": "object",
    "additionalProperties": {
        "type": "integer",
        "description": "MK_AP_DESC extra value"
    }
}

pattern = {
    "type": "object",
    "patternProperties": {
        "^mk_pp_": {
            "type": "boolean",
            "description": "MK_PP_DESC flag"
        }
    }
}

for schema, marker in (
    (extra, "MK_AP_DESC"),
    (pattern, "MK_PP_DESC")
):
    for fmt in ("markdown", "xml"):
        assert marker not in render(
            msgs,
            tool(schema),
            tool_presentation_format=fmt
        )

    assert marker in render(
        msgs,
        tool(schema),
        tool_presentation_format="json"
    )

Expected: the schema's content appears in the default presentation, or the schema falls back to verbatim JSON.

Observed: the markdown tools block is **Parameters** followed by - None, and the xml block is <parameters></parameters>. Neither contains the schema content.

Reproduction 2b (a realistic variant of the same root). A $ref inside a single-schema applicator value (propertyNames, not) is printed as a pointer and never inlined. The referenced definition appears nowhere, and no fallback is triggered. Pydantic 2.13.5 emits exactly this shape for an enum-keyed dict field.

import enum
import pydantic


class Color(str, enum.Enum):
    red = "redQ9"
    green = "greenQ9"


class Args(pydantic.BaseModel):
    weights: dict[Color, int]


schema = Args.model_json_schema()

assert schema["properties"]["weights"]["propertyNames"] == {
    "$ref": "#/$defs/Color"
}

weights_tool = [{
    "type": "function",
    "function": {
        "name": "set_weights",
        "description": "d",
        "parameters": schema
    }
}]

for fmt in ("markdown", "xml"):
    out = render(
        [{"role": "user", "content": "q"}],
        weights_tool,
        tool_presentation_format=fmt
    )
    assert "#/$defs/Color" in out
    assert "redQ9" not in out
    assert "greenQ9" not in out

out = render(
    [{"role": "user", "content": "q"}],
    weights_tool,
    tool_presentation_format="json"
)

assert "redQ9" in out
assert "greenQ9" in out

Observed. In markdown the parameter shows propertyNames: {'$ref': '#/$defs/Color'}, and the allowed keys redQ9 / greenQ9 are absent. The xml presentation behaves the same way.

Matched control for 2b. The same constraint written inline is shown; behind a $ref it is not.

def t(parameters):
    return [{
        "type": "function",
        "function": {
            "name": "t",
            "description": "d",
            "parameters": parameters
        }
    }]


inline = {
    "type": "object",
    "properties": {
        "p": {
            "type": "string",
            "not": {"const": "ZQXK"}
        }
    },
    "required": ["p"]
}

by_ref = {
    "type": "object",
    "properties": {
        "p": {
            "type": "string",
            "not": {"$ref": "#/$defs/D"}
        }
    },
    "required": ["p"],
    "$defs": {
        "D": {"const": "ZQXK"}
    }
}

for fmt in ("markdown", "xml"):
    assert "ZQXK" in render(
        [{"role": "user", "content": "q"}],
        t(inline),
        tool_presentation_format=fmt
    )

    assert "ZQXK" not in render(
        [{"role": "user", "content": "q"}],
        t(by_ref),
        tool_presentation_format=fmt
    )

The template's own contract. The fallback comment at L189–191, and the $ref design note at L37–41 ("each def inlines at most once; later references render by def name"), describe behaviour this case does not get: here the first and only reference is never inlined.

Controls for 2a.

  • A schema with an ordinary properties entry shows its description in all three presentations.
  • The same additionalProperties / patternProperties keys nested under a property are rendered.
  • An unknown container-valued root key triggers the JSON fallback, as the template's fallback rule describes.

Current applicability.

  • Body A: CURRENT BY STATIC IDENTITY.
  • 0.9B: CURRENT BY STATIC IDENTITY of the macros used. The macros these cases use are byte-identical: the classifier, the markdown, xml and json renderers, and $ref handling. The changed top-level lines do not alter their output. This is a static reading; the 0.9B templates were not re-rendered.

Status.

  • FACT:
    • the rendered outputs above;
    • the default prompt therefore does not contain the first tool's additional-argument schema, or the second tool's allowed keys.
  • NOT TESTED:
    • model behaviour;
    • whether serving stacks dereference $ref before templating;
    • schema generators other than Pydantic.

Prior art. None is cited. No novelty is claimed.

Area worth inspecting.

  • The root-key allow-list in validate_tools against what the root renderers actually print.
  • The $ref inlining sites against applicator values (propertyNames, not, and similar).

3. A function tool without parameters makes the template raise

Claim (FACT). When a function tool's definition omits parameters, or sets it to null, the K2 chat templates raise in all three presentation formats. That covers body A, and the 0.9B default and generation templates as rendered at revision 76db896c. The guard is validate_tools, L253–259. The error is:

TemplateError: Tool 'get_time' is missing required 'parameters' field. Each tool must have a 'parameters' dict with 'type', 'properties', and 'required' keys.

Reproduction.

import jinja2

no_params = [{
    "type": "function",
    "function": {
        "name": "get_time",
        "description": "Return the current time."
    }
}]

try:
    render([
        {
            "role": "user",
            "content": "What time is it?"
        }
    ], no_params)

    raise AssertionError("expected the template to raise")

except jinja2.exceptions.TemplateError as e:
    assert "is missing required 'parameters' field" in str(e)


with_empty = [{
    "type": "function",
    "function": {
        "name": "get_time",
        "description": "Return the current time.",
        "parameters": {}
    }
}]

render([
    {
        "role": "user",
        "content": "What time is it?"
    }
], with_empty)

render([
    {
        "role": "user",
        "content": "What time is it?"
    }
])

Expected: a tool without parameters renders as a function with no parameters, which is what the OpenAI function-definition format means by omitting the field.

Observed: the template raises the error above.

Controls. The last two lines of the reproduction:

  • the same tool with "parameters": {} renders normally — this is also the workaround;
  • a request with no tools renders normally.

The two contracts, kept separate.

  • Omission. The OpenAI function-definition format treats parameters as optional: omitting it defines a function with an empty parameter list. This is per the OpenAI Python SDK's FunctionDefinition type, read on 2026-10-04.
  • null. This is a separate observation, about runtime data models rather than the OpenAI format. vLLM v0.30.0 and SGLang v0.5.21 declare parameters optional / nullable, and pass the tool on to template rendering. That was a static source reading; the newer vLLM v0.31.0 was not re-read.

Documentation. The model cards and appendices of the six repositories (8 documents) do not state the requirement.

Current applicability.

  • Body A: CURRENT BY STATIC IDENTITY.
  • 0.9B: CURRENT BY STATIC IDENTITY of the macros used. validate_tools and the two macros it calls are byte-identical, and the changed top-level lines do not alter their output. This is a static reading; the 0.9B templates were not re-rendered.

Status.

  • FACT: the raise, and the {} workaround.
  • INFERENCE: at those runtime versions, a vLLM or SGLang request with a no-argument tool would fail at template rendering.
  • NOT TESTED:
    • the HTTP status and response a server returns;
    • whether clients send such tools.

Prior art. The same class of issue appears in other software, for example tabbyAPI #485 and autogen #1051. No novelty is claimed.

Area worth inspecting. Whether an omitted parameters should be treated as an empty object schema, or the requirement documented.

4. The 0.9B appendix's vLLM example reads reasoning_content; vLLM defines reasoning

Claim (FACT, static). In the K2-Horizon-0.9B APPENDIX.md:

  • L198–207 is a vLLM client example. It targets http://localhost:8000/v1 and prints the model's thinking with print(getattr(message, "reasoning_content", None)).
  • L186–188 say the reasoning parser moves thinking into the response's reasoning_content field.

vLLM v0.31.0 (released 2026-10-05) and vLLM main at 385d86a6cf6f05051899b53d83ec55c63cccb379 define the response field as reasoning. In both, ChatMessage and DeltaMessage define reasoning, and neither class defines a reasoning_content field. The classes live in:

  • ChatMessage: vllm/entrypoints/openai/chat_completion/protocol.py;
  • DeltaMessage: vllm/entrypoints/generate/base/protocol.py.

vLLM's reasoning-outputs documentation (docs/features/reasoning_outputs.md) states that reasoning was formerly called reasoning_content. It warns that clients still reading reasoning_content can get an empty value.

Expected: the example reads the field the server it targets returns.

Observed: the example reads reasoning_content, which the vLLM response classes above do not define.

Control. SGLang v0.5.21 defines reasoning_content on its ChatMessage and DeltaMessage classes, so the same reading finds the field where it exists. None of the 8 K2 documents names a reasoning response field.

Scope.

  • Only the 0.9B appendix's vLLM example.
  • The model cards also mention reasoning_content. Their client examples target port 30000, the server their SGLang command starts, and SGLang v0.5.21 defines reasoning_content. They are not part of this observation.
  • The appendix pins a vLLM development build (0.26.1rc1.dev1212, commit d9fd5f11) that could not be resolved when checked on 2026-10-04. vLLM v0.26.0 and v0.27.0, the releases on either side, both define reasoning (INFERENCE for the pinned build itself).

Current applicability. CURRENT. The appendix is unchanged at revision 383cb663, and vLLM v0.31.0 and main were re-read.

Status.

  • FACT: the appendix text, and vLLM's class fields and documentation.
  • INFERENCE: run against a current vLLM server, the example prints None for the thinking.
  • NOT TESTED: no server response was observed.

Prior art. vLLM's rename is public: vllm-project/vllm #27755, and the documentation above.

Area worth inspecting. The appendix example (L207), and the sentence at L186–188.

What was not tested (all issues)

  • Model weights, inference and model behaviour.
  • Any vLLM or SGLang server, HTTP or streaming path.
  • Any deployed system.
  • The derivative repositories (FP8, GGUF, Uno, NVFP4).
  • A re-render of the 0.9B templates as updated on 2026-10-06. The static comparison is described above.
  • Issues 1 and 2b rendered with the 3.7B, 32B, MoVA-36B-A4B or 375B-A23B tokenizers.
    • Their body-A renders used the 7B tokenizer, and the 0.9B renders used the 0.9B tokenizer. Issue 3 was rendered with each repository's own tokenizer.
    • The quoted SHA-256 values were computed with the 7B tokenizer.
    • The collisions should not depend on the tokenizer, which contributes the same prefix to both sides of a pair (INFERENCE).

Sahal AlKubaisi
Business Management bachelor's student | Independent AI learner & builder

reminder @hunterhector

Sign up or log in to comment