add new parser, tests, and templates

This commit is contained in:
ParthSareen 2025-05-07 15:50:51 -07:00
parent b5a982ecb0
commit a44734b030
6 changed files with 222 additions and 70 deletions

44
server/testdata/tools/llama3.2.gotmpl vendored Normal file
View File

@ -0,0 +1,44 @@
<|start_header_id|>system<|end_header_id|>
Cutting Knowledge Date: December 2023
{{ if .System }}{{ .System }}
{{- end }}
{{- if .Tools }}When you receive a tool call response, use the output to format an answer to the orginal user question.
You are a helpful assistant with tool calling capabilities.
{{- end }}<|eot_id|>
{{- range $i, $_ := .Messages }}
{{- $last := eq (len (slice $.Messages $i)) 1 }}
{{- if eq .Role "user" }}<|start_header_id|>user<|end_header_id|>
{{- if and $.Tools $last }}
Given the following functions, please respond with a JSON for a function call with its proper arguments that best answers the given prompt.
Respond in the format {"name": function name, "parameters": dictionary of argument name and its value}. Do not use variables.
{{ range $.Tools }}
{{- . }}
{{ end }}
{{ .Content }}<|eot_id|>
{{- else }}
{{ .Content }}<|eot_id|>
{{- end }}{{ if $last }}<|start_header_id|>assistant<|end_header_id|>
{{ end }}
{{- else if eq .Role "assistant" }}<|start_header_id|>assistant<|end_header_id|>
{{- if .ToolCalls }}
{{ range .ToolCalls }}
{"name": "{{ .Function.Name }}", "parameters": {{ .Function.Arguments }}}{{ end }}
{{- else }}
{{ .Content }}
{{- end }}{{ if not $last }}<|eot_id|>{{ end }}
{{- else if eq .Role "tool" }}<|start_header_id|>ipython<|end_header_id|>
{{ .Content }}<|eot_id|>{{ if $last }}<|start_header_id|>assistant<|end_header_id|>
{{ end }}
{{- end }}
{{- end }}

24
server/testdata/tools/llama3.2.out vendored Normal file
View File

@ -0,0 +1,24 @@
<|start_header_id|>system<|end_header_id|>
Cutting Knowledge Date: December 2023
You are a knowledgeable assistant. You can answer questions and perform tasks.When you receive a tool call response, use the output to format an answer to the orginal user question.
You are a helpful assistant with tool calling capabilities.<|eot_id|><|start_header_id|>user<|end_header_id|>
What's the weather like today in Paris?<|eot_id|><|start_header_id|>assistant<|end_header_id|>
{"name": "get_current_weather", "parameters": {"format":"celsius","location":"Paris, France"}}<|eot_id|><|start_header_id|>ipython<|end_header_id|>
22<|eot_id|><|start_header_id|>assistant<|end_header_id|>
The current temperature in Paris, France is 22 degrees Celsius.<|eot_id|><|start_header_id|>user<|end_header_id|>
Given the following functions, please respond with a JSON for a function call with its proper arguments that best answers the given prompt.
Respond in the format {"name": function name, "parameters": dictionary of argument name and its value}. Do not use variables.
{"type":"function","function":{"name":"get_current_weather","description":"Get the current weather","parameters":{"type":"object","required":["location","format"],"properties":{"format":{"type":"string","description":"The temperature unit to use. Infer this from the user's location.","enum":["celsius","fahrenheit"]},"location":{"type":"string","description":"The city and state, e.g. San Francisco, CA"}}}}}
What's the weather like today in San Francisco and Toronto?<|eot_id|><|start_header_id|>assistant<|end_header_id|>

50
server/testdata/tools/qwen3.gotmpl vendored Normal file
View File

