Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Comment thread
marcelsafin marked this conversation as resolved.

Expand Down
1 change: 1 addition & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
336 changes: 336 additions & 0 deletions cmd/usage/usage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,336 @@
// 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()
}
Comment thread
marcelsafin marked this conversation as resolved.

if year == 0 {
year = now.Year()
}
if month == 0 {
month = int(now.Month())
}

Comment thread
marcelsafin marked this conversation as resolved.
// Validate date arguments to fail fast with a clear message
// instead of letting the API return a confusing error.
if year < 1 {
return fmt.Errorf("invalid value for --year: %d (must be >= 1)", year)
}
if month < 1 || month > 12 {
return fmt.Errorf("invalid value for --month: %d (must be between 1 and 12)", month)
}
// day == 0 means "not specified" and is allowed; validate only if non-zero.
if day < 0 || day > 31 {
return fmt.Errorf("invalid value for --day: %d (must be between 1 and 31)", day)
}

// 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 across the rows that will actually be rendered
// (matching the GrossQuantity > 0 filter applied below) so the
// printed totals agree with the visible rows.
var totalReqs, totalGross, totalNet float64
for _, item := range items {
if item.GrossQuantity == 0 {
continue
}
totalReqs += item.GrossQuantity
totalGross += item.GrossAmount
totalNet += item.NetAmount
Comment thread
marcelsafin marked this conversation as resolved.
}

// 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 totalGross > 0 && totalNet > 0 {
pct := (totalNet / totalGross) * 100
cfg.WriteToOut(yellowColor(fmt.Sprintf("Net cost: $%.2f (%.0f%% of gross)", totalNet, pct)) + "\n")
} else if totalNet > 0 {
// Defensive: avoid divide-by-zero / Inf / NaN if the API
// reports net cost without a gross amount.
cfg.WriteToOut(yellowColor(fmt.Sprintf("Net cost: $%.2f (gross amount unavailable; percentage not shown)", totalNet)) + "\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")

// --today is shorthand for the current Y/M/D, so it conflicts with
// any explicit date flag; refuse the combination instead of silently
// overriding the user's input.
cmd.MarkFlagsMutuallyExclusive("today", "year")
cmd.MarkFlagsMutuallyExclusive("today", "month")
cmd.MarkFlagsMutuallyExclusive("today", "day")

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))
}
Comment thread
marcelsafin marked this conversation as resolved.

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
}
Loading