From 7325791599409de52534429897481918717a9e85 Mon Sep 17 00:00:00 2001 From: Parth Sareen Date: Thu, 18 Dec 2025 07:55:37 -0800 Subject: [PATCH] parsers/renderers: functiongemma (#13521) --- convert/tokenizer_spm.go | 3 +- model/parsers/functiongemma.go | 323 ++++++++++++++++ model/parsers/functiongemma_test.go | 423 +++++++++++++++++++++ model/parsers/parsers.go | 2 + model/renderers/functiongemma.go | 287 ++++++++++++++ model/renderers/functiongemma_test.go | 514 ++++++++++++++++++++++++++ model/renderers/renderer.go | 2 + 7 files changed, 1553 insertions(+), 1 deletion(-) create mode 100644 model/parsers/functiongemma.go create mode 100644 model/parsers/functiongemma_test.go create mode 100644 model/renderers/functiongemma.go create mode 100644 model/renderers/functiongemma_test.go diff --git a/convert/tokenizer_spm.go b/convert/tokenizer_spm.go index 340c3d58..51efb35e 100644 --- a/convert/tokenizer_spm.go +++ b/convert/tokenizer_spm.go @@ -49,7 +49,8 @@ func parseSentencePiece(fsys fs.FS) (*Vocabulary, error) { tt := int32(sentencepiece.ModelProto_SentencePiece_NORMAL) // temporary fix to handle gemma3 broken configs - if slices.Contains([]string{"", ""}, piece.GetPiece()) { + // TODO(parthsareen): allow reading of tokenizer.json to allow managing special tokens when using spm + if slices.Contains([]string{"", "", "", "", "", "", "", "", ""}, piece.GetPiece()) { tt = int32(sentencepiece.ModelProto_SentencePiece_CONTROL) } diff --git a/model/parsers/functiongemma.go b/model/parsers/functiongemma.go new file mode 100644 index 00000000..35f8791c --- /dev/null +++ b/model/parsers/functiongemma.go @@ -0,0 +1,323 @@ +package parsers + +import ( + "fmt" + "regexp" + "strings" + + "github.com/ollama/ollama/api" +) + +type FunctionGemmaParserState int + +const ( + FunctionGemmaCollectingContent FunctionGemmaParserState = iota + FunctionGemmaCollectingToolCalls +) + +const ( + functionGemmaFunctionCallOpen = "" + functionGemmaFunctionCallClose = "" +) + +// This format uses call:name{args} for tool calls. +type FunctionGemmaParser struct { + state FunctionGemmaParserState + buffer strings.Builder + tools []api.Tool +} + +func (p *FunctionGemmaParser) HasToolSupport() bool { return true } +func (p *FunctionGemmaParser) HasThinkingSupport() bool { return false } + +func (p *FunctionGemmaParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool { + p.tools = tools + p.state = FunctionGemmaCollectingContent + return tools +} + +type functionGemmaEvent interface { + isFunctionGemmaEvent() +} + +type FunctionGemmaEventContent struct { + content string +} + +type functionGemmaEventToolCall struct { + toolCall api.ToolCall +} + +func (FunctionGemmaEventContent) isFunctionGemmaEvent() {} +func (functionGemmaEventToolCall) isFunctionGemmaEvent() {} + +func (p *FunctionGemmaParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) { + p.buffer.WriteString(s) + events := p.parseEvents() + + var toolCalls []api.ToolCall + var contentSb strings.Builder + for _, event := range events { + switch event := event.(type) { + case functionGemmaEventToolCall: + toolCalls = append(toolCalls, event.toolCall) + case FunctionGemmaEventContent: + contentSb.WriteString(event.content) + } + } + + return contentSb.String(), "", toolCalls, nil +} + +func (p *FunctionGemmaParser) parseEvents() []functionGemmaEvent { + var all []functionGemmaEvent + + keepLooping := true + for keepLooping { + var events []functionGemmaEvent + events, keepLooping = p.eat() + if len(events) > 0 { + all = append(all, events...) + } + } + + return all +} + +// emitWithPartialCheck extracts unambiguous content before a potential partial tag +func (p *FunctionGemmaParser) emitWithPartialCheck(bufStr, tag string) (unambiguous, ambiguous string) { + if overlapLen := overlap(bufStr, tag); overlapLen > 0 { + beforePartialTag := bufStr[:len(bufStr)-overlapLen] + return beforePartialTag, bufStr[len(beforePartialTag):] + } + return bufStr, "" +} + +func (p *FunctionGemmaParser) eat() ([]functionGemmaEvent, bool) { + bufStr := p.buffer.String() + if bufStr == "" { + return nil, false + } + + switch p.state { + case FunctionGemmaCollectingContent: + if strings.Contains(bufStr, functionGemmaFunctionCallOpen) { + split := strings.SplitN(bufStr, functionGemmaFunctionCallOpen, 2) + content := split[0] + p.buffer.Reset() + p.buffer.WriteString(split[1]) + p.state = FunctionGemmaCollectingToolCalls + if content != "" { + return []functionGemmaEvent{FunctionGemmaEventContent{content: content}}, true + } + return nil, true + } + unambig, ambig := p.emitWithPartialCheck(bufStr, functionGemmaFunctionCallOpen) + p.buffer.Reset() + p.buffer.WriteString(ambig) + if unambig != "" { + return []functionGemmaEvent{FunctionGemmaEventContent{content: unambig}}, false + } + return nil, false + + case FunctionGemmaCollectingToolCalls: + if strings.Contains(bufStr, functionGemmaFunctionCallClose) { + split := strings.SplitN(bufStr, functionGemmaFunctionCallClose, 2) + remaining := split[1] + p.buffer.Reset() + p.buffer.WriteString(remaining) + + var events []functionGemmaEvent + if tc, err := p.parseToolCall(split[0]); err == nil { + events = append(events, functionGemmaEventToolCall{toolCall: tc}) + } + + if !strings.Contains(remaining, functionGemmaFunctionCallOpen) { + p.state = FunctionGemmaCollectingContent + } + return events, true + } + return nil, false + } + + return nil, false +} + +// Matches call:function_name{args} +var functionGemmaCallRegex = regexp.MustCompile(`call:([^{]+)\{(.*)\}`) + +func (p *FunctionGemmaParser) parseToolCall(content string) (api.ToolCall, error) { + toolCall := api.ToolCall{} + + // Extract function name and arguments + match := functionGemmaCallRegex.FindStringSubmatch(content) + if len(match) < 3 { + return toolCall, nil + } + + toolCall.Function.Name = match[1] + argsStr := match[2] + + // Parse arguments + toolCall.Function.Arguments = p.parseArguments(argsStr) + + return toolCall, nil +} + +// parseArguments parses the key:value,key:value format +func (p *FunctionGemmaParser) parseArguments(argsStr string) api.ToolCallFunctionArguments { + args := make(api.ToolCallFunctionArguments) + if argsStr == "" { + return args + } + + // Split by comma, but handle nested structures + parts := p.splitArguments(argsStr) + + for _, part := range parts { + // Find the first colon to split key:value + colonIdx := strings.Index(part, ":") + if colonIdx == -1 { + continue + } + + key := part[:colonIdx] + value := part[colonIdx+1:] + + // Parse the value + args[key] = p.parseValue(value) + } + + return args +} + +// splitArguments splits arguments by comma, respecting nested structures +func (p *FunctionGemmaParser) splitArguments(argsStr string) []string { + var parts []string + var current strings.Builder + depth := 0 + inEscape := false + + for i := 0; i < len(argsStr); i++ { + ch := argsStr[i] + + // Check for tags + if i+8 <= len(argsStr) && argsStr[i:i+8] == "" { + inEscape = !inEscape + current.WriteString("") + i += 7 // Skip the rest of + continue + } + + if !inEscape { + switch ch { + case '{', '[': + depth++ + current.WriteByte(ch) + case '}', ']': + depth-- + current.WriteByte(ch) + case ',': + if depth == 0 { + if current.Len() > 0 { + parts = append(parts, current.String()) + current.Reset() + } + continue + } + current.WriteByte(ch) + default: + current.WriteByte(ch) + } + } else { + current.WriteByte(ch) + } + } + + if current.Len() > 0 { + parts = append(parts, current.String()) + } + + return parts +} + +// parseValue parses a single value from the FunctionGemma format +func (p *FunctionGemmaParser) parseValue(value string) any { + // Check for escaped string + if strings.HasPrefix(value, "") && strings.HasSuffix(value, "") { + // Remove the escape tags + return value[8 : len(value)-8] + } + + // Check for boolean + if value == "true" { + return true + } + if value == "false" { + return false + } + + // Check for number + if num, ok := parseNumber(value); ok { + return num + } + + // Check for array + if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") { + return p.parseArray(value[1 : len(value)-1]) + } + + // Check for object + if strings.HasPrefix(value, "{") && strings.HasSuffix(value, "}") { + return p.parseObject(value[1 : len(value)-1]) + } + + // Default to string + return value +} + +// parseArray parses an array value +func (p *FunctionGemmaParser) parseArray(content string) []any { + var result []any + parts := p.splitArguments(content) + for _, part := range parts { + result = append(result, p.parseValue(part)) + } + return result +} + +// parseObject parses an object value +func (p *FunctionGemmaParser) parseObject(content string) map[string]any { + result := make(map[string]any) + parts := p.splitArguments(content) + for _, part := range parts { + colonIdx := strings.Index(part, ":") + if colonIdx == -1 { + continue + } + key := part[:colonIdx] + value := part[colonIdx+1:] + result[key] = p.parseValue(value) + } + return result +} + +// parseNumber tries to parse a string as a number +func parseNumber(s string) (any, bool) { + // Try integer first + var intVal int64 + if _, err := fmt.Sscanf(s, "%d", &intVal); err == nil { + // Check if the entire string was consumed + if fmt.Sprintf("%d", intVal) == s { + return intVal, true + } + } + + // Try float + var floatVal float64 + if _, err := fmt.Sscanf(s, "%f", &floatVal); err == nil { + return floatVal, true + } + + return nil, false +} diff --git a/model/parsers/functiongemma_test.go b/model/parsers/functiongemma_test.go new file mode 100644 index 00000000..227abdb8 --- /dev/null +++ b/model/parsers/functiongemma_test.go @@ -0,0 +1,423 @@ +package parsers + +import ( + "testing" + + "github.com/ollama/ollama/api" + "github.com/stretchr/testify/assert" +) + +func TestFunctionGemmaParser(t *testing.T) { + tests := []struct { + name string + chunks []string + tools []api.Tool + expectedCalls []api.ToolCall + expectedText string + }{ + { + name: "plain_content", + chunks: []string{"H", "e", "l", "l", "o", ",", " ", "w", "o", "r", "l", "d", "!"}, + expectedCalls: nil, + expectedText: "Hello, world!", + }, + { + name: "simple_tool_call", + chunks: []string{ + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "get", "_", "weather", "{", + "city", ":", "<", "escape", ">", "Paris", "<", "escape", ">", + "}", "<", "end", "_", "function", "_", "call", ">", + }, + tools: []api.Tool{ + { + Type: "function", + Function: api.ToolFunction{ + Name: "get_weather", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Properties: map[string]api.ToolProperty{ + "city": {Type: api.PropertyType{"string"}}, + }, + }, + }, + }, + }, + expectedCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "get_weather", + Arguments: api.ToolCallFunctionArguments{"city": "Paris"}, + }, + }, + }, + expectedText: "", + }, + { + name: "content_before_tool_call", + chunks: []string{ + "L", "et", " ", "me", " ", "check", ".", + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "get", "_", "weather", "{", + "city", ":", "<", "escape", ">", "Paris", "<", "escape", ">", + "}", "<", "end", "_", "function", "_", "call", ">", + }, + expectedCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "get_weather", + Arguments: api.ToolCallFunctionArguments{"city": "Paris"}, + }, + }, + }, + expectedText: "Let me check.", + }, + { + name: "numeric_arguments", + chunks: []string{ + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "add", "{", + "a", ":", "1", ",", "b", ":", "2", + "}", "<", "end", "_", "function", "_", "call", ">", + }, + expectedCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "add", + Arguments: api.ToolCallFunctionArguments{"a": int64(1), "b": int64(2)}, + }, + }, + }, + expectedText: "", + }, + { + name: "boolean_arguments", + chunks: []string{ + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "set", "_", "flag", "{", + "enabled", ":", "true", ",", "verbose", ":", "false", + "}", "<", "end", "_", "function", "_", "call", ">", + }, + expectedCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "set_flag", + Arguments: api.ToolCallFunctionArguments{"enabled": true, "verbose": false}, + }, + }, + }, + expectedText: "", + }, + { + name: "multiple_tool_calls", + chunks: []string{ + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "get", "_", "weather", "{", + "city", ":", "<", "escape", ">", "Paris", "<", "escape", ">", + "}", "<", "end", "_", "function", "_", "call", ">", + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "get", "_", "weather", "{", + "city", ":", "<", "escape", ">", "London", "<", "escape", ">", + "}", "<", "end", "_", "function", "_", "call", ">", + }, + expectedCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "get_weather", + Arguments: api.ToolCallFunctionArguments{"city": "Paris"}, + }, + }, + { + Function: api.ToolCallFunction{ + Name: "get_weather", + Arguments: api.ToolCallFunctionArguments{"city": "London"}, + }, + }, + }, + expectedText: "", + }, + { + name: "array_argument", + chunks: []string{ + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "process", "{", + "items", ":", "[", + "<", "escape", ">", "a", "<", "escape", ">", ",", + "<", "escape", ">", "b", "<", "escape", ">", ",", + "<", "escape", ">", "c", "<", "escape", ">", + "]", + "}", "<", "end", "_", "function", "_", "call", ">", + }, + expectedCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "process", + Arguments: api.ToolCallFunctionArguments{"items": []any{"a", "b", "c"}}, + }, + }, + }, + expectedText: "", + }, + { + name: "object_argument", + chunks: []string{ + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "update", "{", + "data", ":", "{", + "name", ":", "<", "escape", ">", "test", "<", "escape", ">", ",", + "value", ":", "42", + "}", + "}", "<", "end", "_", "function", "_", "call", ">", + }, + expectedCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "update", + Arguments: api.ToolCallFunctionArguments{ + "data": map[string]any{"name": "test", "value": int64(42)}, + }, + }, + }, + }, + expectedText: "", + }, + { + name: "empty_input", + chunks: []string{}, + expectedCalls: nil, + expectedText: "", + }, + { + name: "tool_call_with_no_arguments", + chunks: []string{ + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "get", "_", "time", "{", "}", + "<", "end", "_", "function", "_", "call", ">", + }, + expectedCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "get_time", + Arguments: api.ToolCallFunctionArguments{}, + }, + }, + }, + expectedText: "", + }, + { + name: "content_with_angle_brackets", + chunks: []string{ + "The", " ", "result", " ", "is", " ", "a", " ", "<", "value", ">", " ", "tag", + }, + expectedCalls: nil, + expectedText: "The result is a tag", + }, + { + name: "float_argument", + chunks: []string{ + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "set", "_", "temp", "{", + "value", ":", "3", ".", "14", + "}", "<", "end", "_", "function", "_", "call", ">", + }, + expectedCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "set_temp", + Arguments: api.ToolCallFunctionArguments{"value": 3.14}, + }, + }, + }, + expectedText: "", + }, + { + name: "content_after_tool_call", + chunks: []string{ + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "test", "{", "}", + "<", "end", "_", "function", "_", "call", ">", + "Done", "!", + }, + expectedCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "test", + Arguments: api.ToolCallFunctionArguments{}, + }, + }, + }, + expectedText: "Done!", + }, + { + name: "unicode_content_and_arguments", + chunks: []string{ + "こんにちは", " ", + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "greet", "{", + "name", ":", "<", "escape", ">", "日本語", "<", "escape", ">", + "}", "<", "end", "_", "function", "_", "call", ">", + }, + expectedCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "greet", + Arguments: api.ToolCallFunctionArguments{"name": "日本語"}, + }, + }, + }, + expectedText: "こんにちは ", + }, + { + name: "multiple_params_sorted", + chunks: []string{ + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "search", "{", + "query", ":", "<", "escape", ">", "test", "<", "escape", ">", ",", + "limit", ":", "10", ",", + "offset", ":", "0", + "}", "<", "end", "_", "function", "_", "call", ">", + }, + expectedCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "search", + Arguments: api.ToolCallFunctionArguments{ + "query": "test", + "limit": int64(10), + "offset": int64(0), + }, + }, + }, + }, + expectedText: "", + }, + { + name: "nested_object_argument", + chunks: []string{ + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "create", "{", + "config", ":", "{", + "settings", ":", "{", + "enabled", ":", "true", ",", + "name", ":", "<", "escape", ">", "test", "<", "escape", ">", + "}", + "}", + "}", "<", "end", "_", "function", "_", "call", ">", + }, + expectedCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "create", + Arguments: api.ToolCallFunctionArguments{ + "config": map[string]any{ + "settings": map[string]any{ + "enabled": true, + "name": "test", + }, + }, + }, + }, + }, + }, + expectedText: "", + }, + { + name: "partial_start_tag_in_content", + chunks: []string{ + "Hello", " ", "<", "start", " ", "world", + }, + expectedCalls: nil, + expectedText: "Hello ", + "call", ":", "get", "_", "weather", "{", + "city", ":", "<", "escape", ">", "Paris", "<", "escape", ">", + "}", "<", "end", "_", "function", "_", "call", ">", + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "get", "_", "time", "{", + "timezone", ":", "<", "escape", ">", "UTC", "<", "escape", ">", + "}", "<", "end", "_", "function", "_", "call", ">", + }, + expectedCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "get_weather", + Arguments: api.ToolCallFunctionArguments{"city": "Paris"}, + }, + }, + { + Function: api.ToolCallFunction{ + Name: "get_time", + Arguments: api.ToolCallFunctionArguments{"timezone": "UTC"}, + }, + }, + }, + expectedText: "", + }, + { + name: "content_between_tool_calls", + chunks: []string{ + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "first", "{", "}", + "<", "end", "_", "function", "_", "call", ">", + "Some", " ", "text", " ", "here", + "<", "start", "_", "function", "_", "call", ">", + "call", ":", "second", "{", "}", + "<", "end", "_", "function", "_", "call", ">", + }, + expectedCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "first", + Arguments: api.ToolCallFunctionArguments{}, + }, + }, + { + Function: api.ToolCallFunction{ + Name: "second", + Arguments: api.ToolCallFunctionArguments{}, + }, + }, + }, + expectedText: "Some text here", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := &FunctionGemmaParser{} + parser.Init(tt.tools, nil, nil) + + var allContent string + var allCalls []api.ToolCall + + for i, chunk := range tt.chunks { + done := i == len(tt.chunks)-1 + content, _, calls, err := parser.Add(chunk, done) + assert.NoError(t, err) + allContent += content + allCalls = append(allCalls, calls...) + } + + // Handle empty chunks case + if len(tt.chunks) == 0 { + content, _, calls, err := parser.Add("", true) + assert.NoError(t, err) + allContent = content + allCalls = calls + } + + assert.Equal(t, tt.expectedText, allContent) + assert.Equal(t, tt.expectedCalls, allCalls) + }) + } +} + +func TestFunctionGemmaParser_HasSupport(t *testing.T) { + parser := &FunctionGemmaParser{} + assert.True(t, parser.HasToolSupport()) + assert.False(t, parser.HasThinkingSupport()) +} diff --git a/model/parsers/parsers.go b/model/parsers/parsers.go index 280b71b6..79039e52 100644 --- a/model/parsers/parsers.go +++ b/model/parsers/parsers.go @@ -66,6 +66,8 @@ func ParserForName(name string) Parser { return &Olmo3ThinkParser{} case "nemotron-3-nano": return &Nemotron3NanoParser{} + case "functiongemma": + return &FunctionGemmaParser{} default: return nil } diff --git a/model/renderers/functiongemma.go b/model/renderers/functiongemma.go new file mode 100644 index 00000000..dcbcc062 --- /dev/null +++ b/model/renderers/functiongemma.go @@ -0,0 +1,287 @@ +package renderers + +import ( + "fmt" + "sort" + "strings" + + "github.com/ollama/ollama/api" +) + +type FunctionGemmaRenderer struct{} + +const defaultSystemMessage = "You can do function calling with the following functions:" + +func (r *FunctionGemmaRenderer) Render(messages []api.Message, tools []api.Tool, thinkValue *api.ThinkValue) (string, error) { + var sb strings.Builder + + sb.WriteString("") + + var systemMessage string + var loopMessages []api.Message + if len(messages) > 0 && (messages[0].Role == "system" || messages[0].Role == "developer") { + systemMessage = messages[0].Content + loopMessages = messages[1:] + } else { + loopMessages = messages + } + + if systemMessage != "" || len(tools) > 0 { + sb.WriteString("developer\n") + if systemMessage != "" { + sb.WriteString(strings.TrimSpace(systemMessage)) + } + if len(tools) > 0 { + if systemMessage != "" { + sb.WriteString("\n") + } + if strings.TrimSpace(systemMessage) != defaultSystemMessage { + // Only add default message if user does not provide it + sb.WriteString(defaultSystemMessage) + } + } + for _, tool := range tools { + sb.WriteString(r.renderToolDeclaration(tool)) + } + sb.WriteString("\n") + } + + // Track previous message type for tool response handling + prevMessageType := "" + + for i, message := range loopMessages { + switch message.Role { + case "assistant": + if prevMessageType != "tool_response" { + sb.WriteString("model\n") + } + prevMessageType = "" + + if message.Content != "" { + sb.WriteString(strings.TrimSpace(message.Content)) + } + + if len(message.ToolCalls) > 0 { + for _, tc := range message.ToolCalls { + sb.WriteString(r.formatToolCall(tc)) + } + // After tool calls, expect tool responses + if i+1 < len(loopMessages) && loopMessages[i+1].Role == "tool" { + sb.WriteString("") + prevMessageType = "tool_call" + } else { + sb.WriteString("\n") + } + } else { + sb.WriteString("\n") + } + + case "user": + if prevMessageType != "tool_response" { + sb.WriteString("user\n") + } + prevMessageType = "" + sb.WriteString(strings.TrimSpace(message.Content)) + sb.WriteString("\n") + + case "tool": + toolName := "" + // Find the tool name from the previous assistant's tool call + for j := i - 1; j >= 0; j-- { + if loopMessages[j].Role == "assistant" && len(loopMessages[j].ToolCalls) > 0 { + // Count how many tool messages came before this one + toolIdx := 0 + for k := j + 1; k < i; k++ { + if loopMessages[k].Role == "tool" { + toolIdx++ + } + } + if toolIdx < len(loopMessages[j].ToolCalls) { + toolName = loopMessages[j].ToolCalls[toolIdx].Function.Name + } + break + } + } + + if prevMessageType != "tool_call" { + sb.WriteString("") + } + sb.WriteString("response:" + toolName + "{" + r.formatArgValue(message.Content) + "}") + prevMessageType = "tool_response" + + default: + sb.WriteString("" + message.Role + "\n") + sb.WriteString(strings.TrimSpace(message.Content)) + sb.WriteString("\n") + } + } + + if prevMessageType != "tool_response" { + sb.WriteString("model\n") + } + + return sb.String(), nil +} + +func (r *FunctionGemmaRenderer) renderToolDeclaration(tool api.Tool) string { + var sb strings.Builder + + fn := tool.Function + sb.WriteString("declaration:" + fn.Name + "{") + sb.WriteString("description:" + fn.Description + "") + + if fn.Parameters.Properties != nil || fn.Parameters.Type != "" { + sb.WriteString(",parameters:{") + + needsComma := false + + // Only include properties:{} if there are actual properties + if len(fn.Parameters.Properties) > 0 { + sb.WriteString("properties:{") + r.writeProperties(&sb, fn.Parameters.Properties) + sb.WriteString("}") + needsComma = true + } + + if len(fn.Parameters.Required) > 0 { + if needsComma { + sb.WriteString(",") + } + sb.WriteString("required:[") + for i, req := range fn.Parameters.Required { + if i > 0 { + sb.WriteString(",") + } + sb.WriteString("" + req + "") + } + sb.WriteString("]") + needsComma = true + } + + if fn.Parameters.Type != "" { + if needsComma { + sb.WriteString(",") + } + sb.WriteString("type:" + strings.ToUpper(fn.Parameters.Type) + "") + } + + sb.WriteString("}") + } + + sb.WriteString("}") + return sb.String() +} + +func (r *FunctionGemmaRenderer) writeProperties(sb *strings.Builder, props map[string]api.ToolProperty) { + keys := make([]string, 0, len(props)) + for k := range props { + keys = append(keys, k) + } + sort.Strings(keys) + + first := true + for _, name := range keys { + prop := props[name] + if !first { + sb.WriteString(",") + } + first = false + + sb.WriteString(name + ":{description:") + sb.WriteString(prop.Description) + sb.WriteString("") + + if len(prop.Type) > 0 { + sb.WriteString(",type:" + strings.ToUpper(prop.Type[0]) + "") + } + + sb.WriteString("}") + } +} + +func (r *FunctionGemmaRenderer) formatToolCall(tc api.ToolCall) string { + var sb strings.Builder + sb.WriteString("call:" + tc.Function.Name + "{") + + keys := make([]string, 0, len(tc.Function.Arguments)) + for k := range tc.Function.Arguments { + keys = append(keys, k) + } + sort.Strings(keys) + + first := true + for _, key := range keys { + value := tc.Function.Arguments[key] + if !first { + sb.WriteString(",") + } + first = false + sb.WriteString(key + ":" + r.formatArgValue(value)) + } + + sb.WriteString("}") + return sb.String() +} + +func (r *FunctionGemmaRenderer) formatArgValue(value any) string { + switch v := value.(type) { + case string: + return "" + v + "" + 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.formatMapValue(v) + case []any: + return r.formatArrayValue(v) + default: + return fmt.Sprintf("%v", v) + } +} + +func (r *FunctionGemmaRenderer) formatMapValue(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(key + ":" + r.formatArgValue(m[key])) + } + + sb.WriteString("}") + return sb.String() +} + +func (r *FunctionGemmaRenderer) formatArrayValue(arr []any) string { + var sb strings.Builder + sb.WriteString("[") + + for i, item := range arr { + if i > 0 { + sb.WriteString(",") + } + sb.WriteString(r.formatArgValue(item)) + } + + sb.WriteString("]") + return sb.String() +} diff --git a/model/renderers/functiongemma_test.go b/model/renderers/functiongemma_test.go new file mode 100644 index 00000000..733ff374 --- /dev/null +++ b/model/renderers/functiongemma_test.go @@ -0,0 +1,514 @@ +package renderers + +import ( + "testing" + + "github.com/ollama/ollama/api" + "github.com/stretchr/testify/assert" +) + +func TestFunctionGemmaRenderer(t *testing.T) { + tests := []struct { + name string + messages []api.Message + tools []api.Tool + expected string + }{ + { + name: "basic_user_message", + messages: []api.Message{ + {Role: "user", Content: "Hello!"}, + }, + expected: "user\nHello!\nmodel\n", + }, + { + name: "with_system_message", + messages: []api.Message{ + {Role: "system", Content: "You are helpful"}, + {Role: "user", Content: "Hello!"}, + }, + expected: "developer\nYou are helpful\nuser\nHello!\nmodel\n", + }, + { + name: "with_developer_role", + messages: []api.Message{ + {Role: "developer", Content: "You are a coding assistant"}, + {Role: "user", Content: "Hello!"}, + }, + expected: "developer\nYou are a coding assistant\nuser\nHello!\nmodel\n", + }, + { + name: "custom_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: map[string]api.ToolProperty{ + "city": {Type: api.PropertyType{"string"}, Description: "City"}, + }, + }, + }, + }, + }, + // Custom system message is preserved, tools are appended + expected: "developer\nYou are a weather expert.\nYou can do function calling with the following functions:declaration:get_weather{description:Get weather,parameters:{properties:{city:{description:City,type:STRING}},type:OBJECT}}\nuser\nWeather?\nmodel\n", + }, + { + name: "developer_role_with_tools", + messages: []api.Message{ + {Role: "developer", Content: "Be concise."}, + {Role: "user", Content: "Weather?"}, + }, + tools: []api.Tool{ + { + Type: "function", + Function: api.ToolFunction{ + Name: "get_weather", + Description: "Get weather", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Properties: map[string]api.ToolProperty{ + "city": {Type: api.PropertyType{"string"}, Description: "City"}, + }, + }, + }, + }, + }, + // Developer role message is preserved, tools are appended + expected: "developer\nBe concise.\nYou can do function calling with the following functions:declaration:get_weather{description:Get weather,parameters:{properties:{city:{description:City,type:STRING}},type:OBJECT}}\nuser\nWeather?\nmodel\n", + }, + { + name: "multi_turn", + messages: []api.Message{ + {Role: "user", Content: "Hi"}, + {Role: "assistant", Content: "Hello!"}, + {Role: "user", Content: "More"}, + }, + expected: "user\nHi\nmodel\nHello!\nuser\nMore\nmodel\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: map[string]api.ToolProperty{ + "city": {Type: api.PropertyType{"string"}, Description: "City"}, + }, + }, + }, + }, + }, + expected: "developer\nYou can do function calling with the following functions:declaration:get_weather{description:Get weather,parameters:{properties:{city:{description:City,type:STRING}},type:OBJECT}}\nuser\nWeather?\nmodel\n", + }, + { + name: "tool_call", + messages: []api.Message{ + {Role: "user", Content: "Weather?"}, + { + Role: "assistant", + ToolCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "get_weather", + Arguments: api.ToolCallFunctionArguments{"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: map[string]api.ToolProperty{ + "city": {Type: api.PropertyType{"string"}, Description: "City"}, + }, + }, + }, + }, + }, + expected: "developer\nYou can do function calling with the following functions:declaration:get_weather{description:Get weather,parameters:{properties:{city:{description:City,type:STRING}},type:OBJECT}}\nuser\nWeather?\nmodel\ncall:get_weather{city:Paris}response:get_weather{Sunny}", + }, + { + 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: api.ToolCallFunctionArguments{"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: map[string]api.ToolProperty{ + "city": {Type: api.PropertyType{"string"}, Description: "City"}, + }, + }, + }, + }, + }, + expected: "developer\nYou can do function calling with the following functions:declaration:get_weather{description:Get weather,parameters:{properties:{city:{description:City,type:STRING}},type:OBJECT}}\nuser\nWeather?\nmodel\nLet me check.call:get_weather{city:Paris}response:get_weather{Sunny}", + }, + { + name: "numeric_arguments", + messages: []api.Message{ + {Role: "user", Content: "Add"}, + { + Role: "assistant", + ToolCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "add", + Arguments: api.ToolCallFunctionArguments{"a": float64(1), "b": float64(2)}, + }, + }, + }, + }, + {Role: "tool", Content: "3"}, + }, + tools: []api.Tool{ + { + Type: "function", + Function: api.ToolFunction{ + Name: "add", + Description: "Add numbers", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Properties: map[string]api.ToolProperty{ + "a": {Type: api.PropertyType{"number"}}, + "b": {Type: api.PropertyType{"number"}}, + }, + }, + }, + }, + }, + expected: "developer\nYou can do function calling with the following functions:declaration:add{description:Add numbers,parameters:{properties:{a:{description:,type:NUMBER},b:{description:,type:NUMBER}},type:OBJECT}}\nuser\nAdd\nmodel\ncall:add{a:1,b:2}response:add{3}", + }, + { + name: "empty_messages", + messages: []api.Message{}, + expected: "model\n", + }, + { + 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: map[string]api.ToolProperty{ + "city": {Type: api.PropertyType{"string"}, Description: "City Name"}, + "country": {Type: api.PropertyType{"string"}, Description: "Country Name"}, + }, + }, + }, + }, + }, + // Required params are escaped: required:[city] + expected: "developer\nYou can do function calling with the following functions:declaration:get_weather{description:Gets the weather for a given city,parameters:{properties:{city:{description:City Name,type:STRING},country:{description:Country Name,type:STRING}},required:[city],type:OBJECT}}\nuser\nWeather?\nmodel\n", + }, + { + name: "multiple_tools", + messages: []api.Message{ + {Role: "user", Content: "Weather and time?"}, + }, + tools: []api.Tool{ + { + Type: "function", + Function: api.ToolFunction{ + Name: "get_weather", + Description: "Get weather", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Properties: 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: map[string]api.ToolProperty{ + "timezone": {Type: api.PropertyType{"string"}, Description: "Timezone"}, + }, + }, + }, + }, + }, + // Multiple tool declarations are consecutive + expected: "developer\nYou can do function calling with the following functions:declaration:get_weather{description:Get weather,parameters:{properties:{city:{description:City,type:STRING}},type:OBJECT}}declaration:get_time{description:Get current time,parameters:{properties:{timezone:{description:Timezone,type:STRING}},type:OBJECT}}\nuser\nWeather and time?\nmodel\n", + }, + { + 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: api.ToolCallFunctionArguments{"city": "Paris"}, + }, + }, + { + Function: api.ToolCallFunction{ + Name: "get_time", + Arguments: api.ToolCallFunctionArguments{"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: 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: map[string]api.ToolProperty{ + "timezone": {Type: api.PropertyType{"string"}, Description: "Timezone"}, + }, + }, + }, + }, + }, + // Multiple tool calls and responses are consecutive + expected: "developer\nYou can do function calling with the following functions:declaration:get_weather{description:Get weather,parameters:{properties:{city:{description:City,type:STRING}},type:OBJECT}}declaration:get_time{description:Get current time,parameters:{properties:{timezone:{description:Timezone,type:STRING}},type:OBJECT}}\nuser\nWeather and time?\nmodel\ncall:get_weather{city:Paris}call:get_time{timezone:UTC}response:get_weather{Sunny}response:get_time{12:00}", + }, + { + name: "user_after_tool_response", + messages: []api.Message{ + {Role: "user", Content: "Weather?"}, + { + Role: "assistant", + ToolCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "get_weather", + Arguments: api.ToolCallFunctionArguments{"city": "Paris"}, + }, + }, + }, + }, + {Role: "tool", Content: "Sunny"}, + {Role: "user", Content: "Thanks! What about London?"}, + }, + tools: []api.Tool{ + { + Type: "function", + Function: api.ToolFunction{ + Name: "get_weather", + Description: "Get weather", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Properties: map[string]api.ToolProperty{ + "city": {Type: api.PropertyType{"string"}, Description: "City"}, + }, + }, + }, + }, + }, + // User message after tool response gets concatenated (user reverted to this behavior) + expected: "developer\nYou can do function calling with the following functions:declaration:get_weather{description:Get weather,parameters:{properties:{city:{description:City,type:STRING}},type:OBJECT}}\nuser\nWeather?\nmodel\ncall:get_weather{city:Paris}response:get_weather{Sunny}Thanks! What about London?\nmodel\n", + }, + // Edge cases + { + name: "tool_empty_properties", + messages: []api.Message{ + {Role: "user", Content: "Test"}, + }, + tools: []api.Tool{ + { + Type: "function", + Function: api.ToolFunction{ + Name: "test_fn", + Description: "", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Properties: map[string]api.ToolProperty{}, + }, + }, + }, + }, + // Empty properties are omitted + expected: "developer\nYou can do function calling with the following functions:declaration:test_fn{description:,parameters:{type:OBJECT}}\nuser\nTest\nmodel\n", + }, + { + name: "unicode_content", + messages: []api.Message{ + {Role: "user", Content: "こんにちは 🎉"}, + }, + expected: "user\nこんにちは 🎉\nmodel\n", + }, + { + name: "newlines_in_content", + messages: []api.Message{ + {Role: "user", Content: "Line 1\nLine 2\nLine 3"}, + }, + expected: "user\nLine 1\nLine 2\nLine 3\nmodel\n", + }, + { + name: "special_chars_in_content", + messages: []api.Message{ + {Role: "user", Content: "Test & \"quotes\" chars"}, + }, + expected: "user\nTest & \"quotes\" chars\nmodel\n", + }, + { + name: "boolean_argument", + messages: []api.Message{ + {Role: "user", Content: "Set flag"}, + { + Role: "assistant", + ToolCalls: []api.ToolCall{ + { + Function: api.ToolCallFunction{ + Name: "set_flag", + Arguments: api.ToolCallFunctionArguments{"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: map[string]api.ToolProperty{ + "enabled": {Type: api.PropertyType{"boolean"}, Description: "Flag value"}, + }, + }, + }, + }, + }, + expected: "developer\nYou can do function calling with the following functions:declaration:set_flag{description:Set a flag,parameters:{properties:{enabled:{description:Flag value,type:BOOLEAN}},type:OBJECT}}\nuser\nSet flag\nmodel\ncall:set_flag{enabled:true}response:set_flag{done}", + }, + { + name: "multiple_required_params", + messages: []api.Message{ + {Role: "user", Content: "Test"}, + }, + tools: []api.Tool{ + { + Type: "function", + Function: api.ToolFunction{ + Name: "test", + Description: "Test", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Required: []string{"a", "b", "c"}, + Properties: map[string]api.ToolProperty{ + "a": {Type: api.PropertyType{"string"}, Description: "A"}, + "b": {Type: api.PropertyType{"string"}, Description: "B"}, + "c": {Type: api.PropertyType{"string"}, Description: "C"}, + }, + }, + }, + }, + }, + expected: "developer\nYou can do function calling with the following functions:declaration:test{description:Test,parameters:{properties:{a:{description:A,type:STRING},b:{description:B,type:STRING},c:{description:C,type:STRING}},required:[a,b,c],type:OBJECT}}\nuser\nTest\nmodel\n", + }, + { + name: "array_type_param", + messages: []api.Message{ + {Role: "user", Content: "Test"}, + }, + tools: []api.Tool{ + { + Type: "function", + Function: api.ToolFunction{ + Name: "test", + Description: "Test", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Properties: map[string]api.ToolProperty{ + "items": {Type: api.PropertyType{"array"}, Description: "List of items"}, + }, + }, + }, + }, + }, + expected: "developer\nYou can do function calling with the following functions:declaration:test{description:Test,parameters:{properties:{items:{description:List of items,type:ARRAY}},type:OBJECT}}\nuser\nTest\nmodel\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + renderer := &FunctionGemmaRenderer{} + result, err := renderer.Render(tt.messages, tt.tools, nil) + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/model/renderers/renderer.go b/model/renderers/renderer.go index ceb683ff..2aed5dca 100644 --- a/model/renderers/renderer.go +++ b/model/renderers/renderer.go @@ -78,6 +78,8 @@ func rendererForName(name string) Renderer { return renderer case "nemotron-3-nano": return &Nemotron3NanoRenderer{} + case "functiongemma": + return &FunctionGemmaRenderer{} default: return nil }