Skip to content

Commit 5f2d2a5

Browse files
marcelsafinCopilot
andcommitted
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 #81 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent af47898 commit 5f2d2a5

File tree

4 files changed

+566
-2
lines changed

4 files changed

+566
-2
lines changed

cmd/root.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/github/gh-models/cmd/generate"
1313
"github.com/github/gh-models/cmd/list"
1414
"github.com/github/gh-models/cmd/run"
15+
"github.com/github/gh-models/cmd/usage"
1516
"github.com/github/gh-models/cmd/view"
1617
"github.com/github/gh-models/internal/azuremodels"
1718
"github.com/github/gh-models/pkg/command"
@@ -54,11 +55,12 @@ func NewRootCommand() *cobra.Command {
5455
}
5556
}
5657

57-
cfg := command.NewConfigWithTerminal(terminal, client)
58+
cfg := command.NewConfigWithTerminal(terminal, client, token)
5859

5960
cmd.AddCommand(eval.NewEvalCommand(cfg))
6061
cmd.AddCommand(list.NewListCommand(cfg))
6162
cmd.AddCommand(run.NewRunCommand(cfg))
63+
cmd.AddCommand(usage.NewUsageCommand(cfg))
6264
cmd.AddCommand(view.NewViewCommand(cfg))
6365
cmd.AddCommand(generate.NewGenerateCommand(cfg))
6466

