Skip to content

Commit fad07b9

Browse files
Merge pull request #93 from SKaiNET-developers/feature/apertus-chat-template-docs
docs(apertus): document chat-template format
2 parents 1e0f4d4 + 48e2f03 commit fad07b9

3 files changed

Lines changed: 205 additions & 5 deletions

File tree

APERTUS_ROLLOUT.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Apertus Support Rollout
22

3-
**Status:** PR 1 in flight (skainet-cli routing fix).
3+
**Status:** PR 2 in flight (chat-template documentation).
44
**Owner:** unassigned.
5-
**Plan PR:** #91 (merged).
5+
**Plan PR:** #91 (merged). PR 1: #92 (merged).
66

77
## Context
88

@@ -23,8 +23,8 @@ The architecture / library layer itself is solid:
2323

2424
## Staged delivery
2525

26-
- [x] **PR 1 — `fix(apertus): route through OptimizedLLMRuntime + apertusNetwork()`** (correctness fix) — this PR
27-
- [ ] **PR 2 — `docs(apertus): document chat template format`** (research)
26+
- [x] **PR 1 — `fix(apertus): route through OptimizedLLMRuntime + apertusNetwork()`** (correctness fix) — #92
27+
- [x] **PR 2 — `docs(apertus): document chat template format`** (research) — this PR
2828
- [ ] **PR 3 — `feat(apertus): tool calling support`** (implementation, depends on PR 2)
2929
- [ ] **PR 4 — `feat(kapertus): rebuild CLI under llm-apps/`** (parity, optional)
3030

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# Apertus chat-template format
2+
3+
Spec for the `ApertusChatTemplate` implementation that PR 3 of the Apertus rollout (see `APERTUS_ROLLOUT.md`) will land. Source: HuggingFace `swiss-ai/Apertus-8B-Instruct-2509` `chat_template.jinja` (14601 bytes), `tokenizer_config.json`, `special_tokens_map.json` — fetched 2026-05-01.
4+
5+
> The Apertus chat template is **NOT** chatml-compatible. `ModelRegistry.kt`'s previous setting of `chatTemplateFamily = "chatml"` was a fallback guess; PR 2 of the rollout (this spec lands alongside it) corrects it to `"apertus"`. PR 3 implements the dedicated `ApertusChatTemplate` against this spec.
6+
7+
## Special tokens
8+
9+
| Purpose | Token | Notes |
10+
| ---------------------- | ---------------------- | ------------------------------------------ |
11+
| BOS | `<s>` | SentencePiece-style; emitted once at start |
12+
| EOS | `<|assistant_end|>` | Closes assistant turn |
13+
| PAD | `<pad>` | |
14+
| UNK | `<unk>` | |
15+
| System turn open | `<|system_start|>` | |
16+
| System turn close | `<|system_end|>` | |
17+
| Developer block open | `<|developer_start|>` | Auto-emitted; carries tool capabilities |
18+
| Developer block close | `<|developer_end|>` | |
19+
| User turn open | `<|user_start|>` | |
20+
| User turn close | `<|user_end|>` | |
21+
| Assistant turn open | `<|assistant_start|>` | One opens at first assistant message |
22+
| Assistant turn close | `<|assistant_end|>` | Same as EOS; closes a complete turn |
23+
| Inner prefix | `<|inner_prefix|>` | Wraps `thoughts` / deliberation block |
24+
| Inner suffix | `<|inner_suffix|>` | Closes inner block; emits before response |
25+
| Tool calls prefix | `<|tools_prefix|>` | Prefixes the JSON tool-call array |
26+
| Tool calls suffix | `<|tools_suffix|>` | Closes the tool-call array |
27+
| Image | `<|image|>` | Reserved for multimodal (not used in 2509) |
28+
29+
The `add_bos_token` field in `tokenizer_config.json` is `true`; the chat template emits `{{ bos_token }}` at the very start.
30+
31+
## Turn structure
32+
33+
```
34+
<s>
35+
<|system_start|> <system_text> <|system_end|>
36+
<|developer_start|>
37+
Deliberation: enabled|disabled
38+
Tool Capabilities:
39+
<typescript-tool-defs> # or "Tool Capabilities: disabled"
40+
<|developer_end|>
41+
<|user_start|> <user_text> <|user_end|>
42+
<|assistant_start|>
43+
... assistant content (see below) ...
44+
<|assistant_end|>
45+
<|user_start|> ... # next turn
46+
```
47+
48+
### System message
49+
50+
If the caller doesn't supply a system message, the template emits a default:
51+
52+
```
53+
You are Apertus, a helpful assistant created by the SwissAI initiative.
54+
Knowledge cutoff: 2024-04
55+
Current date: <YYYY-MM-DD> # filled via strftime_now('%Y-%m-%d')
56+
```
57+
58+
### Developer block (auto-injected after system)
59+
60+
Always emitted, even if the caller didn't supply tools or `enable_thinking`:
61+
62+
```
63+
Deliberation: enabled # if `enable_thinking=true`, else "disabled\n"
64+
Tool Capabilities:
65+
<typescript-tool-defs> # if tools present
66+
```
67+
68+
or
69+
70+
```
71+
Deliberation: disabled
72+
Tool Capabilities: disabled
73+
```
74+
75+
### Tool-capability rendering (TypeScript-style)
76+
77+
Tools are NOT rendered as JSON Schema. The Jinja macro `render_tools` emits TypeScript-like type declarations:
78+
79+
```
80+
// <tool description>
81+
type <tool_name> = (_: {
82+
// <param description>
83+
<param_name>: <typescript_type>,
84+
<optional_param>?: <typescript_type>, // default: <value>
85+
}) => any;
86+
```
87+
88+
Type mapping (from `render_typescript_type` macro):
89+
90+
| JSON Schema type | TypeScript |
91+
| ---------------------- | --------------------------------------- |
92+
| `"string"` | `string` (or `"a" \| "b"` if `enum`) |
93+
| `"number"`/`"integer"` | `number` |
94+
| `"boolean"` | `boolean` |
95+
| `"array"` of primitive | `string[]` / `number[]` / `boolean[]` |
96+
| `"object"` | `{ prop: type, ... }` if `properties` else `object` |
97+
| `"oneOf"` (objects) | `any` (multi-variant unions collapse) |
98+
| `nullable: true` | appends ` | null` to the type |
99+
100+
Tool calls without parameters render as `() => any;`. Multiple tools are joined with newlines.
101+
102+
## Assistant content
103+
104+
Assistant messages can come in two shapes; the template chooses based on `message.content` type:
105+
106+
### Shape 1 — string content
107+
108+
```
109+
<|assistant_start|> <plain assistant text> <|assistant_end|>
110+
```
111+
112+
Used when `message.content` is a string. Simplest path; what most chat frameworks emit by default.
113+
114+
### Shape 2 — block content (`message.content.blocks` array)
115+
116+
```
117+
<|assistant_start|>
118+
[<|inner_prefix|> <thoughts text> <|inner_suffix|>]? # optional thoughts block
119+
[<|tools_prefix|>[{"<tool>": <args_json>}, ...]<|tools_suffix|> [<output1>, <output2>] ]* # zero or more tool-call+output cycles
120+
[<response text>]? # optional final response block
121+
<|assistant_end|>
122+
```
123+
124+
Block types:
125+
- `thoughts` — wraps text in `<|inner_prefix|>...<|inner_suffix|>`. Used for deliberation when `enable_thinking=true`.
126+
- `tool_calls` — emits `<|tools_prefix|>[{"name": args_json}, ...]<|tools_suffix|>`. The args are stringified JSON, NOT a parsed object. Multiple calls comma-separated inside the array.
127+
- `tool_outputs` — emits `[output1, output2, ...]` (a literal-bracket-comma list, NOT JSON). Pairs with the preceding `tool_calls`.
128+
- `response` — emits the final visible answer text. If a prior `thoughts` block opened `<|inner_prefix|>`, the `response` first emits `<|inner_suffix|>` to close the thinking section.
129+
130+
### Tool role messages (alternative tool-output encoding)
131+
132+
When the caller passes `role: "tool"` messages between assistant turns (instead of bundling outputs in an assistant `tool_outputs` block), the template encodes the outputs inline:
133+
134+
```
135+
<|tools_prefix|>[{"calc": ...}]<|tools_suffix|>
136+
[<tool_output_1>, <tool_output_2>, ...]
137+
<assistant continues, e.g., with response text>
138+
<|assistant_end|>
139+
```
140+
141+
The `[...]` after `<|tools_suffix|>` is the tool result; multiple tool messages stack into one comma-separated bracket.
142+
143+
## Tool-call output format from the model
144+
145+
The model emits tool calls in the same shape the template renders historical calls:
146+
147+
```
148+
<|tools_prefix|>[{"<tool_name>": {"<arg>": <value>, ...}}]<|tools_suffix|>
149+
```
150+
151+
- `<|tools_prefix|>` and `<|tools_suffix|>` are special tokens (single token each).
152+
- The bracket contains a JSON array.
153+
- Each element is a JSON object with **one** key (the tool name) whose value is the args object.
154+
- Multiple parallel tool calls = multiple objects in the array, comma-separated.
155+
- Args are rendered as proper JSON (not stringified TypeScript).
156+
157+
Parser strategy for `ApertusToolCallParserStrategy` (PR 3):
158+
159+
1. Scan model output for `<|tools_prefix|>`.
160+
2. Read until `<|tools_suffix|>`.
161+
3. Parse the inner string as JSON array.
162+
4. For each element, the single key is the tool name; the value is the args dict.
163+
5. Emit one `ToolCall(name, args)` per element.
164+
6. After `<|tools_suffix|>`, the next assistant text (until `<|assistant_end|>` or another marker) is the response.
165+
166+
## Generation prompt
167+
168+
When the caller sets `add_generation_prompt = true`, the template appends a final `<|assistant_start|>` to prompt the model to begin a new assistant turn. This is the standard "open the next turn for the model to fill" pattern.
169+
170+
## Comparison vs other chat templates we support
171+
172+
| Aspect | chatml | llama3 | gemma2 | **Apertus** |
173+
| ------------------------ | --------------------- | ----------------------- | --------------------- | ---------------------------- |
174+
| Role open token | `<\|im_start\|>role` | `<\|start_header_id\|>role<\|end_header_id\|>` | `<start_of_turn>role` | `<\|<role>_start\|>` |
175+
| Role close token | `<\|im_end\|>` | `<\|eot_id\|>` | `<end_of_turn>` | `<\|<role>_end\|>` |
176+
| Auto developer/tool block| no | no | no | **yes** (`<\|developer_…\|>`)|
177+
| Tool-def serialization | JSON Schema | JSON Schema | JSON Schema | **TypeScript types** |
178+
| Tool-call format | `<\|tool_call\|>{…}` | `<\|python_tag\|>{…}` | `<\|tool_call\|>…` | `<\|tools_prefix\|>[{…}]<\|tools_suffix\|>` |
179+
| Inner thoughts marker | n/a | n/a | n/a | **`<\|inner_prefix\|>…<\|inner_suffix\|>`** |
180+
| Default system on absent | none | none | none | **emits a default** |
181+
182+
Apertus shares no markup with the existing chat templates; it deserves its own `ApertusChatTemplate.kt` and `ApertusToolCallingSupport.kt` (PR 3 of the rollout) rather than reusing any existing class.
183+
184+
## Implementation notes for PR 3
185+
186+
- The default system message is **always** emitted when the caller omits a system message — `ApertusChatTemplate` should mirror this. Don't silently drop the default; the model is trained on it.
187+
- The developer block is emitted **always**, even with no tools and `enable_thinking=false` (renders `Deliberation: disabled\nTool Capabilities: disabled`). If `enable_thinking` isn't surfaced at the SKaiNET layer yet, default it to `false` and emit `disabled` in the developer block.
188+
- The TypeScript-style tool renderer is non-trivial — recursive type lowering, `oneOf` collapse to `any`, nullable handling. Either port the Jinja macro logic verbatim into Kotlin, OR (simpler) keep an embedded Jinja template + Pebble/jinjava and just substitute the Tools list. The kgemma chat-template work (`Gemma4ChatTemplate.kt`) ports Jinja to hand-coded Kotlin; Apertus's renderer is significantly more involved, so a Jinja-runtime approach may be the lower-risk path.
189+
- `<|inner_prefix|>` / `<|inner_suffix|>` is the deliberation/CoT marker. If the agent loop doesn't emit `thoughts` blocks today, the parser for assistant output should at least know to skip everything between these tokens before looking for tool calls or the final response.
190+
- `<|assistant_end|>` is BOTH the EOS token AND the assistant-turn close. The agent loop's stop condition is unchanged (still EOS-driven).
191+
- Tool-call args are JSON. Multiple calls in one assistant turn = multiple `{"name": {...}}` objects in the JSON array between `<|tools_prefix|>` and `<|tools_suffix|>`.
192+
193+
## Verification artifacts
194+
195+
- `chat_template.jinja` (14601 bytes) — fetched from `swiss-ai/Apertus-8B-Instruct-2509@main`. Pin this exact byte content as the parity reference in `ApertusChatTemplateHfParityTest` (PR 3, mirroring `Gemma4ChatTemplateHfParityTest` shape).
196+
- The four canonical parity cases to assert byte-for-byte against the Jinja:
197+
1. user-only (no system, no tools) — exercises default system + disabled developer block
198+
2. system + user — exercises caller-supplied system
199+
3. system + user + assistant string content — exercises Shape 1 assistant
200+
4. system + user + assistant block content with `tool_calls` + `tool_outputs` + `response` — exercises Shape 2 assistant + tool call rendering

llm-core/src/commonMain/kotlin/sk/ainet/apps/llm/ModelRegistry.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public enum class ModelFamily(
6363
LLAMA("llama", "LLaMA / Mistral", true, "llama3"),
6464
QWEN("qwen", "Qwen", true, "qwen"),
6565
GEMMA("gemma", "Gemma", true, "gemma"),
66-
APERTUS("apertus", "Apertus", false, "chatml"),
66+
APERTUS("apertus", "Apertus", false, "apertus"),
6767
BERT("bert", "BERT", false, null),
6868
VOXTRAL("voxtral", "Voxtral TTS", false, null),
6969
UNKNOWN("unknown", "Unknown", false, null);

0 commit comments

Comments
 (0)