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) }