-
Notifications
You must be signed in to change notification settings - Fork 21
feat: add gh models usage command for premium request billing
#97
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if year == 0 { | ||||||||||||||||||||||||||||
| year = now.Year() | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| if month == 0 { | ||||||||||||||||||||||||||||
| month = int(now.Month()) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
| // 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
AI
Mar 29, 2026
There was a problem hiding this comment.
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
AI
Mar 29, 2026
There was a problem hiding this comment.
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.
| } 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
AI
Mar 29, 2026
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 newusagesubcommand appears in help. Add an assertion for theusagecommand description so the registration change is covered.