Skip to content
Merged
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
37 changes: 27 additions & 10 deletions cmd/command.org.go
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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 UTC. 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'",
},
},
},
Expand Down Expand Up @@ -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 {
Expand Down
234 changes: 232 additions & 2 deletions internal/cmd/command.org.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,243 @@
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 UTC", 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
// 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) {
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),
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", "",
billingSumCell(sumTotal, okTotal),
billingSumCell(sumCredits, okCredits),
billingSumCell(sumSubtotal, okSubtotal),
})
}

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Some of these tiny helpers could be inlined, but not a big deal since they are reasonably qualified

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)
}

// 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.
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()
}
Loading
Loading