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 on lines 60 to 65
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is existing root help output test coverage in cmd/root_test.go, but it isn't asserting the new usage subcommand appears in help. Add an assertion for the usage command description so the registration change is covered.

Copilot generated this review using guidance from repository custom instructions.

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
307 changes: 307 additions & 0 deletions cmd/usage/usage.go
Original file line number Diff line number Diff line change
@@ -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()
}
Comment on lines +131 to +135
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--today currently overrides any provided --year/--month/--day values silently. This can confuse users (e.g., --today --year 2025 still queries today). Consider making --today mutually exclusive with the date flags (e.g., error if combined, or use Cobra’s mutual-exclusion helpers) so the effective query period is unambiguous.

Copilot uses AI. Check for mistakes.

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

Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The command accepts arbitrary integers for --month and --day and will build the API query even for invalid values (e.g., month 13, day 0/99), leading to confusing server errors. Add local validation for valid ranges (month 1–12; day 1–31 when provided; optionally validate year > 0) and return a clear argument error before making API calls.

Suggested change
// Validate date arguments
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)
}

Copilot uses AI. Check for mistakes.
// 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
Comment on lines +180 to +184
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totals are computed across all items, but the table rendering later skips rows where item.GrossQuantity == 0. This can make the printed totals disagree with the visible rows. Consider either removing the skip or applying the same filter when calculating totals (and/or sorting).

Copilot uses AI. Check for mistakes.
}

// 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")
Comment on lines +227 to +229
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pct := (totalNet / totalGross) * 100 can produce +Inf/NaN when totalGross is 0 (e.g., unexpected API data), leading to incorrect output. Guard against totalGross == 0 before dividing and choose an appropriate message/percentage in that case.

Suggested change
} else if totalNet > 0 {
pct := (totalNet / totalGross) * 100
cfg.WriteToOut(yellowColor(fmt.Sprintf("Net cost: $%.2f (%.0f%% of gross)", totalNet, pct)) + "\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 {
cfg.WriteToOut(yellowColor(fmt.Sprintf("Net cost: $%.2f (gross amount unavailable; percentage not shown)", totalNet)) + "\n")

Copilot uses AI. Check for mistakes.
}
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))
}
Comment on lines +291 to +294
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says there is test coverage for the helpful missing-user-scope error message, but there isn't a test exercising the 404 branch here. Add a test that returns 404 for the usage endpoint and asserts the error text includes the refresh guidance.

Copilot uses AI. Check for mistakes.

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
Loading