Skip to content
Open
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
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -397,3 +397,35 @@ jobs:
# the .exe-suffixed checkers, and every Python block that shells out.
- name: Suite
run: bash tests/run.sh

# A required, CPU-only check that the tool protocol serve/glmtools.py
# renders is the one GLM's own chat_template.jinja defines — the same
# ground-truth rule the K2 template check in tests/run.sh applies, except
# this one may not skip. The template is vendored at
# tests/serve/glm_upstream/ (provenance in its README.md), so no model
# download, no weights, and no machine-local ~/models are involved: the
# tokenizer/engine boundary is FakeEngine. CI_GLM_ORACLE_STRICT=1 turns
# every skip path in test_glm_upstream.py into a failure and asserts all
# three oracle checks ran, so this job cannot be green because it did
# nothing. It says nothing about end-to-end model compatibility — the
# comparison is renderer vs template, nothing more.
glm_upstream_oracle:
name: GLM tool protocol vs upstream template
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install the pinned test dependencies
run: pip install -r requirements-test.txt
- name: The three oracle checks, no skips allowed
env:
CI_GLM_ORACLE_STRICT: '1'
GLM_DIR: tests/serve/glm_upstream
run: |
python3 -m unittest tests.serve.test_glm_upstream -v 2>&1 | tee /tmp/oracle.log
grep -q "^OK" /tmp/oracle.log
ran=$(grep -oE "^Ran [0-9]+ tests?" /tmp/oracle.log | grep -oE "[0-9]+")
[ "$ran" = 3 ] || { echo "expected 3 oracle checks, ran $ran"; exit 1; }

20 changes: 11 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,15 +261,17 @@ Stdlib-only OpenAI-compatible HTTP. `xtml.py` is a **port** of the release's
against that file whenever `K3_DIR` is set; `regions.py` is the streaming
parser that reads replies back into reasoning / content / `tool_calls`;
`chatfmt.py` is the fallback for a container with no XTML markers, serving
it from the same `chat.json` the CLI reads — plain conversation only, with
tools, thinking and images refused by name rather than dropped;
`kimitools.py` is Kimi's native tool-call protocol — the five markers, the
rendering and the reply reader — which is neither of the two formats and so
gets its own module: it is carried in a container's *tokenizer* while its
`chat.json` says nothing about it, and Kimi-Linear ships those tokens with
no chat template at all. `tests/serve/test_chatfmt_upstream.py` diffs it
against K2's published one, `K2_DIR` naming the release, the way
`test_xtml` does for K3;
it from the same `chat.json` the CLI reads — plain conversation, with
thinking and images from the format and everything else refused by name
rather than dropped; `kimitools.py` and `glmtools.py` are the two native
tool-call protocols a container's *tokenizer* can carry while its
`chat.json` says nothing about them — Kimi K2's five control tokens and
GLM-5.3-Flash's `<tool_call>` XML grammar, each with its rendering and its
reply reader, enabled only when the whole marker set resolves.
`tests/serve/test_chatfmt_upstream.py` diffs the first against K2's
published one and `tests/serve/test_glm_upstream.py` the second against
GLM's, `K2_DIR`/`GLM_DIR` naming the release, the way `test_xtml` does for
K3;
`engine.py` is the ctypes binding plus one lock held for a whole generation
(a `waste_ctx` is not thread-safe). Struct layouts in `engine.py` mirror
`waste.h` field for field — change one, change the other.
Expand Down
24 changes: 13 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,17 +443,19 @@ It supports streaming, tools, structured output, thinking controls, and images.

A GLM container is served the same way, from its own `chat.json`: plain
conversation and images, with the reasoning channel returned as
`reasoning_content` beside `content`. Tools are refused by name rather than
half-rendered — four strings cannot express a tool declaration, and GLM's
tokenizer carries no protocol that could.