@ -0,0 +1,50 @@
{{- if .Messages }}
{{- if or .System .Tools }}<|im_start|>system
{{- if .System }}
{{ .System }}
{{- end }}
{{- if .Tools }}
# Tools
You may call one or more functions to assist with the user query.
You are provided with function signatures within <tools></tools> XML tags:
<tools>
{{- range .Tools }}
{"type": "function", "function": {{ .Function }}}
{{- end }}
</tools>
For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call>
{{- end }}<|im_end|>
{{ end }}
{{- range $i, $_ := .Messages }}
{{- $last := eq (len (slice $.Messages $i)) 1 -}}
{{- if eq .Role "user" }}<|im_start|>user
{{ .Content }}<|im_end|>
{{ else if eq .Role "assistant" }}<|im_start|>assistant
{{ if .Content }}{{ .Content }}
{{- else if .ToolCalls }}<tool_call>
{{ range .ToolCalls }}{"name": "{{ .Function.Name }}", "arguments": {{ .Function.Arguments }}}
{{ end }}</tool_call>
{{- end }}{{ if not $last }}<|im_end|>
{{ end }}
{{- else if eq .Role "tool" }}<|im_start|>user
<tool_response>
{{ .Content }}
</tool_response><|im_end|>
{{ end }}
{{- if and (ne .Role "assistant") $last }}<|im_start|>assistant
{{ end }}
{{- end }}
{{- else }}
{{- if .System }}<|im_start|>system
{{ .System }}<|im_end|>
{{ end }}{{ if .Prompt }}<|im_start|>user
{{ .Prompt }}<|im_end|>
{{ end }}<|im_start|>assistant
{{ end }}{{ .Response }}{{ if .Response }}<|im_end|>{{ end }}

31
server/testdata/tools/qwen3.out vendored Normal file
View File

@ -0,0 +1,31 @@
<|im_start|>system
You are a knowledgeable assistant. You can answer questions and perform tasks.
# Tools
You may call one or more functions to assist with the user query.
You are provided with function signatures within <tools></tools> XML tags:
<tools>
{"type": "function", "function": {"name":"get_current_weather","description":"Get the current weather","parameters":{"type":"object","required":["location","format"],"properties":{"format":{"type":"string","description":"The temperature unit to use. Infer this from the user's location.","enum":["celsius","fahrenheit"]},"location":{"type":"string","description":"The city and state, e.g. San Francisco, CA"}}}}}
</tools>
For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call><|im_end|>
<|im_start|>user
What's the weather like today in Paris?<|im_end|>
<|im_start|>assistant
<tool_call>
{"name": "get_current_weather", "arguments": {"format":"celsius","location":"Paris, France"}}
</tool_call><|im_end|>
<|im_start|>user
<tool_response>
22
</tool_response><|im_end|>
<|im_start|>assistant
The current temperature in Paris, France is 22 degrees Celsius.<|im_end|>
<|im_start|>user
What's the weather like today in San Francisco and Toronto?<|im_end|>
<|im_start|>assistant

View File

