From f676231de9a54b158d83bff893fb20dbc7472ad2 Mon Sep 17 00:00:00 2001 From: Devon Rifkin Date: Thu, 12 Mar 2026 20:27:24 -0700 Subject: [PATCH] server: remove experimental aliases support (#14810) --- api/client.go | 22 -- server/aliases.go | 438 ------------------------------- server/routes.go | 21 -- server/routes_aliases.go | 159 ------------ server/routes_aliases_test.go | 475 ---------------------------------- 5 files changed, 1115 deletions(-) delete mode 100644 server/aliases.go delete mode 100644 server/routes_aliases.go delete mode 100644 server/routes_aliases_test.go diff --git a/api/client.go b/api/client.go index f56639c9a..2fc29b069 100644 --- a/api/client.go +++ b/api/client.go @@ -476,25 +476,3 @@ func (c *Client) Whoami(ctx context.Context) (*UserResponse, error) { } return &resp, nil } - -// AliasRequest is the request body for creating or updating a model alias. -type AliasRequest struct { - Alias string `json:"alias"` - Target string `json:"target"` - PrefixMatching bool `json:"prefix_matching,omitempty"` -} - -// SetAliasExperimental creates or updates a model alias via the experimental aliases API. -func (c *Client) SetAliasExperimental(ctx context.Context, req *AliasRequest) error { - return c.do(ctx, http.MethodPost, "/api/experimental/aliases", req, nil) -} - -// AliasDeleteRequest is the request body for deleting a model alias. -type AliasDeleteRequest struct { - Alias string `json:"alias"` -} - -// DeleteAliasExperimental deletes a model alias via the experimental aliases API. -func (c *Client) DeleteAliasExperimental(ctx context.Context, req *AliasDeleteRequest) error { - return c.do(ctx, http.MethodDelete, "/api/experimental/aliases", req, nil) -} diff --git a/server/aliases.go b/server/aliases.go deleted file mode 100644 index 18e9447e5..000000000 --- a/server/aliases.go +++ /dev/null @@ -1,438 +0,0 @@ -package server - -import ( - "encoding/json" - "errors" - "fmt" - "log/slog" - "os" - "path/filepath" - "sort" - "strings" - "sync" - - "github.com/ollama/ollama/manifest" - "github.com/ollama/ollama/types/model" -) - -const ( - serverConfigFilename = "server.json" - serverConfigVersion = 1 -) - -var errAliasCycle = errors.New("alias cycle detected") - -type aliasEntry struct { - Alias string `json:"alias"` - Target string `json:"target"` - PrefixMatching bool `json:"prefix_matching,omitempty"` -} - -type serverConfig struct { - Version int `json:"version"` - Aliases []aliasEntry `json:"aliases"` -} - -type store struct { - mu sync.RWMutex - path string - entries map[string]aliasEntry // normalized alias -> entry (exact matches) - prefixEntries []aliasEntry // prefix matches, sorted longest-first -} - -func createStore(path string) (*store, error) { - store := &store{ - path: path, - entries: make(map[string]aliasEntry), - } - if err := store.load(); err != nil { - return nil, err - } - return store, nil -} - -func (s *store) load() error { - data, err := os.ReadFile(s.path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return err - } - - var cfg serverConfig - if err := json.Unmarshal(data, &cfg); err != nil { - return err - } - - if cfg.Version != 0 && cfg.Version != serverConfigVersion { - return fmt.Errorf("unsupported router config version %d", cfg.Version) - } - - for _, entry := range cfg.Aliases { - targetName := model.ParseName(entry.Target) - if !targetName.IsValid() { - slog.Warn("invalid alias target in router config", "target", entry.Target) - continue - } - canonicalTarget := displayAliasName(targetName) - - if entry.PrefixMatching { - // Prefix aliases don't need to be valid model names - alias := strings.TrimSpace(entry.Alias) - if alias == "" { - slog.Warn("empty prefix alias in router config") - continue - } - s.prefixEntries = append(s.prefixEntries, aliasEntry{ - Alias: alias, - Target: canonicalTarget, - PrefixMatching: true, - }) - } else { - aliasName := model.ParseName(entry.Alias) - if !aliasName.IsValid() { - slog.Warn("invalid alias name in router config", "alias", entry.Alias) - continue - } - canonicalAlias := displayAliasName(aliasName) - s.entries[normalizeAliasKey(aliasName)] = aliasEntry{ - Alias: canonicalAlias, - Target: canonicalTarget, - } - } - } - - // Sort prefix entries by alias length descending (longest prefix wins) - s.sortPrefixEntriesLocked() - - return nil -} - -func (s *store) saveLocked() error { - dir := filepath.Dir(s.path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - - // Read existing file into a generic map to preserve unknown fields - // (e.g. disable_ollama_cloud) that aliasStore doesn't own. - existing := make(map[string]json.RawMessage) - if data, err := os.ReadFile(s.path); err == nil { - if err := json.Unmarshal(data, &existing); err != nil { - slog.Debug("failed to parse existing server config; preserving unknown fields skipped", "path", s.path, "error", err) - } - } - - // Combine exact and prefix entries - entries := make([]aliasEntry, 0, len(s.entries)+len(s.prefixEntries)) - for _, entry := range s.entries { - entries = append(entries, entry) - } - entries = append(entries, s.prefixEntries...) - - sort.Slice(entries, func(i, j int) bool { - return strings.Compare(entries[i].Alias, entries[j].Alias) < 0 - }) - - // Overwrite only the keys we own - versionJSON, err := json.Marshal(serverConfigVersion) - if err != nil { - return err - } - aliasesJSON, err := json.Marshal(entries) - if err != nil { - return err - } - existing["version"] = versionJSON - existing["aliases"] = aliasesJSON - - f, err := os.CreateTemp(dir, "router-*.json") - if err != nil { - return err - } - - enc := json.NewEncoder(f) - enc.SetIndent("", " ") - if err := enc.Encode(existing); err != nil { - _ = f.Close() - _ = os.Remove(f.Name()) - return err - } - - if err := f.Close(); err != nil { - _ = os.Remove(f.Name()) - return err - } - - if err := os.Chmod(f.Name(), 0o644); err != nil { - _ = os.Remove(f.Name()) - return err - } - - return os.Rename(f.Name(), s.path) -} - -func (s *store) ResolveName(name model.Name) (model.Name, bool, error) { - // If a local model exists, do not allow alias shadowing (highest priority). - exists, err := localModelExists(name) - if err != nil { - return name, false, err - } - if exists { - return name, false, nil - } - - key := normalizeAliasKey(name) - - s.mu.RLock() - entry, exactMatch := s.entries[key] - var prefixMatch *aliasEntry - if !exactMatch { - // Try prefix matching - prefixEntries is sorted longest-first - nameStr := strings.ToLower(displayAliasName(name)) - for i := range s.prefixEntries { - prefix := strings.ToLower(s.prefixEntries[i].Alias) - if strings.HasPrefix(nameStr, prefix) { - prefixMatch = &s.prefixEntries[i] - break // First match is longest due to sorting - } - } - } - s.mu.RUnlock() - - if !exactMatch && prefixMatch == nil { - return name, false, nil - } - - var current string - var visited map[string]struct{} - - if exactMatch { - visited = map[string]struct{}{key: {}} - current = entry.Target - } else { - // For prefix match, use the target as-is - visited = map[string]struct{}{} - current = prefixMatch.Target - } - - targetKey := normalizeAliasKeyString(current) - - for { - targetName := model.ParseName(current) - if !targetName.IsValid() { - return name, false, fmt.Errorf("alias target %q is invalid", current) - } - - if _, seen := visited[targetKey]; seen { - return name, false, errAliasCycle - } - visited[targetKey] = struct{}{} - - s.mu.RLock() - next, ok := s.entries[targetKey] - s.mu.RUnlock() - if !ok { - return targetName, true, nil - } - - current = next.Target - targetKey = normalizeAliasKeyString(current) - } -} - -func (s *store) Set(alias, target model.Name, prefixMatching bool) error { - targetKey := normalizeAliasKey(target) - - s.mu.Lock() - defer s.mu.Unlock() - - if prefixMatching { - // For prefix aliases, we skip cycle detection since prefix matching - // works differently and the target is a specific model - aliasStr := displayAliasName(alias) - - // Remove any existing prefix entry with the same alias - for i, e := range s.prefixEntries { - if strings.EqualFold(e.Alias, aliasStr) { - s.prefixEntries = append(s.prefixEntries[:i], s.prefixEntries[i+1:]...) - break - } - } - - s.prefixEntries = append(s.prefixEntries, aliasEntry{ - Alias: aliasStr, - Target: displayAliasName(target), - PrefixMatching: true, - }) - s.sortPrefixEntriesLocked() - return s.saveLocked() - } - - aliasKey := normalizeAliasKey(alias) - - if aliasKey == targetKey { - return fmt.Errorf("alias cannot point to itself") - } - - visited := map[string]struct{}{aliasKey: {}} - currentKey := targetKey - for { - if _, seen := visited[currentKey]; seen { - return errAliasCycle - } - visited[currentKey] = struct{}{} - - next, ok := s.entries[currentKey] - if !ok { - break - } - currentKey = normalizeAliasKeyString(next.Target) - } - - s.entries[aliasKey] = aliasEntry{ - Alias: displayAliasName(alias), - Target: displayAliasName(target), - } - - return s.saveLocked() -} - -func (s *store) Delete(alias model.Name) (bool, error) { - aliasKey := normalizeAliasKey(alias) - - s.mu.Lock() - defer s.mu.Unlock() - - // Try exact match first - if _, ok := s.entries[aliasKey]; ok { - delete(s.entries, aliasKey) - return true, s.saveLocked() - } - - // Try prefix entries - aliasStr := displayAliasName(alias) - for i, e := range s.prefixEntries { - if strings.EqualFold(e.Alias, aliasStr) { - s.prefixEntries = append(s.prefixEntries[:i], s.prefixEntries[i+1:]...) - return true, s.saveLocked() - } - } - - return false, nil -} - -// DeleteByString deletes an alias by its raw string value, useful for prefix -// aliases that may not be valid model names. -func (s *store) DeleteByString(alias string) (bool, error) { - alias = strings.TrimSpace(alias) - aliasLower := strings.ToLower(alias) - - s.mu.Lock() - defer s.mu.Unlock() - - // Try prefix entries first (since this is mainly for prefix aliases) - for i, e := range s.prefixEntries { - if strings.EqualFold(e.Alias, alias) { - s.prefixEntries = append(s.prefixEntries[:i], s.prefixEntries[i+1:]...) - return true, s.saveLocked() - } - } - - // Also check exact entries by normalized key - if _, ok := s.entries[aliasLower]; ok { - delete(s.entries, aliasLower) - return true, s.saveLocked() - } - - return false, nil -} - -func (s *store) List() []aliasEntry { - s.mu.RLock() - defer s.mu.RUnlock() - - entries := make([]aliasEntry, 0, len(s.entries)+len(s.prefixEntries)) - for _, entry := range s.entries { - entries = append(entries, entry) - } - entries = append(entries, s.prefixEntries...) - - sort.Slice(entries, func(i, j int) bool { - return strings.Compare(entries[i].Alias, entries[j].Alias) < 0 - }) - return entries -} - -func normalizeAliasKey(name model.Name) string { - return strings.ToLower(displayAliasName(name)) -} - -func (s *store) sortPrefixEntriesLocked() { - sort.Slice(s.prefixEntries, func(i, j int) bool { - // Sort by length descending (longest prefix first) - return len(s.prefixEntries[i].Alias) > len(s.prefixEntries[j].Alias) - }) -} - -func normalizeAliasKeyString(value string) string { - n := model.ParseName(value) - if !n.IsValid() { - return strings.ToLower(strings.TrimSpace(value)) - } - return normalizeAliasKey(n) -} - -func displayAliasName(n model.Name) string { - display := n.DisplayShortest() - if strings.EqualFold(n.Tag, "latest") { - if idx := strings.LastIndex(display, ":"); idx != -1 { - return display[:idx] - } - } - return display -} - -func localModelExists(name model.Name) (bool, error) { - manifests, err := manifest.Manifests(true) - if err != nil { - return false, err - } - needle := name.String() - for existing := range manifests { - if strings.EqualFold(existing.String(), needle) { - return true, nil - } - } - return false, nil -} - -func serverConfigPath() string { - home, err := os.UserHomeDir() - if err != nil { - return filepath.Join(".ollama", serverConfigFilename) - } - return filepath.Join(home, ".ollama", serverConfigFilename) -} - -func (s *Server) aliasStore() (*store, error) { - s.aliasesOnce.Do(func() { - s.aliases, s.aliasesErr = createStore(serverConfigPath()) - }) - - return s.aliases, s.aliasesErr -} - -func (s *Server) resolveAlias(name model.Name) (model.Name, bool, error) { - store, err := s.aliasStore() - if err != nil { - return name, false, err - } - - if store == nil { - return name, false, nil - } - - return store.ResolveName(name) -} diff --git a/server/routes.go b/server/routes.go index 91ac67745..32adef36c 100644 --- a/server/routes.go +++ b/server/routes.go @@ -22,7 +22,6 @@ import ( "os/signal" "slices" "strings" - "sync" "sync/atomic" "syscall" "time" @@ -101,9 +100,6 @@ type Server struct { addr net.Addr sched *Scheduler defaultNumCtx int - aliasesOnce sync.Once - aliases *store - aliasesErr error } func init() { @@ -225,13 +221,6 @@ func (s *Server) GenerateHandler(c *gin.Context) { name := modelRef.Name - resolvedName, _, err := s.resolveAlias(name) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - name = resolvedName - // We cannot currently consolidate this into GetModel because all we'll // induce infinite recursion given the current code structure. name, err = getExistingName(name) @@ -1692,9 +1681,6 @@ func (s *Server) GenerateRoutes(rc *ollama.Registry) (http.Handler, error) { r.POST("/api/blobs/:digest", s.CreateBlobHandler) r.HEAD("/api/blobs/:digest", s.HeadBlobHandler) r.POST("/api/copy", s.CopyHandler) - r.GET("/api/experimental/aliases", s.ListAliasesHandler) - r.POST("/api/experimental/aliases", s.CreateAliasHandler) - r.DELETE("/api/experimental/aliases", s.DeleteAliasHandler) r.POST("/api/experimental/web_search", s.WebSearchExperimentalHandler) r.POST("/api/experimental/web_fetch", s.WebFetchExperimentalHandler) @@ -2119,13 +2105,6 @@ func (s *Server) ChatHandler(c *gin.Context) { name := modelRef.Name - resolvedName, _, err := s.resolveAlias(name) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - name = resolvedName - name, err = getExistingName(name) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "model is required"}) diff --git a/server/routes_aliases.go b/server/routes_aliases.go deleted file mode 100644 index d68514e9c..000000000 --- a/server/routes_aliases.go +++ /dev/null @@ -1,159 +0,0 @@ -package server - -import ( - "errors" - "fmt" - "io" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - - "github.com/ollama/ollama/types/model" -) - -type aliasListResponse struct { - Aliases []aliasEntry `json:"aliases"` -} - -type aliasDeleteRequest struct { - Alias string `json:"alias"` -} - -func (s *Server) ListAliasesHandler(c *gin.Context) { - store, err := s.aliasStore() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - var aliases []aliasEntry - if store != nil { - aliases = store.List() - } - - c.JSON(http.StatusOK, aliasListResponse{Aliases: aliases}) -} - -func (s *Server) CreateAliasHandler(c *gin.Context) { - var req aliasEntry - if err := c.ShouldBindJSON(&req); errors.Is(err, io.EOF) { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "missing request body"}) - return - } else if err != nil { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - req.Alias = strings.TrimSpace(req.Alias) - req.Target = strings.TrimSpace(req.Target) - if req.Alias == "" || req.Target == "" { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "alias and target are required"}) - return - } - - // Target must always be a valid model name - targetName := model.ParseName(req.Target) - if !targetName.IsValid() { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("target %q is invalid", req.Target)}) - return - } - - var aliasName model.Name - if req.PrefixMatching { - // For prefix aliases, we still parse the alias to normalize it, - // but we allow any non-empty string since prefix patterns may not be valid model names - aliasName = model.ParseName(req.Alias) - // Even if not valid as a model name, we accept it for prefix matching - } else { - aliasName = model.ParseName(req.Alias) - if !aliasName.IsValid() { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("alias %q is invalid", req.Alias)}) - return - } - - if normalizeAliasKey(aliasName) == normalizeAliasKey(targetName) { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "alias cannot point to itself"}) - return - } - - exists, err := localModelExists(aliasName) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if exists { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("alias %q conflicts with existing model", req.Alias)}) - return - } - } - - store, err := s.aliasStore() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - if err := store.Set(aliasName, targetName, req.PrefixMatching); err != nil { - status := http.StatusInternalServerError - if errors.Is(err, errAliasCycle) { - status = http.StatusBadRequest - } - c.AbortWithStatusJSON(status, gin.H{"error": err.Error()}) - return - } - - resp := aliasEntry{ - Alias: displayAliasName(aliasName), - Target: displayAliasName(targetName), - PrefixMatching: req.PrefixMatching, - } - if req.PrefixMatching && !aliasName.IsValid() { - // For prefix aliases that aren't valid model names, use the raw alias - resp.Alias = req.Alias - } - c.JSON(http.StatusOK, resp) -} - -func (s *Server) DeleteAliasHandler(c *gin.Context) { - var req aliasDeleteRequest - if err := c.ShouldBindJSON(&req); errors.Is(err, io.EOF) { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "missing request body"}) - return - } else if err != nil { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - req.Alias = strings.TrimSpace(req.Alias) - if req.Alias == "" { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "alias is required"}) - return - } - - store, err := s.aliasStore() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - aliasName := model.ParseName(req.Alias) - var deleted bool - if aliasName.IsValid() { - deleted, err = store.Delete(aliasName) - } else { - // For invalid model names (like prefix aliases), try deleting by raw string - deleted, err = store.DeleteByString(req.Alias) - } - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if !deleted { - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("alias %q not found", req.Alias)}) - return - } - - c.JSON(http.StatusOK, gin.H{"deleted": true}) -} diff --git a/server/routes_aliases_test.go b/server/routes_aliases_test.go deleted file mode 100644 index 27d06229f..000000000 --- a/server/routes_aliases_test.go +++ /dev/null @@ -1,475 +0,0 @@ -package server - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "testing" - - "github.com/gin-gonic/gin" - - "github.com/ollama/ollama/api" - "github.com/ollama/ollama/types/model" -) - -func TestAliasShadowingRejected(t *testing.T) { - gin.SetMode(gin.TestMode) - setTestHome(t, t.TempDir()) - - s := Server{} - w := createRequest(t, s.CreateHandler, api.CreateRequest{ - Model: "shadowed-model", - RemoteHost: "example.com", - From: "test", - Info: map[string]any{ - "capabilities": []string{"completion"}, - }, - Stream: &stream, - }) - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", w.Code) - } - - w = createRequest(t, s.CreateAliasHandler, aliasEntry{Alias: "shadowed-model", Target: "other-model"}) - if w.Code != http.StatusBadRequest { - t.Fatalf("expected status 400, got %d", w.Code) - } -} - -func TestAliasResolvesForChatRemote(t *testing.T) { - gin.SetMode(gin.TestMode) - setTestHome(t, t.TempDir()) - - var remoteModel string - rs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req api.ChatRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Fatal(err) - } - remoteModel = req.Model - - w.Header().Set("Content-Type", "application/json") - resp := api.ChatResponse{ - Model: req.Model, - Done: true, - DoneReason: "load", - } - if err := json.NewEncoder(w).Encode(&resp); err != nil { - t.Fatal(err) - } - })) - defer rs.Close() - - p, err := url.Parse(rs.URL) - if err != nil { - t.Fatal(err) - } - - t.Setenv("OLLAMA_REMOTES", p.Hostname()) - - s := Server{} - w := createRequest(t, s.CreateHandler, api.CreateRequest{ - Model: "target-model", - RemoteHost: rs.URL, - From: "test", - Info: map[string]any{ - "capabilities": []string{"completion"}, - }, - Stream: &stream, - }) - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", w.Code) - } - - w = createRequest(t, s.CreateAliasHandler, aliasEntry{Alias: "alias-model", Target: "target-model"}) - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", w.Code) - } - - w = createRequest(t, s.ChatHandler, api.ChatRequest{ - Model: "alias-model", - Messages: []api.Message{{Role: "user", Content: "hi"}}, - Stream: &stream, - }) - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", w.Code) - } - - var resp api.ChatResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatal(err) - } - - if resp.Model != "alias-model" { - t.Fatalf("expected response model to be alias-model, got %q", resp.Model) - } - - if remoteModel != "test" { - t.Fatalf("expected remote model to be 'test', got %q", remoteModel) - } -} - -func TestPrefixAliasBasicMatching(t *testing.T) { - tmpDir := t.TempDir() - store, err := createStore(filepath.Join(tmpDir, "server.json")) - if err != nil { - t.Fatal(err) - } - - // Create a prefix alias: "myprefix-" -> "targetmodel" - targetName := model.ParseName("targetmodel") - - // Set a prefix alias (using "myprefix-" as the pattern) - store.mu.Lock() - store.prefixEntries = append(store.prefixEntries, aliasEntry{ - Alias: "myprefix-", - Target: "targetmodel", - PrefixMatching: true, - }) - store.mu.Unlock() - - // Test that "myprefix-foo" resolves to "targetmodel" - testName := model.ParseName("myprefix-foo") - resolved, wasResolved, err := store.ResolveName(testName) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !wasResolved { - t.Fatal("expected name to be resolved") - } - if resolved.DisplayShortest() != targetName.DisplayShortest() { - t.Fatalf("expected resolved name to be %q, got %q", targetName.DisplayShortest(), resolved.DisplayShortest()) - } - - // Test that "otherprefix-foo" does not resolve - otherName := model.ParseName("otherprefix-foo") - _, wasResolved, err = store.ResolveName(otherName) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if wasResolved { - t.Fatal("expected name not to be resolved") - } - - // Test that exact alias takes precedence - exactAlias := model.ParseName("myprefix-exact") - exactTarget := model.ParseName("exacttarget") - if err := store.Set(exactAlias, exactTarget, false); err != nil { - t.Fatal(err) - } - - resolved, wasResolved, err = store.ResolveName(exactAlias) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !wasResolved { - t.Fatal("expected name to be resolved") - } - if resolved.DisplayShortest() != exactTarget.DisplayShortest() { - t.Fatalf("expected resolved name to be %q (exact match), got %q", exactTarget.DisplayShortest(), resolved.DisplayShortest()) - } -} - -func TestPrefixAliasLongestMatchWins(t *testing.T) { - tmpDir := t.TempDir() - store, err := createStore(filepath.Join(tmpDir, "server.json")) - if err != nil { - t.Fatal(err) - } - - // Add two prefix aliases with overlapping patterns - store.mu.Lock() - store.prefixEntries = []aliasEntry{ - {Alias: "abc-", Target: "short-target", PrefixMatching: true}, - {Alias: "abc-def-", Target: "long-target", PrefixMatching: true}, - } - store.sortPrefixEntriesLocked() - store.mu.Unlock() - - // "abc-def-ghi" should match the longer prefix "abc-def-" - testName := model.ParseName("abc-def-ghi") - resolved, wasResolved, err := store.ResolveName(testName) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !wasResolved { - t.Fatal("expected name to be resolved") - } - expectedLongTarget := model.ParseName("long-target") - if resolved.DisplayShortest() != expectedLongTarget.DisplayShortest() { - t.Fatalf("expected resolved name to be %q (longest prefix match), got %q", expectedLongTarget.DisplayShortest(), resolved.DisplayShortest()) - } - - // "abc-xyz" should match the shorter prefix "abc-" - testName2 := model.ParseName("abc-xyz") - resolved, wasResolved, err = store.ResolveName(testName2) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !wasResolved { - t.Fatal("expected name to be resolved") - } - expectedShortTarget := model.ParseName("short-target") - if resolved.DisplayShortest() != expectedShortTarget.DisplayShortest() { - t.Fatalf("expected resolved name to be %q, got %q", expectedShortTarget.DisplayShortest(), resolved.DisplayShortest()) - } -} - -func TestPrefixAliasChain(t *testing.T) { - tmpDir := t.TempDir() - store, err := createStore(filepath.Join(tmpDir, "server.json")) - if err != nil { - t.Fatal(err) - } - - // Create a chain: prefix "test-" -> "intermediate" -> "final" - intermediate := model.ParseName("intermediate") - final := model.ParseName("final") - - // Add prefix alias - store.mu.Lock() - store.prefixEntries = []aliasEntry{ - {Alias: "test-", Target: "intermediate", PrefixMatching: true}, - } - store.mu.Unlock() - - // Add exact alias for the intermediate step - if err := store.Set(intermediate, final, false); err != nil { - t.Fatal(err) - } - - // "test-foo" should resolve through the chain to "final" - testName := model.ParseName("test-foo") - resolved, wasResolved, err := store.ResolveName(testName) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !wasResolved { - t.Fatal("expected name to be resolved") - } - if resolved.DisplayShortest() != final.DisplayShortest() { - t.Fatalf("expected resolved name to be %q, got %q", final.DisplayShortest(), resolved.DisplayShortest()) - } -} - -func TestPrefixAliasCRUD(t *testing.T) { - gin.SetMode(gin.TestMode) - setTestHome(t, t.TempDir()) - - s := Server{} - - // Create a prefix alias via API - w := createRequest(t, s.CreateAliasHandler, aliasEntry{ - Alias: "myprefix-", - Target: "llama2", - PrefixMatching: true, - }) - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var createResp aliasEntry - if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil { - t.Fatal(err) - } - if !createResp.PrefixMatching { - t.Fatal("expected prefix_matching to be true in response") - } - - // List aliases and verify the prefix alias is included - w = createRequest(t, s.ListAliasesHandler, nil) - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", w.Code) - } - - var listResp aliasListResponse - if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil { - t.Fatal(err) - } - - found := false - for _, a := range listResp.Aliases { - if a.PrefixMatching && a.Target == "llama2" { - found = true - break - } - } - if !found { - t.Fatal("expected to find prefix alias in list") - } - - // Delete the prefix alias - w = createRequest(t, s.DeleteAliasHandler, aliasDeleteRequest{Alias: "myprefix-"}) - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - // Verify it's deleted - w = createRequest(t, s.ListAliasesHandler, nil) - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", w.Code) - } - - if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil { - t.Fatal(err) - } - - for _, a := range listResp.Aliases { - if a.PrefixMatching { - t.Fatal("expected prefix alias to be deleted") - } - } -} - -func TestPrefixAliasCaseInsensitive(t *testing.T) { - tmpDir := t.TempDir() - store, err := createStore(filepath.Join(tmpDir, "server.json")) - if err != nil { - t.Fatal(err) - } - - // Add a prefix alias with mixed case - store.mu.Lock() - store.prefixEntries = []aliasEntry{ - {Alias: "MyPrefix-", Target: "targetmodel", PrefixMatching: true}, - } - store.mu.Unlock() - - // Test that matching is case-insensitive - testName := model.ParseName("myprefix-foo") - resolved, wasResolved, err := store.ResolveName(testName) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !wasResolved { - t.Fatal("expected name to be resolved (case-insensitive)") - } - expectedTarget := model.ParseName("targetmodel") - if resolved.DisplayShortest() != expectedTarget.DisplayShortest() { - t.Fatalf("expected resolved name to be %q, got %q", expectedTarget.DisplayShortest(), resolved.DisplayShortest()) - } - - // Test uppercase request - testName2 := model.ParseName("MYPREFIX-BAR") - _, wasResolved, err = store.ResolveName(testName2) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !wasResolved { - t.Fatal("expected name to be resolved (uppercase)") - } -} - -func TestPrefixAliasLocalModelPrecedence(t *testing.T) { - gin.SetMode(gin.TestMode) - setTestHome(t, t.TempDir()) - - s := Server{} - - // Create a local model that would match a prefix alias - w := createRequest(t, s.CreateHandler, api.CreateRequest{ - Model: "myprefix-localmodel", - RemoteHost: "example.com", - From: "test", - Info: map[string]any{ - "capabilities": []string{"completion"}, - }, - Stream: &stream, - }) - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - // Create a prefix alias that would match the local model name - w = createRequest(t, s.CreateAliasHandler, aliasEntry{ - Alias: "myprefix-", - Target: "someothermodel", - PrefixMatching: true, - }) - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - // Verify that resolving "myprefix-localmodel" returns the local model, not the alias target - store, err := s.aliasStore() - if err != nil { - t.Fatal(err) - } - - localModelName := model.ParseName("myprefix-localmodel") - resolved, wasResolved, err := store.ResolveName(localModelName) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if wasResolved { - t.Fatalf("expected local model to take precedence (wasResolved should be false), but got resolved to %q", resolved.DisplayShortest()) - } - if resolved.DisplayShortest() != localModelName.DisplayShortest() { - t.Fatalf("expected resolved name to be local model %q, got %q", localModelName.DisplayShortest(), resolved.DisplayShortest()) - } - - // Also verify that a non-local model matching the prefix DOES resolve to the alias target - nonLocalName := model.ParseName("myprefix-nonexistent") - resolved, wasResolved, err = store.ResolveName(nonLocalName) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !wasResolved { - t.Fatal("expected non-local model to resolve via prefix alias") - } - expectedTarget := model.ParseName("someothermodel") - if resolved.DisplayShortest() != expectedTarget.DisplayShortest() { - t.Fatalf("expected resolved name to be %q, got %q", expectedTarget.DisplayShortest(), resolved.DisplayShortest()) - } -} - -func TestAliasSavePreservesCloudDisable(t *testing.T) { - gin.SetMode(gin.TestMode) - tmpDir := t.TempDir() - setTestHome(t, tmpDir) - - configPath := filepath.Join(tmpDir, ".ollama", "server.json") - if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { - t.Fatal(err) - } - - initial := map[string]any{ - "version": serverConfigVersion, - "disable_ollama_cloud": true, - "aliases": []aliasEntry{}, - } - data, err := json.Marshal(initial) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(configPath, data, 0o644); err != nil { - t.Fatal(err) - } - - s := Server{} - w := createRequest(t, s.CreateAliasHandler, aliasEntry{Alias: "alias-model", Target: "target-model"}) - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - updated, err := os.ReadFile(configPath) - if err != nil { - t.Fatal(err) - } - - var updatedCfg map[string]json.RawMessage - if err := json.Unmarshal(updated, &updatedCfg); err != nil { - t.Fatal(err) - } - - raw, ok := updatedCfg["disable_ollama_cloud"] - if !ok { - t.Fatal("expected disable_ollama_cloud key to be preserved") - } - if string(raw) != "true" { - t.Fatalf("expected disable_ollama_cloud to remain true, got %s", string(raw)) - } -}