Compare commits

...
Author SHA1 Message Date
ParthSareen b0b7bf26f3 refactor: simplify usage command 2026-07-27 12:14:37 -04:00
ParthSareen 90dd5b3a70 feat(cli): add usage command 2026-07-23 12:58:14 -07:00
ParthSareen 4855c61358 feat(api): add account usage endpoint 2026-07-23 12:58:08 -07:00
7 changed files with 312 additions and 1 deletions

No files matched your search

+10
View File
@@ -510,3 +510,13 @@ func (c *Client) Whoami(ctx context.Context) (*UserResponse, error) {
}
return &resp, nil
}
// Usage returns the authenticated user's recent activity and included-usage
// limits.
func (c *Client) Usage(ctx context.Context) (*UsageResponse, error) {
var resp UsageResponse
if err := c.do(ctx, http.MethodGet, "/api/usage", nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
+26
View File
@@ -51,6 +51,32 @@ func TestClientFromEnvironment(t *testing.T) {
}
}
func TestClientUsage(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/usage" {
t.Fatalf("request = %s %s, want GET /api/usage", r.Method, r.URL.Path)
}
fmt.Fprint(w, `{"activity":{"cost":"0.00709","period":{"type":"last_4_weeks","starting_at":"2026-06-29T00:00:00Z","ending_at":"2026-07-27T00:00:00Z"},"models":[{"name":"qwen3-coder:480b","request_count":1,"cost":"0.00709"}]},"limits":{"session":{"usage":0.006,"models":[]},"weekly":{"usage":0,"models":[]}}}`)
}))
defer ts.Close()
base, err := url.Parse(ts.URL)
if err != nil {
t.Fatal(err)
}
got, err := NewClient(base, ts.Client()).Usage(t.Context())
if err != nil {
t.Fatal(err)
}
if got.Activity.Cost != "0.00709" {
t.Errorf("activity cost = %q, want 0.00709", got.Activity.Cost)
}
if len(got.Activity.Models) != 1 || got.Activity.Models[0].Name != "qwen3-coder:480b" {
t.Errorf("activity models = %#v, want qwen3-coder:480b", got.Activity.Models)
}
}
// testError represents an internal error type with status code and message
// this is used since the error response from the server is not a standard error struct
type testError struct {
+39
View File
@@ -978,6 +978,45 @@ type UserResponse struct {
Plan string `json:"plan,omitempty"`
}
// UsageResponse reports recent activity and included-usage limits.
type UsageResponse struct {
Activity UsageActivity `json:"activity"`
Limits UsageLimits `json:"limits"`
}
// UsageActivity reports usage activity over a period.
type UsageActivity struct {
Cost string `json:"cost"`
Period UsagePeriod `json:"period"`
Models []UsageModel `json:"models"`
}
// UsagePeriod describes the time window the usage covers.
type UsagePeriod struct {
Type string `json:"type"`
StartingAt time.Time `json:"starting_at"`
EndingAt time.Time `json:"ending_at"`
}
// UsageLimits reports included usage for the current session and week.
type UsageLimits struct {
Session UsageLimit `json:"session"`
Weekly UsageLimit `json:"weekly"`
}
// UsageLimit reports the consumed fraction of an included-usage limit.
type UsageLimit struct {
Usage float64 `json:"usage"`
Models []UsageModel `json:"models"`
}
// UsageModel reports a model's activity.
type UsageModel struct {
Name string `json:"name"`
RequestCount int `json:"request_count"`
Cost string `json:"cost,omitempty"`
}
// Tensor describes the metadata for a given tensor.
type Tensor struct {
Name string `json:"name"`
+105
View File
@@ -27,6 +27,7 @@ import (
"strings"
"sync/atomic"
"syscall"
"text/tabwriter"
"time"
"github.com/containerd/console"
@@ -977,6 +978,100 @@ func SignoutHandler(cmd *cobra.Command, args []string) error {
return nil
}
func UsageHandler(cmd *cobra.Command, args []string) error {
out := cmd.OutOrStdout()
client, err := api.ClientFromEnvironment()
if err != nil {
return err
}
usage, err := client.Usage(cmd.Context())
if err != nil {
var aErr api.AuthorizationError
if errors.As(err, &aErr) && aErr.StatusCode == http.StatusUnauthorized {
fmt.Fprintln(out, "You need to be signed in to Ollama to view usage.")
fmt.Fprintln(out)
if aErr.SigninURL != "" {
_ = browser.OpenURL(aErr.SigninURL)
fmt.Fprintf(out, ConnectInstructions, aErr.SigninURL)
}
return nil
}
return err
}
fmt.Fprintln(out, "Usage")
details := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
fmt.Fprintf(details, " Period\t%s to %s\n", usage.Activity.Period.StartingAt.Format("2006-01-02"), usage.Activity.Period.EndingAt.Format("2006-01-02"))
fmt.Fprintf(details, " Spend\t$%s\n", usage.Activity.Cost)
if err := details.Flush(); err != nil {
return err
}
if len(usage.Activity.Models) == 0 && usageLimitEmpty(usage.Limits.Session) && usageLimitEmpty(usage.Limits.Weekly) {
fmt.Fprintln(out)
fmt.Fprintln(out, "No usage recorded for this period.")
return nil
}
if len(usage.Activity.Models) > 0 {
fmt.Fprintln(out)
fmt.Fprintln(out, "Activity")
table := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
fmt.Fprintln(table, " Model\tRequests\tSpend")
for _, m := range usage.Activity.Models {
fmt.Fprintf(table, " %s\t%d\t$%s\n", usageModelName(m.Name), m.RequestCount, m.Cost)
}
if err := table.Flush(); err != nil {
return err
}
}
if err := writeUsageLimit(out, "Session", usage.Limits.Session); err != nil {
return err
}
if err := writeUsageLimit(out, "Weekly", usage.Limits.Weekly); err != nil {
return err
}
return nil
}
func usageLimitEmpty(limit api.UsageLimit) bool {
return limit.Usage == 0 && len(limit.Models) == 0
}
func usageModelName(name string) string {
switch name {
case "web search":
return "Web Search"
case "web fetch":
return "Web Fetch"
default:
return name
}
}
func writeUsageLimit(out io.Writer, name string, limit api.UsageLimit) error {
if usageLimitEmpty(limit) {
return nil
}
fmt.Fprintln(out)
fmt.Fprintln(out, name)
table := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
fmt.Fprintf(table, " Used\t%.1f%%\n", limit.Usage*100)
if len(limit.Models) > 0 {
fmt.Fprintln(table, " Model\tRequests")
}
for _, m := range limit.Models {
fmt.Fprintf(table, " %s\t%d\n", usageModelName(m.Name), m.RequestCount)
}
return table.Flush()
}
func PushHandler(cmd *cobra.Command, args []string) error {
client, err := api.ClientFromEnvironment()
if err != nil {
@@ -2450,6 +2545,14 @@ func NewCLI() *cobra.Command {
RunE: SignoutHandler,
}
usageCmd := &cobra.Command{
Use: "usage",
Short: "Show your ollama.com usage",
Args: cobra.ExactArgs(0),
PreRunE: checkServerHeartbeat,
RunE: UsageHandler,
}
listCmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
@@ -2514,6 +2617,7 @@ func NewCLI() *cobra.Command {
stopCmd,
pullCmd,
pushCmd,
usageCmd,
listCmd,
psCmd,
copyCmd,
@@ -2566,6 +2670,7 @@ func NewCLI() *cobra.Command {
loginCmd,
signoutCmd,
logoutCmd,
usageCmd,
listCmd,
psCmd,
copyCmd,
+96
View File
@@ -1398,6 +1398,102 @@ func TestListHandler(t *testing.T) {
}
}
func TestUsageHandler(t *testing.T) {
startsAt := time.Date(2026, time.June, 29, 0, 0, 0, 0, time.UTC)
endsAt := time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
statusCode int
response any
want string
}{
{
name: "activity and limits",
statusCode: http.StatusOK,
response: api.UsageResponse{
Activity: api.UsageActivity{
Cost: "12.34000",
Period: api.UsagePeriod{
Type: "last_4_weeks",
StartingAt: startsAt,
EndingAt: endsAt,
},
Models: []api.UsageModel{{Name: "gpt-oss:120b", RequestCount: 42, Cost: "12.34000"}},
},
Limits: api.UsageLimits{
Session: api.UsageLimit{Usage: 0.006, Models: []api.UsageModel{{Name: "web search", RequestCount: 1}}},
},
},
want: "Usage\n" +
" Period 2026-06-29 to 2026-07-27\n" +
" Spend $12.34000\n\n" +
"Activity\n" +
" Model Requests Spend\n" +
" gpt-oss:120b 42 $12.34000\n\n" +
"Session\n" +
" Used 0.6%\n" +
" Model Requests\n" +
" Web Search 1\n",
},
{
name: "no usage",
statusCode: http.StatusOK,
response: api.UsageResponse{
Activity: api.UsageActivity{
Cost: "0.00000",
Period: api.UsagePeriod{Type: "last_4_weeks", StartingAt: startsAt, EndingAt: endsAt},
Models: []api.UsageModel{},
},
Limits: api.UsageLimits{
Session: api.UsageLimit{Models: []api.UsageModel{}},
Weekly: api.UsageLimit{Models: []api.UsageModel{}},
},
},
want: "Usage\n" +
" Period 2026-06-29 to 2026-07-27\n" +
" Spend $0.00000\n\n" +
"No usage recorded for this period.\n",
},
{
name: "not signed in",
statusCode: http.StatusUnauthorized,
response: map[string]string{"error": "unauthorized"},
want: "You need to be signed in to Ollama to view usage.\n\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/usage" {
t.Fatalf("request = %s %s, want GET /api/usage", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tt.statusCode)
if err := json.NewEncoder(w).Encode(tt.response); err != nil {
t.Fatal(err)
}
}))
defer server.Close()
t.Setenv("OLLAMA_HOST", server.URL)
cmd := &cobra.Command{}
cmd.SetContext(t.Context())
var out bytes.Buffer
cmd.SetOut(&out)
if err := UsageHandler(cmd, nil); err != nil {
t.Fatal(err)
}
if got := out.String(); got != tt.want {
t.Errorf("unexpected output (-want +got):\n%s", cmp.Diff(tt.want, got))
}
})
}
}
func TestCreateHandler(t *testing.T) {
tests := []struct {
name string
+6 -1
View File
@@ -61,6 +61,7 @@ const (
cloudErrRemoteModelDetailsUnavailable = "remote model details are unavailable"
cloudErrWebSearchUnavailable = "web search is unavailable"
cloudErrWebFetchUnavailable = "web fetch is unavailable"
cloudErrUsageUnavailable = "usage is unavailable"
copilotChatUserAgentPrefix = "GitHubCopilotChat/"
)
@@ -1882,7 +1883,7 @@ func (s *Server) GenerateRoutes() (http.Handler, error) {
r.DELETE("/api/delete", s.DeleteHandler)
r.POST("/api/me", s.WhoamiHandler)
r.GET("/api/usage", s.UsageHandler)
r.POST("/api/signout", s.SignoutHandler)
// deprecated
r.DELETE("/api/user/keys/:encodedKey", s.SignoutHandler)
@@ -2226,6 +2227,10 @@ func (s *Server) WhoamiHandler(c *gin.Context) {
c.JSON(http.StatusOK, user)
}
func (s *Server) UsageHandler(c *gin.Context) {
proxyCloudRequest(c, nil, cloudErrUsageUnavailable)
}
func (s *Server) SignoutHandler(c *gin.Context) {
pubKey, err := auth.GetPublicKey()
if err != nil {
+30
View File
@@ -0,0 +1,30 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
internalcloud "github.com/ollama/ollama/internal/cloud"
)
func TestUsageHandlerCloudDisabled(t *testing.T) {
t.Setenv("OLLAMA_NO_CLOUD", "1")
server := &Server{}
router, err := server.GenerateRoutes()
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/api/usage", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden)
}
if got, want := response.Body.String(), `{"error":"`+internalcloud.DisabledError(cloudErrUsageUnavailable)+`"}`; got != want {
t.Fatalf("body = %q, want %q", got, want)
}
}