@ -167,7 +167,7 @@ func (p *ToolParser) parseJSONToolCalls(s string) ([]api.ToolCall, bool, bool) {
return toolCalls, false, true
}
func (p *ToolParser) updateState(ok bool, partial bool, tcs []api.ToolCall) {
func (p *ToolParser) updateOutputState(ok bool, partial bool, tcs []api.ToolCall) {
switch {
case !ok && !partial && p.state == ForceTools:
fmt.Println("Case: !ok && !partial && ForceTools - staying in force tools, resetting buffer")
@ -188,7 +188,6 @@ func (p *ToolParser) updateState(ok bool, partial bool, tcs []api.ToolCall) {
p.sb.Reset()
case !ok && partial:
fmt.Println("Case: !ok && partial - accumulating partial content")
// ! acucumulate
case len(tcs) > 0:
@ -204,68 +203,74 @@ func (p *ToolParser) updateState(ok bool, partial bool, tcs []api.ToolCall) {
}
}
func (p *ToolParser) updateInputState(s string, hasPrefix bool) (string, bool) {
if p.toolPrefix == "" {
return s, true
}
if hasPrefix {
p.state = ForceTools
// partial tool possibly
} else if strings.HasPrefix(p.toolPrefix, s) {
slog.Debug("tool prefix partially", "prefix", p.toolPrefix, "content", s)
// TODO: could possibly err maybe this should be greedy instead?
p.state = ForceTools
return "", false
} else if strings.Contains(s, p.toolPrefix) {
idx := strings.Index(s, p.toolPrefix)
if idx != -1 {
// still keeps the prefix
p.state = ContainsPartialPrefix
p.sb.Reset()
p.sb.WriteString(s[idx:])
return s[:idx], false
}
}
// Special token end case
if strings.HasSuffix(s, p.toolPrefix[2:]) {
// can be with string or just the token
if hasPrefix {
s = strings.TrimSpace(s[:len(s)-(len(p.toolPrefix)+1)])
} else {
p.state = ToolSuffix
p.sb.Reset()
return "", false
}
slog.Debug("setting to no tool", "content", s)
}
return s, true
}
// ParseToolCalls extracts tool calls from a string using a tool token prefix or direct JSON parsing.
// Returns tool calls, whether parsing is incomplete, and any errors.
func (p *ToolParser) ParseToolCalls(s string) ([]api.ToolCall, string, bool) {
// append input
p.sb.WriteString(s)
s = p.sb.String()
s = strings.TrimSpace(s)
slog.Debug("parse tool calls", "content", s)
if len(s) == 0 {
return nil, "", false
}
s, hasPrefix := strings.CutPrefix(s, p.toolPrefix)
fmt.Println("hasPrefix", hasPrefix)
var tcs []api.ToolCall
var partial bool
var ok bool
if p.toolPrefix != "" {
if hasPrefix {
p.state = ForceTools
slog.Debug("tool prefix in prefix", "prefix", p.toolPrefix, "content", s)
// partial tool possibly
} else if strings.HasPrefix(p.toolPrefix, s) {
slog.Debug("tool prefix partially", "prefix", p.toolPrefix, "content", s)
// TODO: could possibly err maybe this should be greedy instead?
p.state = ForceTools
return nil, "", false
} else if strings.Contains(s, p.toolPrefix) {
idx := strings.Index(s, p.toolPrefix)
if idx != -1 {
// still keeps the prefix
p.state = ContainsPartialPrefix
p.sb.Reset()
p.sb.WriteString(s[idx:])
return nil, s[:idx], false
}
s, hasPrefix := strings.CutPrefix(s, p.toolPrefix)
s, ok := p.updateInputState(s, hasPrefix)
if !ok {
if p.state == ContainsPartialPrefix {
return nil, s, false
}
// Special token end case
// if s, ok := strings.CutSuffix(s, p.toolPrefix[2:]); ok {
if strings.HasSuffix(s, p.toolPrefix[2:]) {
// can be with string or just the token
if hasPrefix {
s = strings.TrimSpace(s[:len(s)-(len(p.toolPrefix)+1)])
} else {
p.state = ToolSuffix
p.sb.Reset()
return nil, "", false
}
slog.Debug("setting to no tool", "content", s)
}
}
fmt.Println("s before parsing", s)
if p.state == SendTokens {
fmt.Println("returning nil cause of send tokens")
return nil, "", false
}
tcs, partial, ok = p.parseJSONToolCalls(s)
slog.Debug("returning tool calls", "tool calls", tcs)
fmt.Println("end state", p.state)
fmt.Println("len tcs", len(tcs))
p.updateState(ok, partial, tcs)
if p.state == SendTokens {
return nil, "", false
}
var tcs []api.ToolCall
var partial bool
tcs, partial, ok = p.parseJSONToolCalls(s)
p.updateOutputState(ok, partial, tcs)
if !ok {
return nil, "", false
}
@ -276,7 +281,6 @@ func (p *ToolParser) ParseToolCalls(s string) ([]api.ToolCall, string, bool) {
func NewToolParser(model *Model) *ToolParser {
templateToolPrefix, _ := ToolPrefix(model.Template.Template)
templateToolPrefix = strings.TrimSpace(templateToolPrefix)
slog.Debug("tool prefix", "prefix", templateToolPrefix)
tmpl, ok := ToolTemplate(model)
if !ok {
return nil
@ -288,6 +292,7 @@ func NewToolParser(model *Model) *ToolParser {
} else {
state = GreedyToolWithPrefix
}
fmt.Println("state", state)
return &ToolParser{
tmpl: tmpl,
sb: &strings.Builder{},

View File

@ -51,7 +51,6 @@ func TestParseToolCalls(t *testing.T) {
name string
model string
output string
prefix string
expected []api.ToolCall
wantErr bool
}{
@ -59,7 +58,6 @@ func TestParseToolCalls(t *testing.T) {
name: "mistral invalid json",
model: "mistral",
output: `[TOOL_CALLS] [{"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, CA"}},{"name": "get_curren}]`,
prefix: "[TOOL_CALLS]",
expected: []api.ToolCall{},
wantErr: true,
},
@ -67,7 +65,6 @@ func TestParseToolCalls(t *testing.T) {
name: "mistral multiple tool calls - no prefix",
model: "mistral",
output: `[{"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, CA"}},{"name": "get_current_weather", "arguments": {"format":"celsius","location":"Toronto, Canada"}}]`,
prefix: "[TOOL_CALLS]",
expected: []api.ToolCall{t1, t2},
wantErr: false,
},
@ -76,7 +73,6 @@ func TestParseToolCalls(t *testing.T) {
model: "mistral",
output: `[{"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, CA"}},{"name": "get_current_weather", "arguments": {"format":"celsius","location":"Toronto, Canada"}}]
model outputs more tokens here and then [{"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, CA"}},{"name": "get_current_weather", "arguments": {"format":"celsius","location":"Toronto, Canada"}}]`,
prefix: "[TOOL_CALLS]",
expected: []api.ToolCall{t1, t2},
wantErr: false,
},
@ -84,7 +80,6 @@ func TestParseToolCalls(t *testing.T) {
name: "mistral valid json - with prefix",
model: "mistral",
output: `[TOOL_CALLS] [{"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, CA"}},{"name": "get_current_weather", "arguments": {"format":"celsius","location":"Toronto, Canada"}}]`,
prefix: "[TOOL_CALLS]",
expected: []api.ToolCall{t1, t2},
wantErr: false,
},
@ -94,7 +89,6 @@ func TestParseToolCalls(t *testing.T) {
model: "mistral",
output: `[TOOL_CALLS] [{"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, CA"}},{"name": "get_current_weather", "arguments": {"format":"celsius","location":"Toronto, Canada"}}]
model outputs more tokens here and then [{"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, CA"}},{"name": "get_current_weather", "arguments": {"format":"celsius","location":"Toronto, Canada"}}]`,
prefix: "[TOOL_CALLS]",
expected: []api.ToolCall{t1, t2, t1, t2},
wantErr: false,
},
@ -102,7 +96,6 @@ func TestParseToolCalls(t *testing.T) {
name: "mistral incomplete json",
model: "mistral",
output: `[TOOL_CALLS] [{"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, `,
prefix: "[TOOL_CALLS]",
expected: []api.ToolCall{},
wantErr: true,
},
@ -112,7 +105,6 @@ func TestParseToolCalls(t *testing.T) {
output: `I'm not aware of that information. However, I can suggest searching for the weather using the "get_current_weather" function:
[{"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, CA"}},{"name": "get_current_weather", "arguments": {"format":"celsius","location":"Toronto, Canada"}}]`,
prefix: "[TOOL_CALLS]",
expected: []api.ToolCall{},
wantErr: true,
},
@ -120,7 +112,6 @@ func TestParseToolCalls(t *testing.T) {
name: "mistral without tool token - tool first",
model: "mistral",
output: `[{"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, CA"}},{"name": "get_current_weather", "arguments": {"format":"celsius","location":"Toronto, Canada"}}]`,
prefix: "[TOOL_CALLS]",
expected: []api.ToolCall{t1, t2},
wantErr: false,
},
@ -145,7 +136,6 @@ func TestParseToolCalls(t *testing.T) {
}
]
` + "```",
prefix: "Action: ```json",
expected: []api.ToolCall{t1, t2},
wantErr: false,
},
@ -153,7 +143,6 @@ func TestParseToolCalls(t *testing.T) {
name: "firefunction with functools",
model: "firefunction",
output: ` functools[{"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, CA"}},{"name": "get_current_weather", "arguments": {"format":"celsius","location":"Toronto, Canada"}}]`,
prefix: "functools",
expected: []api.ToolCall{t1, t2},
wantErr: false,
},
@ -163,7 +152,6 @@ func TestParseToolCalls(t *testing.T) {
output: `<tool_call>
{"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, CA"}}
</tool_call>`,
prefix: "<tool_call>",
expected: []api.ToolCall{t1},
wantErr: false,
},
@ -171,7 +159,6 @@ func TestParseToolCalls(t *testing.T) {
name: "xlam with tool_calls wrapper",
model: "xlam",
output: `{"tool_calls": [{"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, CA"}},{"name": "get_current_weather", "arguments": {"format":"celsius","location":"Toronto, Canada"}}]}`,
prefix: "",
expected: []api.ToolCall{t1, t2},
wantErr: false,
},
@ -179,7 +166,6 @@ func TestParseToolCalls(t *testing.T) {
name: "qwen with single tool call",
model: "qwen2.5-coder",
output: `<tool_call>{"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, CA"}}</tool_call>`,
prefix: "<tool_call>",
expected: []api.ToolCall{t1},
wantErr: false,
},
@ -187,7 +173,6 @@ func TestParseToolCalls(t *testing.T) {
name: "qwen with invalid tool token",
model: "qwen2.5-coder",
output: `[{"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, CA"}}, {"name": "get_current_weather", "arguments": {"format":"celsius","location":"Toronto, Canada"}}]`,
prefix: "[TOOL_CALLS]",
expected: []api.ToolCall{t1, t2},
wantErr: false,
},
@ -195,7 +180,6 @@ func TestParseToolCalls(t *testing.T) {
name: "qwen3 with single tool call and thinking",
model: "qwen3",
output: `<think>Okay, let me think what tool we should use...</think><tool_call>{"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, CA"}}</tool_call>`,
prefix: "<tool_call>",
expected: []api.ToolCall{t1},
wantErr: false,
},
@ -203,7 +187,6 @@ func TestParseToolCalls(t *testing.T) {
name: "qwen3 with single tool call and thinking spaces",
model: "qwen3",
output: `<think>Okay, let me think what tool we should use...</think> <tool_call> {"name": "get_current_weather", "arguments": {"format":"fahrenheit","location":"San Francisco, CA"}} </tool_call>`,
prefix: "<tool_call>",
expected: []api.ToolCall{t1},
wantErr: false,
},
@ -211,7 +194,20 @@ func TestParseToolCalls(t *testing.T) {
name: "qwen with no tool calls",
model: "qwen2.5-coder",
output: " The weather in San Francisco, CA is 70°F and in Toronto, Canada is 20°C.",
prefix: "",
expected: []api.ToolCall{},
wantErr: true,
},
{
name: "llama3.2 with tool call - no prefix",
model: "llama3.2",
output: `{"name": "get_current_weather", "parameters": {"format":"fahrenheit","location":"San Francisco, CA"}}`,
expected: []api.ToolCall{t1},
wantErr: false,
},
{
name: "llama3.2 with tool call - in middle",
model: "llama3.2",
output: `some non json text{"name": "get_current_weather", "parameters": {"format":"fahrenheit","location":"San Francisco, CA"}}`,
expected: []api.ToolCall{},
wantErr: true,
},
@ -235,11 +231,13 @@ func TestParseToolCalls(t *testing.T) {
}
t.Run("template", func(t *testing.T) {
var actual bytes.Buffer
if err := tmpl.Execute(&actual, template.Values{Tools: tools, Messages: messages}); err != nil {
actual := &bytes.Buffer{} // Create new buffer for each test
t.Log("template", tmpl, "model", tt.model)
if err := tmpl.Execute(actual, template.Values{Tools: tools, Messages: messages}); err != nil {
t.Fatal(err)
}
t.Log("actual", actual.String())
if diff := cmp.Diff(actual.String(), readFile(t, p, fmt.Sprintf("%s.out", tt.model)).String()); diff != "" {
t.Errorf("mismatch (-got +want):\n%s", diff)
}