gemma4: rewrite renderer to match HF Jinja2 template exactly

Fix 8 bugs found by building 55 reference tests verified against the
HF Jinja2 chat template (VERIFY_JINJA2=1 shells out to Python):

- Tool responses use separate <|turn>tool turns (not inline tags)
- Tool calls emitted before content in assistant messages
- Thinking content stripped from assistant history (strip_thinking)
- User, tool, and system content trimmed (template does | trim)
- Empty system message still emits system turn (check role, not content)
- Nested object properties rendered recursively with required field
- Array items specification rendered for array-type properties
- OBJECT/ARRAY type-specific rendering comma logic matches template

Also adds Required field to api.ToolProperty for nested object schemas,
replaces old gemma4_test.go with comprehensive gemma4_reference_test.go,
and commits the Jinja2 template as testdata for verification.
This commit is contained in:
Daniel Hiltgen
2026-04-01 22:35:26 -07:00
parent c29932c631
commit 95073400fc
5 changed files with 1673 additions and 603 deletions

View File

@@ -436,6 +436,7 @@ type ToolProperty struct {
Description string `json:"description,omitempty"`
Enum []any `json:"enum,omitempty"`
Properties *ToolPropertiesMap `json:"properties,omitempty"`
Required []string `json:"required,omitempty"`
}
// ToTypeScriptType converts a ToolProperty to a TypeScript type string

View File

