package chat
import (
"encoding/json"
"fmt"
"strings"
)
// Qwen3Renderer implements the Qwen3 chat template behavior commonly shipped
// in tokenizer_config.json (including tool-call wrappers).
// It is a deterministic renderer (not a general Jinja interpreter).
//
//nolint:revive // exported API
type Qwen3Renderer struct{}
func (r *Qwen3Renderer) Render(messages []Message, opts Options) (string, error) {
var sb strings.Builder
// Tools header block (only when tools are provided)
if len(opts.Tools) > 0 {
sb.WriteString("<|im_start|>system\n")
if len(messages) > 0 && messages[0].Role == "system" {
sb.WriteString(messages[0].Content)
sb.WriteString("\n\n")
}
sb.WriteString("# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within XML tags:\n")
for _, tool := range opts.Tools {
b, err := json.Marshal(tool)
if err != nil {
return "", fmt.Errorf("marshal tool: %w", err)
}
sb.WriteString("\n")
sb.Write(b)
}
sb.WriteString("\n\n\nFor each function call, return a json object with function name and arguments within XML tags:\n\n{\"name\": , \"arguments\": }\n<|im_end|>\n")
} else {
// No-tools path: include system message only if first message is system.
if len(messages) > 0 && messages[0].Role == "system" {
sb.WriteString("<|im_start|>system\n")
sb.WriteString(messages[0].Content)
sb.WriteString("<|im_end|>\n")
}
}
// Find last user query index for thinking rendering.
lastQueryIdx := len(messages) - 1
multiStepTool := true
for i := len(messages) - 1; i >= 0; i-- {
m := messages[i]
if multiStepTool && m.Role == "user" && !strings.HasPrefix(m.Content, "") {
multiStepTool = false
lastQueryIdx = i
break
}
}
// Render message stream.
inToolGroup := false
for i := 0; i < len(messages); i++ {
m := messages[i]
switch m.Role {
case "tool":
// Tool responses are wrapped as a fake user message with blocks.
if !inToolGroup {
sb.WriteString("<|im_start|>user")
inToolGroup = true
}
sb.WriteString("\n\n")
sb.WriteString(m.Content)
sb.WriteString("\n")
// Close tool group if next isn't tool.
if i == len(messages)-1 || messages[i+1].Role != "tool" {
sb.WriteString("<|im_end|>\n")
inToolGroup = false
}
continue
default:
if inToolGroup {
// Safety: close an unterminated tool group.
sb.WriteString("<|im_end|>\n")
inToolGroup = false
}
}
content := m.Content
if m.Role == "user" || (m.Role == "system" && i != 0) {
sb.WriteString("<|im_start|>")
sb.WriteString(m.Role)
sb.WriteString("\n")
sb.WriteString(content)
sb.WriteString("<|im_end|>\n")
continue
}
if m.Role == "assistant" {
sb.WriteString("<|im_start|>assistant\n")
reasoning := m.ReasoningContent
if opts.EnableThinking {
// Render thinking only after the last user query index.
if i > lastQueryIdx {
sb.WriteString("\n")
sb.WriteString(strings.Trim(reasoning, "\n"))
sb.WriteString("\n\n\n")
}
}
sb.WriteString(strings.TrimLeft(content, "\n"))
// Render tool calls, if any.
if len(m.ToolCalls) > 0 {
for j, tc := range m.ToolCalls {
if (j == 0 && content != "") || j > 0 {
sb.WriteString("\n")
}
sb.WriteString("\n{\"name\": \"")
sb.WriteString(tc.Name)
sb.WriteString("\", \"arguments\": ")
if len(tc.Arguments) == 0 {
sb.WriteString("{}")
} else {
sb.Write(tc.Arguments)
}
sb.WriteString("}\n")
}
}
sb.WriteString("<|im_end|>\n")
continue
}
}
if opts.AddGenerationPrompt {
sb.WriteString("<|im_start|>assistant\n")
}
return sb.String(), nil
}