Kimi-Linear's does. Since 0.7.2 a container whose tokenizer holds all five
of Kimi's native tool-call markers gets tool calling over HTTP even though
its `chat.json` describes only the ordinary turns — the format lives in
`serve/kimitools.py`, and the server says which of the three capabilities a
container has when it starts. All five or none: half of that rendering
encodes as ordinary text, so a partial set is a different protocol rather
than a smaller one.
`reasoning_content` beside `content`. Tools work here too: GLM's tokenizer
carries its own tool protocol (`<tool_call>`, `<arg_key>`, `<arg_value>`)
as single tokens, so `serve/glmtools.py` renders a request and reads a
reply the way GLM's own `chat_template.jinja` spells them — flat XML, an
`<|observation|>` turn for results.

Kimi-Linear's is the other one. Since 0.7.2 a container whose tokenizer
holds all five of Kimi's native tool-call markers gets tool calling over
HTTP even though its `chat.json` describes only the ordinary turns — the
format lives in `serve/kimitools.py`, and the server says which of the
three capabilities a container has when it starts. All or none, for either
protocol: half of that rendering encodes as ordinary text, so a partial set
is a different protocol rather than a smaller one.

```bash
python3 -m serve ~/models/glm53.waste --port 8000
Expand Down
13 changes: 9 additions & 4 deletions docs/GLM.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,10 +331,15 @@ advances `low` to `content_height + 1` rather than to the aligned height.
release. A container converted from a release that shares a selection
across layers is refused rather than produced.
- **MTP.** The extra prediction layer is dropped, as above.
- **Tools.** GLM's template carries a full tool-call protocol; the
declarative `chat.json` cannot express one and refuses by name rather
than half-rendering it. The raw `.jinja` is in the container for a host
that does interpret Jinja.
- **Tools.** GLM's template carries a full tool-call protocol, and the
declarative `chat.json` cannot express one — so the server renders it
from the tokenizer instead. GLM's specials carry the whole XML grammar
(`<tool_call>`, `<arg_key>`, `<arg_value>` and the response/observation
markers) as single tokens, `serve/glmtools.py` renders and reads it back
the way the release's own `chat_template.jinja` spells it, and
`tests/serve/test_glm_upstream.py` diffs that rendering against the
template with `GLM_DIR` naming the release. The raw `.jinja` stays in the
container for a host that does interpret Jinja.

## What is checked

Expand Down
46 changes: 29 additions & 17 deletions docs/SERVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,35 +136,47 @@ startup the server asks for the richer format first and falls back:

```
chat from ~/models/kimi-linear.waste/chat.json — plain conversation, no reasoning channel,
no images, native tools
no images, kimi tools
chat from ~/models/glm53.waste/chat.json — plain conversation, a reasoning channel,
images, no tools
images, glm tools
```

The three capabilities are read from the container, never assumed: the
channel and the images from `chat.json`, the tools from whether the
tokenizer carries **all five** of Kimi's native tool-call markers as single
tokens. Kimi-Linear does; GLM does not, and is refused by name.
tokenizer carries a whole native tool protocol as single tokens. There are
two of them: **all five** of Kimi K2's markers, which Kimi-Linear carries,
or **all nine** of GLM's, which GLM-5.3-Flash does — `<tool_call>`,
`</tool_call>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`,
`<tool_response>`, `</tool_response>` and `<|observation|>`. A container
with neither is refused by name.

That last one is a rendering `chat.json` itself cannot describe — four
prefix/suffix strings say nothing about a tool declaration or an argument
list — so the protocol lives in `serve/kimitools.py`, its own module beside
`xtml.py`, and is enabled only when the whole marker set resolves.
list — so each protocol lives in its own module beside `xtml.py`
(`serve/kimitools.py`, `serve/glmtools.py`), and is enabled only when the
whole marker set resolves.

The split is by subject rather than by size. *Whether* a container can do
tools is a fact about its `chat.json` and its tokenizer, so `chatfmt.py`
decides it and refuses with `ChatFormatError`. *How* a tool call is spelled
is a fact about the protocol, so `kimitools.py` owns it and a malformed one
raises `KimiToolError` — the same shape `xtml.py` has with `XTMLError`, and
`api.py` maps each to a 400. Nothing in `kimitools.py` imports `chatfmt`,
which is what lets `chatfmt` import it. **It is Kimi K2's**, and it is checked
against K2's own published `chat_template.jinja` rather than transcribed
from memory: `tests/serve/test_chatfmt_upstream.py`, which `tests/run.sh`
runs whenever `K2_DIR` names a release directory, the same discipline
`test_xtml.TestAgainstUpstream` applies to K3 with `K3_DIR`. Kimi-Linear's
own release carries the five tokens and **no chat template at all**, which
is why the grammar has to come from K2 and why an oracle for it matters
more than usual.
is a fact about the protocol, so `kimitools.py` or `glmtools.py` owns it and
a malformed one raises `KimiToolError` or `GlmToolError` — the same shape
`xtml.py` has with `XTMLError`, and `api.py` maps each to a 400. Nothing in
either imports `chatfmt`, which is what lets `chatfmt` import them. Each is
**the release's own grammar**, checked against the template that defines it
rather than transcribed from memory:
`tests/serve/test_chatfmt_upstream.py`, which `tests/run.sh` runs whenever
`K2_DIR` names a release directory, and `tests/serve/test_glm_upstream.py`
for `GLM_DIR` — the same discipline `test_xtml.TestAgainstUpstream` applies
to K3 with `K3_DIR`. Kimi-Linear's own release carries the five tokens and
**no chat template at all**, which is why the grammar has to come from K2
and why an oracle for it matters more than usual; GLM's release ships its
template, and the two grammars differ enough that each gets its own module
and its own reader — a Kimi call is `ID<|tool_call_argument_begin|>ARGS` in
a section, a GLM call is flat XML with the name after the opening tag and
one `<arg_key>`/`<arg_value>` pair per argument, and a GLM result is an
`<|observation|>` turn wrapping `<tool_response>` blocks where a Kimi result
is a system turn named for the tool.

One difference from that template is deliberate and asserted rather than
fixed: with no system turn first, K2's template inserts Moonshot's own
Expand Down
12 changes: 12 additions & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 SQLite Cloud, Inc.
#
# Test-only dependencies, pinned so the oracle checks in CI render the
# upstream chat templates with the same Jinja2 everywhere. Nothing here is
# needed to build or run the engine; tests/run.sh fetches jinja2 on the fly
# via `uv run --with jinja2` for local development.
#
# 3.1.6 is the current 3.1.x stable and carries the sandbox security fixes
# (CVE-2025-27516); the templates use the loopcontrols extension and a
# custom `tojson` filter, nothing version-sensitive beyond that.
Jinja2==3.1.6
3 changes: 2 additions & 1 deletion serve/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,8 @@ def main(argv=None) -> int:
think = ("a reasoning channel" if srv.chat_format.think
else "no reasoning channel")
images = "images" if srv.chat_format.image else "no images"
tools = "native tools" if srv.chat_format.tool_markers else "no tools"
protocol = srv.chat_format.tool_protocol
tools = f"{protocol} tools" if protocol else "no tools"
print(f"chat from {model}/chat.json — plain conversation, "
f"{think},\n {images}, {tools}")

Expand Down
4 changes: 3 additions & 1 deletion serve/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from pathlib import Path
from typing import Any, Optional

from . import chatfmt, kimitools, xtml
from . import chatfmt, glmtools, kimitools, xtml
from .engine import Engine
from .regions import RegionParser

Expand Down Expand Up @@ -385,6 +385,8 @@ def build_prompt(engine: Engine, body: dict, *, default_thinking: bool,
raise APIError(str(e), param="messages")
except kimitools.KimiToolError as e:
raise APIError(str(e), param=e.param or "messages")
except glmtools.GlmToolError as e:
raise APIError(str(e), param=e.param or "messages")

tokens = engine.tokenize_segments(segments)
if n_images:
Expand Down
Loading