mirror of
https://github.com/ollama/ollama.git
synced 2026-04-27 03:05:43 +02:00
Right now we deserialize tool call definitions' arguments into golang maps. These purposefully don't have a predictable iteration order, whereas we want to maintain the order the user originally provided. Unstable rendering of arguments means that we break the kv cache, which this change fixes. There's no way to build this in a fully backwards compatible way when executing existing templates exactly as they are. We get around this by rewriting templates dynamically just before they're rendered. This is fragile, but perhaps the least bad option?
522 lines
11 KiB
Go
522 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"math"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestKeepAliveParsingFromJSON(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
req string
|
|
exp *Duration
|
|
}{
|
|
{
|
|
name: "Unset",
|
|
req: `{ }`,
|
|
exp: nil,
|
|
},
|
|
{
|
|
name: "Positive Integer",
|
|
req: `{ "keep_alive": 42 }`,
|
|
exp: &Duration{42 * time.Second},
|
|
},
|
|
{
|
|
name: "Positive Float",
|
|
req: `{ "keep_alive": 42.5 }`,
|
|
exp: &Duration{42500 * time.Millisecond},
|
|
},
|
|
{
|
|
name: "Positive Integer String",
|
|
req: `{ "keep_alive": "42m" }`,
|
|
exp: &Duration{42 * time.Minute},
|
|
},
|
|
{
|
|
name: "Negative Integer",
|
|
req: `{ "keep_alive": -1 }`,
|
|
exp: &Duration{math.MaxInt64},
|
|
},
|
|
{
|
|
name: "Negative Float",
|
|
req: `{ "keep_alive": -3.14 }`,
|
|
exp: &Duration{math.MaxInt64},
|
|
},
|
|
{
|
|
name: "Negative Integer String",
|
|
req: `{ "keep_alive": "-1m" }`,
|
|
exp: &Duration{math.MaxInt64},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var dec ChatRequest
|
|
err := json.Unmarshal([]byte(test.req), &dec)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, test.exp, dec.KeepAlive)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDurationMarshalUnmarshal(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input time.Duration
|
|
expected time.Duration
|
|
}{
|
|
{
|
|
"negative duration",
|
|
time.Duration(-1),
|
|
time.Duration(math.MaxInt64),
|
|
},
|
|
{
|
|
"positive duration",
|
|
42 * time.Second,
|
|
42 * time.Second,
|
|
},
|
|
{
|
|
"another positive duration",
|
|
42 * time.Minute,
|
|
42 * time.Minute,
|
|
},
|
|
{
|
|
"zero duration",
|
|
time.Duration(0),
|
|
time.Duration(0),
|
|
},
|
|
{
|
|
"max duration",
|
|
time.Duration(math.MaxInt64),
|
|
time.Duration(math.MaxInt64),
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
b, err := json.Marshal(Duration{test.input})
|
|
require.NoError(t, err)
|
|
|
|
var d Duration
|
|
err = json.Unmarshal(b, &d)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, test.expected, d.Duration, "input %v, marshalled %v, got %v", test.input, string(b), d.Duration)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUseMmapParsingFromJSON(t *testing.T) {
|
|
tr := true
|
|
fa := false
|
|
tests := []struct {
|
|
name string
|
|
req string
|
|
exp *bool
|
|
}{
|
|
{
|
|
name: "Undefined",
|
|
req: `{ }`,
|
|
exp: nil,
|
|
},
|
|
{
|
|
name: "True",
|
|
req: `{ "use_mmap": true }`,
|
|
exp: &tr,
|
|
},
|
|
{
|
|
name: "False",
|
|
req: `{ "use_mmap": false }`,
|
|
exp: &fa,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var oMap map[string]any
|
|
err := json.Unmarshal([]byte(test.req), &oMap)
|
|
require.NoError(t, err)
|
|
opts := DefaultOptions()
|
|
err = opts.FromMap(oMap)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, test.exp, opts.UseMMap)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUseMmapFormatParams(t *testing.T) {
|
|
tr := true
|
|
fa := false
|
|
tests := []struct {
|
|
name string
|
|
req map[string][]string
|
|
exp *bool
|
|
err error
|
|
}{
|
|
{
|
|
name: "True",
|
|
req: map[string][]string{
|
|
"use_mmap": {"true"},
|
|
},
|
|
exp: &tr,
|
|
err: nil,
|
|
},
|
|
{
|
|
name: "False",
|
|
req: map[string][]string{
|
|
"use_mmap": {"false"},
|
|
},
|
|
exp: &fa,
|
|
err: nil,
|
|
},
|
|
{
|
|
name: "Numeric True",
|
|
req: map[string][]string{
|
|
"use_mmap": {"1"},
|
|
},
|
|
exp: &tr,
|
|
err: nil,
|
|
},
|
|
{
|
|
name: "Numeric False",
|
|
req: map[string][]string{
|
|
"use_mmap": {"0"},
|
|
},
|
|
exp: &fa,
|
|
err: nil,
|
|
},
|
|
{
|
|
name: "invalid string",
|
|
req: map[string][]string{
|
|
"use_mmap": {"foo"},
|
|
},
|
|
exp: nil,
|
|
err: errors.New("invalid bool value [foo]"),
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
resp, err := FormatParams(test.req)
|
|
require.Equal(t, test.err, err)
|
|
respVal, ok := resp["use_mmap"]
|
|
if test.exp != nil {
|
|
assert.True(t, ok, "resp: %v", resp)
|
|
assert.Equal(t, *test.exp, *respVal.(*bool))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMessage_UnmarshalJSON(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{`{"role": "USER", "content": "Hello!"}`, "user"},
|
|
{`{"role": "System", "content": "Initialization complete."}`, "system"},
|
|
{`{"role": "assistant", "content": "How can I help you?"}`, "assistant"},
|
|
{`{"role": "TOOl", "content": "Access granted."}`, "tool"},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
var msg Message
|
|
if err := json.Unmarshal([]byte(test.input), &msg); err != nil {
|
|
t.Errorf("Unexpected error: %v", err)
|
|
}
|
|
|
|
if msg.Role != test.expected {
|
|
t.Errorf("role not lowercased: got %v, expected %v", msg.Role, test.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestToolFunction_UnmarshalJSON(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "valid enum with same types",
|
|
input: `{
|
|
"name": "test",
|
|
"description": "test function",
|
|
"parameters": {
|
|
"type": "object",
|
|
"required": ["test"],
|
|
"properties": {
|
|
"test": {
|
|
"type": "string",
|
|
"description": "test prop",
|
|
"enum": ["a", "b", "c"]
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
wantErr: "",
|
|
},
|
|
{
|
|
name: "empty enum array",
|
|
input: `{
|
|
"name": "test",
|
|
"description": "test function",
|
|
"parameters": {
|
|
"type": "object",
|
|
"required": ["test"],
|
|
"properties": {
|
|
"test": {
|
|
"type": "string",
|
|
"description": "test prop",
|
|
"enum": []
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
wantErr: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var tf ToolFunction
|
|
err := json.Unmarshal([]byte(tt.input), &tf)
|
|
|
|
if tt.wantErr != "" {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tt.wantErr)
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPropertyType_UnmarshalJSON(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected PropertyType
|
|
}{
|
|
{
|
|
name: "string type",
|
|
input: `"string"`,
|
|
expected: PropertyType{"string"},
|
|
},
|
|
{
|
|
name: "array of types",
|
|
input: `["string", "number"]`,
|
|
expected: PropertyType{"string", "number"},
|
|
},
|
|
{
|
|
name: "array with single type",
|
|
input: `["string"]`,
|
|
expected: PropertyType{"string"},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var pt PropertyType
|
|
if err := json.Unmarshal([]byte(test.input), &pt); err != nil {
|
|
t.Errorf("Unexpected error: %v", err)
|
|
}
|
|
|
|
if len(pt) != len(test.expected) {
|
|
t.Errorf("Length mismatch: got %v, expected %v", len(pt), len(test.expected))
|
|
}
|
|
|
|
for i, v := range pt {
|
|
if v != test.expected[i] {
|
|
t.Errorf("Value mismatch at index %d: got %v, expected %v", i, v, test.expected[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPropertyType_MarshalJSON(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input PropertyType
|
|
expected string
|
|
}{
|
|
{
|
|
name: "single type",
|
|
input: PropertyType{"string"},
|
|
expected: `"string"`,
|
|
},
|
|
{
|
|
name: "multiple types",
|
|
input: PropertyType{"string", "number"},
|
|
expected: `["string","number"]`,
|
|
},
|
|
{
|
|
name: "empty type",
|
|
input: PropertyType{},
|
|
expected: `[]`,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
data, err := json.Marshal(test.input)
|
|
if err != nil {
|
|
t.Errorf("Unexpected error: %v", err)
|
|
}
|
|
|
|
if string(data) != test.expected {
|
|
t.Errorf("Marshaled data mismatch: got %v, expected %v", string(data), test.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestThinking_UnmarshalJSON(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expectedThinking *ThinkValue
|
|
expectedError bool
|
|
}{
|
|
{
|
|
name: "true",
|
|
input: `{ "think": true }`,
|
|
expectedThinking: &ThinkValue{Value: true},
|
|
},
|
|
{
|
|
name: "false",
|
|
input: `{ "think": false }`,
|
|
expectedThinking: &ThinkValue{Value: false},
|
|
},
|
|
{
|
|
name: "unset",
|
|
input: `{ }`,
|
|
expectedThinking: nil,
|
|
},
|
|
{
|
|
name: "string_high",
|
|
input: `{ "think": "high" }`,
|
|
expectedThinking: &ThinkValue{Value: "high"},
|
|
},
|
|
{
|
|
name: "string_medium",
|
|
input: `{ "think": "medium" }`,
|
|
expectedThinking: &ThinkValue{Value: "medium"},
|
|
},
|
|
{
|
|
name: "string_low",
|
|
input: `{ "think": "low" }`,
|
|
expectedThinking: &ThinkValue{Value: "low"},
|
|
},
|
|
{
|
|
name: "invalid_string",
|
|
input: `{ "think": "invalid" }`,
|
|
expectedThinking: nil,
|
|
expectedError: true,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var req GenerateRequest
|
|
err := json.Unmarshal([]byte(test.input), &req)
|
|
if test.expectedError {
|
|
require.Error(t, err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
if test.expectedThinking == nil {
|
|
assert.Nil(t, req.Think)
|
|
} else {
|
|
require.NotNil(t, req.Think)
|
|
assert.Equal(t, test.expectedThinking.Value, req.Think.Value)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestToolFunctionParameters_String(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
params ToolFunctionParameters
|
|
expected string
|
|
}{
|
|
{
|
|
name: "simple object with string property",
|
|
params: NewToolFunctionParametersWithProps(
|
|
"object",
|
|
[]string{"name"},
|
|
func() *ToolProperties {
|
|
om := NewToolProperties()
|
|
om.Set("name", ToolProperty{
|
|
Type: PropertyType{"string"},
|
|
Description: "The name of the person",
|
|
})
|
|
return om
|
|
}(),
|
|
),
|
|
expected: `{"type":"object","required":["name"],"properties":{"name":{"type":"string","description":"The name of the person"}}}`,
|
|
},
|
|
{
|
|
name: "marshal failure returns empty string",
|
|
params: func() ToolFunctionParameters {
|
|
p := NewToolFunctionParametersWithProps("object", nil, NewToolProperties())
|
|
p.Defs = func() any {
|
|
// Create a cycle that will cause json.Marshal to fail
|
|
type selfRef struct {
|
|
Self *selfRef
|
|
}
|
|
s := &selfRef{}
|
|
s.Self = s
|
|
return s
|
|
}()
|
|
return p
|
|
}(),
|
|
expected: "",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
result := test.params.String()
|
|
assert.Equal(t, test.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTemplateRenderingWithArguments(t *testing.T) {
|
|
// Test that ToolCallFunctionArguments renders correctly in templates
|
|
// This verifies the String() method works for template interpolation
|
|
args := NewToolCallFunctionArguments()
|
|
args.Set("location", "San Francisco")
|
|
args.Set("unit", "fahrenheit")
|
|
|
|
// Simulate what a template would do: convert to string
|
|
rendered := args.String()
|
|
|
|
// Should produce valid JSON
|
|
var parsed map[string]any
|
|
err := json.Unmarshal([]byte(rendered), &parsed)
|
|
require.NoError(t, err, "Arguments should render as valid JSON")
|
|
|
|
// Verify the values are present and in order
|
|
assert.Equal(t, "San Francisco", parsed["location"])
|
|
assert.Equal(t, "fahrenheit", parsed["unit"])
|
|
|
|
// Verify it maintains insertion order by checking the JSON string directly
|
|
// The first Set was "location", so it should appear before "unit"
|
|
assert.Contains(t, rendered, `"location":"San Francisco"`)
|
|
assert.Contains(t, rendered, `"unit":"fahrenheit"`)
|
|
locIndex := strings.Index(rendered, "location")
|
|
unitIndex := strings.Index(rendered, "unit")
|
|
assert.Less(t, locIndex, unitIndex, "insertion order should be preserved")
|
|
}
|