Skip to content

Commit b7e4dd3

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 b7e4dd3

File tree

5 files changed

+592
-2
lines changed

5 files changed

+592
-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/root_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ func TestRoot(t *testing.T) {
2222
require.Regexp(t, regexp.MustCompile(`eval\s+Evaluate prompts using test data and evaluators`), output)
2323
require.Regexp(t, regexp.MustCompile(`list\s+List available models`), output)
2424
require.Regexp(t, regexp.MustCompile(`run\s+Run inference with the specified model`), output)
25+
require.Regexp(t, regexp.MustCompile(`usage\s+Show premium request usage and costs`), output)
2526
require.Regexp(t, regexp.MustCompile(`view\s+View details about a model`), output)
2627
require.Regexp(t, regexp.MustCompile(`generate\s+Generate tests and evaluations for prompts`), output)
2728
})

cmd/usage/usage.go

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

0 commit comments

Comments
 (0)