mirror of
https://github.com/ollama/ollama.git
synced 2026-09-08 12:13:43 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0b7bf26f3 | ||
|
|
90dd5b3a70 | ||
|
|
4855c61358 |
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user