diff --git a/api/client.go b/api/client.go index f19f6c5e..ea61eb79 100644 --- a/api/client.go +++ b/api/client.go @@ -6,26 +6,31 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/url" ) -type StatusError struct { - StatusCode int - Status string - Message string +type Client struct { + base url.URL + HTTP http.Client + Headers http.Header } -func (e StatusError) Error() string { - if e.Message != "" { - return fmt.Sprintf("%s: %s", e.Status, e.Message) +func checkError(resp *http.Response, body []byte) error { + if resp.StatusCode >= 200 && resp.StatusCode < 400 { + return nil } - return e.Status -} + apiError := StatusError{StatusCode: resp.StatusCode} -type Client struct { - base url.URL + err := json.Unmarshal(body, &apiError) + if err != nil { + // Use the full body as the message if we fail to decode a response. + apiError.Message = string(body) + } + + return apiError } func NewClient(hosts ...string) *Client { @@ -36,9 +41,60 @@ func NewClient(hosts ...string) *Client { return &Client{ base: url.URL{Scheme: "http", Host: host}, + HTTP: http.Client{}, } } +func (c *Client) do(ctx context.Context, method, path string, reqData, respData any) error { + var reqBody io.Reader + var data []byte + var err error + if reqData != nil { + data, err = json.Marshal(reqData) + if err != nil { + return err + } + reqBody = bytes.NewReader(data) + } + + url := c.base.JoinPath(path).String() + + req, err := http.NewRequestWithContext(ctx, method, url, reqBody) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + for k, v := range c.Headers { + req.Header[k] = v + } + + respObj, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer respObj.Body.Close() + + respBody, err := io.ReadAll(respObj.Body) + if err != nil { + return err + } + + if err := checkError(respObj, respBody); err != nil { + return err + } + + if len(respBody) > 0 && respData != nil { + if err := json.Unmarshal(respBody, respData); err != nil { + return err + } + } + return nil + +} + func (c *Client) stream(ctx context.Context, method, path string, data any, fn func([]byte) error) error { var buf *bytes.Buffer if data != nil { @@ -142,3 +198,11 @@ func (c *Client) Create(ctx context.Context, req *CreateRequest, fn CreateProgre return fn(resp) }) } + +func (c *Client) List(ctx context.Context) (*ListResponse, error) { + var lr ListResponse + if err := c.do(ctx, http.MethodGet, "/api/tags", nil, &lr); err != nil { + return nil, err + } + return &lr, nil +} diff --git a/api/types.go b/api/types.go index af6e0e41..f9b48aa5 100644 --- a/api/types.go +++ b/api/types.go @@ -7,6 +7,19 @@ import ( "time" ) +type StatusError struct { + StatusCode int + Status string + Message string +} + +func (e StatusError) Error() string { + if e.Message != "" { + return fmt.Sprintf("%s: %s", e.Status, e.Message) + } + return e.Status +} + type GenerateRequest struct { Model string `json:"model"` Prompt string `json:"prompt"` @@ -52,6 +65,16 @@ type PushProgress struct { Percent float64 `json:"percent,omitempty"` } +type ListResponse struct { + Models []ListResponseModel `json:"models"` +} + +type ListResponseModel struct { + Name string `json:"name"` + ModifiedAt time.Time `json:"modified_at"` + Size int `json:"size"` +} + type GenerateResponse struct { Model string `json:"model"` CreatedAt time.Time `json:"created_at"` diff --git a/cmd/cmd.go b/cmd/cmd.go index 43b186e8..47f32fa3 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -13,11 +13,14 @@ import ( "strings" "time" + "github.com/dustin/go-humanize" + "github.com/olekukonko/tablewriter" "github.com/schollz/progressbar/v3" "github.com/spf13/cobra" "golang.org/x/term" "github.com/jmorganca/ollama/api" + "github.com/jmorganca/ollama/format" "github.com/jmorganca/ollama/server" ) @@ -89,6 +92,34 @@ func push(cmd *cobra.Command, args []string) error { return nil } +func list(cmd *cobra.Command, args []string) error { + client := api.NewClient() + + models, err := client.List(context.Background()) + if err != nil { + return err + } + + var data [][]string + + for _, m := range models.Models { + data = append(data, []string{m.Name, humanize.Bytes(uint64(m.Size)), format.HumanTime(m.ModifiedAt, "Never")}) + } + + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"NAME", "SIZE", "MODIFIED"}) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetHeaderLine(false) + table.SetBorder(false) + table.SetNoWhiteSpace(true) + table.SetTablePadding("\t") + table.AppendBulk(data) + table.Render() + + return nil +} + func RunPull(cmd *cobra.Command, args []string) error { return pull(args[0]) } @@ -308,12 +339,19 @@ func NewCLI() *cobra.Command { RunE: push, } + listCmd := &cobra.Command{ + Use: "list", + Short: "List models", + RunE: list, + } + rootCmd.AddCommand( serveCmd, createCmd, runCmd, pullCmd, pushCmd, + listCmd, ) return rootCmd diff --git a/format/time.go b/format/time.go new file mode 100644 index 00000000..a5a2ba53 --- /dev/null +++ b/format/time.go @@ -0,0 +1,141 @@ +package format + +import ( + "fmt" + "math" + "strings" + "time" +) + +// HumanDuration returns a human-readable approximation of a duration +// (eg. "About a minute", "4 hours ago", etc.). +// Modified version of github.com/docker/go-units.HumanDuration +func HumanDuration(d time.Duration) string { + return HumanDurationWithCase(d, true) +} + +// HumanDurationWithCase returns a human-readable approximation of a +// duration (eg. "About a minute", "4 hours ago", etc.). but allows +// you to specify whether the first word should be capitalized +// (eg. "About" vs. "about") +func HumanDurationWithCase(d time.Duration, useCaps bool) string { + seconds := int(d.Seconds()) + + switch { + case seconds < 1: + if useCaps { + return "Less than a second" + } + return "less than a second" + case seconds == 1: + return "1 second" + case seconds < 60: + return fmt.Sprintf("%d seconds", seconds) + } + + minutes := int(d.Minutes()) + switch { + case minutes == 1: + if useCaps { + return "About a minute" + } + return "about a minute" + case minutes < 60: + return fmt.Sprintf("%d minutes", minutes) + } + + hours := int(math.Round(d.Hours())) + switch { + case hours == 1: + if useCaps { + return "About an hour" + } + return "about an hour" + case hours < 48: + return fmt.Sprintf("%d hours", hours) + case hours < 24*7*2: + return fmt.Sprintf("%d days", hours/24) + case hours < 24*30*2: + return fmt.Sprintf("%d weeks", hours/24/7) + case hours < 24*365*2: + return fmt.Sprintf("%d months", hours/24/30) + } + + return fmt.Sprintf("%d years", int(d.Hours())/24/365) +} + +func HumanTime(t time.Time, zeroValue string) string { + return humanTimeWithCase(t, zeroValue, true) +} + +func HumanTimeLower(t time.Time, zeroValue string) string { + return humanTimeWithCase(t, zeroValue, false) +} + +func humanTimeWithCase(t time.Time, zeroValue string, useCaps bool) string { + if t.IsZero() { + return zeroValue + } + + delta := time.Since(t) + if delta < 0 { + return HumanDurationWithCase(-delta, useCaps) + " from now" + } + return HumanDurationWithCase(delta, useCaps) + " ago" +} + +// ExcatDuration returns a human readable hours/minutes/seconds or milliseconds format of a duration +// the most precise level of duration is milliseconds +func ExactDuration(d time.Duration) string { + if d.Seconds() < 1 { + if d.Milliseconds() == 1 { + return fmt.Sprintf("%d millisecond", d.Milliseconds()) + } + return fmt.Sprintf("%d milliseconds", d.Milliseconds()) + } + + var readableDur strings.Builder + + dur := d.String() + + // split the default duration string format of 0h0m0s into something nicer to read + h := strings.Split(dur, "h") + if len(h) > 1 { + hours := h[0] + if hours == "1" { + readableDur.WriteString(fmt.Sprintf("%s hour ", hours)) + } else { + readableDur.WriteString(fmt.Sprintf("%s hours ", hours)) + } + dur = h[1] + } + + m := strings.Split(dur, "m") + if len(m) > 1 { + mins := m[0] + switch mins { + case "0": + // skip + case "1": + readableDur.WriteString(fmt.Sprintf("%s minute ", mins)) + default: + readableDur.WriteString(fmt.Sprintf("%s minutes ", mins)) + } + dur = m[1] + } + + s := strings.Split(dur, "s") + if len(s) > 0 { + sec := s[0] + switch sec { + case "0": + // skip + case "1": + readableDur.WriteString(fmt.Sprintf("%s second ", sec)) + default: + readableDur.WriteString(fmt.Sprintf("%s seconds ", sec)) + } + } + + return strings.TrimSpace(readableDur.String()) +} diff --git a/format/time_test.go b/format/time_test.go new file mode 100644 index 00000000..6f8bb4a9 --- /dev/null +++ b/format/time_test.go @@ -0,0 +1,102 @@ +package format + +import ( + "testing" + "time" +) + +func assertEqual(t *testing.T, a interface{}, b interface{}) { + if a != b { + t.Errorf("Assert failed, expected %v, got %v", b, a) + } +} + +func TestHumanDuration(t *testing.T) { + day := 24 * time.Hour + week := 7 * day + month := 30 * day + year := 365 * day + + assertEqual(t, "Less than a second", HumanDuration(450*time.Millisecond)) + assertEqual(t, "Less than a second", HumanDurationWithCase(450*time.Millisecond, true)) + assertEqual(t, "less than a second", HumanDurationWithCase(450*time.Millisecond, false)) + assertEqual(t, "1 second", HumanDuration(1*time.Second)) + assertEqual(t, "45 seconds", HumanDuration(45*time.Second)) + assertEqual(t, "46 seconds", HumanDuration(46*time.Second)) + assertEqual(t, "59 seconds", HumanDuration(59*time.Second)) + assertEqual(t, "About a minute", HumanDuration(60*time.Second)) + assertEqual(t, "About a minute", HumanDurationWithCase(1*time.Minute, true)) + assertEqual(t, "about a minute", HumanDurationWithCase(1*time.Minute, false)) + assertEqual(t, "3 minutes", HumanDuration(3*time.Minute)) + assertEqual(t, "35 minutes", HumanDuration(35*time.Minute)) + assertEqual(t, "35 minutes", HumanDuration(35*time.Minute+40*time.Second)) + assertEqual(t, "45 minutes", HumanDuration(45*time.Minute)) + assertEqual(t, "45 minutes", HumanDuration(45*time.Minute+40*time.Second)) + assertEqual(t, "46 minutes", HumanDuration(46*time.Minute)) + assertEqual(t, "59 minutes", HumanDuration(59*time.Minute)) + assertEqual(t, "About an hour", HumanDuration(1*time.Hour)) + assertEqual(t, "About an hour", HumanDurationWithCase(1*time.Hour+29*time.Minute, true)) + assertEqual(t, "about an hour", HumanDurationWithCase(1*time.Hour+29*time.Minute, false)) + assertEqual(t, "2 hours", HumanDuration(1*time.Hour+31*time.Minute)) + assertEqual(t, "2 hours", HumanDuration(1*time.Hour+59*time.Minute)) + assertEqual(t, "3 hours", HumanDuration(3*time.Hour)) + assertEqual(t, "3 hours", HumanDuration(3*time.Hour+29*time.Minute)) + assertEqual(t, "4 hours", HumanDuration(3*time.Hour+31*time.Minute)) + assertEqual(t, "4 hours", HumanDuration(3*time.Hour+59*time.Minute)) + assertEqual(t, "4 hours", HumanDuration(3*time.Hour+60*time.Minute)) + assertEqual(t, "24 hours", HumanDuration(24*time.Hour)) + assertEqual(t, "36 hours", HumanDuration(1*day+12*time.Hour)) + assertEqual(t, "2 days", HumanDuration(2*day)) + assertEqual(t, "7 days", HumanDuration(7*day)) + assertEqual(t, "13 days", HumanDuration(13*day+5*time.Hour)) + assertEqual(t, "2 weeks", HumanDuration(2*week)) + assertEqual(t, "2 weeks", HumanDuration(2*week+4*day)) + assertEqual(t, "3 weeks", HumanDuration(3*week)) + assertEqual(t, "4 weeks", HumanDuration(4*week)) + assertEqual(t, "4 weeks", HumanDuration(4*week+3*day)) + assertEqual(t, "4 weeks", HumanDuration(1*month)) + assertEqual(t, "6 weeks", HumanDuration(1*month+2*week)) + assertEqual(t, "2 months", HumanDuration(2*month)) + assertEqual(t, "2 months", HumanDuration(2*month+2*week)) + assertEqual(t, "3 months", HumanDuration(3*month)) + assertEqual(t, "3 months", HumanDuration(3*month+1*week)) + assertEqual(t, "5 months", HumanDuration(5*month+2*week)) + assertEqual(t, "13 months", HumanDuration(13*month)) + assertEqual(t, "23 months", HumanDuration(23*month)) + assertEqual(t, "24 months", HumanDuration(24*month)) + assertEqual(t, "2 years", HumanDuration(24*month+2*week)) + assertEqual(t, "3 years", HumanDuration(3*year+2*month)) +} + +func TestHumanTime(t *testing.T) { + now := time.Now() + + t.Run("zero value", func(t *testing.T) { + assertEqual(t, HumanTime(time.Time{}, "never"), "never") + }) + t.Run("time in the future", func(t *testing.T) { + v := now.Add(48 * time.Hour) + assertEqual(t, HumanTime(v, ""), "2 days from now") + }) + t.Run("time in the past", func(t *testing.T) { + v := now.Add(-48 * time.Hour) + assertEqual(t, HumanTime(v, ""), "2 days ago") + }) +} + +func TestExactDuration(t *testing.T) { + assertEqual(t, "1 millisecond", ExactDuration(1*time.Millisecond)) + assertEqual(t, "10 milliseconds", ExactDuration(10*time.Millisecond)) + assertEqual(t, "1 second", ExactDuration(1*time.Second)) + assertEqual(t, "10 seconds", ExactDuration(10*time.Second)) + assertEqual(t, "1 minute", ExactDuration(1*time.Minute)) + assertEqual(t, "10 minutes", ExactDuration(10*time.Minute)) + assertEqual(t, "1 hour", ExactDuration(1*time.Hour)) + assertEqual(t, "10 hours", ExactDuration(10*time.Hour)) + assertEqual(t, "1 hour 1 second", ExactDuration(1*time.Hour+1*time.Second)) + assertEqual(t, "1 hour 10 seconds", ExactDuration(1*time.Hour+10*time.Second)) + assertEqual(t, "1 hour 1 minute", ExactDuration(1*time.Hour+1*time.Minute)) + assertEqual(t, "1 hour 10 minutes", ExactDuration(1*time.Hour+10*time.Minute)) + assertEqual(t, "1 hour 1 minute 1 second", ExactDuration(1*time.Hour+1*time.Minute+1*time.Second)) + assertEqual(t, "10 hours 10 minutes 10 seconds", ExactDuration(10*time.Hour+10*time.Minute+10*time.Second)) +} diff --git a/go.mod b/go.mod index f5fee20d..55dfbad3 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module github.com/jmorganca/ollama go 1.20 require ( + github.com/dustin/go-humanize v1.0.1 github.com/gin-gonic/gin v1.9.1 + github.com/olekukonko/tablewriter v0.0.5 github.com/spf13/cobra v1.7.0 ) diff --git a/go.sum b/go.sum index 735413a3..12c1b0b1 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -43,6 +45,7 @@ github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNa github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= @@ -52,6 +55,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/server/images.go b/server/images.go index 2955586e..5970e557 100644 --- a/server/images.go +++ b/server/images.go @@ -59,6 +59,15 @@ type RootFS struct { DiffIDs []string `json:"diff_ids"` } +func (m *ManifestV2) GetTotalSize() int { + var total int + for _, layer := range m.Layers { + total += layer.Size + } + total += m.Config.Size + return total +} + func GetManifest(mp ModelPath) (*ManifestV2, error) { fp, err := mp.GetManifestPath(false) if err != nil { diff --git a/server/modelpath.go b/server/modelpath.go index d23c1933..a7f54d57 100644 --- a/server/modelpath.go +++ b/server/modelpath.go @@ -91,6 +91,15 @@ func (mp ModelPath) GetManifestPath(createDir bool) (string, error) { return path, nil } +func GetManifestPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + return filepath.Join(home, ".ollama", "models", "manifests"), nil +} + func GetBlobsPath(digest string) (string, error) { home, err := os.UserHomeDir() if err != nil { diff --git a/server/routes.go b/server/routes.go index af7e59fa..7f6b86e1 100644 --- a/server/routes.go +++ b/server/routes.go @@ -181,6 +181,51 @@ func create(c *gin.Context) { streamResponse(c, ch) } +func list(c *gin.Context) { + var models []api.ListResponseModel + fp, err := GetManifestPath() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + err = filepath.Walk(fp, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + fi, err := os.Stat(path) + if err != nil { + return err + } + path := path[len(fp)+1:] + slashIndex := strings.LastIndex(path, "/") + if slashIndex == -1 { + return nil + } + tag := path[:slashIndex] + ":" + path[slashIndex+1:] + mp := ParseModelPath(tag) + manifest, err := GetManifest(mp) + if err != nil { + log.Printf("couldn't get manifest: %v", err) + return err + } + model := api.ListResponseModel{ + Name: mp.GetShortTagname(), + Size: manifest.GetTotalSize(), + ModifiedAt: fi.ModTime(), + } + models = append(models, model) + } + return nil + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, api.ListResponse{models}) +} + func Serve(ln net.Listener) error { r := gin.Default() @@ -192,6 +237,7 @@ func Serve(ln net.Listener) error { r.POST("/api/generate", generate) r.POST("/api/create", create) r.POST("/api/push", push) + r.GET("/api/tags", list) log.Printf("Listening on %s", ln.Addr()) s := &http.Server{