gemma4: update renderer to match new jinja template (#15490)

* gemma4: update renderer to match new jinja template

Google has updated their jinja template for gemma4, and so this change
gives us parity with the new template. The parsing also slightly changed
upstream, so we make a small change to our parser as well.

I've also corrected a few probably existing edge cases, especially
around type unions. The upstream output format is weird (a stringified
array), but in practice the models seem to understand it well.

* gemma4: special case simple `AnyOf`s

The upstream template doesn't handle `AnyOf`s, but since in the previous
commit we saw type unions work reasonably well, I'm now treating very
simple `AnyOf`s as type unions to help in cases where they might be used

* fix lint

* gemma4: prefer empty instead of `None`

We can't currently distinguish between a result being not-present vs.
empty. The empty case seems more important (e.g., a legitimately empty
tool call)

* gemma4: be more careful for tool results with missing IDs
This commit is contained in:
Devon Rifkin
2026-04-10 15:45:27 -07:00
committed by GitHub
parent fdfe9cec98
commit 40a1317dfd
5 changed files with 1465 additions and 319 deletions

View File

@@ -26,6 +26,7 @@ const (
gemma4ThinkingCloseTag = "<channel|>"
gemma4ToolCallOpenTag = "<|tool_call>"
gemma4ToolCallCloseTag = "<tool_call|>"
gemma4ToolResponseTag = "<|tool_response>"
gemma4StringDelimiter = `<|"|>`
)
@@ -326,26 +327,39 @@ func (p *Gemma4Parser) eat(done bool) ([]gemma4Event, bool) {
case Gemma4IgnoringPostToolCallNoise:
// We've observed Gemma 4 occasionally emitting extra <tool_call|> tags
// after a valid tool call. We suppress leading close tags in this immediate
// post-tool-call state so the extra close tags do not leak into assistant
// content. The tradeoff is that if the model intentionally begins its next
// content span with the literal string "<tool_call|>", we will erroneously
// treat it as noise and drop it.
// after a valid tool call. We suppress those leading control tags in this
// immediate post-tool-call state so they do not leak into assistant
// content. The tradeoff is that if the model intentionally begins its next
// content span with one of those literal strings, we will erroneously
// treat it as noise and drop it. We also suppress a leading
// <|tool_response> marker here because the updated upstream parser/template
// uses it as a post-tool-call boundary.
bufStr = strings.TrimLeftFunc(bufStr, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(bufStr)
for strings.HasPrefix(bufStr, gemma4ToolCallCloseTag) {
bufStr = strings.TrimLeftFunc(bufStr[len(gemma4ToolCallCloseTag):], unicode.IsSpace)
for {
switch {
case strings.HasPrefix(bufStr, gemma4ToolCallCloseTag):
bufStr = strings.TrimLeftFunc(bufStr[len(gemma4ToolCallCloseTag):], unicode.IsSpace)
case strings.HasPrefix(bufStr, gemma4ToolResponseTag):
bufStr = strings.TrimLeftFunc(bufStr[len(gemma4ToolResponseTag):], unicode.IsSpace)
default:
p.buffer.Reset()
p.buffer.WriteString(bufStr)
goto strippedPostToolCallNoise
}
p.buffer.Reset()
p.buffer.WriteString(bufStr)
}
strippedPostToolCallNoise:
if bufStr == "" {
return events, false
}
if strings.HasPrefix(gemma4ToolCallCloseTag, bufStr) {
if strings.HasPrefix(gemma4ToolCallCloseTag, bufStr) || strings.HasPrefix(gemma4ToolResponseTag, bufStr) {
if done {
p.buffer.Reset()
p.state = Gemma4CollectingContent

View File

@@ -530,6 +530,77 @@ func TestGemma4Parser_IgnoresExtraToolCallCloseTags(t *testing.T) {
}
}
func TestGemma4Parser_IgnoresToolResponseBoundaryAfterToolCall(t *testing.T) {
tests := []struct {
name string
chunks []string
expectedContent string
}{
{
name: "same_chunk_without_trailing_content",
chunks: []string{
`<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|><|tool_response>`,
},
expectedContent: "",
},
{
name: "same_chunk_before_real_content",
chunks: []string{
`<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|><|tool_response>Done.`,
},
expectedContent: "Done.",
},
{
name: "split_across_chunks_before_real_content",
chunks: []string{
`<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|><|tool_res`,
`ponse>Done.`,
},
expectedContent: "Done.",
},
}
expectedToolCalls := []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{
"location": "Paris",
}),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &Gemma4Parser{hasThinkingSupport: false}
parser.Init(nil, nil, nil)
var finalContent strings.Builder
var finalToolCalls []api.ToolCall
for i, chunk := range tt.chunks {
done := i == len(tt.chunks)-1
content, _, toolCalls, err := parser.Add(chunk, done)
if err != nil {
t.Fatalf("Add() error on chunk %d: %v", i, err)
}
finalContent.WriteString(content)
finalToolCalls = append(finalToolCalls, toolCalls...)
}
if diff := cmp.Diff(tt.expectedContent, finalContent.String()); diff != "" {
t.Errorf("content mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(expectedToolCalls, finalToolCalls, argsComparer); diff != "" {
t.Errorf("tool calls mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestGemma4Parser_StreamingSplitThinkingTag(t *testing.T) {
tests := []struct {
name string

View File

@@ -43,7 +43,7 @@ func (r *Gemma4Renderer) Render(messages []api.Message, tools []api.Tool, thinkV
if hasSystemRole || len(tools) > 0 || hasThink {
sb.WriteString("<|turn>system\n")
if hasThink {
sb.WriteString("<|think|>")
sb.WriteString("<|think|>\n")
}
if systemMessage != "" {
sb.WriteString(strings.TrimSpace(systemMessage))
@@ -54,41 +54,80 @@ func (r *Gemma4Renderer) Render(messages []api.Message, tools []api.Tool, thinkV
sb.WriteString("<turn|>\n")
}
// 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":
sb.WriteString("<|turn>user\n")
r.renderContent(&sb, message, &imageOffset, true)
sb.WriteString("<turn|>\n")
lastUserIdx := -1
for i, message := range loopMessages {
if message.Role == "user" {
lastUserIdx = i
}
}
case "assistant":
sb.WriteString("<|turn>model\n")
// Tool calls come before content (matching HF template order)
var prevMessageType string
// Consecutive tool messages are folded into the preceding assistant turn,
// and adjacent assistant messages continue in the same model turn.
for i, message := range loopMessages {
if message.Role == "tool" {
continue
}
messageHadContent := r.messageHasContent(message)
prevMessageType = ""
role := message.Role
if role == "assistant" {
role = "model"
}
continueSameModelTurn := role == "model" && r.previousNonToolRole(loopMessages, i) == "assistant"
if !continueSameModelTurn {
sb.WriteString("<|turn>" + role + "\n")
}
if message.Role == "assistant" && message.Thinking != "" && i > lastUserIdx && len(message.ToolCalls) > 0 {
sb.WriteString("<|channel>thought\n")
sb.WriteString(message.Thinking)
sb.WriteString("\n<channel|>")
}
if len(message.ToolCalls) > 0 {
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))
prevMessageType = "tool_call"
}
toolResponsesEmitted := false
if len(message.ToolCalls) > 0 {
for k := i + 1; k < len(loopMessages) && loopMessages[k].Role == "tool"; k++ {
sb.WriteString(r.formatToolResponseBlock(r.toolResponseName(loopMessages[k], message.ToolCalls), loopMessages[k].Content))
toolResponsesEmitted = true
prevMessageType = "tool_response"
}
sb.WriteString("<turn|>\n")
case "tool":
sb.WriteString("<|turn>tool\n")
sb.WriteString(strings.TrimSpace(message.Content))
sb.WriteString("<turn|>\n")
}
switch role {
case "model":
if message.Content != "" || len(message.Images) > 0 {
message.Content = stripThinking(message.Content)
r.renderContent(&sb, message, &imageOffset, false)
}
default:
sb.WriteString("<|turn>" + message.Role + "\n")
sb.WriteString(strings.TrimSpace(message.Content))
r.renderContent(&sb, message, &imageOffset, true)
}
if prevMessageType == "tool_call" && !toolResponsesEmitted {
sb.WriteString("<|tool_response>")
} else if !(toolResponsesEmitted && !messageHadContent) {
sb.WriteString("<turn|>\n")
}
}
// Generation prompt
sb.WriteString("<|turn>model\n")
// Generation prompt.
if prevMessageType != "tool_response" && prevMessageType != "tool_call" {
sb.WriteString("<|turn>model\n")
if !hasThink {
sb.WriteString("<|channel>thought\n<channel|>")
}
}
return sb.String(), nil
}
@@ -130,6 +169,36 @@ func (r *Gemma4Renderer) renderContent(sb *strings.Builder, msg api.Message, ima
sb.WriteString(content)
}
func (r *Gemma4Renderer) previousNonToolRole(messages []api.Message, idx int) string {
for i := idx - 1; i >= 0; i-- {
if messages[i].Role != "tool" {
return messages[i].Role
}
}
return ""
}
func (r *Gemma4Renderer) messageHasContent(message api.Message) bool {
return message.Content != "" || len(message.Images) > 0
}
func (r *Gemma4Renderer) toolResponseName(message api.Message, toolCalls []api.ToolCall) string {
name := message.ToolName
if name == "" {
name = "unknown"
}
if message.ToolCallID != "" {
for _, tc := range toolCalls {
if tc.ID == message.ToolCallID {
name = tc.Function.Name
break
}
}
}
return name
}
func (r *Gemma4Renderer) renderToolDeclaration(tool api.Tool) string {
var sb strings.Builder
fn := tool.Function
@@ -144,7 +213,7 @@ func (r *Gemma4Renderer) renderToolDeclaration(tool api.Tool) string {
if fn.Parameters.Properties != nil && fn.Parameters.Properties.Len() > 0 {
sb.WriteString("properties:{")
r.writeProperties(&sb, fn.Parameters.Properties)
r.writeTypedProperties(&sb, fn.Parameters.Properties)
sb.WriteString("}")
needsComma = true
}
@@ -178,93 +247,25 @@ func (r *Gemma4Renderer) renderToolDeclaration(tool api.Tool) string {
return sb.String()
}
func (r *Gemma4Renderer) writeProperties(sb *strings.Builder, props *api.ToolPropertiesMap) {
keys := make([]string, 0, props.Len())
for k := range props.All() {
keys = append(keys, k)
func (r *Gemma4Renderer) writeTypedProperties(sb *strings.Builder, props *api.ToolPropertiesMap) {
if props == nil || props.Len() == 0 {
return
}
sort.Strings(keys)
first := true
for _, name := range keys {
prop, _ := props.Get(name)
if !first {
sb.WriteString(",")
}
first = false
sb.WriteString(name + ":{")
hasContent := false
if prop.Description != "" {
sb.WriteString("description:" + g4Q + prop.Description + g4Q)
hasContent = true
}
if len(prop.Type) > 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 + typeName + g4Q)
}
sb.WriteString("}")
}
r.writeSchemaProperties(sb, typedSchemaPropertiesMap(props))
}
// writeItemsSpec renders the items specification for array-type properties,
func typedSchemaPropertiesMap(props *api.ToolPropertiesMap) map[string]any {
out := make(map[string]any, props.Len())
for key, prop := range props.All() {
out[key] = topLevelTypedSchemaValueFromToolProperty(prop)
}
return out
}
// writeSchemaItemsSpec 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) {
func (r *Gemma4Renderer) writeSchemaItemsSpec(sb *strings.Builder, items map[string]any) {
keys := make([]string, 0, len(items))
for k := range items {
keys = append(keys, k)
@@ -283,16 +284,379 @@ func (r *Gemma4Renderer) writeItemsSpec(sb *strings.Builder, items map[string]an
first = false
switch key {
case "properties":
sb.WriteString("properties:{")
if props, ok := r.asSchemaMap(value); ok {
r.writeSchemaProperties(sb, props)
}
sb.WriteString("}")
case "required":
sb.WriteString("required:[")
for i, req := range normalizeStringSlice(value) {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(g4Q + req + g4Q)
}
sb.WriteString("]")
case "type":
if s, ok := value.(string); ok {
sb.WriteString("type:" + g4Q + strings.ToUpper(s) + g4Q)
typeNames := normalizeTypeNames(value)
if len(typeNames) == 1 {
sb.WriteString("type:" + g4Q + typeNames[0] + g4Q)
} else if len(typeNames) > 1 {
sb.WriteString("type:[")
for i, typeName := range typeNames {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(g4Q + typeName + g4Q)
}
sb.WriteString("]")
}
default:
sb.WriteString(key + ":" + r.formatArgValue(value))
sb.WriteString(key + ":" + r.formatSchemaValue(value))
}
}
}
func (r *Gemma4Renderer) writeSchemaProperties(sb *strings.Builder, props map[string]any) {
keys := make([]string, 0, len(props))
for k := range props {
keys = append(keys, k)
}
sort.Strings(keys)
first := true
for _, name := range keys {
if isSchemaStandardKey(name) {
continue
}
prop, ok := r.asSchemaMap(props[name])
if !ok {
continue
}
if !first {
sb.WriteString(",")
}
first = false
sb.WriteString(name + ":{")
addComma := false
if description, ok := prop["description"].(string); ok && description != "" {
sb.WriteString("description:" + g4Q + description + g4Q)
addComma = true
}
typeNames := normalizeTypeNames(prop["type"])
typeName := ""
if len(typeNames) > 0 {
typeName = typeNames[0]
}
switch typeName {
case "STRING":
if enumValues := normalizeSlice(prop["enum"]); len(enumValues) > 0 {
if addComma {
sb.WriteString(",")
} else {
addComma = true
}
sb.WriteString("enum:[")
for i, value := range enumValues {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(g4Q + fmt.Sprintf("%v", value) + g4Q)
}
sb.WriteString("]")
}
case "ARRAY":
if items, ok := r.asSchemaMap(prop["items"]); ok && len(items) > 0 {
if addComma {
sb.WriteString(",")
} else {
addComma = true
}
sb.WriteString("items:{")
r.writeSchemaItemsSpec(sb, items)
sb.WriteString("}")
}
}
if nullable, ok := prop["nullable"].(bool); ok && nullable {
if addComma {
sb.WriteString(",")
} else {
addComma = true
}
sb.WriteString("nullable:true")
}
if typeName == "OBJECT" {
if nestedProps, ok := r.asSchemaMap(prop["properties"]); ok {
if addComma {
sb.WriteString(",")
} else {
addComma = true
}
sb.WriteString("properties:{")
r.writeSchemaProperties(sb, nestedProps)
sb.WriteString("}")
} else {
if addComma {
sb.WriteString(",")
} else {
addComma = true
}
sb.WriteString("properties:{")
r.writeSchemaProperties(sb, prop)
sb.WriteString("}")
}
required := normalizeStringSlice(prop["required"])
if len(required) > 0 {
if addComma {
sb.WriteString(",")
} else {
addComma = true
}
sb.WriteString("required:[")
for i, req := range required {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(g4Q + req + g4Q)
}
sb.WriteString("]")
}
}
if len(typeNames) > 0 {
if addComma {
sb.WriteString(",")
}
if len(typeNames) == 1 {
sb.WriteString("type:" + g4Q + typeNames[0] + g4Q)
} else {
sb.WriteString("type:[")
for i, name := range typeNames {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(g4Q + name + g4Q)
}
sb.WriteString("]")
}
}
sb.WriteString("}")
}
}
func (r *Gemma4Renderer) asSchemaMap(value any) (map[string]any, bool) {
switch v := value.(type) {
case map[string]any:
return v, true
case *api.ToolPropertiesMap:
if v == nil {
return nil, false
}
out := make(map[string]any, v.Len())
for key, prop := range v.All() {
out[key] = schemaValueFromToolProperty(prop)
}
return out, true
case api.ToolProperty:
return schemaValueFromToolProperty(v), true
default:
return nil, false
}
}
func schemaValueFromToolProperty(prop api.ToolProperty) map[string]any {
out := make(map[string]any)
if len(prop.Type) > 0 {
if len(prop.Type) == 1 {
out["type"] = prop.Type[0]
} else {
out["type"] = []string(prop.Type)
}
} else if unionTypes, ok := simpleAnyOfTypes(prop); ok {
if len(unionTypes) == 1 {
out["type"] = unionTypes[0]
} else {
out["type"] = []string(unionTypes)
}
}
if prop.Description != "" {
out["description"] = prop.Description
}
if len(prop.Enum) > 0 {
out["enum"] = prop.Enum
}
if prop.Items != nil {
out["items"] = prop.Items
}
if prop.Properties != nil {
out["properties"] = prop.Properties
}
if len(prop.Required) > 0 {
out["required"] = prop.Required
}
return out
}
func topLevelTypedSchemaValueFromToolProperty(prop api.ToolProperty) map[string]any {
out := make(map[string]any)
if len(prop.Type) > 0 {
// api.ToolProperty intentionally models nullability through type unions
// that include "null" rather than OpenAPI 3.0's nullable:true keyword.
// Gemma's template accepts nullable:true as well, but our typed top-level
// tool properties do not carry that field. For multi-type unions, the
// template stringifies the uppercase list rather than emitting a structured
// type array. That is odd, but we match upstream here.
out["type"] = upstreamTypedPropertyTypeValue(prop.Type)
} else if unionTypes, ok := simpleAnyOfTypes(prop); ok {
// Gemma's declaration format does not have a dedicated anyOf construct, so
// we lower simple unions of bare type branches into the same typed union
// form used for api.PropertyType.
out["type"] = upstreamTypedPropertyTypeValue(unionTypes)
}
if prop.Description != "" {
out["description"] = prop.Description
}
if len(prop.Enum) > 0 {
out["enum"] = prop.Enum
}
if prop.Items != nil {
out["items"] = prop.Items
}
if prop.Properties != nil {
out["properties"] = typedSchemaPropertiesMap(prop.Properties)
}
if len(prop.Required) > 0 {
out["required"] = prop.Required
}
return out
}
func upstreamTypedPropertyTypeValue(types api.PropertyType) string {
if len(types) == 1 {
return types[0]
}
var sb strings.Builder
sb.WriteString("[")
for i, typ := range types {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString("'" + strings.ToUpper(typ) + "'")
}
sb.WriteString("]")
return sb.String()
}
func simpleAnyOfTypes(prop api.ToolProperty) (api.PropertyType, bool) {
if len(prop.AnyOf) == 0 {
return nil, false
}
var out api.PropertyType
seen := make(map[string]struct{})
for _, branch := range prop.AnyOf {
if !isBareTypeOnlyToolProperty(branch) || len(branch.Type) == 0 {
return nil, false
}
for _, typ := range branch.Type {
if _, ok := seen[typ]; ok {
continue
}
seen[typ] = struct{}{}
out = append(out, typ)
}
}
return out, len(out) > 0
}
func isBareTypeOnlyToolProperty(prop api.ToolProperty) bool {
return len(prop.AnyOf) == 0 &&
len(prop.Type) > 0 &&
prop.Items == nil &&
prop.Description == "" &&
len(prop.Enum) == 0 &&
prop.Properties == nil &&
len(prop.Required) == 0
}
func isSchemaStandardKey(key string) bool {
switch key {
case "description", "type", "properties", "required", "nullable":
return true
default:
return false
}
}
func normalizeTypeNames(value any) []string {
switch v := value.(type) {
case string:
return []string{strings.ToUpper(v)}
case []string:
out := make([]string, 0, len(v))
for _, item := range v {
out = append(out, strings.ToUpper(item))
}
return out
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, strings.ToUpper(s))
}
}
return out
case api.PropertyType:
return normalizeTypeNames([]string(v))
default:
return nil
}
}
func normalizeStringSlice(value any) []string {
switch v := value.(type) {
case []string:
return append([]string(nil), v...)
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
default:
return nil
}
}
func normalizeSlice(value any) []any {
switch v := value.(type) {
case []any:
return v
case []string:
out := make([]any, 0, len(v))
for _, item := range v {
out = append(out, item)
}
return out
default:
return nil
}
}
func (r *Gemma4Renderer) formatToolCall(tc api.ToolCall) string {
var sb strings.Builder
sb.WriteString("<|tool_call>call:" + tc.Function.Name + "{")
@@ -317,6 +681,10 @@ func (r *Gemma4Renderer) formatToolCall(tc api.ToolCall) string {
return sb.String()
}
func (r *Gemma4Renderer) formatToolResponseBlock(toolName, response string) string {
return "<|tool_response>response:" + toolName + "{value:" + r.formatArgValue(response) + "}<tool_response|>"
}
func (r *Gemma4Renderer) formatArgValue(value any) string {
switch v := value.(type) {
case string:
@@ -365,6 +733,73 @@ func (r *Gemma4Renderer) formatMapValue(m map[string]any) string {
return sb.String()
}
func (r *Gemma4Renderer) formatSchemaValue(value any) string {
switch v := value.(type) {
case string:
return g4Q + v + g4Q
case bool:
if v {
return "true"
}
return "false"
case float64:
if v == float64(int64(v)) {
return fmt.Sprintf("%d", int64(v))
}
return fmt.Sprintf("%v", v)
case int, int64, int32:
return fmt.Sprintf("%d", v)
case map[string]any:
return r.formatSchemaMapValue(v)
case []any:
return r.formatSchemaArrayValue(v)
case []string:
out := make([]any, 0, len(v))
for _, item := range v {
out = append(out, item)
}
return r.formatSchemaArrayValue(out)
default:
return fmt.Sprintf("%v", v)
}
}
func (r *Gemma4Renderer) formatSchemaMapValue(m map[string]any) string {
var sb strings.Builder
sb.WriteString("{")
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
first := true
for _, key := range keys {
if !first {
sb.WriteString(",")
}
first = false
sb.WriteString(g4Q + key + g4Q + ":" + r.formatSchemaValue(m[key]))
}
sb.WriteString("}")
return sb.String()
}
func (r *Gemma4Renderer) formatSchemaArrayValue(arr []any) string {
var sb strings.Builder
sb.WriteString("[")
for i, item := range arr {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(r.formatSchemaValue(item))
}
sb.WriteString("]")
return sb.String()
}
func (r *Gemma4Renderer) formatArrayValue(arr []any) string {
var sb strings.Builder
sb.WriteString("[")

File diff suppressed because it is too large Load Diff

View File

@@ -11,34 +11,15 @@
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:{
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
items:{
{%- set ns_items = namespace(found_first=false) -%}
{%- for item_key, item_value in value['items'] | dictsort -%}
{%- if item_value is not none -%}
@@ -71,6 +52,32 @@
}
{%- endif -%}
{%- endif -%}
{%- if value['nullable'] %}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
nullable:true
{%- endif -%}
{%- if value['type'] | upper == 'OBJECT' -%}
{%- if value['properties'] is defined and value['properties'] is mapping -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
properties:{
{{- format_parameters(value['properties'], value['required'] | default([])) -}}
}
{%- elif value is mapping -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
properties:{
{{- format_parameters(value, value['required'] | default([])) -}}
}
{%- endif -%}
{%- if value['required'] -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
required:[
{%- for item in value['required'] | default([]) -%}
<|"|>{{- item -}}<|"|>
{%- if not loop.last %},{% endif -%}
{%- endfor -%}
]
{%- endif -%}
{%- endif -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
type:<|"|>{{ value['type'] | upper }}<|"|>}
{%- endif -%}
@@ -150,16 +157,31 @@
{{- ns.result | trim -}}
{%- endmacro -%}
{%- macro format_tool_response_block(tool_name, response) -%}
{{- '<|tool_response>' -}}
{%- if response is mapping -%}
{{- 'response:' + tool_name + '{' -}}
{%- for key, value in response | dictsort -%}
{{- key -}}:{{- format_argument(value, escape_keys=False) -}}
{%- if not loop.last %},{% endif -%}
{%- endfor -%}
{{- '}' -}}
{%- else -%}
{{- 'response:' + tool_name + '{value:' + format_argument(response, escape_keys=False) + '}' -}}
{%- endif -%}
{{- '<tool_response|>' -}}
{%- endmacro -%}
{%- set ns = namespace(prev_message_type=None) -%}
{%- set loop_messages = messages -%}
{{ bos_token }}
{{- 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|>' -}}
{{- '<|think|>\n' -}}
{%- set ns.prev_message_type = 'think' -%}
{%- endif -%}
@@ -180,11 +202,41 @@
{{- '<turn|>\n' -}}
{%- endif %}
{#- Pre-scan: find last user message index for reasoning guard -#}
{%- set ns_turn = namespace(last_user_idx=-1) -%}
{%- for i in range(loop_messages | length) -%}
{%- if loop_messages[i]['role'] == 'user' -%}
{%- set ns_turn.last_user_idx = i -%}
{%- endif -%}
{%- endfor -%}
{#- Loop through messages -#}
{%- for message in loop_messages -%}
{%- if message['role'] != 'tool' -%}
{%- set ns.prev_message_type = None -%}
{%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%}
{#- Detect continuation: suppress duplicate <|turn>model when previous non-tool message was also assistant -#}
{%- set prev_nt = namespace(role=None, found=false) -%}
{%- if loop.index0 > 0 -%}
{%- for j in range(loop.index0 - 1, -1, -1) -%}
{%- if not prev_nt.found -%}
{%- if loop_messages[j]['role'] != 'tool' -%}
{%- set prev_nt.role = loop_messages[j]['role'] -%}
{%- set prev_nt.found = true -%}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{%- endif -%}
{%- set continue_same_model_turn = (role == 'model' and prev_nt.role == 'assistant') -%}
{%- if not continue_same_model_turn -%}
{{- '<|turn>' + role + '\n' }}
{%- endif -%}
{#- Render reasoning/reasoning_content as thinking channel -#}
{%- set thinking_text = message.get('reasoning') or message.get('reasoning_content') -%}
{%- if thinking_text and loop.index0 > ns_turn.last_user_idx and message.get('tool_calls') -%}
{{- '<|channel>thought\n' + thinking_text + '\n<channel|>' -}}
{%- endif -%}
{%- if message['tool_calls'] -%}
{%- for tool_call in message['tool_calls'] -%}
@@ -205,23 +257,49 @@
{%- set ns.prev_message_type = 'tool_call' -%}
{%- endif -%}
{%- if message['tool_responses'] -%}
{#- Tool Response handling -#}
{%- set ns_tr_out = namespace(flag=false) -%}
{%- if message.get('tool_responses') -%}
{#- Legacy: tool_responses embedded on the assistant message (Google/Gemma native) -#}
{%- 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|>' -}}
{{- format_tool_response_block(tool_response['name'] | default('unknown'), tool_response['response']) -}}
{%- set ns_tr_out.flag = true -%}
{%- set ns.prev_message_type = 'tool_response' -%}
{%- endfor -%}
{%- elif message.get('tool_calls') -%}
{#- OpenAI Chat Completions: forward-scan consecutive role:tool messages -#}
{%- set ns_tool_scan = namespace(stopped=false) -%}
{%- for k in range(loop.index0 + 1, loop_messages | length) -%}
{%- if ns_tool_scan.stopped -%}
{%- elif loop_messages[k]['role'] != 'tool' -%}
{%- set ns_tool_scan.stopped = true -%}
{%- else -%}
{%- set follow = loop_messages[k] -%}
{#- Resolve tool_call_id to function name -#}
{%- set ns_tname = namespace(name=follow.get('name') | default('unknown')) -%}
{%- for tc in message['tool_calls'] -%}
{%- if tc.get('id') == follow.get('tool_call_id') -%}
{%- set ns_tname.name = tc['function']['name'] -%}
{%- endif -%}
{%- endfor -%}
{#- Handle content as string or content-parts array -#}
{%- set tool_body = follow.get('content') -%}
{%- if tool_body is string -%}
{{- format_tool_response_block(ns_tname.name, tool_body) -}}
{%- elif tool_body is sequence and tool_body is not string -%}
{%- set ns_txt = namespace(s='') -%}
{%- for part in tool_body -%}
{%- if part.get('type') == 'text' -%}
{%- set ns_txt.s = ns_txt.s + (part.get('text') | default('')) -%}
{%- endif -%}
{%- endfor -%}
{{- format_tool_response_block(ns_tname.name, ns_txt.s) -}}
{%- else -%}
{{- format_tool_response_block(ns_tname.name, tool_body) -}}
{%- endif -%}
{%- set ns_tr_out.flag = true -%}
{%- set ns.prev_message_type = 'tool_response' -%}
{%- endif -%}
{%- endfor -%}
{%- set ns.prev_message_type = 'tool_response' -%}
{%- endif -%}
{%- if message['content'] is string -%}
@@ -239,25 +317,31 @@
{{- item['text'] | trim -}}
{%- endif -%}
{%- elif item['type'] == 'image' -%}
{{- '\n\n<|image|>\n\n' -}}
{{- '<|image|>' -}}
{%- 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' -}}
{{- '<|video|>' -}}
{%- set ns.prev_message_type = 'video' -%}
{%- endif -%}
{%- endfor -%}
{%- endif -%}
{%- if not (message['tool_responses'] and not message['content']) -%}
{%- if ns.prev_message_type == 'tool_call' and not ns_tr_out.flag -%}
{{- '<|tool_response>' -}}
{%- elif not (ns_tr_out.flag and not message.get('content')) -%}
{{- '<turn|>\n' -}}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{%- if add_generation_prompt -%}
{%- if ns.prev_message_type != 'tool_response' -%}
{%- if ns.prev_message_type != 'tool_response' and ns.prev_message_type != 'tool_call' -%}
{{- '<|turn>model\n' -}}
{%- if not enable_thinking | default(false) -%}
{{- '<|channel>thought\n<channel|>' -}}
{%- endif -%}
{%- endif -%}
{%- endif -%}