From 28121d6a2345221d3dde0844433c446afe43639d Mon Sep 17 00:00:00 2001 From: Corey Quinn Date: Tue, 23 Jun 2026 04:22:48 +0000 Subject: [PATCH 1/2] Teach `org billing usage` to stop panicking about money MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For its entire existence, `baseten org billing usage` has answered the question "how much am I spending" by panicking and dumping a goroutine stack trace at whoever asked. It was a literal `panic("TODO: implement org billing usage")`—the most honest a stub has ever been, and completely unshippable. It now asks the management API how much you owe and prints it in a table. Users, I'm told, prefer this to the stack trace. The parts worth knowing: - Every cost field comes back as either a JSON number or a JSON string, seemingly per the API's mood. In production you get "0.00"—a string—for an amount of money. I've stopped asking why; the parser accepts both and gets on with its day. - Money and minutes now carry thousands separators, because a billing tool that renders a real invoice as $1234567.89 is just a confession. - --since (default 7d, max 31d) or --start/--end, never both. Asking for a relative and an absolute window at once isn't a query, it's a koan. - Flag types went from string to time.Duration/time.Time so the window parsing matches `model deployment metrics` instead of hand-rolling date math out of spite. Tested against the live API, where it confirmed I owe nothing—the first good news this command has delivered in its life. --- cmd/command.org.go | 37 ++- internal/cmd/command.org.go | 210 ++++++++++++++++- internal/cmd/command.org_billing_test.go | 286 +++++++++++++++++++++++ internal/cmd/command_test.go | 16 +- 4 files changed, 533 insertions(+), 16 deletions(-) create mode 100644 internal/cmd/command.org_billing_test.go diff --git a/cmd/command.org.go b/cmd/command.org.go index 0deb3dd..c78ff76 100644 --- a/cmd/command.org.go +++ b/cmd/command.org.go @@ -1,6 +1,10 @@ package cmd -import "github.com/basetenlabs/baseten-go/client/managementapi" +import ( + "time" + + "github.com/basetenlabs/baseten-go/client/managementapi" +) var commandOrg = Command{ Name: "org", @@ -93,26 +97,39 @@ var commandOrg = Command{ { Name: "usage", Summary: "Show billing usage summary", - Description: "Show a billing usage summary for the organization. Pass --since (relative " + + Description: "Show a billing usage summary for the organization, broken down into " + + "dedicated deployments, model APIs, and training. Pass --since (relative " + "duration, e.g. 7d, 24h) for a sliding window ending now, or --start and --end " + "together for an explicit ISO 8601 range. The two modes are mutually exclusive. The " + - "range cannot exceed 31 days. Defaults to --since 7d.", + "range cannot exceed 31 days and cannot start before 2026-01-01. Defaults to " + + "--since 7d.", Flags: OrgBillingUsageFlags{}, - Output: &CommandOutput[JSONAny]{ - TextDescription: "Not yet implemented. The output shape is TBD.", + Output: &CommandOutput[managementapi.UsageSummary]{ + TextDescription: "The resolved window on stderr, then a table on stdout with one row " + + "per category present (Dedicated, Model APIs, Training), plus an \"All\" total row " + + "when more than one category is present, with columns CATEGORY, MINUTES, TOTAL, " + + "CREDITS, SUBTOTAL. Costs are in USD; SUBTOTAL is the net cost after credits. Prints " + + "\"No usage in the selected window.\" to stderr when every category is absent.", + JSONDescription: "The usage summary: optional dedicated_usage, model_apis_usage, and " + + "training_usage objects, each with total/credits_used/subtotal costs and a " + + "per-resource breakdown whose items each carry an optional daily series.", Examples: []CommandExample{ { Description: "Show usage over the last 7 days (default).", Command: "baseten org billing usage", }, + { + Description: "Show usage over the last 30 days.", + Command: "baseten org billing usage --since 30d", + }, { Description: "Show usage over an explicit ISO 8601 range.", Command: "baseten org billing usage --start 2026-05-01 --end 2026-05-08", }, }, JQExample: CommandExample{ - Description: "Print a top-level total (shape TBD).", - Command: "baseten org billing usage --jq '.total'", + Description: "Print the total cost of model API usage.", + Command: "baseten org billing usage --jq '.model_apis_usage.total'", }, }, }, @@ -220,9 +237,9 @@ type OrgAPIKeyDeleteFlags struct { type OrgBillingUsageFlags struct { CommandFlags - Since string `flag:"since" desc:"Relative window ending now, as a Go duration (e.g. 7d, 24h). Mutually exclusive with --start/--end." default:"7d"` - Start string `flag:"start" desc:"Start of the window (ISO 8601). Requires --end. Mutually exclusive with --since."` - End string `flag:"end" desc:"End of the window (ISO 8601). Requires --start. Mutually exclusive with --since."` + Since time.Duration `flag:"since" desc:"Relative window ending now (e.g. 24h, 7d). Used when neither --start nor --end is given. Maximum 31d. Mutually exclusive with --start/--end."` + Start time.Time `flag:"start" desc:"Start of the window. Accepts ISO 8601 (e.g. '2026-05-01', '2026-05-01T12:00:00Z'); values without a timezone are interpreted in the local timezone. Requires --end. Mutually exclusive with --since."` + End time.Time `flag:"end" desc:"End of the window. Accepts ISO 8601; values without a timezone are interpreted in the local timezone. Requires --start. Mutually exclusive with --since."` } type OrgSecretListFlags struct { diff --git a/internal/cmd/command.org.go b/internal/cmd/command.org.go index c5e4f3d..ae06476 100644 --- a/internal/cmd/command.org.go +++ b/internal/cmd/command.org.go @@ -1,13 +1,219 @@ package cmd import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + "github.com/basetenlabs/baseten-cli/cmd" + "github.com/basetenlabs/baseten-go/client/managementapi" ) +// maxBillingRange caps the usage window; the billing endpoint rejects ranges +// longer than 31 days. +const maxBillingRange = 31 * 24 * time.Hour + +// billingEarliest is the earliest date the usage endpoint can be queried for. +var billingEarliest = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + func init() { Register("org billing usage", commandOrgBillingUsage) } -func commandOrgBillingUsage(_ *CommandContext, _ *cmd.OrgBillingUsageFlags) error { - panic("TODO: implement org billing usage") +func commandOrgBillingUsage(ctx *CommandContext, flags *cmd.OrgBillingUsageFlags) error { + hasStart := !flags.Start.IsZero() + hasEnd := !flags.End.IsZero() + // Use Changed rather than the zero value so an explicit --since 0 fails the + // positive-duration check below instead of being silently replaced by the + // default. + hasSince := ctx.Command.Flags().Changed("since") + + if hasSince && (hasStart || hasEnd) { + return cmd.NewErrUsagef("--since cannot be combined with --start or --end") + } + + var start, end time.Time + if hasStart || hasEnd { + // The endpoint requires both bounds; there is no server-side backfill. + if !hasStart || !hasEnd { + return cmd.NewErrUsagef("--start and --end must be used together") + } + if !flags.Start.Before(flags.End) { + return cmd.NewErrUsagef("--start must be earlier than --end") + } + if flags.End.Sub(flags.Start) > maxBillingRange { + return cmd.NewErrUsagef("usage window must be at most 31 days; narrow --start/--end") + } + start, end = flags.Start, flags.End + } else { + since := flags.Since + if !hasSince { + since = 7 * 24 * time.Hour + } + if since <= 0 { + return cmd.NewErrUsagef("--since must be a positive duration") + } + if since > maxBillingRange { + return cmd.NewErrUsagef("--since must be at most 31d") + } + end = ctx.Now() + start = end.Add(-since) + } + + if start.Before(billingEarliest) { + return cmd.NewErrUsagef("usage is not available before %s", billingEarliest.Format("2006-01-02")) + } + + api, err := ctx.NewManagementClient() + if err != nil { + return err + } + + summary, err := api.API().GetBillingUsageSummary(ctx, managementapi.GetV1BillingUsageSummaryParams{ + StartDate: start, + EndDate: end, + }) + if err != nil { + return fmt.Errorf("fetching billing usage: %w", err) + } + + if ctx.JSON { + ctx.OutputJSON(summary) + return nil + } + + // Print the resolved window with its time-of-day: the bounds are precise + // instants (--since is now-relative), so a date alone would misreport the + // edges by up to a day. Matches the model-deployment metrics window line. + ctx.LogLine(fmt.Sprintf("Window: %s – %s local", + start.Local().Format("2006-01-02 15:04"), end.Local().Format("2006-01-02 15:04"))) + + headers := []string{"CATEGORY", "MINUTES", "TOTAL", "CREDITS", "SUBTOTAL"} + rightAligned := []int{1, 2, 3, 4} + var rows [][]string + var sumTotal, sumCredits, sumSubtotal float64 + + addRow := func(name string, minutes *int, total, credits, subtotal json.Marshaler) { + t, _ := billingMoney(total) + c, _ := billingMoney(credits) + s, _ := billingMoney(subtotal) + sumTotal += t + sumCredits += c + sumSubtotal += s + rows = append(rows, []string{ + name, + billingMinutes(minutes), + billingMoneyString(total), + billingMoneyString(credits), + billingMoneyString(subtotal), + }) + } + + if d := summary.DedicatedUsage; d != nil { + addRow("Dedicated", &d.Minutes, &d.Total, &d.CreditsUsed, &d.Subtotal) + } + if m := summary.ModelApisUsage; m != nil { + addRow("Model APIs", nil, &m.Total, &m.CreditsUsed, &m.Subtotal) + } + if tr := summary.TrainingUsage; tr != nil { + addRow("Training", &tr.Minutes, &tr.Total, &tr.CreditsUsed, &tr.Subtotal) + } + + if len(rows) == 0 { + ctx.LogLine("No usage in the selected window.") + return nil + } + + // A trailing total row only earns its keep when more than one category is + // present; with a single row it would just repeat it. + if len(rows) > 1 { + rows = append(rows, []string{ + "All", "", + billingFormatMoney(sumTotal), billingFormatMoney(sumCredits), billingFormatMoney(sumSubtotal), + }) + } + + ctx.OutputTable(TableOutput{Headers: headers, Rows: rows, RightAlignedColumns: rightAligned}) + return nil +} + +// billingMinutes renders an optional minutes count with thousands separators, +// using "-" for categories (e.g. model APIs) that are billed by tokens rather +// than time. +func billingMinutes(minutes *int) string { + if minutes == nil { + return "-" + } + return billingGroupDigits(strconv.Itoa(*minutes)) +} + +// billingMoney parses a cost field into a float. The backend models costs as a +// union of number or string, so the value is recovered from its JSON form +// either way. ok is false for an absent or unparseable value. +func billingMoney(m json.Marshaler) (value float64, ok bool) { + if m == nil { + return 0, false + } + b, err := m.MarshalJSON() + if err != nil || len(b) == 0 || string(b) == "null" { + return 0, false + } + s := string(b) + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + s = s[1 : len(s)-1] + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, false + } + return f, true +} + +// billingMoneyString renders a cost field as a USD amount, or "-" when absent. +func billingMoneyString(m json.Marshaler) string { + f, ok := billingMoney(m) + if !ok { + return "-" + } + return billingFormatMoney(f) +} + +// billingFormatMoney renders a dollar amount with thousands separators, e.g. +// $1,234.56 or -$1,234.56. The billing API reports every cost in dollars (there +// is no currency field), so the USD sign is not an assumption. +func billingFormatMoney(f float64) string { + neg := f < 0 + if neg { + f = -f + } + s := strconv.FormatFloat(f, 'f', 2, 64) + intPart, frac, _ := strings.Cut(s, ".") + out := "$" + billingGroupDigits(intPart) + "." + frac + if neg { + out = "-" + out + } + return out +} + +// billingGroupDigits inserts commas every three digits into a string of digits +// (no sign, no decimal point). +func billingGroupDigits(s string) string { + if len(s) <= 3 { + return s + } + var b strings.Builder + if pre := len(s) % 3; pre > 0 { + b.WriteString(s[:pre]) + b.WriteByte(',') + s = s[pre:] + } + for i := 0; i < len(s); i += 3 { + b.WriteString(s[i : i+3]) + if i+3 < len(s) { + b.WriteByte(',') + } + } + return b.String() } diff --git a/internal/cmd/command.org_billing_test.go b/internal/cmd/command.org_billing_test.go new file mode 100644 index 0000000..7e06eb6 --- /dev/null +++ b/internal/cmd/command.org_billing_test.go @@ -0,0 +1,286 @@ +package cmd_test + +import ( + "net/http" + "strings" + "testing" + "time" + + "github.com/basetenlabs/baseten-cli/internal/cmd" +) + +// usageSummaryBody is a representative billing usage_summary payload exercising +// all three categories and both the numeric and string cost encodings the +// backend may return. +func usageSummaryBody() map[string]any { + return map[string]any{ + "dedicated_usage": map[string]any{ + "minutes": 1234, + "total": 12.34, + "credits_used": 0, + "subtotal": 12.34, + }, + "model_apis_usage": map[string]any{ + // String-encoded costs: the union accepts either form. Also has no + // "minutes" field, so it should render "-" in the MINUTES column. + "total": "5.00", + "credits_used": "1.00", + "subtotal": "4.00", + }, + "training_usage": map[string]any{ + "minutes": 525600, + "total": 3, + "credits_used": 0, + "subtotal": 3, + }, + } +} + +// findRow returns the rendered table line whose first column equals name. +func findRow(t *testing.T, out, name string) string { + t.Helper() + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), name) { + return line + } + } + t.Fatalf("no row starting with %q in:\n%s", name, out) + return "" +} + +func Test_Org_Billing_Usage_Table(t *testing.T) { + h := NewCommandHarness(t) + h.MockManagementAPI().SetRoute("GET", "/v1/billing/usage_summary", 200, usageSummaryBody()) + + h.Require.NoError(h.Execute("org", "billing", "usage")) + + out := h.Stdout.String() + h.Require.Contains(out, "CATEGORY") + h.Require.Contains(out, "MINUTES") + h.Require.Contains(out, "SUBTOTAL") + + // Per-row values, anchored to their row so a sum bug can't pass by matching + // a coincidental substring elsewhere. + dedicated := findRow(t, out, "Dedicated") + h.Require.Contains(dedicated, "1,234") // minutes grouped + h.Require.Contains(dedicated, "$12.34") + + modelAPIs := findRow(t, out, "Model APIs") + h.Require.Contains(modelAPIs, "$5.00") + h.Require.Contains(modelAPIs, "$4.00") + // Model APIs are token-billed: no minutes, rendered "-". + fields := strings.Fields(modelAPIs) + h.Require.Equal("-", fields[2], "model-apis MINUTES column should be '-'") + + training := findRow(t, out, "Training") + h.Require.Contains(training, "525,600") // grouped at scale + + h.Require.Contains(h.Stderr.String(), "Window:") +} + +func Test_Org_Billing_Usage_TotalRowArithmetic(t *testing.T) { + h := NewCommandHarness(t) + h.MockManagementAPI().SetRoute("GET", "/v1/billing/usage_summary", 200, usageSummaryBody()) + + h.Require.NoError(h.Execute("org", "billing", "usage")) + + all := findRow(t, h.Stdout.String(), "All") + h.Require.Contains(all, "$20.34") // total: 12.34 + 5.00 + 3 + h.Require.Contains(all, "$1.00") // credits: 0 + 1.00 + 0 + h.Require.Contains(all, "$19.34") // subtotal: 12.34 + 4.00 + 3 +} + +func Test_Org_Billing_Usage_SingleCategoryNoTotalRow(t *testing.T) { + h := NewCommandHarness(t) + h.MockManagementAPI().SetRoute("GET", "/v1/billing/usage_summary", 200, map[string]any{ + "dedicated_usage": map[string]any{ + "minutes": 10, "total": 1.5, "credits_used": 0, "subtotal": 1.5, + }, + }) + + h.Require.NoError(h.Execute("org", "billing", "usage")) + out := h.Stdout.String() + h.Require.Contains(out, "Dedicated") + // With a single category the "All" total row is suppressed. + h.Require.NotContains(out, "All") +} + +func Test_Org_Billing_Usage_UnparseableCostRendersDash(t *testing.T) { + h := NewCommandHarness(t) + h.MockManagementAPI().SetRoute("GET", "/v1/billing/usage_summary", 200, map[string]any{ + "dedicated_usage": map[string]any{ + "minutes": 5, "total": "not-a-number", "credits_used": 0, "subtotal": 1.0, + }, + }) + + h.Require.NoError(h.Execute("org", "billing", "usage")) + dedicated := findRow(t, h.Stdout.String(), "Dedicated") + fields := strings.Fields(dedicated) + // CATEGORY MINUTES TOTAL CREDITS SUBTOTAL -> TOTAL is index 2. + h.Require.Equal("-", fields[2], "an unparseable cost should render '-'") +} + +func Test_Org_Billing_Usage_DefaultsToSevenDays(t *testing.T) { + h := NewCommandHarness(t) + m := h.MockManagementAPI() + m.SetRoute("GET", "/v1/billing/usage_summary", 200, usageSummaryBody()) + now := time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC) + h.Context = cmd.WithNow(h.Context, func() time.Time { return now }) + + h.Require.NoError(h.Execute("org", "billing", "usage")) + + call := m.FindCall("GET", "/v1/billing/usage_summary") + h.Require.NotNil(call) + start, end := parseUsageWindow(t, call) + h.Require.Equal(now, end.UTC()) + h.Require.Equal(now.Add(-7*24*time.Hour), start.UTC()) +} + +func Test_Org_Billing_Usage_SinceDays(t *testing.T) { + h := NewCommandHarness(t) + m := h.MockManagementAPI() + m.SetRoute("GET", "/v1/billing/usage_summary", 200, usageSummaryBody()) + now := time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC) + h.Context = cmd.WithNow(h.Context, func() time.Time { return now }) + + h.Require.NoError(h.Execute("org", "billing", "usage", "--since", "30d")) + + call := m.FindCall("GET", "/v1/billing/usage_summary") + h.Require.NotNil(call) + start, end := parseUsageWindow(t, call) + h.Require.Equal(now, end.UTC()) + h.Require.Equal(now.Add(-30*24*time.Hour), start.UTC()) +} + +func Test_Org_Billing_Usage_ExplicitRange(t *testing.T) { + h := NewCommandHarness(t) + m := h.MockManagementAPI() + m.SetRoute("GET", "/v1/billing/usage_summary", 200, usageSummaryBody()) + + h.Require.NoError(h.Execute("org", "billing", "usage", + "--start", "2026-05-01T00:00:00Z", "--end", "2026-05-08T00:00:00Z")) + + call := m.FindCall("GET", "/v1/billing/usage_summary") + h.Require.NotNil(call) + start, end := parseUsageWindow(t, call) + h.Require.Equal("2026-05-01T00:00:00Z", start.UTC().Format(time.RFC3339)) + h.Require.Equal("2026-05-08T00:00:00Z", end.UTC().Format(time.RFC3339)) +} + +func Test_Org_Billing_Usage_Empty(t *testing.T) { + h := NewCommandHarness(t) + h.MockManagementAPI().SetRoute("GET", "/v1/billing/usage_summary", 200, map[string]any{}) + + h.Require.NoError(h.Execute("org", "billing", "usage")) + h.Require.Equal("", h.Stdout.String()) + h.Require.Contains(h.Stderr.String(), "No usage in the selected window.") +} + +func Test_Org_Billing_Usage_JSON(t *testing.T) { + h := NewCommandHarness(t) + h.MockManagementAPI().SetRoute("GET", "/v1/billing/usage_summary", 200, usageSummaryBody()) + + h.Require.NoError(h.Execute("org", "billing", "usage", "--output", "json")) + out := h.Stdout.String() + h.Require.True(strings.HasPrefix(strings.TrimSpace(out), "{"), "JSON output should be an object") + h.Require.Contains(out, `"dedicated_usage"`) + h.Require.Contains(out, `"model_apis_usage"`) +} + +func Test_Org_Billing_Usage_APIError(t *testing.T) { + h := NewCommandHarness(t) + h.MockManagementAPI().SetRoute("GET", "/v1/billing/usage_summary", 500, + map[string]any{"error": "boom"}) + + err := h.Execute("org", "billing", "usage") + h.Require.Error(err) + h.Require.Contains(err.Error(), "fetching billing usage") +} + +func Test_Org_Billing_Usage_SinceWithStartIsError(t *testing.T) { + h := NewCommandHarness(t) + h.MockManagementAPI().SetHandlerFallback(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatalf("server should not be hit on usage error") + }) + + err := h.Execute("org", "billing", "usage", "--since", "7d", "--start", "2026-05-01") + h.Require.Error(err) + h.Require.Contains(err.Error(), "--since cannot be combined with --start or --end") +} + +func Test_Org_Billing_Usage_StartWithoutEndIsError(t *testing.T) { + h := NewCommandHarness(t) + h.MockManagementAPI().SetHandlerFallback(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatalf("server should not be hit on usage error") + }) + + err := h.Execute("org", "billing", "usage", "--start", "2026-05-01") + h.Require.Error(err) + h.Require.Contains(err.Error(), "--start and --end must be used together") +} + +func Test_Org_Billing_Usage_EqualStartEndIsError(t *testing.T) { + h := NewCommandHarness(t) + h.MockManagementAPI().SetHandlerFallback(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatalf("server should not be hit on usage error") + }) + + err := h.Execute("org", "billing", "usage", + "--start", "2026-05-01T00:00:00Z", "--end", "2026-05-01T00:00:00Z") + h.Require.Error(err) + h.Require.Contains(err.Error(), "earlier") +} + +func Test_Org_Billing_Usage_ExplicitRangeTooLongIsError(t *testing.T) { + h := NewCommandHarness(t) + h.MockManagementAPI().SetHandlerFallback(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatalf("server should not be hit on usage error") + }) + + err := h.Execute("org", "billing", "usage", + "--start", "2026-05-01T00:00:00Z", "--end", "2026-06-15T00:00:00Z") + h.Require.Error(err) + h.Require.Contains(err.Error(), "at most 31 days") +} + +func Test_Org_Billing_Usage_SinceRangeTooLongIsError(t *testing.T) { + h := NewCommandHarness(t) + h.MockManagementAPI().SetHandlerFallback(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatalf("server should not be hit on usage error") + }) + + err := h.Execute("org", "billing", "usage", "--since", "60d") + h.Require.Error(err) + h.Require.Contains(err.Error(), "at most 31d") +} + +func Test_Org_Billing_Usage_SinceZeroOrNegativeIsError(t *testing.T) { + // Use the --flag=value form for the negative case so pflag doesn't read the + // leading '-' as another flag. + for _, arg := range []string{"--since=0s", "--since=-5m"} { + h := NewCommandHarness(t) + h.MockManagementAPI().SetHandlerFallback(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatalf("server should not be hit on usage error") + }) + + err := h.Execute("org", "billing", "usage", arg) + h.Require.Error(err) + h.Require.Contains(err.Error(), "positive duration") + } +} + +// parseUsageWindow extracts the start_date/end_date query params from a +// recorded request. +func parseUsageWindow(t *testing.T, call *MockAPICall) (start, end time.Time) { + t.Helper() + q := call.Query() + start, err := time.Parse(time.RFC3339, q.Get("start_date")) + if err != nil { + t.Fatalf("parsing start_date %q: %v", q.Get("start_date"), err) + } + end, err = time.Parse(time.RFC3339, q.Get("end_date")) + if err != nil { + t.Fatalf("parsing end_date %q: %v", q.Get("end_date"), err) + } + return start, end +} diff --git a/internal/cmd/command_test.go b/internal/cmd/command_test.go index 038519d..e8c24b1 100644 --- a/internal/cmd/command_test.go +++ b/internal/cmd/command_test.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "sync" "testing" @@ -95,9 +96,16 @@ func (h *CommandHarness) MockManagementAPI() *MockManagementAPI { // MockAPICall captures a single request received by MockManagementAPI. type MockAPICall struct { - Method string - Path string - Body string + Method string + Path string + RawQuery string + Body string +} + +// Query parses RawQuery into url.Values. +func (c *MockAPICall) Query() url.Values { + v, _ := url.ParseQuery(c.RawQuery) + return v } // BodyJSON parses Body as a JSON object. Returns an empty map if Body is empty. @@ -133,7 +141,7 @@ func (m *MockManagementAPI) serve(w http.ResponseWriter, r *http.Request) { r.Body = io.NopCloser(bytes.NewReader(raw)) m.mu.Lock() - m.calls = append(m.calls, MockAPICall{Method: r.Method, Path: r.URL.Path, Body: string(raw)}) + m.calls = append(m.calls, MockAPICall{Method: r.Method, Path: r.URL.Path, RawQuery: r.URL.RawQuery, Body: string(raw)}) handler, ok := m.routes[r.Method+" "+r.URL.Path] if !ok { handler = m.fallback From 47248c718e470fd29140f6a7076b015174b6063f Mon Sep 17 00:00:00 2001 From: Corey Quinn Date: Tue, 23 Jun 2026 04:35:44 +0000 Subject: [PATCH 2/2] Address review: dash out understated totals, label the floor UTC Two fixes from PR review: - If a category's cost won't parse, the "All" row no longer prints a confident sum that quietly omits it. The affected column shows "-" instead, because a total that silently understates is worse than no total. (Adding 0 was never an undercount, but the displayed sum was still a lie of omission.) - The 2026-01-01 floor is a UTC instant while bare --start/--end parse as local, so the cutoff error and the help text now say so out loud. --- cmd/command.org.go | 4 +-- internal/cmd/command.org.go | 40 +++++++++++++++++++----- internal/cmd/command.org_billing_test.go | 22 +++++++++++++ 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/cmd/command.org.go b/cmd/command.org.go index c78ff76..4051160 100644 --- a/cmd/command.org.go +++ b/cmd/command.org.go @@ -101,8 +101,8 @@ var commandOrg = Command{ "dedicated deployments, model APIs, and training. Pass --since (relative " + "duration, e.g. 7d, 24h) for a sliding window ending now, or --start and --end " + "together for an explicit ISO 8601 range. The two modes are mutually exclusive. The " + - "range cannot exceed 31 days and cannot start before 2026-01-01. Defaults to " + - "--since 7d.", + "range cannot exceed 31 days and cannot start before 2026-01-01 UTC. Defaults " + + "to --since 7d.", Flags: OrgBillingUsageFlags{}, Output: &CommandOutput[managementapi.UsageSummary]{ TextDescription: "The resolved window on stderr, then a table on stdout with one row " + diff --git a/internal/cmd/command.org.go b/internal/cmd/command.org.go index ae06476..f40ea06 100644 --- a/internal/cmd/command.org.go +++ b/internal/cmd/command.org.go @@ -63,7 +63,7 @@ func commandOrgBillingUsage(ctx *CommandContext, flags *cmd.OrgBillingUsageFlags } if start.Before(billingEarliest) { - return cmd.NewErrUsagef("usage is not available before %s", billingEarliest.Format("2006-01-02")) + return cmd.NewErrUsagef("usage is not available before %s UTC", billingEarliest.Format("2006-01-02")) } api, err := ctx.NewManagementClient() @@ -94,14 +94,27 @@ func commandOrgBillingUsage(ctx *CommandContext, flags *cmd.OrgBillingUsageFlags rightAligned := []int{1, 2, 3, 4} var rows [][]string var sumTotal, sumCredits, sumSubtotal float64 + // Track whether every contributing value in a column parsed. A single + // unparseable cost makes that column's grand total an understatement, so we + // render "-" rather than a confident-looking partial sum. + okTotal, okCredits, okSubtotal := true, true, true addRow := func(name string, minutes *int, total, credits, subtotal json.Marshaler) { - t, _ := billingMoney(total) - c, _ := billingMoney(credits) - s, _ := billingMoney(subtotal) - sumTotal += t - sumCredits += c - sumSubtotal += s + if t, ok := billingMoney(total); ok { + sumTotal += t + } else { + okTotal = false + } + if c, ok := billingMoney(credits); ok { + sumCredits += c + } else { + okCredits = false + } + if s, ok := billingMoney(subtotal); ok { + sumSubtotal += s + } else { + okSubtotal = false + } rows = append(rows, []string{ name, billingMinutes(minutes), @@ -131,7 +144,9 @@ func commandOrgBillingUsage(ctx *CommandContext, flags *cmd.OrgBillingUsageFlags if len(rows) > 1 { rows = append(rows, []string{ "All", "", - billingFormatMoney(sumTotal), billingFormatMoney(sumCredits), billingFormatMoney(sumSubtotal), + billingSumCell(sumTotal, okTotal), + billingSumCell(sumCredits, okCredits), + billingSumCell(sumSubtotal, okSubtotal), }) } @@ -180,6 +195,15 @@ func billingMoneyString(m json.Marshaler) string { return billingFormatMoney(f) } +// billingSumCell renders a column's grand total, or "-" when any contributing +// value failed to parse (in which case the sum would understate the truth). +func billingSumCell(sum float64, ok bool) string { + if !ok { + return "-" + } + return billingFormatMoney(sum) +} + // billingFormatMoney renders a dollar amount with thousands separators, e.g. // $1,234.56 or -$1,234.56. The billing API reports every cost in dollars (there // is no currency field), so the USD sign is not an assumption. diff --git a/internal/cmd/command.org_billing_test.go b/internal/cmd/command.org_billing_test.go index 7e06eb6..7468917 100644 --- a/internal/cmd/command.org_billing_test.go +++ b/internal/cmd/command.org_billing_test.go @@ -90,6 +90,28 @@ func Test_Org_Billing_Usage_TotalRowArithmetic(t *testing.T) { h.Require.Contains(all, "$19.34") // subtotal: 12.34 + 4.00 + 3 } +func Test_Org_Billing_Usage_TotalDashesUnparseableColumn(t *testing.T) { + h := NewCommandHarness(t) + h.MockManagementAPI().SetRoute("GET", "/v1/billing/usage_summary", 200, map[string]any{ + "dedicated_usage": map[string]any{ + "minutes": 10, "total": 10.0, "credits_used": 1.0, "subtotal": 9.0, + }, + "model_apis_usage": map[string]any{ + // total is unparseable; credits/subtotal are fine. + "total": "oops", "credits_used": 2.0, "subtotal": 3.0, + }, + }) + + h.Require.NoError(h.Execute("org", "billing", "usage")) + + all := findRow(t, h.Stdout.String(), "All") + fields := strings.Fields(all) + // "All" + h.Require.Equal("-", fields[1], "TOTAL must dash out when a contributor is unparseable") + h.Require.Equal("$3.00", fields[2], "CREDITS still sums: 1.00 + 2.00") + h.Require.Equal("$12.00", fields[3], "SUBTOTAL still sums: 9.00 + 3.00") +} + func Test_Org_Billing_Usage_SingleCategoryNoTotalRow(t *testing.T) { h := NewCommandHarness(t) h.MockManagementAPI().SetRoute("GET", "/v1/billing/usage_summary", 200, map[string]any{