@@ -1,7 +1,6 @@
package renderers
import (
"encoding/json"
"fmt"
"sort"
"strings"
@@ -31,22 +30,23 @@ func (r *Gemma4Renderer) Render(messages []api.Message, tools []api.Tool, thinkV
// Extract system message if present.
var systemMessage string
var loopMessages []api.Message
if len(messages) > 0 && (messages[0].Role == "system" || messages[0].Role == "developer") {
hasSystemRole := len(messages) > 0 && (messages[0].Role == "system" || messages[0].Role == "developer")
if hasSystemRole {
systemMessage = messages[0].Content
loopMessages = messages[1:]
} else {
loopMessages = messages
}
// Emit system turn if there's a system message, tools, or thinking.
// Emit system turn if there's a system/developer role, tools, or thinking.
hasThink := thinkValue != nil && thinkValue.Bool()
if systemMessage != "" || len(tools) > 0 || hasThink {
if hasSystemRole || len(tools) > 0 || hasThink {
sb.WriteString("<|turn>system\n")
if hasThink {
sb.WriteString("<|think|>")
}
if systemMessage != "" {
sb.WriteString(systemMessage)
sb.WriteString(strings.TrimSpace(systemMessage))
}
for _, tool := range tools {
sb.WriteString(r.renderToolDeclaration(tool))
@@ -54,79 +54,80 @@ func (r *Gemma4Renderer) Render(messages []api.Message, tools []api.Tool, thinkV
sb.WriteString("<turn|>\n")
}
// inModelTurn tracks whether we're inside an open <|turn>model block.
// Tool responses are appended inline (no separate turn), and the model
// turn is only closed when we see a non-tool message or reach the end.
inModelTurn := false
for i, message := range loopMessages {
// Each message gets its own <|turn>role\n ... <turn|>\n block,
// matching the HF chat template exactly.
for _, message := range loopMessages {
switch message.Role {
case "user":
if inModelTurn {
// Check if the preceding content was a tool response (no <turn|>
// between tool response and next user turn per HF reference).
prevIsToolResponse := i > 0 && loopMessages[i-1].Role == "tool"
if !prevIsToolResponse {
sb.WriteString("<turn|>\n")
}
inModelTurn = false
}
sb.WriteString("<|turn>user\n")
r.renderContent(&sb, message, &imageOffset)
r.renderContent(&sb, message, &imageOffset, true)
sb.WriteString("<turn|>\n")
case "assistant":
if inModelTurn {
sb.WriteString("<turn|>\n")
}
sb.WriteString("<|turn>model\n")
inModelTurn = true
if message.Content != "" {
sb.WriteString(message.Content)
}
// Tool calls come before content (matching HF template order)
for _, tc := range message.ToolCalls {
sb.WriteString(r.formatToolCall(tc))
}
// Strip thinking from history (matching HF strip_thinking macro)
if message.Content != "" {
sb.WriteString(stripThinking(message.Content))
}
sb.WriteString("<turn|>\n")
case "tool":
// Tool responses are rendered inline in the preceding model turn,
// matching the reference format from HuggingFace's chat template.
// Format: <|tool_response>response:NAME{key:value,...}<tool_response|>
toolName := r.findToolName(loopMessages, i)
sb.WriteString("<|tool_response>response:" + toolName + "{")
r.renderToolResponseContent(&sb, message.Content)
sb.WriteString("}<tool_response|>")
// Keep the model turn open — it will be closed when we see the
// next non-tool message or the assistant adds content after the response.
sb.WriteString("<|turn>tool\n")
sb.WriteString(strings.TrimSpace(message.Content))
sb.WriteString("<turn|>\n")
default:
if inModelTurn {
sb.WriteString("<turn|>\n")
inModelTurn = false
}
sb.WriteString("<|turn>" + message.Role + "\n")
sb.WriteString(message.Content)
sb.WriteString(strings.TrimSpace(message.Content))
sb.WriteString("<turn|>\n")
}
}
// If the last message is not an open assistant turn, add the generation prompt.
if !inModelTurn {
sb.WriteString("<|turn>model\n")
}
// Generation prompt
sb.WriteString("<|turn>model\n")
return sb.String(), nil
}
// stripThinking removes <|channel>...<channel|> thinking blocks from content,
// matching the HF chat template's strip_thinking macro.
func stripThinking(text string) string {
var result strings.Builder
for {
start := strings.Index(text, "<|channel>")
if start == -1 {
result.WriteString(text)
break
}
result.WriteString(text[:start])
end := strings.Index(text[start:], "<channel|>")
if end == -1 {
break
}
text = text[start+end+len("<channel|>"):]
}
return strings.TrimSpace(result.String())
}
// renderContent writes a message's content, interleaving [img-N] tags for images.
func (r *Gemma4Renderer) renderContent(sb *strings.Builder, msg api.Message, imageOffset *int) {
// When trim is true, leading/trailing whitespace is stripped (matching the Jinja2
// template's | trim filter applied to non-model content).
func (r *Gemma4Renderer) renderContent(sb *strings.Builder, msg api.Message, imageOffset *int, trim bool) {
if len(msg.Images) > 0 && r.useImgTags {
for range msg.Images {
sb.WriteString(fmt.Sprintf("[img-%d]", *imageOffset))
*imageOffset++
}
}
sb.WriteString(msg.Content)
content := msg.Content
if trim {
content = strings.TrimSpace(content)
}
sb.WriteString(content)
}
func (r *Gemma4Renderer) renderToolDeclaration(tool api.Tool) string {
@@ -193,32 +194,105 @@ func (r *Gemma4Renderer) writeProperties(sb *strings.Builder, props *api.ToolPro
first = false
sb.WriteString(name + ":{")
hasContent := false
if prop.Description != "" {
sb.WriteString("description:" + g4Q + prop.Description + g4Q)
hasContent = true
}
if len(prop.Enum) > 0 {
if prop.Description != "" {
sb.WriteString(",")
}
sb.WriteString("enum:[")
for j, e := range prop.Enum {
if j > 0 {
sb.WriteString(",")
}
sb.WriteString(g4Q + fmt.Sprintf("%v", e) + g4Q)
}
sb.WriteString("]")
}
if len(prop.Type) > 0 {
if prop.Description != "" || len(prop.Enum) > 0 {
typeName := strings.ToUpper(prop.Type[0])
switch typeName {
case "STRING":
if len(prop.Enum) > 0 {
if hasContent {
sb.WriteString(",")
}
sb.WriteString("enum:[")
for j, e := range prop.Enum {
if j > 0 {
sb.WriteString(",")
}
sb.WriteString(g4Q + fmt.Sprintf("%v", e) + g4Q)
}
sb.WriteString("]")
hasContent = true
}
case "OBJECT":
// Render nested properties recursively.
// Note: the leading comma is hardcoded (matching the template),
// and this does NOT set hasContent — the comma before type:
// depends only on whether description was present.
sb.WriteString(",properties:{")
if prop.Properties != nil && prop.Properties.Len() > 0 {
r.writeProperties(sb, prop.Properties)
}
sb.WriteString("}")
if len(prop.Required) > 0 {
sb.WriteString(",required:[")
for j, req := range prop.Required {
if j > 0 {
sb.WriteString(",")
}
sb.WriteString(g4Q + req + g4Q)
}
sb.WriteString("]")
}
case "ARRAY":
// Render items specification.
// Same as OBJECT: leading comma is hardcoded, does NOT set hasContent.
if items, ok := prop.Items.(map[string]any); ok && len(items) > 0 {
sb.WriteString(",items:{")
r.writeItemsSpec(sb, items)
sb.WriteString("}")
}
}
if hasContent {
sb.WriteString(",")
}
sb.WriteString("type:" + g4Q + strings.ToUpper(prop.Type[0]) + g4Q)
sb.WriteString("type:" + g4Q + typeName + g4Q)
}
sb.WriteString("}")
}
}
// writeItemsSpec renders the items specification for array-type properties,
// matching the Jinja2 template's dictsort iteration over items.
func (r *Gemma4Renderer) writeItemsSpec(sb *strings.Builder, items map[string]any) {
keys := make([]string, 0, len(items))
for k := range items {
keys = append(keys, k)
}
sort.Strings(keys)
first := true
for _, key := range keys {
value := items[key]
if value == nil {
continue
}
if !first {
sb.WriteString(",")
}
first = false
switch key {
case "type":
if s, ok := value.(string); ok {
sb.WriteString("type:" + g4Q + strings.ToUpper(s) + g4Q)
}
default:
sb.WriteString(key + ":" + r.formatArgValue(value))
}
}
}
func (r *Gemma4Renderer) formatToolCall(tc api.ToolCall) string {
var sb strings.Builder
sb.WriteString("<|tool_call>call:" + tc.Function.Name + "{")
@@ -304,50 +378,3 @@ func (r *Gemma4Renderer) formatArrayValue(arr []any) string {
return sb.String()
}
// renderToolResponseContent renders tool response content in Gemma 4 format.
// If the content is valid JSON, it renders each field as key:value pairs with
// proper type formatting (strings get <|"|> delimiters, numbers/bools are bare).
// If not valid JSON, wraps the entire content as a single "value" string.
func (r *Gemma4Renderer) renderToolResponseContent(sb *strings.Builder, content string) {
// Try to parse as JSON object.
var obj map[string]any
if err := json.Unmarshal([]byte(content), &obj); err == nil {
keys := make([]string, 0, len(obj))
for k := range obj {
keys = append(keys, k)
}
sort.Strings(keys)
first := true
for _, key := range keys {
if !first {
sb.WriteString(",")
}
first = false
sb.WriteString(key + ":" + r.formatArgValue(obj[key]))
}
return
}
// Not JSON — wrap as a single string value.
sb.WriteString("value:" + g4Q + content + g4Q)
}
// findToolName walks backwards from tool message index to find the matching tool call name.
func (r *Gemma4Renderer) findToolName(messages []api.Message, toolIdx int) string {
for j := toolIdx - 1; j >= 0; j-- {
if messages[j].Role == "assistant" && len(messages[j].ToolCalls) > 0 {
toolOffset := 0
for k := j + 1; k < toolIdx; k++ {
if messages[k].Role == "tool" {
toolOffset++
}
}
if toolOffset < len(messages[j].ToolCalls) {
return messages[j].ToolCalls[toolOffset].Function.Name
}
break
}
}
return ""
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,493 +0,0 @@
package renderers
import (
"testing"
"github.com/ollama/ollama/api"
"github.com/stretchr/testify/assert"
)
func TestGemma4Renderer(t *testing.T) {
q := `<|"|>` // string delimiter shorthand for readability
tests := []struct {
name string
messages []api.Message
tools []api.Tool
think *api.ThinkValue
expected string
}{
{
name: "basic_user_message",
messages: []api.Message{
{Role: "user", Content: "Hello!"},
},
expected: "<|turn>user\nHello!<turn|>\n<|turn>model\n",
},
{
name: "with_system_message",
messages: []api.Message{
{Role: "system", Content: "You are helpful"},
{Role: "user", Content: "Hello!"},
},
expected: "<|turn>system\nYou are helpful<turn|>\n<|turn>user\nHello!<turn|>\n<|turn>model\n",
},
{
name: "with_developer_role",
messages: []api.Message{
{Role: "developer", Content: "You are a coding assistant"},
{Role: "user", Content: "Hello!"},
},
expected: "<|turn>system\nYou are a coding assistant<turn|>\n<|turn>user\nHello!<turn|>\n<|turn>model\n",
},
{
name: "multi_turn",
messages: []api.Message{
{Role: "user", Content: "Hi"},
{Role: "assistant", Content: "Hello!"},
{Role: "user", Content: "More"},
},
expected: "<|turn>user\nHi<turn|>\n<|turn>model\nHello!<turn|>\n<|turn>user\nMore<turn|>\n<|turn>model\n",
},
{
name: "assistant_last_message_no_close",
messages: []api.Message{
{Role: "user", Content: "Hi"},
{Role: "assistant", Content: "Hello!"},
},
expected: "<|turn>user\nHi<turn|>\n<|turn>model\nHello!",
},
{
name: "empty_messages",
messages: []api.Message{},
expected: "<|turn>model\n",
},
{
name: "thinking_enabled",
messages: []api.Message{
{Role: "user", Content: "Think hard"},
},
think: thinkTrue(),
expected: "<|turn>system\n<|think|><turn|>\n<|turn>user\nThink hard<turn|>\n<|turn>model\n",
},
{
name: "thinking_with_system",
messages: []api.Message{
{Role: "system", Content: "Be careful"},
{Role: "user", Content: "Think hard"},
},
think: thinkTrue(),
expected: "<|turn>system\n<|think|>Be careful<turn|>\n<|turn>user\nThink hard<turn|>\n<|turn>model\n",
},
{
// Tools with no system message — tool declarations follow immediately after system\n
name: "with_tools",
messages: []api.Message{
{Role: "user", Content: "Weather?"},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Description: "Get weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"city": {Type: api.PropertyType{"string"}, Description: "City"},
}),
},
},
},
},
expected: "<|turn>system\n<|tool>declaration:get_weather{description:" + q + "Get weather" + q + ",parameters:{properties:{city:{description:" + q + "City" + q + ",type:" + q + "STRING" + q + "}},type:" + q + "OBJECT" + q + "}}<tool|><turn|>\n<|turn>user\nWeather?<turn|>\n<|turn>model\n",
},
{
// System message with tools — tools follow directly after system content (no newline)
name: "system_message_with_tools",
messages: []api.Message{
{Role: "system", Content: "You are a weather expert."},
{Role: "user", Content: "Weather?"},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Description: "Get weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"city": {Type: api.PropertyType{"string"}, Description: "City"},
}),
},
},
},
},
expected: "<|turn>system\nYou are a weather expert.<|tool>declaration:get_weather{description:" + q + "Get weather" + q + ",parameters:{properties:{city:{description:" + q + "City" + q + ",type:" + q + "STRING" + q + "}},type:" + q + "OBJECT" + q + "}}<tool|><turn|>\n<|turn>user\nWeather?<turn|>\n<|turn>model\n",
},
{
// Tool call + tool response: response is inline in the model turn, no separate <|turn>tool
// Non-JSON tool response falls back to {value:<|"|>...<|"|>}
name: "tool_call",
messages: []api.Message{
{Role: "user", Content: "Weather?"},
{
Role: "assistant",
ToolCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
},
},
},
{Role: "tool", Content: "Sunny"},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Description: "Get weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"city": {Type: api.PropertyType{"string"}, Description: "City"},
}),
},
},
},
},
expected: "<|turn>system\n<|tool>declaration:get_weather{description:" + q + "Get weather" + q + ",parameters:{properties:{city:{description:" + q + "City" + q + ",type:" + q + "STRING" + q + "}},type:" + q + "OBJECT" + q + "}}<tool|><turn|>\n" +
"<|turn>user\nWeather?<turn|>\n" +
"<|turn>model\n<|tool_call>call:get_weather{city:" + q + "Paris" + q + "}<tool_call|>" +
"<|tool_response>response:get_weather{value:" + q + "Sunny" + q + "}<tool_response|>",
},
{
// Assistant content + tool call + tool response inline
name: "assistant_content_with_tool_call",
messages: []api.Message{
{Role: "user", Content: "Weather?"},
{
Role: "assistant",
Content: "Let me check.",
ToolCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
},
},
},
{Role: "tool", Content: "Sunny"},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Description: "Get weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"city": {Type: api.PropertyType{"string"}, Description: "City"},
}),
},
},
},
},
expected: "<|turn>system\n<|tool>declaration:get_weather{description:" + q + "Get weather" + q + ",parameters:{properties:{city:{description:" + q + "City" + q + ",type:" + q + "STRING" + q + "}},type:" + q + "OBJECT" + q + "}}<tool|><turn|>\n" +
"<|turn>user\nWeather?<turn|>\n" +
"<|turn>model\nLet me check.<|tool_call>call:get_weather{city:" + q + "Paris" + q + "}<tool_call|>" +
"<|tool_response>response:get_weather{value:" + q + "Sunny" + q + "}<tool_response|>",
},
{
// Parallel tool calls — both responses inline
name: "parallel_tool_calls",
messages: []api.Message{
{Role: "user", Content: "Weather and time?"},
{
Role: "assistant",
ToolCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
},
{
Function: api.ToolCallFunction{
Name: "get_time",
Arguments: testArgs(map[string]any{"timezone": "UTC"}),
},
},
},
},
{Role: "tool", Content: "Sunny"},
{Role: "tool", Content: "12:00"},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Description: "Get weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"city": {Type: api.PropertyType{"string"}, Description: "City"},
}),
},
},
},
{
Type: "function",
Function: api.ToolFunction{
Name: "get_time",
Description: "Get current time",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"timezone": {Type: api.PropertyType{"string"}, Description: "Timezone"},
}),
},
},
},
},
expected: "<|turn>system\n<|tool>declaration:get_weather{description:" + q + "Get weather" + q + ",parameters:{properties:{city:{description:" + q + "City" + q + ",type:" + q + "STRING" + q + "}},type:" + q + "OBJECT" + q + "}}<tool|><|tool>declaration:get_time{description:" + q + "Get current time" + q + ",parameters:{properties:{timezone:{description:" + q + "Timezone" + q + ",type:" + q + "STRING" + q + "}},type:" + q + "OBJECT" + q + "}}<tool|><turn|>\n" +
"<|turn>user\nWeather and time?<turn|>\n" +
"<|turn>model\n<|tool_call>call:get_weather{city:" + q + "Paris" + q + "}<tool_call|><|tool_call>call:get_time{timezone:" + q + "UTC" + q + "}<tool_call|>" +
"<|tool_response>response:get_weather{value:" + q + "Sunny" + q + "}<tool_response|>" +
"<|tool_response>response:get_time{value:" + q + "12:00" + q + "}<tool_response|>",
},
{
// Numeric arguments — JSON tool response with individual key:value pairs
name: "numeric_arguments",
messages: []api.Message{
{Role: "user", Content: "Add"},
{
Role: "assistant",
ToolCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "add",
Arguments: testArgs(map[string]any{"a": float64(1), "b": float64(2)}),
},
},
},
},
{Role: "tool", Content: `{"result":3}`},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "add",
Description: "Add numbers",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"a": {Type: api.PropertyType{"number"}},
"b": {Type: api.PropertyType{"number"}},
}),
},
},
},
},
expected: "<|turn>system\n<|tool>declaration:add{description:" + q + "Add numbers" + q + ",parameters:{properties:{a:{type:" + q + "NUMBER" + q + "},b:{type:" + q + "NUMBER" + q + "}},type:" + q + "OBJECT" + q + "}}<tool|><turn|>\n" +
"<|turn>user\nAdd<turn|>\n" +
"<|turn>model\n<|tool_call>call:add{a:1,b:2}<tool_call|>" +
"<|tool_response>response:add{result:3}<tool_response|>",
},
{
// Boolean argument — non-JSON tool response
name: "boolean_argument",
messages: []api.Message{
{Role: "user", Content: "Set flag"},
{
Role: "assistant",
ToolCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "set_flag",
Arguments: testArgs(map[string]any{"enabled": true}),
},
},
},
},
{Role: "tool", Content: "done"},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "set_flag",
Description: "Set a flag",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"enabled": {Type: api.PropertyType{"boolean"}, Description: "Flag value"},
}),
},
},
},
},
expected: "<|turn>system\n<|tool>declaration:set_flag{description:" + q + "Set a flag" + q + ",parameters:{properties:{enabled:{description:" + q + "Flag value" + q + ",type:" + q + "BOOLEAN" + q + "}},type:" + q + "OBJECT" + q + "}}<tool|><turn|>\n" +
"<|turn>user\nSet flag<turn|>\n" +
"<|turn>model\n<|tool_call>call:set_flag{enabled:true}<tool_call|>" +
"<|tool_response>response:set_flag{value:" + q + "done" + q + "}<tool_response|>",
},
{
name: "tool_with_required_params",
messages: []api.Message{
{Role: "user", Content: "Weather?"},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Description: "Gets the weather for a given city",
Parameters: api.ToolFunctionParameters{
Type: "object",
Required: []string{"city"},
Properties: testPropsMap(map[string]api.ToolProperty{
"city": {Type: api.PropertyType{"string"}, Description: "City Name"},
"country": {Type: api.PropertyType{"string"}, Description: "Country Name"},
}),
},
},
},
},
expected: "<|turn>system\n<|tool>declaration:get_weather{description:" + q + "Gets the weather for a given city" + q + ",parameters:{properties:{city:{description:" + q + "City Name" + q + ",type:" + q + "STRING" + q + "},country:{description:" + q + "Country Name" + q + ",type:" + q + "STRING" + q + "}},required:[" + q + "city" + q + "],type:" + q + "OBJECT" + q + "}}<tool|><turn|>\n" +
"<|turn>user\nWeather?<turn|>\n<|turn>model\n",
},
{
name: "tool_with_enum",
messages: []api.Message{
{Role: "user", Content: "Test"},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "set_mode",
Description: "Set mode",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"mode": {Type: api.PropertyType{"string"}, Description: "The mode", Enum: []any{"fast", "slow"}},
}),
},
},
},
},
expected: "<|turn>system\n<|tool>declaration:set_mode{description:" + q + "Set mode" + q + ",parameters:{properties:{mode:{description:" + q + "The mode" + q + ",enum:[" + q + "fast" + q + "," + q + "slow" + q + "],type:" + q + "STRING" + q + "}},type:" + q + "OBJECT" + q + "}}<tool|><turn|>\n" +
"<|turn>user\nTest<turn|>\n<|turn>model\n",
},
{
name: "unicode_content",
messages: []api.Message{
{Role: "user", Content: "こんにちは"},
},
expected: "<|turn>user\nこんにちは<turn|>\n<|turn>model\n",
},
{
name: "newlines_in_content",
messages: []api.Message{
{Role: "user", Content: "Line 1\nLine 2\nLine 3"},
},
expected: "<|turn>user\nLine 1\nLine 2\nLine 3<turn|>\n<|turn>model\n",
},
{
// Thinking + tools — <|think|> immediately followed by tool declarations
name: "thinking_with_tools",
messages: []api.Message{
{Role: "user", Content: "Weather?"},
},
think: thinkTrue(),
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Description: "Get weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"city": {Type: api.PropertyType{"string"}, Description: "City"},
}),
},
},
},
},
expected: "<|turn>system\n<|think|><|tool>declaration:get_weather{description:" + q + "Get weather" + q + ",parameters:{properties:{city:{description:" + q + "City" + q + ",type:" + q + "STRING" + q + "}},type:" + q + "OBJECT" + q + "}}<tool|><turn|>\n" +
"<|turn>user\nWeather?<turn|>\n<|turn>model\n",
},
{
name: "image_tags_when_enabled",
messages: []api.Message{
{Role: "user", Content: "What is this?", Images: []api.ImageData{[]byte("fake")}},
},
expected: "<|turn>user\n[img-0]What is this?<turn|>\n<|turn>model\n",
},
{
// JSON tool response — parsed into individual key:value pairs
name: "json_tool_response",
messages: []api.Message{
{Role: "user", Content: "Weather?"},
{
Role: "assistant",
ToolCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Tokyo"}),
},
},
},
},
{Role: "tool", Content: `{"temperature":15,"weather":"sunny"}`},
{Role: "user", Content: "Thanks!"},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Description: "Get weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"city": {Type: api.PropertyType{"string"}, Description: "City"},
}),
},
},
},
},
// Matches HF reference: tool response inline, JSON fields as key:value, no <turn|> before next user
expected: "<|turn>system\n<|tool>declaration:get_weather{description:" + q + "Get weather" + q + ",parameters:{properties:{city:{description:" + q + "City" + q + ",type:" + q + "STRING" + q + "}},type:" + q + "OBJECT" + q + "}}<tool|><turn|>\n" +
"<|turn>user\nWeather?<turn|>\n" +
"<|turn>model\n<|tool_call>call:get_weather{city:" + q + "Tokyo" + q + "}<tool_call|>" +
"<|tool_response>response:get_weather{temperature:15,weather:" + q + "sunny" + q + "}<tool_response|>" +
"<|turn>user\nThanks!<turn|>\n" +
"<|turn>model\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
renderer := &Gemma4Renderer{useImgTags: true}
result, err := renderer.Render(tt.messages, tt.tools, tt.think)
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
func thinkTrue() *api.ThinkValue {
return &api.ThinkValue{Value: true}
}

