diff --git a/CMakeLists.txt b/CMakeLists.txt index 29fbd00cd..e97745d10 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -98,10 +98,12 @@ check_language(HIP) if(CMAKE_HIP_COMPILER) set(HIP_PLATFORM "amd") - find_package(hip REQUIRED) if(NOT AMDGPU_TARGETS) + find_package(hip REQUIRED) list(FILTER AMDGPU_TARGETS INCLUDE REGEX "^gfx(900|94[012]|101[02]|1030|110[012]|120[01])$") - elseif(WIN32 AND WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX) + endif() + + if(WIN32 AND WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX) list(FILTER AMDGPU_TARGETS EXCLUDE REGEX ${WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX}) endif() diff --git a/cmd/cmd.go b/cmd/cmd.go index e8cfa1347..369a27a48 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -540,6 +540,25 @@ func PushHandler(cmd *cobra.Command, args []string) error { return err } + n := model.ParseName(args[0]) + if strings.HasSuffix(n.Host, ".ollama.ai") || strings.HasSuffix(n.Host, ".ollama.com") { + _, err := client.Whoami(cmd.Context()) + if err != nil { + var aErr api.AuthorizationError + if errors.As(err, &aErr) && aErr.StatusCode == http.StatusUnauthorized { + fmt.Println("You need to be signed in to push models to ollama.com.") + fmt.Println() + + if aErr.SigninURL != "" { + fmt.Printf(ConnectInstructions, aErr.SigninURL) + } + return nil + } + + return err + } + } + p := progress.NewProgress(os.Stderr) defer p.Stop() @@ -576,7 +595,6 @@ func PushHandler(cmd *cobra.Command, args []string) error { request := api.PushRequest{Name: args[0], Insecure: insecure} - n := model.ParseName(args[0]) if err := client.Push(cmd.Context(), &request, fn); err != nil { if spinner != nil { spinner.Stop() @@ -1100,6 +1118,51 @@ type runOptions struct { ShowConnect bool } +func (r runOptions) Copy() runOptions { + var messages []api.Message + if r.Messages != nil { + messages = make([]api.Message, len(r.Messages)) + copy(messages, r.Messages) + } + + var images []api.ImageData + if r.Images != nil { + images = make([]api.ImageData, len(r.Images)) + copy(images, r.Images) + } + + var opts map[string]any + if r.Options != nil { + opts = make(map[string]any, len(r.Options)) + for k, v := range r.Options { + opts[k] = v + } + } + + var think *api.ThinkValue + if r.Think != nil { + cThink := *r.Think + think = &cThink + } + + return runOptions{ + Model: r.Model, + ParentModel: r.ParentModel, + Prompt: r.Prompt, + Messages: messages, + WordWrap: r.WordWrap, + Format: r.Format, + System: r.System, + Images: images, + Options: opts, + MultiModal: r.MultiModal, + KeepAlive: r.KeepAlive, + Think: think, + HideThinking: r.HideThinking, + ShowConnect: r.ShowConnect, + } +} + type displayResponseState struct { lineLength int wordBuffer string diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 24d287055..a84272c8e 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "os" + "reflect" "strings" "testing" "time" @@ -491,9 +492,35 @@ func TestPushHandler(t *testing.T) { w.(http.Flusher).Flush() } }, + "/api/me": func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + }, }, expectedOutput: "\nYou can find your model at:\n\n\thttps://ollama.com/test-model\n", }, + { + name: "not signed in push", + modelName: "notsignedin-model", + serverResponse: map[string]func(w http.ResponseWriter, r *http.Request){ + "/api/me": func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + err := json.NewEncoder(w).Encode(map[string]string{ + "error": "unauthorized", + "signin_url": "https://somethingsomething", + }) + if err != nil { + t.Fatal(err) + } + }, + }, + expectedOutput: "You need to be signed in to push", + }, { name: "unauthorized push", modelName: "unauthorized-model", @@ -508,6 +535,11 @@ func TestPushHandler(t *testing.T) { t.Fatal(err) } }, + "/api/me": func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + }, }, expectedError: "you are not authorized to push to this namespace, create the model under a namespace you own", }, @@ -564,7 +596,7 @@ func TestPushHandler(t *testing.T) { t.Errorf("expected no error, got %v", err) } if tt.expectedOutput != "" { - if got := string(stdout); got != tt.expectedOutput { + if got := string(stdout); !strings.Contains(got, tt.expectedOutput) { t.Errorf("expected output %q, got %q", tt.expectedOutput, got) } } @@ -922,3 +954,286 @@ func TestNewCreateRequest(t *testing.T) { }) } } + +func TestRunOptions_Copy(t *testing.T) { + // Setup test data + originalKeepAlive := &api.Duration{Duration: 5 * time.Minute} + originalThink := &api.ThinkValue{Value: "test reasoning"} + + original := runOptions{ + Model: "test-model", + ParentModel: "parent-model", + Prompt: "test prompt", + Messages: []api.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi there"}, + }, + WordWrap: true, + Format: "json", + System: "system prompt", + Images: []api.ImageData{ + []byte("image1"), + []byte("image2"), + }, + Options: map[string]any{ + "temperature": 0.7, + "max_tokens": 1000, + "top_p": 0.9, + }, + MultiModal: true, + KeepAlive: originalKeepAlive, + Think: originalThink, + HideThinking: false, + ShowConnect: true, + } + + // Test the copy + copied := original.Copy() + + // Test 1: Verify the copy is not the same instance + if &copied == &original { + t.Error("Copy should return a different instance") + } + + // Test 2: Verify all fields are copied correctly + tests := []struct { + name string + got interface{} + want interface{} + }{ + {"Model", copied.Model, original.Model}, + {"ParentModel", copied.ParentModel, original.ParentModel}, + {"Prompt", copied.Prompt, original.Prompt}, + {"WordWrap", copied.WordWrap, original.WordWrap}, + {"Format", copied.Format, original.Format}, + {"System", copied.System, original.System}, + {"MultiModal", copied.MultiModal, original.MultiModal}, + {"HideThinking", copied.HideThinking, original.HideThinking}, + {"ShowConnect", copied.ShowConnect, original.ShowConnect}, + } + + for _, tt := range tests { + if !reflect.DeepEqual(tt.got, tt.want) { + t.Errorf("%s mismatch: got %v, want %v", tt.name, tt.got, tt.want) + } + } + + // Test 3: Verify Messages slice is deeply copied + if len(copied.Messages) != len(original.Messages) { + t.Errorf("Messages length mismatch: got %d, want %d", len(copied.Messages), len(original.Messages)) + } + + if len(copied.Messages) > 0 && &copied.Messages[0] == &original.Messages[0] { + t.Error("Messages should be different instances") + } + + // Modify original to verify independence + if len(original.Messages) > 0 { + originalContent := original.Messages[0].Content + original.Messages[0].Content = "modified" + if len(copied.Messages) > 0 && copied.Messages[0].Content == "modified" { + t.Error("Messages should be independent after copy") + } + // Restore for other tests + original.Messages[0].Content = originalContent + } + + // Test 4: Verify Images slice is deeply copied + if len(copied.Images) != len(original.Images) { + t.Errorf("Images length mismatch: got %d, want %d", len(copied.Images), len(original.Images)) + } + + if len(copied.Images) > 0 && &copied.Images[0] == &original.Images[0] { + t.Error("Images should be different instances") + } + + // Modify original to verify independence + if len(original.Images) > 0 { + originalImage := original.Images[0] + original.Images[0] = []byte("modified") + if len(copied.Images) > 0 && string(copied.Images[0]) == "modified" { + t.Error("Images should be independent after copy") + } + // Restore for other tests + original.Images[0] = originalImage + } + + // Test 5: Verify Options map is deeply copied + if len(copied.Options) != len(original.Options) { + t.Errorf("Options length mismatch: got %d, want %d", len(copied.Options), len(original.Options)) + } + + if len(copied.Options) > 0 && &copied.Options == &original.Options { + t.Error("Options map should be different instances") + } + + // Modify original to verify independence + if len(original.Options) > 0 { + originalTemp := original.Options["temperature"] + original.Options["temperature"] = 0.9 + if copied.Options["temperature"] == 0.9 { + t.Error("Options should be independent after copy") + } + // Restore for other tests + original.Options["temperature"] = originalTemp + } + + // Test 6: Verify KeepAlive pointer is copied (shallow copy) + if copied.KeepAlive != original.KeepAlive { + t.Error("KeepAlive pointer should be the same (shallow copy)") + } + + // Test 7: Verify Think pointer creates a new instance + if original.Think != nil && copied.Think == original.Think { + t.Error("Think should be a different instance") + } + + if original.Think != nil && copied.Think != nil { + if !reflect.DeepEqual(copied.Think.Value, original.Think.Value) { + t.Errorf("Think.Value mismatch: got %v, want %v", copied.Think.Value, original.Think.Value) + } + } + + // Test 8: Test with zero values + zeroOriginal := runOptions{} + zeroCopy := zeroOriginal.Copy() + + if !reflect.DeepEqual(zeroCopy, zeroOriginal) { + fmt.Printf("orig: %#v\ncopy: %#v\n", zeroOriginal, zeroCopy) + t.Error("Copy of zero value should equal original zero value") + } +} + +func TestRunOptions_Copy_EmptySlicesAndMaps(t *testing.T) { + // Test with empty slices and maps + original := runOptions{ + Messages: []api.Message{}, + Images: []api.ImageData{}, + Options: map[string]any{}, + } + + copied := original.Copy() + + if copied.Messages == nil { + t.Error("Empty Messages slice should remain empty, not nil") + } + + if copied.Images == nil { + t.Error("Empty Images slice should remain empty, not nil") + } + + if copied.Options == nil { + t.Error("Empty Options map should remain empty, not nil") + } + + if len(copied.Messages) != 0 { + t.Error("Empty Messages slice should remain empty") + } + + if len(copied.Images) != 0 { + t.Error("Empty Images slice should remain empty") + } + + if len(copied.Options) != 0 { + t.Error("Empty Options map should remain empty") + } +} + +func TestRunOptions_Copy_NilPointers(t *testing.T) { + // Test with nil pointers + original := runOptions{ + KeepAlive: nil, + Think: nil, + } + + copied := original.Copy() + + if copied.KeepAlive != nil { + t.Error("Nil KeepAlive should remain nil") + } + + if copied.Think != nil { + t.Error("Nil Think should remain nil") + } +} + +func TestRunOptions_Copy_ThinkValueVariants(t *testing.T) { + tests := []struct { + name string + think *api.ThinkValue + }{ + {"nil Think", nil}, + {"bool true", &api.ThinkValue{Value: true}}, + {"bool false", &api.ThinkValue{Value: false}}, + {"string value", &api.ThinkValue{Value: "reasoning text"}}, + {"int value", &api.ThinkValue{Value: 42}}, + {"nil value", &api.ThinkValue{Value: nil}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + original := runOptions{Think: tt.think} + copied := original.Copy() + + if tt.think == nil { + if copied.Think != nil { + t.Error("Nil Think should remain nil") + } + return + } + + if copied.Think == nil { + t.Error("Non-nil Think should not become nil") + return + } + + if copied.Think == original.Think { + t.Error("Think should be a different instance") + } + + if !reflect.DeepEqual(copied.Think.Value, original.Think.Value) { + t.Errorf("Think.Value mismatch: got %v, want %v", copied.Think.Value, original.Think.Value) + } + }) + } +} + +func TestRunOptions_Copy_Independence(t *testing.T) { + // Test that modifications to original don't affect copy + originalThink := &api.ThinkValue{Value: "original"} + original := runOptions{ + Model: "original-model", + Messages: []api.Message{{Role: "user", Content: "original"}}, + Options: map[string]any{"key": "value"}, + Think: originalThink, + } + + copied := original.Copy() + + // Modify original + original.Model = "modified-model" + if len(original.Messages) > 0 { + original.Messages[0].Content = "modified" + } + original.Options["key"] = "modified" + if original.Think != nil { + original.Think.Value = "modified" + } + + // Verify copy is unchanged + if copied.Model == "modified-model" { + t.Error("Copy Model should not be affected by original modification") + } + + if len(copied.Messages) > 0 && copied.Messages[0].Content == "modified" { + t.Error("Copy Messages should not be affected by original modification") + } + + if copied.Options["key"] == "modified" { + t.Error("Copy Options should not be affected by original modification") + } + + if copied.Think != nil && copied.Think.Value == "modified" { + t.Error("Copy Think should not be affected by original modification") + } +} diff --git a/cmd/interactive.go b/cmd/interactive.go index e290d84ce..cf0aced14 100644 --- a/cmd/interactive.go +++ b/cmd/interactive.go @@ -195,16 +195,24 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error { fmt.Println("Usage:\n /load ") continue } + origOpts := opts.Copy() + opts.Model = args[1] opts.Messages = []api.Message{} fmt.Printf("Loading model '%s'\n", opts.Model) opts.Think, err = inferThinkingOption(nil, &opts, thinkExplicitlySet) if err != nil { + if strings.Contains(err.Error(), "not found") { + fmt.Printf("Couldn't find model '%s'\n", opts.Model) + opts = origOpts.Copy() + continue + } return err } if err := loadOrUnloadModel(cmd, &opts); err != nil { if strings.Contains(err.Error(), "not found") { - fmt.Printf("error: %v\n", err) + fmt.Printf("Couldn't find model '%s'\n", opts.Model) + opts = origOpts.Copy() continue } if strings.Contains(err.Error(), "does not support thinking") { diff --git a/model/parsers/qwen3coder.go b/model/parsers/qwen3coder.go index 0cff1ec15..f44d7c8ef 100644 --- a/model/parsers/qwen3coder.go +++ b/model/parsers/qwen3coder.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" "unicode" + "unicode/utf8" "github.com/ollama/ollama/api" "github.com/ollama/ollama/logutil" @@ -204,12 +205,21 @@ func overlap(s, delim string) int { } func trailingWhitespaceLen(s string) int { - for i := len(s) - 1; i >= 0; i-- { - if !unicode.IsSpace(rune(s[i])) { - return len(s) - i - 1 + remaining := s + total := 0 + for len(remaining) > 0 { + r, size := utf8.DecodeLastRuneInString(remaining) + // if it's an invalid utf8 rune, assume it isn't whitespace + if r == utf8.RuneError && size == 1 { + break } + if !unicode.IsSpace(r) { + break + } + total += size + remaining = remaining[:len(remaining)-size] } - return len(s) + return total } type XMLFunctionCall struct { diff --git a/model/parsers/qwen3coder_test.go b/model/parsers/qwen3coder_test.go index 43823e6fc..c77fe2d95 100644 --- a/model/parsers/qwen3coder_test.go +++ b/model/parsers/qwen3coder_test.go @@ -166,6 +166,137 @@ func TestQwenParserStreaming(t *testing.T) { }, }, }, + { + desc: "unicode content", + steps: []step{ + { + input: "你好 🌍testمرحبا", + wantEvents: []qwenEvent{ + qwenEventContent{content: "你好 🌍"}, + qwenEventRawToolCall{raw: "test"}, + qwenEventContent{content: "مرحبا"}, + }, + }, + }, + }, + { + desc: "arabic text handling", + steps: []step{ + { + input: "مرحبا بالعالم", + wantEvents: []qwenEvent{qwenEventContent{content: "مرحبا بالعالم"}}, + }, + }, + }, + { + desc: "emoji passthrough", + steps: []step{ + { + input: "✅", + wantEvents: []qwenEvent{qwenEventContent{content: "✅"}}, + }, + }, + }, + { + desc: "emoji after tool call", + steps: []step{ + { + input: "test完成 ✅", + wantEvents: []qwenEvent{ + qwenEventRawToolCall{raw: "test"}, + qwenEventContent{content: "完成 ✅"}, + }, + }, + }, + }, + { + desc: "unicode streaming with whitespace handling", + steps: []step{ + { + input: "مرحبا", + wantEvents: []qwenEvent{ + qwenEventContent{content: "مرحبا"}, + }, + }, + { + input: " \n", + wantEvents: []qwenEvent{}, + }, + { + input: "世界", + wantEvents: []qwenEvent{ + qwenEventContent{content: " \n世界"}, + }, + }, + }, + }, + { + desc: "non-breaking space withheld across chunks", + steps: []step{ + { + input: "Hello\u00a0", + wantEvents: []qwenEvent{ + qwenEventContent{content: "Hello"}, + }, + }, + { + input: "world", + wantEvents: []qwenEvent{ + qwenEventContent{content: "\u00a0world"}, + }, + }, + }, + }, + { + desc: "ideographic space before partial tool", + steps: []step{ + { + input: "Hello\u3000abc", + wantEvents: []qwenEvent{}, + }, + { + input: "def", + wantEvents: []qwenEvent{ + qwenEventRawToolCall{raw: "abc"}, + qwenEventContent{content: "def"}, + }, + }, + }, + }, + { + desc: "ideographic space before partial tool fakeout", + steps: []step{ + { + input: "Hello\u3000abc", + wantEvents: []qwenEvent{ + qwenEventContent{content: "\u3000abc"}, + }, + }, + }, + }, + { + desc: "unicode with partial tool tag", + steps: []step{ + { + input: "测试🎯 b and a < b" }, }, }, + { + name: "unicode in function names and parameters", + tools: []api.Tool{}, + rawToolCall: ` + +北京 + + +Hello! 你好! 🌟 مرحبا + +`, + wantToolCall: api.ToolCall{ + Function: api.ToolCallFunction{ + Name: "获取天气", + Arguments: map[string]any{ + "城市": "北京", + "message": "Hello! 你好! 🌟 مرحبا", + }, + }, + }, + }, } for i, step := range steps { @@ -360,6 +512,42 @@ ls && echo "a > b and a < b" } } +func TestTrailingWhitespaceLenUnicode(t *testing.T) { + cases := []struct { + name string + input string + want int + }{ + { + name: "ascii space", + input: "Hello ", + want: 1, + }, + { + name: "non-breaking space", + input: "Hello\u00a0", + want: 2, + }, + { + name: "ideographic space", + input: "Hello\u3000", + want: 3, + }, + { + name: "multiple runes of whitespace", + input: "Hi\u00a0\u3000", + want: 5, + }, + } + + for _, tc := range cases { + got := trailingWhitespaceLen(tc.input) + if got != tc.want { + t.Errorf("%s: trailingWhitespaceLen(%q) = %d, want %d", tc.name, tc.input, got, tc.want) + } + } +} + func TestQwenToolCallValueParsing(t *testing.T) { cases := []struct { desc string @@ -867,6 +1055,8 @@ func TestTrailingWhitespaceLen(t *testing.T) { {desc: "trailing whitespace with newlines", s: "abc \n", want: 2}, {desc: "only whitespace", s: " \n ", want: 4}, {desc: "leading whitespace doesn't count", s: " \n abc", want: 0}, + {desc: "unicode with trailing space", s: "测试🎯 ", want: 1}, + {desc: "unicode with trailing tab and newline", s: "مرحبا\t\n", want: 2}, } for _, tc := range cases { @@ -876,3 +1066,30 @@ func TestTrailingWhitespaceLen(t *testing.T) { } } } + +func TestOverlapFunction(t *testing.T) { + cases := []struct { + desc string + s string + delim string + want int + }{ + {desc: "no overlap", s: "hello", delim: "", want: 5}, + {desc: "partial overlap", s: "hello", want: 3}, + {desc: "unicode with partial overlap", s: "测试🎯", want: 3}, + {desc: "unicode string with no overlap", s: "مرحبا", delim: "", want: 0}, + {desc: "unicode at boundary", s: "世界<", delim: "", want: 1}, + {desc: "unicode delimiter single rune", s: "hello🔧", delim: "🔧工具", want: len("🔧")}, + {desc: "unicode delimiter multiple runes", s: "hello🔧工", delim: "🔧工具", want: len("🔧工")}, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + got := overlap(tc.s, tc.delim) + if got != tc.want { + t.Errorf("overlap(%q, %q) = %d, want %d", tc.s, tc.delim, got, tc.want) + } + }) + } +} diff --git a/tools/tools.go b/tools/tools.go index 80fc6e0d0..f9a2d3b9b 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -273,9 +273,21 @@ func findArguments(buffer []byte) (map[string]any, int) { if args, ok := obj["arguments"].(map[string]any); ok { return args, true } + if argsStr, ok := obj["arguments"].(string); ok { + var argsData map[string]interface{} + if err := json.Unmarshal([]byte(argsStr), &argsData); err == nil { + return argsData, ok + } + } if args, ok := obj["parameters"].(map[string]any); ok { return args, true } + if argsStr, ok := obj["parameters"].(string); ok { + var argsData map[string]interface{} + if err := json.Unmarshal([]byte(argsStr), &argsData); err == nil { + return argsData, ok + } + } return nil, true } diff --git a/tools/tools_test.go b/tools/tools_test.go index 2a449a0ea..288fa73c5 100644 --- a/tools/tools_test.go +++ b/tools/tools_test.go @@ -1274,6 +1274,22 @@ func TestFindArguments(t *testing.T) { "items": []any{"{", "}", map[string]any{"key": "value"}}, }, }, + { + name: "stringified arguments", + buffer: []byte(`{"name": "get_temperature", "arguments": "{\"format\": \"fahrenheit\", \"location\": \"San Francisco, CA\"}"}`), + want: map[string]any{ + "format": "fahrenheit", + "location": "San Francisco, CA", + }, + }, + { + name: "stringified parameters", + buffer: []byte(`{"name": "get_temperature", "parameters": "{\"format\": \"fahrenheit\", \"location\": \"San Francisco, CA\"}"}`), + want: map[string]any{ + "format": "fahrenheit", + "location": "San Francisco, CA", + }, + }, } for _, tt := range tests {