From b7e4dd32b0a4f53aa9651735a98192cb737c8174 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sun, 29 Mar 2026 01:40:36 +0100 Subject: [PATCH] feat: add usage command for premium request billing Adds `gh models usage` command that shows premium request usage statistics from the GitHub billing API, with breakdown by model. Features: - Shows requests, gross cost, and net cost per model - Supports --today, --year, --month, --day flags - Sorted by gross amount descending - Color-coded discount/cost summary - Full test coverage with httptest mock server Closes github/gh-models#81 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/root.go | 4 +- cmd/root_test.go | 1 + cmd/usage/usage.go | 307 ++++++++++++++++++++++++++++++++++++++++ cmd/usage/usage_test.go | 272 +++++++++++++++++++++++++++++++++++ pkg/command/config.go | 10 +- 5 files changed, 592 insertions(+), 2 deletions(-) create mode 100644 cmd/usage/usage.go create mode 100644 cmd/usage/usage_test.go diff --git a/cmd/root.go b/cmd/root.go index ac6002f6..5fa32862 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,6 +12,7 @@ import ( "github.com/github/gh-models/cmd/generate" "github.com/github/gh-models/cmd/list" "github.com/github/gh-models/cmd/run" + "github.com/github/gh-models/cmd/usage" "github.com/github/gh-models/cmd/view" "github.com/github/gh-models/internal/azuremodels" "github.com/github/gh-models/pkg/command" @@ -54,11 +55,12 @@ func NewRootCommand() *cobra.Command { } } - cfg := command.NewConfigWithTerminal(terminal, client) + cfg := command.NewConfigWithTerminal(terminal, client, token) cmd.AddCommand(eval.NewEvalCommand(cfg)) cmd.AddCommand(list.NewListCommand(cfg)) cmd.AddCommand(run.NewRunCommand(cfg)) + cmd.AddCommand(usage.NewUsageCommand(cfg)) cmd.AddCommand(view.NewViewCommand(cfg)) cmd.AddCommand(generate.NewGenerateCommand(cfg)) diff --git a/cmd/root_test.go b/cmd/root_test.go index 0dd07ec4..300060d7 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -22,6 +22,7 @@ func TestRoot(t *testing.T) { require.Regexp(t, regexp.MustCompile(`eval\s+Evaluate prompts using test data and evaluators`), output) require.Regexp(t, regexp.MustCompile(`list\s+List available models`), output) require.Regexp(t, regexp.MustCompile(`run\s+Run inference with the specified model`), output) + require.Regexp(t, regexp.MustCompile(`usage\s+Show premium request usage and costs`), output) require.Regexp(t, regexp.MustCompile(`view\s+View details about a model`), output) require.Regexp(t, regexp.MustCompile(`generate\s+Generate tests and evaluations for prompts`), output) }) diff --git a/cmd/usage/usage.go b/cmd/usage/usage.go new file mode 100644 index 00000000..888f8224 --- /dev/null +++ b/cmd/usage/usage.go @@ -0,0 +1,307 @@ +// Package usage provides a gh command to show GitHub Models and Copilot usage information. +package usage + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/go-gh/v2/pkg/tableprinter" + "github.com/github/gh-models/pkg/command" + "github.com/mgutz/ansi" + "github.com/spf13/cobra" +) + +const defaultGitHubAPIBase = "https://api.github.com" + +var ( + headerColor = ansi.ColorFunc("white+du") + greenColor = ansi.ColorFunc("green") + yellowColor = ansi.ColorFunc("yellow") +) + +// usageOptions holds configuration for the usage command, allowing dependency injection for testing. +type usageOptions struct { + apiBase string + httpClient *http.Client +} + +// premiumRequestUsageResponse represents the API response for premium request usage. +type premiumRequestUsageResponse struct { + TimePeriod timePeriod `json:"timePeriod"` + User string `json:"user"` + UsageItems []usageItem `json:"usageItems"` +} + +type timePeriod struct { + Year int `json:"year"` + Month int `json:"month"` + Day int `json:"day,omitempty"` +} + +type usageItem struct { + Product string `json:"product"` + SKU string `json:"sku"` + Model string `json:"model"` + UnitType string `json:"unitType"` + PricePerUnit float64 `json:"pricePerUnit"` + GrossQuantity float64 `json:"grossQuantity"` + GrossAmount float64 `json:"grossAmount"` + DiscountQuantity float64 `json:"discountQuantity"` + DiscountAmount float64 `json:"discountAmount"` + NetQuantity float64 `json:"netQuantity"` + NetAmount float64 `json:"netAmount"` +} + +func defaultOptions() *usageOptions { + return &usageOptions{ + apiBase: defaultGitHubAPIBase, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// NewUsageCommand returns a new command to show model usage information. +func NewUsageCommand(cfg *command.Config) *cobra.Command { + return newUsageCommand(cfg, nil) +} + +// newUsageCommand is the internal constructor that accepts options for testing. +func newUsageCommand(cfg *command.Config, opts *usageOptions) *cobra.Command { + if opts == nil { + opts = defaultOptions() + } + + var ( + flagMonth int + flagYear int + flagDay int + flagToday bool + ) + + cmd := &cobra.Command{ + Use: "usage", + Short: "Show premium request usage and costs", + Long: heredoc.Docf(` + Display premium request usage statistics for GitHub Models and Copilot. + + Shows a breakdown of requests by model, with gross and net costs. + By default, shows usage for the current billing period (month). + + Use %[1]s--today%[1]s to see only today's usage, or %[1]s--month%[1]s and + %[1]s--year%[1]s to query a specific billing period. + + Requires the %[1]suser%[1]s scope on your GitHub token. If you get a 404 error, + run: %[1]sgh auth refresh -h github.com -s user%[1]s + `, "`"), + Example: heredoc.Doc(` + # Show current month's usage + $ gh models usage + + # Show today's usage + $ gh models usage --today + + # Show usage for a specific month + $ gh models usage --year 2026 --month 2 + + # Show usage for a specific day + $ gh models usage --year 2026 --month 3 --day 15 + `), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + token := cfg.Token + if token == "" { + return fmt.Errorf("no GitHub token found. Please run 'gh auth login' to authenticate") + } + + ctx := cmd.Context() + + // Resolve time period + now := time.Now().UTC() + year := flagYear + month := flagMonth + day := flagDay + + if flagToday { + year = now.Year() + month = int(now.Month()) + day = now.Day() + } + + if year == 0 { + year = now.Year() + } + if month == 0 { + month = int(now.Month()) + } + + // Get username + username, err := getUsername(ctx, opts, token) + if err != nil { + return fmt.Errorf("failed to get username: %w", err) + } + + // Build query params + query := fmt.Sprintf("?year=%d&month=%d", year, month) + if day > 0 { + query += fmt.Sprintf("&day=%d", day) + } + + // Fetch usage + data, err := fetchPremiumRequestUsage(ctx, opts, token, username, query) + if err != nil { + return err + } + + // Format period string + periodStr := fmt.Sprintf("%d-%02d", data.TimePeriod.Year, data.TimePeriod.Month) + if data.TimePeriod.Day > 0 { + periodStr += fmt.Sprintf("-%02d", data.TimePeriod.Day) + } + + if len(data.UsageItems) == 0 { + cfg.WriteToOut(fmt.Sprintf("\nNo usage found for %s\n\n", periodStr)) + return nil + } + + // Sort by gross amount descending + items := data.UsageItems + sort.Slice(items, func(i, j int) bool { + return items[i].GrossAmount > items[j].GrossAmount + }) + + // Calculate totals + var totalReqs, totalGross, totalNet float64 + for _, item := range items { + totalReqs += item.GrossQuantity + totalGross += item.GrossAmount + totalNet += item.NetAmount + } + + // Print header + if cfg.IsTerminalOutput { + cfg.WriteToOut("\n") + if flagToday { + cfg.WriteToOut(fmt.Sprintf("Premium request usage for %s (%s, today)\n", username, periodStr)) + } else { + cfg.WriteToOut(fmt.Sprintf("Premium request usage for %s (%s)\n", username, periodStr)) + } + cfg.WriteToOut("\n") + } + + // Print table + printer := cfg.NewTablePrinter() + + printer.AddHeader([]string{"PRODUCT", "MODEL", "REQUESTS", "GROSS", "NET"}, tableprinter.WithColor(headerColor)) + printer.EndRow() + + for _, item := range items { + if item.GrossQuantity == 0 { + continue + } + printer.AddField(item.Product) + printer.AddField(item.Model) + printer.AddField(fmt.Sprintf("%.1f", item.GrossQuantity)) + printer.AddField(fmt.Sprintf("$%.2f", item.GrossAmount)) + printer.AddField(fmt.Sprintf("$%.2f", item.NetAmount)) + printer.EndRow() + } + + if err := printer.Render(); err != nil { + return err + } + + // Print summary + if cfg.IsTerminalOutput { + cfg.WriteToOut("\n") + cfg.WriteToOut(fmt.Sprintf("Total: %.0f requests, $%.2f gross, $%.2f net\n", totalReqs, totalGross, totalNet)) + + if totalGross > 0 && totalNet == 0 { + cfg.WriteToOut(greenColor("All usage included in your plan (100% discount)") + "\n") + } else if totalNet > 0 { + pct := (totalNet / totalGross) * 100 + cfg.WriteToOut(yellowColor(fmt.Sprintf("Net cost: $%.2f (%.0f%% of gross)", totalNet, pct)) + "\n") + } + cfg.WriteToOut("\n") + } + + return nil + }, + } + + cmd.Flags().IntVar(&flagYear, "year", 0, "Filter by year (default: current year)") + cmd.Flags().IntVar(&flagMonth, "month", 0, "Filter by month (default: current month)") + cmd.Flags().IntVar(&flagDay, "day", 0, "Filter by specific day") + cmd.Flags().BoolVar(&flagToday, "today", false, "Show only today's usage") + + return cmd +} + +func getUsername(ctx context.Context, opts *usageOptions, token string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.apiBase+"/user", nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := opts.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to get user info: HTTP %d", resp.StatusCode) + } + + var user struct { + Login string `json:"login"` + } + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return "", err + } + return user.Login, nil +} + +func fetchPremiumRequestUsage(ctx context.Context, opts *usageOptions, token, username, query string) (*premiumRequestUsageResponse, error) { + url := fmt.Sprintf("%s/users/%s/settings/billing/premium_request/usage%s", opts.apiBase, username, query) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := opts.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("usage data not available (HTTP 404). You may need the 'user' scope.\nRun: gh auth refresh -h github.com -s user\n\nResponse: %s", string(body)) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to fetch usage data: HTTP %d\n%s", resp.StatusCode, string(body)) + } + + var data premiumRequestUsageResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, fmt.Errorf("failed to parse usage response: %w", err) + } + + return &data, nil +} diff --git a/cmd/usage/usage_test.go b/cmd/usage/usage_test.go new file mode 100644 index 00000000..e6b3e9e7 --- /dev/null +++ b/cmd/usage/usage_test.go @@ -0,0 +1,272 @@ +package usage + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/github/gh-models/internal/azuremodels" + "github.com/github/gh-models/pkg/command" + "github.com/stretchr/testify/require" +) + +func newTestServer(t *testing.T, userResp map[string]string, usageResp *premiumRequestUsageResponse, usageStatus int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/user": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(userResp) + case strings.HasPrefix(r.URL.Path, "/users/") && strings.HasSuffix(r.URL.Path, "/settings/billing/premium_request/usage"): + w.Header().Set("Content-Type", "application/json") + if usageStatus != 0 && usageStatus != http.StatusOK { + w.WriteHeader(usageStatus) + _, _ = w.Write([]byte(`{"message":"Not Found"}`)) + return + } + _ = json.NewEncoder(w).Encode(usageResp) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"unknown path"}`)) + } + })) +} + +func newTestOpts(serverURL string) *usageOptions { + return &usageOptions{ + apiBase: serverURL, + httpClient: http.DefaultClient, + } +} + +func TestUsageCommand(t *testing.T) { + t.Run("shows usage table", func(t *testing.T) { + usageResp := &premiumRequestUsageResponse{ + TimePeriod: timePeriod{Year: 2026, Month: 3}, + User: "testuser", + UsageItems: []usageItem{ + { + Product: "Copilot", + Model: "Claude Opus 4.6", + GrossQuantity: 100, + GrossAmount: 4.0, + NetAmount: 0.0, + }, + { + Product: "Copilot", + Model: "GPT-5.2", + GrossQuantity: 50, + GrossAmount: 2.0, + NetAmount: 0.0, + }, + }, + } + + server := newTestServer(t, map[string]string{"login": "testuser"}, usageResp, 0) + defer server.Close() + + buf := new(bytes.Buffer) + client := azuremodels.NewMockClient() + cfg := command.NewConfig(buf, buf, client, true, 120) + cfg.Token = "test-token" + + cmd := newUsageCommand(cfg, newTestOpts(server.URL)) + cmd.SetArgs([]string{"--year", "2026", "--month", "3"}) + + _, err := cmd.ExecuteC() + require.NoError(t, err) + + output := buf.String() + require.Contains(t, output, "testuser") + require.Contains(t, output, "2026-03") + require.Contains(t, output, "Claude Opus 4.6") + require.Contains(t, output, "GPT-5.2") + require.Contains(t, output, "100.0") + require.Contains(t, output, "$4.00") + require.Contains(t, output, "Total: 150 requests") + require.Contains(t, output, "100% discount") + }) + + t.Run("shows net cost when applicable", func(t *testing.T) { + usageResp := &premiumRequestUsageResponse{ + TimePeriod: timePeriod{Year: 2026, Month: 3}, + User: "testuser", + UsageItems: []usageItem{ + { + Product: "Copilot", + Model: "Claude Opus 4.6", + GrossQuantity: 100, + GrossAmount: 4.0, + NetAmount: 2.0, + }, + }, + } + + server := newTestServer(t, map[string]string{"login": "testuser"}, usageResp, 0) + defer server.Close() + + buf := new(bytes.Buffer) + client := azuremodels.NewMockClient() + cfg := command.NewConfig(buf, buf, client, true, 120) + cfg.Token = "test-token" + + cmd := newUsageCommand(cfg, newTestOpts(server.URL)) + cmd.SetArgs([]string{"--year", "2026", "--month", "3"}) + + _, err := cmd.ExecuteC() + require.NoError(t, err) + + output := buf.String() + require.Contains(t, output, "Net cost: $2.00") + }) + + t.Run("shows no usage message when empty", func(t *testing.T) { + usageResp := &premiumRequestUsageResponse{ + TimePeriod: timePeriod{Year: 2026, Month: 1}, + User: "testuser", + UsageItems: []usageItem{}, + } + + server := newTestServer(t, map[string]string{"login": "testuser"}, usageResp, 0) + defer server.Close() + + buf := new(bytes.Buffer) + client := azuremodels.NewMockClient() + cfg := command.NewConfig(buf, buf, client, true, 120) + cfg.Token = "test-token" + + cmd := newUsageCommand(cfg, newTestOpts(server.URL)) + cmd.SetArgs([]string{"--year", "2026", "--month", "1"}) + + _, err := cmd.ExecuteC() + require.NoError(t, err) + + output := buf.String() + require.Contains(t, output, "No usage found") + }) + + t.Run("errors without token", func(t *testing.T) { + buf := new(bytes.Buffer) + client := azuremodels.NewMockClient() + cfg := command.NewConfig(buf, buf, client, true, 120) + cfg.Token = "" + + cmd := newUsageCommand(cfg, nil) + cmd.SetArgs([]string{}) + + _, err := cmd.ExecuteC() + require.Error(t, err) + require.Contains(t, err.Error(), "no GitHub token found") + }) + + t.Run("404 returns helpful scope error", func(t *testing.T) { + server := newTestServer(t, map[string]string{"login": "testuser"}, nil, http.StatusNotFound) + defer server.Close() + + buf := new(bytes.Buffer) + client := azuremodels.NewMockClient() + cfg := command.NewConfig(buf, buf, client, true, 120) + cfg.Token = "test-token" + + cmd := newUsageCommand(cfg, newTestOpts(server.URL)) + cmd.SetArgs([]string{"--year", "2026", "--month", "3"}) + + _, err := cmd.ExecuteC() + require.Error(t, err) + require.Contains(t, err.Error(), "HTTP 404") + require.Contains(t, err.Error(), "gh auth refresh") + }) + + t.Run("--today flag works", func(t *testing.T) { + usageResp := &premiumRequestUsageResponse{ + TimePeriod: timePeriod{Year: 2026, Month: 3, Day: 29}, + User: "testuser", + UsageItems: []usageItem{ + { + Product: "Copilot", + Model: "Claude Opus 4.6", + GrossQuantity: 5, + GrossAmount: 0.20, + NetAmount: 0.0, + }, + }, + } + + server := newTestServer(t, map[string]string{"login": "testuser"}, usageResp, 0) + defer server.Close() + + buf := new(bytes.Buffer) + client := azuremodels.NewMockClient() + cfg := command.NewConfig(buf, buf, client, true, 120) + cfg.Token = "test-token" + + cmd := newUsageCommand(cfg, newTestOpts(server.URL)) + cmd.SetArgs([]string{"--today"}) + + _, err := cmd.ExecuteC() + require.NoError(t, err) + + output := buf.String() + require.Contains(t, output, "today") + }) + + t.Run("--help prints usage info", func(t *testing.T) { + outBuf := new(bytes.Buffer) + errBuf := new(bytes.Buffer) + cmd := NewUsageCommand(nil) + cmd.SetOut(outBuf) + cmd.SetErr(errBuf) + cmd.SetArgs([]string{"--help"}) + + err := cmd.Help() + + require.NoError(t, err) + require.Contains(t, outBuf.String(), "Display premium request usage statistics") + require.Empty(t, errBuf.String()) + }) + + t.Run("sorts by gross amount descending", func(t *testing.T) { + usageResp := &premiumRequestUsageResponse{ + TimePeriod: timePeriod{Year: 2026, Month: 3}, + User: "testuser", + UsageItems: []usageItem{ + { + Product: "Copilot", + Model: "GPT-5.2", + GrossQuantity: 10, + GrossAmount: 0.40, + NetAmount: 0.0, + }, + { + Product: "Copilot", + Model: "Claude Opus 4.6", + GrossQuantity: 100, + GrossAmount: 4.0, + NetAmount: 0.0, + }, + }, + } + + server := newTestServer(t, map[string]string{"login": "testuser"}, usageResp, 0) + defer server.Close() + + buf := new(bytes.Buffer) + client := azuremodels.NewMockClient() + cfg := command.NewConfig(buf, buf, client, true, 120) + cfg.Token = "test-token" + + cmd := newUsageCommand(cfg, newTestOpts(server.URL)) + cmd.SetArgs([]string{"--year", "2026", "--month", "3"}) + + _, err := cmd.ExecuteC() + require.NoError(t, err) + + output := buf.String() + opusIdx := bytes.Index([]byte(output), []byte("Claude Opus")) + gptIdx := bytes.Index([]byte(output), []byte("GPT-5.2")) + require.True(t, opusIdx < gptIdx, "Claude Opus 4.6 should appear before GPT-5.2 (sorted by gross amount)") + }) +} diff --git a/pkg/command/config.go b/pkg/command/config.go index 36296b44..c446b1eb 100644 --- a/pkg/command/config.go +++ b/pkg/command/config.go @@ -18,6 +18,8 @@ type Config struct { ErrOut io.Writer // Client is the client for interacting with the models service. Client azuremodels.Client + // Token is the GitHub authentication token. + Token string // IsTerminalOutput is true if the output should be formatted for a terminal. IsTerminalOutput bool // TerminalWidth is the width of the terminal. @@ -29,13 +31,19 @@ func NewConfig(out, errOut io.Writer, client azuremodels.Client, isTerminalOutpu return &Config{Out: out, ErrOut: errOut, Client: client, IsTerminalOutput: isTerminalOutput, TerminalWidth: width} } +// NewConfigWithToken returns a new command configuration with a GitHub token. +func NewConfigWithToken(out, errOut io.Writer, client azuremodels.Client, token string, isTerminalOutput bool, width int) *Config { + return &Config{Out: out, ErrOut: errOut, Client: client, Token: token, IsTerminalOutput: isTerminalOutput, TerminalWidth: width} +} + // NewConfigWithTerminal returns a new command configuration using the given terminal. -func NewConfigWithTerminal(terminal term.Term, client azuremodels.Client) *Config { +func NewConfigWithTerminal(terminal term.Term, client azuremodels.Client, token string) *Config { width, _, _ := terminal.Size() return &Config{ Out: terminal.Out(), ErrOut: terminal.ErrOut(), Client: client, + Token: token, IsTerminalOutput: terminal.IsTerminalOutput(), TerminalWidth: width, }