View File

@@ -0,0 +1,263 @@
{%- macro format_parameters(properties, required) -%}
{%- set standard_keys = ['description', 'type', 'properties', 'required', 'nullable'] -%}
{%- set ns = namespace(found_first=false) -%}
{%- for key, value in properties | dictsort -%}
{%- set add_comma = false -%}
{%- if key not in standard_keys -%}
{%- if ns.found_first %},{% endif -%}
{%- set ns.found_first = true -%}
{{ key }}:{
{%- if value['description'] -%}
description:<|"|>{{ value['description'] }}<|"|>
{%- set add_comma = true -%}
{%- endif -%}
{%- if value['nullable'] %}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
nullable:true
{%- endif -%}
{%- if value['type'] | upper == 'STRING' -%}
{%- if value['enum'] -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
enum:{{ format_argument(value['enum']) }}
{%- endif -%}
{%- elif value['type'] | upper == 'OBJECT' -%}
,properties:{
{%- if value['properties'] is defined and value['properties'] is mapping -%}
{{- format_parameters(value['properties'], value['required'] | default([])) -}}
{%- elif value is mapping -%}
{{- format_parameters(value, value['required'] | default([])) -}}
{%- endif -%}
}
{%- if value['required'] -%}
,required:[
{%- for item in value['required'] | default([]) -%}
<|"|>{{- item -}}<|"|>
{%- if not loop.last %},{% endif -%}
{%- endfor -%}
]
{%- endif -%}
{%- elif value['type'] | upper == 'ARRAY' -%}
{%- if value['items'] is mapping and value['items'] -%}
,items:{
{%- set ns_items = namespace(found_first=false) -%}
{%- for item_key, item_value in value['items'] | dictsort -%}
{%- if item_value is not none -%}
{%- if ns_items.found_first %},{% endif -%}
{%- set ns_items.found_first = true -%}
{%- if item_key == 'properties' -%}
properties:{
{%- if item_value is mapping -%}
{{- format_parameters(item_value, value['items']['required'] | default([])) -}}
{%- endif -%}
}
{%- elif item_key == 'required' -%}
required:[
{%- for req_item in item_value -%}
<|"|>{{- req_item -}}<|"|>
{%- if not loop.last %},{% endif -%}
{%- endfor -%}
]
{%- elif item_key == 'type' -%}
{%- if item_value is string -%}
type:{{ format_argument(item_value | upper) }}
{%- else -%}
type:{{ format_argument(item_value | map('upper') | list) }}
{%- endif -%}
{%- else -%}
{{ item_key }}:{{ format_argument(item_value) }}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
}
{%- endif -%}
{%- endif -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
type:<|"|>{{ value['type'] | upper }}<|"|>}
{%- endif -%}
{%- endfor -%}
{%- endmacro -%}
{%- macro format_function_declaration(tool_data) -%}
declaration:{{- tool_data['function']['name'] -}}{description:<|"|>{{- tool_data['function']['description'] -}}<|"|>
{%- set params = tool_data['function']['parameters'] -%}
{%- if params -%}
,parameters:{
{%- if params['properties'] -%}
properties:{ {{- format_parameters(params['properties'], params['required']) -}} },
{%- endif -%}
{%- if params['required'] -%}
required:[
{%- for item in params['required'] -%}
<|"|>{{- item -}}<|"|>
{{- ',' if not loop.last -}}
{%- endfor -%}
],
{%- endif -%}
{%- if params['type'] -%}
type:<|"|>{{- params['type'] | upper -}}<|"|>}
{%- endif -%}
{%- endif -%}
{%- if 'response' in tool_data['function'] -%}
{%- set response_declaration = tool_data['function']['response'] -%}
,response:{
{%- if response_declaration['description'] -%}
description:<|"|>{{- response_declaration['description'] -}}<|"|>,
{%- endif -%}
{%- if response_declaration['type'] | upper == 'OBJECT' -%}
type:<|"|>{{- response_declaration['type'] | upper -}}<|"|>}
{%- endif -%}
{%- endif -%}
}
{%- endmacro -%}
{%- macro format_argument(argument, escape_keys=True) -%}
{%- if argument is string -%}
{{- '<|"|>' + argument + '<|"|>' -}}
{%- elif argument is boolean -%}
{{- 'true' if argument else 'false' -}}
{%- elif argument is mapping -%}
{{- '{' -}}
{%- set ns = namespace(found_first=false) -%}
{%- for key, value in argument | dictsort -%}
{%- if ns.found_first %},{% endif -%}
{%- set ns.found_first = true -%}
{%- if escape_keys -%}
{{- '<|"|>' + key + '<|"|>' -}}
{%- else -%}
{{- key -}}
{%- endif -%}
:{{- format_argument(value, escape_keys=escape_keys) -}}
{%- endfor -%}
{{- '}' -}}
{%- elif argument is sequence -%}
{{- '[' -}}
{%- for item in argument -%}
{{- format_argument(item, escape_keys=escape_keys) -}}
{%- if not loop.last %},{% endif -%}
{%- endfor -%}
{{- ']' -}}
{%- else -%}
{{- argument -}}
{%- endif -%}
{%- endmacro -%}
{%- macro strip_thinking(text) -%}
{%- set ns = namespace(result='') -%}
{%- for part in text.split('<channel|>') -%}
{%- if '<|channel>' in part -%}
{%- set ns.result = ns.result + part.split('<|channel>')[0] -%}
{%- else -%}
{%- set ns.result = ns.result + part -%}
{%- endif -%}
{%- endfor -%}
{{- ns.result | trim -}}
{%- endmacro -%}
{%- set ns = namespace(prev_message_type=None) -%}
{%- set loop_messages = messages -%}
{{ bos_token }}
{#- Handle System/Tool Definitions Block -#}
{%- if (enable_thinking is defined and enable_thinking) or tools or messages[0]['role'] in ['system', 'developer'] -%}
{{- '<|turn>system\n' -}}
{#- Inject Thinking token at the very top of the FIRST system turn -#}
{%- if enable_thinking is defined and enable_thinking -%}
{{- '<|think|>' -}}
{%- set ns.prev_message_type = 'think' -%}
{%- endif -%}
{%- if messages[0]['role'] in ['system', 'developer'] -%}
{{- messages[0]['content'] | trim -}}
{%- set loop_messages = messages[1:] -%}
{%- endif -%}
{%- if tools -%}
{%- for tool in tools %}
{{- '<|tool>' -}}
{{- format_function_declaration(tool) | trim -}}
{{- '<tool|>' -}}
{%- endfor %}
{%- set ns.prev_message_type = 'tool' -%}
{%- endif -%}
{{- '<turn|>\n' -}}
{%- endif %}
{#- Loop through messages -#}
{%- for message in loop_messages -%}
{%- set ns.prev_message_type = None -%}
{%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%}
{{- '<|turn>' + role + '\n' }}
{%- if message['tool_calls'] -%}
{%- for tool_call in message['tool_calls'] -%}
{%- set function = tool_call['function'] -%}
{{- '<|tool_call>call:' + function['name'] + '{' -}}
{%- if function['arguments'] is mapping -%}
{%- set ns_args = namespace(found_first=false) -%}
{%- for key, value in function['arguments'] | dictsort -%}
{%- if ns_args.found_first %},{% endif -%}
{%- set ns_args.found_first = true -%}
{{- key -}}:{{- format_argument(value, escape_keys=False) -}}
{%- endfor -%}
{%- elif function['arguments'] is string -%}
{{- function['arguments'] -}}
{%- endif -%}
{{- '}<tool_call|>' -}}
{%- endfor -%}
{%- set ns.prev_message_type = 'tool_call' -%}
{%- endif -%}
{%- if message['tool_responses'] -%}
{#- Tool Response handling -#}
{%- for tool_response in message['tool_responses'] -%}
{{- '<|tool_response>' -}}
{%- if tool_response['response'] is mapping -%}
{{- 'response:' + tool_response['name'] | default('unknown') + '{' -}}
{%- for key, value in tool_response['response'] | dictsort -%}
{{- key -}}:{{- format_argument(value, escape_keys=False) -}}
{%- if not loop.last %},{% endif -%}
{%- endfor -%}
{{- '}' -}}
{%- else -%}
{{- 'response:' + tool_response['name'] | default('unknown') + '{value:' + format_argument(tool_response['response'], escape_keys=False) + '}' -}}
{%- endif -%}
{{- '<tool_response|>' -}}
{%- endfor -%}
{%- set ns.prev_message_type = 'tool_response' -%}
{%- endif -%}
{%- if message['content'] is string -%}
{%- if role == 'model' -%}
{{- strip_thinking(message['content']) -}}
{%- else -%}
{{- message['content'] | trim -}}
{%- endif -%}
{%- elif message['content'] is sequence -%}
{%- for item in message['content'] -%}
{%- if item['type'] == 'text' -%}
{%- if role == 'model' -%}
{{- strip_thinking(item['text']) -}}
{%- else -%}
{{- item['text'] | trim -}}
{%- endif -%}
{%- elif item['type'] == 'image' -%}
{{- '\n\n<|image|>\n\n' -}}
{%- set ns.prev_message_type = 'image' -%}
{%- elif item['type'] == 'audio' -%}
{{- '<|audio|>' -}}
{%- set ns.prev_message_type = 'audio' -%}
{%- elif item['type'] == 'video' -%}
{{- '\n\n<|video|>\n\n' -}}
{%- set ns.prev_message_type = 'video' -%}
{%- endif -%}
{%- endfor -%}
{%- endif -%}
{%- if not (message['tool_responses'] and not message['content']) -%}
{{- '<turn|>\n' -}}
{%- endif -%}
{%- endfor -%}
{%- if add_generation_prompt -%}
{%- if ns.prev_message_type != 'tool_response' -%}
{{- '<|turn>model\n' -}}
{%- endif -%}
{%- endif -%}