cmd/usage/usage.go

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
// Package usage provides a gh command to show GitHub Models and Copilot usage information.
2+
package usage
3+
4+
import (
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"time"
11+
12+
"github.com/MakeNowJust/heredoc"
13+
"github.com/cli/go-gh/v2/pkg/tableprinter"
14+
"github.com/github/gh-models/pkg/command"
15+
"github.com/mgutz/ansi"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
var githubAPIBase = "https://api.github.com"
20+
21+
// setAPIBase overrides the API base URL (used in tests).
22+
func setAPIBase(url string) {
23+
githubAPIBase = url
24+
}
25+
26+
var (
27+
headerColor = ansi.ColorFunc("white+du")
28+
greenColor = ansi.ColorFunc("green")
29+
yellowColor = ansi.ColorFunc("yellow")
30+
)
31+
32+
// premiumRequestUsageResponse represents the API response for premium request usage.
33+
type premiumRequestUsageResponse struct {
34+
TimePeriod timePeriod `json:"timePeriod"`
35+
User string `json:"user"`
36+
UsageItems []usageItem `json:"usageItems"`
37+
}
38+
39+
type timePeriod struct {
40+
Year int `json:"year"`
41+
Month int `json:"month"`
42+
Day int `json:"day,omitempty"`
43+
}
44+
45+
type usageItem struct {
46+
Product string `json:"product"`
47+
SKU string `json:"sku"`
48+
Model string `json:"model"`
49+
UnitType string `json:"unitType"`
50+
PricePerUnit float64 `json:"pricePerUnit"`
51+
GrossQuantity float64 `json:"grossQuantity"`
52+
GrossAmount float64 `json:"grossAmount"`
53+
DiscountQuantity float64 `json:"discountQuantity"`
54+
DiscountAmount float64 `json:"discountAmount"`
55+
NetQuantity float64 `json:"netQuantity"`
56+
NetAmount float64 `json:"netAmount"`
57+
}
58+
59+
// NewUsageCommand returns a new command to show model usage information.
60+
func NewUsageCommand(cfg *command.Config) *cobra.Command {
61+
var (
62+
flagMonth int
63+
flagYear int
64+
flagDay int
65+
flagToday bool
66+
)
67+
68+
cmd := &cobra.Command{
69+
Use: "usage",
70+
Short: "Show premium request usage and costs",
71+
Long: heredoc.Docf(`
72+
Display premium request usage statistics for GitHub Models and Copilot.
73+
74+
Shows a breakdown of requests by model, with gross and net costs.
75+
By default, shows usage for the current billing period (month).
76+
77+
Use %[1]s--today%[1]s to see only today's usage, or %[1]s--month%[1]s and
78+
%[1]s--year%[1]s to query a specific billing period.
79+
80+
Requires the %[1]suser%[1]s scope on your GitHub token. If you get a 404 error,
81+
run: %[1]sgh auth refresh -h github.com -s user%[1]s
82+
`, "`"),
83+
Example: heredoc.Doc(`
84+
# Show current month's usage
85+
$ gh models usage
86+
87+
# Show today's usage
88+
$ gh models usage --today
89+
90+
# Show usage for a specific month
91+
$ gh models usage --year 2026 --month 2
92+
93+
# Show usage for a specific day
94+
$ gh models usage --year 2026 --month 3 --day 15
95+
`),
96+
Args: cobra.NoArgs,
97+
RunE: func(cmd *cobra.Command, args []string) error {
98+
token := cfg.Token
99+
if token == "" {
100+
return fmt.Errorf("no GitHub token found. Please run 'gh auth login' to authenticate")
101+
}
102+
103+
ctx := cmd.Context()
104+
105+
// Resolve time period
106+
now := time.Now().UTC()
107+
year := flagYear
108+
month := flagMonth
109+
day := flagDay
110+
111+
if flagToday {
112+
year = now.Year()
113+
month = int(now.Month())
114+
day = now.Day()
115+
}
116+
117+
if year == 0 {
118+
year = now.Year()
119+
}
120+
if month == 0 {
121+
month = int(now.Month())
122+
}
123+
124+
// Get username
125+
username, err := getUsername(ctx, token)
126+
if err != nil {
127+
return fmt.Errorf("failed to get username: %w", err)
128+
}
129+
130+
// Build query params
131+
query := fmt.Sprintf("?year=%d&month=%d", year, month)
132+
if day > 0 {
133+
query += fmt.Sprintf("&day=%d", day)
134+
}
135+
136+
// Fetch usage
137+
data, err := fetchPremiumRequestUsage(ctx, token, username, query)
138+
if err != nil {
139+
return err
140+
}
141+
142+
// Format period string
143+
periodStr := fmt.Sprintf("%d-%02d", data.TimePeriod.Year, data.TimePeriod.Month)
144+
if data.TimePeriod.Day > 0 {
145+
periodStr += fmt.Sprintf("-%02d", data.TimePeriod.Day)
146+
}
147+
148+
if len(data.UsageItems) == 0 {
149+
cfg.WriteToOut(fmt.Sprintf("\nNo usage found for %s\n\n", periodStr))
150+
return nil
151+
}
152+
153+
// Sort by gross amount descending (simple bubble sort for small data)
154+
items := data.UsageItems
155+
for i := 0; i < len(items); i++ {
156+
for j := i + 1; j < len(items); j++ {
157+
if items[j].GrossAmount > items[i].GrossAmount {
158+
items[i], items[j] = items[j], items[i]
159+
}
160+
}
161+
}
162+
163+
// Calculate totals
164+
var totalReqs, totalGross, totalNet float64
165+
for _, item := range items {
166+
totalReqs += item.GrossQuantity
167+
totalGross += item.GrossAmount
168+
totalNet += item.NetAmount
169+
}
170+
171+
// Print header
172+
if cfg.IsTerminalOutput {
173+
cfg.WriteToOut("\n")
174+
if flagToday {
175+
cfg.WriteToOut(fmt.Sprintf("Premium request usage for %s (%s, today)\n", username, periodStr))
176+
} else {
177+
cfg.WriteToOut(fmt.Sprintf("Premium request usage for %s (%s)\n", username, periodStr))
178+
}
179+
cfg.WriteToOut("\n")
180+
}
181+
182+
// Print table
183+
printer := cfg.NewTablePrinter()
184+
185+
printer.AddHeader([]string{"PRODUCT", "MODEL", "REQUESTS", "GROSS", "NET"}, tableprinter.WithColor(headerColor))
186+
printer.EndRow()
187+
188+
for _, item := range items {
189+
if item.GrossQuantity == 0 {
190+
continue
191+
}
192+
printer.AddField(item.Product)
193+
printer.AddField(item.Model)
194+
printer.AddField(fmt.Sprintf("%.1f", item.GrossQuantity))
195+
printer.AddField(fmt.Sprintf("$%.2f", item.GrossAmount))
196+
printer.AddField(fmt.Sprintf("$%.2f", item.NetAmount))
197+
printer.EndRow()
198+
}
199+
200+
if err := printer.Render(); err != nil {
201+
return err
202+
}
203+
204+
// Print summary
205+
if cfg.IsTerminalOutput {
206+
cfg.WriteToOut("\n")
207+
cfg.WriteToOut(fmt.Sprintf("Total: %.0f requests, $%.2f gross, $%.2f net\n", totalReqs, totalGross, totalNet))
208+
209+
if totalGross > 0 && totalNet == 0 {
210+
cfg.WriteToOut(greenColor("All usage included in your plan (100% discount)") + "\n")
211+
} else if totalNet > 0 {
212+
pct := (totalNet / totalGross) * 100
213+
cfg.WriteToOut(yellowColor(fmt.Sprintf("Net cost: $%.2f (%.0f%% of gross)", totalNet, pct)) + "\n")
214+
}
215+
cfg.WriteToOut("\n")
216+
}
217+
218+
return nil
219+
},
220+
}
221+
222+
cmd.Flags().IntVar(&flagYear, "year", 0, "Filter by year (default: current year)")
223+
cmd.Flags().IntVar(&flagMonth, "month", 0, "Filter by month (default: current month)")
224+
cmd.Flags().IntVar(&flagDay, "day", 0, "Filter by specific day")
225+
cmd.Flags().BoolVar(&flagToday, "today", false, "Show only today's usage")
226+
227+
return cmd
228+
}
229+
230+
func getUsername(ctx context.Context, token string) (string, error) {
231+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPIBase+"/user", nil)
232+
if err != nil {
233+
return "", err
234+
}
235+
req.Header.Set("Authorization", "Bearer "+token)
236+
req.Header.Set("Accept", "application/vnd.github+json")
237+
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
238+
239+
resp, err := http.DefaultClient.Do(req)
240+
if err != nil {
241+
return "", err
242+
}
243+
defer resp.Body.Close()
244+
245+
if resp.StatusCode != http.StatusOK {
246+
return "", fmt.Errorf("failed to get user info: HTTP %d", resp.StatusCode)
247+
}
248+
249+
var user struct {
250+
Login string `json:"login"`
251+
}
252+
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
253+
return "", err
254+
}
255+
return user.Login, nil
256+
}
257+
258+
func fetchPremiumRequestUsage(ctx context.Context, token, username, query string) (*premiumRequestUsageResponse, error) {
259+
url := fmt.Sprintf("%s/users/%s/settings/billing/premium_request/usage%s", githubAPIBase, username, query)
260+
261+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
262+
if err != nil {
263+
return nil, err
264+
}
265+
req.Header.Set("Authorization", "Bearer "+token)
266+
req.Header.Set("Accept", "application/vnd.github+json")
267+
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
268+
269+
resp, err := http.DefaultClient.Do(req)
270+
if err != nil {
271+
return nil, err
272+
}
273+
defer resp.Body.Close()
274+
275+
if resp.StatusCode == http.StatusNotFound {
276+
body, _ := io.ReadAll(resp.Body)
277+
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))
278+
}
279+
280+
if resp.StatusCode != http.StatusOK {
281+
body, _ := io.ReadAll(resp.Body)
282+
return nil, fmt.Errorf("failed to fetch usage data: HTTP %d\n%s", resp.StatusCode, string(body))
283+
}
284+
285+
var data premiumRequestUsageResponse
286+
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
287+
return nil, fmt.Errorf("failed to parse usage response: %w", err)
288+
}
289+
290+
return &data, nil
291+
}

0 commit comments

Comments
 (0)