package renderers import ( "encoding/json" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "github.com/google/go-cmp/cmp" "github.com/ollama/ollama/api" ) const nemotron3NanoTemplate = "testdata/nemotron3nano_chat_template.jinja2" func TestNemotron3NanoRendererMatchesReference(t *testing.T) { toolText := `<|im_start|>system # Tools You have access to the following functions: search_docs Search docs query string Search query ["api", "cli"] mode ['string', 'null'] Mode [{"type": "string"}, {"type": "number"}] payload object Payload {"enabled": {"type": "boolean"}} ["enabled"] tags array Tags {"type": "string"} <$defs>{"shared": {"type": "string"}} ["query"] If you choose to call a function ONLY reply in the following format with NO suffix: value_1 This is the value for the second parameter that can span multiple lines Reminder: - Function calls MUST follow the specified format: an inner block must be nested within XML tags - Required parameters MUST be specified - You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after - If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls <|im_end|> ` toolTextWithSystem := `<|im_start|>system Follow policy. # Tools You have access to the following functions: search_docs Search docs query string Search query ["api", "cli"] mode ['string', 'null'] Mode [{"type": "string"}, {"type": "number"}] payload object Payload {"enabled": {"type": "boolean"}} ["enabled"] tags array Tags {"type": "string"} <$defs>{"shared": {"type": "string"}} ["query"] If you choose to call a function ONLY reply in the following format with NO suffix: value_1 This is the value for the second parameter that can span multiple lines Reminder: - Function calls MUST follow the specified format: an inner block must be nested within XML tags - Required parameters MUST be specified - You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after - If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls <|im_end|> ` tests := []struct { name string messages []api.Message tools []api.Tool think *api.ThinkValue expected string }{ { name: "no system default thinking on", messages: []api.Message{ {Role: "user", Content: "Hello"}, }, expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHello<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "no system explicit thinking off", messages: []api.Message{ {Role: "user", Content: "Hello"}, }, think: thinkFalse(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHello<|im_end|>\n\n<|im_start|>assistant\n", }, { name: "literal endthink does not enable thinking", messages: []api.Message{ {Role: "user", Content: "literal only"}, }, think: thinkFalse(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nliteral only<|im_end|>\n\n<|im_start|>assistant\n", }, { name: "user no think toggle", messages: []api.Message{ {Role: "user", Content: "Hello /no_think"}, }, think: thinkTrue(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHello /no_think<|im_end|>\n\n<|im_start|>assistant\n", }, { name: "system think toggle overrides false", messages: []api.Message{ {Role: "system", Content: "Policy /think"}, {Role: "user", Content: "Hello"}, }, think: thinkFalse(), expected: "\n\n\n<|im_start|>system\nPolicy <|im_end|>\n\n<|im_start|>user\nHello<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "later toggle wins", messages: []api.Message{ {Role: "system", Content: "Policy /no_think"}, {Role: "user", Content: "Actually /think"}, }, think: thinkFalse(), expected: "\n\n\n<|im_start|>system\nPolicy <|im_end|>\n\n<|im_start|>user\nActually /think<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "system sanitizes toggles but preserves closing tag", messages: []api.Message{ {Role: "system", Content: "A /think B /no_think C "}, {Role: "user", Content: "Hello"}, }, think: thinkFalse(), expected: "\n\n\n<|im_start|>system\nA B C <|im_end|>\n\n<|im_start|>user\nHello<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "assistant plain content adds empty think block", messages: []api.Message{ {Role: "user", Content: "Hi"}, {Role: "assistant", Content: "Hello there"}, }, think: thinkTrue(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\nHello there<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "assistant reasoning content", messages: []api.Message{ {Role: "user", Content: "Hi"}, {Role: "assistant", Content: "Answer", Thinking: "Need to think"}, }, think: thinkTrue(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n\nNeed to think\n\nAnswer<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "assistant preserves existing think tags", messages: []api.Message{ {Role: "user", Content: "Hi"}, {Role: "assistant", Content: "keptAnswer"}, }, think: thinkTrue(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\nkeptAnswer<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "tools without system", messages: []api.Message{ {Role: "user", Content: "Use a tool"}, }, tools: nemotron3NanoReferenceTools(), think: thinkTrue(), expected: "\n\n\n" + toolText + "\n<|im_start|>user\nUse a tool<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "system with tools", messages: []api.Message{ {Role: "system", Content: "Follow policy."}, {Role: "user", Content: "Use a tool"}, }, tools: nemotron3NanoReferenceTools(), think: thinkTrue(), expected: "\n\n\n" + toolTextWithSystem + "\n<|im_start|>user\nUse a tool<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "assistant tool call with content", messages: []api.Message{ {Role: "user", Content: "Weather?"}, { Role: "assistant", Content: "Checking now.", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{ Name: "get_weather", Arguments: testArgs(map[string]any{"city": "Paris"}), }, }}, }, }, think: thinkTrue(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nWeather?<|im_end|>\n<|im_start|>assistant\nChecking now.\n\n\n\nParis\n\n\n\n<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "assistant tool call with structured arguments", messages: []api.Message{ {Role: "user", Content: "Create data"}, { Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{ Name: "create", Arguments: testArgsOrdered([]orderedArg{ {Key: "payload", Value: map[string]any{"count": 42, "nested": map[string]any{"value": "ok"}}}, {Key: "tags", Value: []any{"a", "b"}}, }), }, }}, }, }, think: thinkTrue(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nCreate data<|im_end|>\n<|im_start|>assistant\n\n\n\n\n{\"count\": 42, \"nested\": {\"value\": \"ok\"}}\n\n\n[\"a\", \"b\"]\n\n\n\n<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "assistant tool call truncated with reasoning", messages: []api.Message{ {Role: "user", Content: "Weather?"}, { Role: "assistant", Content: "Checking now.", Thinking: "Need weather", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{ Name: "get_weather", Arguments: testArgs(map[string]any{"city": "Paris"}), }, }}, }, {Role: "user", Content: "And tomorrow?"}, }, think: thinkTrue(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nWeather?<|im_end|>\n<|im_start|>assistant\nChecking now.\n\n\n\nParis\n\n\n\n<|im_end|>\n<|im_start|>user\nAnd tomorrow?<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "assistant tool call truncated open think only", messages: []api.Message{ {Role: "user", Content: "Weather?"}, { Role: "assistant", Content: "draft", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{ Name: "get_weather", Arguments: testArgs(map[string]any{"city": "Paris"}), }, }}, }, {Role: "user", Content: "And tomorrow?"}, }, think: thinkTrue(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nWeather?<|im_end|>\n<|im_start|>assistant\n\n\n\n\nParis\n\n\n\n<|im_end|>\n<|im_start|>user\nAnd tomorrow?<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "assistant tool call empty content", messages: []api.Message{ {Role: "user", Content: "Weather?"}, { Role: "assistant", Content: "", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{ Name: "get_weather", Arguments: testArgs(map[string]any{"city": "Paris"}), }, }}, }, }, think: thinkTrue(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nWeather?<|im_end|>\n<|im_start|>assistant\n\n\n\n\nParis\n\n\n\n<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "assistant truncated with think pair", messages: []api.Message{ {Role: "user", Content: "Hi"}, {Role: "assistant", Content: "hiddenVisible"}, {Role: "user", Content: "Next"}, }, think: thinkTrue(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\nVisible<|im_end|>\n<|im_start|>user\nNext<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "assistant truncated reasoning content", messages: []api.Message{ {Role: "user", Content: "Hi"}, {Role: "assistant", Thinking: "hidden", Content: "Visible"}, {Role: "user", Content: "Next"}, }, think: thinkTrue(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n\nVisible<|im_end|>\n<|im_start|>user\nNext<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "assistant truncated plain content", messages: []api.Message{ {Role: "user", Content: "Hi"}, {Role: "assistant", Content: "Visible"}, {Role: "user", Content: "Next"}, }, think: thinkTrue(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\nVisible<|im_end|>\n<|im_start|>user\nNext<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "assistant truncated empty content", messages: []api.Message{ {Role: "user", Content: "Hi"}, {Role: "assistant", Content: ""}, {Role: "user", Content: "Next"}, }, think: thinkTrue(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<|im_end|>\n<|im_start|>user\nNext<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "consecutive tool messages grouped", messages: []api.Message{ {Role: "user", Content: "Do work"}, { Role: "assistant", ToolCalls: []api.ToolCall{{ Function: api.ToolCallFunction{ Name: "step", Arguments: testArgs(map[string]any{"value": 1}), }, }}, }, {Role: "tool", Content: "one"}, {Role: "tool", Content: "two"}, }, think: thinkTrue(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nDo work<|im_end|>\n<|im_start|>assistant\n\n\n\n\n1\n\n\n\n<|im_end|>\n<|im_start|>user\n\none\n\n\ntwo\n\n<|im_end|>\n\n<|im_start|>assistant\n\n", }, { name: "fallback role", messages: []api.Message{ {Role: "developer", Content: "Custom role content"}, }, think: thinkTrue(), expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>developer\nCustom role content<|im_end|>\n\n<|im_start|>assistant\n\n", }, } verifyJinja2 := os.Getenv("VERIFY_JINJA2") != "" if verifyJinja2 { if _, err := os.Stat(filepath.Join(nemotron3NanoRepoRoot(t), ".venv", "bin", "python3")); err != nil { t.Fatal("VERIFY_JINJA2=1 requires .venv/bin/python3") } } renderer := &Nemotron3NanoRenderer{} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := renderer.Render(tt.messages, tt.tools, tt.think) if err != nil { t.Fatalf("Render() error = %v", err) } if diff := cmp.Diff(tt.expected, got); diff != "" { t.Fatalf("renderer mismatch (-want +got):\n%s", diff) } if verifyJinja2 { jinja2Output := renderNemotron3NanoWithJinja2(t, tt.messages, tt.tools, tt.think) if diff := cmp.Diff(tt.expected, jinja2Output); diff != "" { t.Fatalf("reference template mismatch (-want +got):\n%s", diff) } } }) } } func nemotron3NanoReferenceTools() []api.Tool { return []api.Tool{{ Type: "function", Function: api.ToolFunction{ Name: "search_docs", Description: "Search docs", Parameters: api.ToolFunctionParameters{ Type: "object", Defs: map[string]any{"shared": map[string]any{"type": "string"}}, Required: []string{"query"}, Properties: testPropsOrdered([]orderedProp{ { Key: "query", Value: api.ToolProperty{ Type: api.PropertyType{"string"}, Description: "Search query", Enum: []any{"api", "cli"}, }, }, { Key: "mode", Value: api.ToolProperty{ Type: api.PropertyType{"string", "null"}, Description: "Mode", AnyOf: []api.ToolProperty{ {Type: api.PropertyType{"string"}}, {Type: api.PropertyType{"number"}}, }, }, }, { Key: "payload", Value: api.ToolProperty{ Type: api.PropertyType{"object"}, Description: "Payload", Properties: testPropsOrdered([]orderedProp{{Key: "enabled", Value: api.ToolProperty{Type: api.PropertyType{"boolean"}}}}), Required: []string{"enabled"}, }, }, { Key: "tags", Value: api.ToolProperty{ Type: api.PropertyType{"array"}, Description: "Tags", Items: map[string]any{"type": "string"}, }, }, }), }, }, }} } func renderNemotron3NanoWithJinja2(t *testing.T, messages []api.Message, tools []api.Tool, think *api.ThinkValue) string { t.Helper() type jinja2ToolCall struct { ID string `json:"id,omitempty"` Function struct { Name string `json:"name"` Arguments any `json:"arguments"` } `json:"function"` } type jinja2Message struct { Role string `json:"role"` Content string `json:"content"` ReasoningContent string `json:"reasoning_content,omitempty"` ToolCalls []jinja2ToolCall `json:"tool_calls,omitempty"` Name string `json:"name,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` } var jMsgs []jinja2Message for _, m := range messages { jm := jinja2Message{ Role: m.Role, Content: m.Content, ReasoningContent: m.Thinking, Name: m.ToolName, ToolCallID: m.ToolCallID, } for _, tc := range m.ToolCalls { jtc := jinja2ToolCall{ID: tc.ID} jtc.Function.Name = tc.Function.Name var args map[string]any raw, _ := tc.Function.Arguments.MarshalJSON() if err := json.Unmarshal(raw, &args); err != nil { t.Fatalf("failed to unmarshal tool args: %v", err) } jtc.Function.Arguments = args jm.ToolCalls = append(jm.ToolCalls, jtc) } jMsgs = append(jMsgs, jm) } msgsJSON, err := json.Marshal(jMsgs) if err != nil { t.Fatalf("failed to marshal messages: %v", err) } toolsJSON := "None" if len(tools) > 0 { b, err := json.Marshal(tools) if err != nil { t.Fatalf("failed to marshal tools: %v", err) } toolsJSON = string(b) } thinking := "unset" if think != nil { if think.Bool() { thinking = "true" } else { thinking = "false" } } repoRoot := nemotron3NanoRepoRoot(t) templatePath := filepath.Join(repoRoot, "model", "renderers", nemotron3NanoTemplate) pythonPath := filepath.Join(repoRoot, ".venv", "bin", "python3") script := ` import json import sys from pathlib import Path from transformers.utils.chat_template_utils import _compile_jinja_template template_path, messages_json, tools_json, thinking = sys.argv[1:5] tmpl = _compile_jinja_template(Path(template_path).read_text()) kwargs = { "messages": json.loads(messages_json), "add_generation_prompt": True, } if tools_json != "None": kwargs["tools"] = json.loads(tools_json) if thinking == "true": kwargs["enable_thinking"] = True elif thinking == "false": kwargs["enable_thinking"] = False print(tmpl.render(**kwargs), end="") ` cmd := exec.Command(pythonPath, "-c", script, templatePath, string(msgsJSON), toolsJSON, thinking) var stdout, stderr strings.Builder cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { t.Fatalf("python render failed: %v\nstderr: %s", err, stderr.String()) } return stdout.String() } func nemotron3NanoRepoRoot(t *testing.T) string { t.Helper() _, filename, _, ok := runtime.Caller(0) if !ok { t.Fatal("failed to locate test file") } return filepath.Dir(filepath.Dir(filepath.Dir(filename))) }