Repository: jmorganca/ollama
Pull request #97 (2023-07-18 05:43:08)

add new list command
This changes lets you list each of the models which you have pulled locally.


Patch:
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,7 +41,58 @@ 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 {
@@ -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/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{


Test patch:
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))
+}