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
1 change: 1 addition & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type UsageLimit struct {
RemainingPercent float64 `json:"remaining_percent"`
LimitReached bool `json:"limit_reached"`
ResetAt *string `json:"reset_at"`
WindowSeconds *int `json:"window_seconds"`
}

type UsageError struct {
Expand Down
7 changes: 5 additions & 2 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestCredentialLifecycleUsesPrismAPIWithoutLeakingSecretsInURL(t *testing.T)
case request.Method == http.MethodGet && request.URL.Path == "/credentials/chatgpt":
_, _ = response.Write([]byte(`{"data":[{"id":"01j00000000000000000000002","provider":"chatgpt","name":"person@example.com"}]}`))
case request.Method == http.MethodGet && request.URL.Path == "/usage/chatgpt":
_, _ = response.Write([]byte(`{"provider":"chatgpt","accounts":[{"id":"01j00000000000000000000002","name":"person@example.com","plan":"pro","observed_at":"2026-08-06T07:25:00Z","limits":[{"name":"default","window":"primary","used_percent":88,"remaining_percent":12,"limit_reached":false,"reset_at":"2026-08-11T00:24:55Z"}]}]}`))
_, _ = response.Write([]byte(`{"provider":"chatgpt","accounts":[{"id":"01j00000000000000000000002","name":"person@example.com","plan":"pro","observed_at":"2026-08-06T07:25:00Z","limits":[{"name":"default","window":"primary","used_percent":88,"remaining_percent":12,"limit_reached":false,"reset_at":"2026-08-11T00:24:55Z","window_seconds":604800}]}]}`))
case request.Method == http.MethodDelete && request.URL.Path == "/credentials/01j00000000000000000000002":
removed = true
response.WriteHeader(http.StatusNoContent)
Expand Down Expand Up @@ -64,7 +64,10 @@ func TestCredentialLifecycleUsesPrismAPIWithoutLeakingSecretsInURL(t *testing.T)
t.Fatal("credential was not removed")
}
usage, err := client.Usage(context.Background(), "chatgpt")
if err != nil || usage.Provider != "chatgpt" || len(usage.Accounts) != 1 || usage.Accounts[0].Limits[0].RemainingPercent != 12 {
if err != nil || usage.Provider != "chatgpt" || len(usage.Accounts) != 1 ||
usage.Accounts[0].Limits[0].RemainingPercent != 12 ||
usage.Accounts[0].Limits[0].WindowSeconds == nil ||
*usage.Accounts[0].Limits[0].WindowSeconds != 604800 {
t.Fatalf("usage = %#v, err = %v", usage, err)
}
}
63 changes: 61 additions & 2 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"math"
"net/url"
"os"
"strings"
Expand Down Expand Up @@ -182,14 +183,14 @@ func printUsageAt(output io.Writer, usage api.ProviderUsage, now time.Time) {
fmt.Fprintf(output, "No %s credentials are registered.\n", usage.Provider)
return
}
rows := [][]string{{"NAME", "PLAN", "LIMIT", "WINDOW", "USED", "REMAINING", "RESET"}}
rows := [][]string{{"NAME", "PLAN", "LIMIT", "WINDOW", "USED", "REMAINING", "RESET", "PACE"}}
for _, account := range usage.Accounts {
plan := "-"
if account.Plan != nil && *account.Plan != "" {
plan = *account.Plan
}
if account.Error != nil {
rows = append(rows, []string{account.Name, plan, "-", "-", "-", "-", "ERROR: " + account.Error.Message})
rows = append(rows, []string{account.Name, plan, "-", "-", "-", "-", "ERROR: " + account.Error.Message, "-"})
continue
}
for index, limit := range account.Limits {
Expand All @@ -203,6 +204,7 @@ func printUsageAt(output io.Writer, usage api.ProviderUsage, now time.Time) {
if limit.ResetAt != nil {
reset = formatUsageReset(*limit.ResetAt, now)
}
pace := usagePace(limit, now)
rows = append(rows, []string{
name,
rowPlan,
Expand All @@ -211,12 +213,69 @@ func printUsageAt(output io.Writer, usage api.ProviderUsage, now time.Time) {
fmt.Sprintf("%g%%", limit.UsedPercent),
fmt.Sprintf("%g%%", limit.RemainingPercent),
reset,
pace,
})
}
}
printTable(output, rows, map[int]bool{4: true, 5: true})
}

func usagePace(limit api.UsageLimit, now time.Time) string {
if limit.LimitReached || limit.UsedPercent >= 100 {
return "LIMIT REACHED"
}
if limit.WindowSeconds == nil || limit.ResetAt == nil || *limit.WindowSeconds <= 0 {
return "-"
}

reset, err := time.Parse(time.RFC3339, *limit.ResetAt)
if err != nil {
return "-"
}
window := time.Duration(*limit.WindowSeconds) * time.Second
remaining := reset.Sub(now)
if remaining <= 0 {
return "-"
}

elapsed := window - remaining
if elapsed < maxDuration(time.Minute, window/100) {
return "TOO EARLY"
}

projected := limit.UsedPercent / elapsed.Seconds() * window.Seconds()
if limit.UsedPercent <= 0 {
return "OK · ~100% left at reset"
}
if projected >= 100 {
rate := limit.UsedPercent / elapsed.Seconds()
if math.Abs(projected-100) < 1e-9 {
return "RUNS OUT at reset"
}
runOut := time.Duration((100 - limit.UsedPercent) / rate * float64(time.Second))
if runOut >= remaining {
return "RUNS OUT at reset"
}
if runOut <= 0 {
return "RUNS OUT"
}
return "RUNS OUT in " + strings.TrimPrefix(formatUsageTimeRemaining(runOut), "in ")
}
if projected <= 90 {
remainingPercent := max(0, int(math.Round(100-projected)))
return fmt.Sprintf("OK · ~%d%% left at reset", remainingPercent)
}
spare := max(1, int(math.Round(100-projected)))
return fmt.Sprintf("CLOSE · ~%d%% spare at reset", spare)
}

func maxDuration(a, b time.Duration) time.Duration {
if a > b {
return a
}
return b
}

func formatUsageReset(value string, now time.Time) string {
reset, err := time.Parse(time.RFC3339, value)
if err != nil {
Expand Down
85 changes: 77 additions & 8 deletions internal/cli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,15 @@ func TestHelpDocumentsSupportedCommandsWithoutInternalDetails(t *testing.T) {
func TestChatGPTUsageOutputShowsEveryLimitAndPartialErrors(t *testing.T) {
plan := "pro"
reset := "2026-08-11T00:24:55.000Z"
windowSeconds := 7 * 24 * 60 * 60
usage := api.ProviderUsage{Provider: "chatgpt", Accounts: []api.UsageAccount{
{
Name: "person@example.com",
Plan: &plan,
Limits: []api.UsageLimit{
{
Name: "default", Window: "primary", UsedPercent: 88,
RemainingPercent: 12, ResetAt: &reset,
RemainingPercent: 12, ResetAt: &reset, WindowSeconds: &windowSeconds,
},
{
Name: "GPT-5.3-Codex-Spark", Window: "primary", UsedPercent: 0,
Expand All @@ -59,19 +60,87 @@ func TestChatGPTUsageOutputShowsEveryLimitAndPartialErrors(t *testing.T) {
}}
var output bytes.Buffer
printUsageAt(&output, usage, time.Date(2026, 8, 7, 9, 24, 55, 0, time.FixedZone("KST", 9*60*60)))
want := `┌────────────────────┬──────┬─────────────────────┬─────────┬──────┬───────────┬─────────────────────────────────────┐
│ NAME │ PLAN │ LIMIT │ WINDOW │ USED │ REMAINING │ RESET │
├────────────────────┼──────┼─────────────────────┼─────────┼──────┼───────────┼─────────────────────────────────────┤
│ person@example.com │ pro │ default │ primary │ 88% │ 12% │ 2026-08-11 09:24 KST (in 4d) │
│ │ │ GPT-5.3-Codex-Spark │ primary │ 0% │ 100% │ - │
│ other@example.com │ - │ - │ - │ - │ - │ ERROR: ChatGPT usage is unavailable │
└────────────────────┴──────┴─────────────────────┴─────────┴──────┴───────────┴─────────────────────────────────────┘
want := `┌────────────────────┬──────┬─────────────────────┬─────────┬──────┬───────────┬─────────────────────────────────────┬────────────────────
│ NAME │ PLAN │ LIMIT │ WINDOW │ USED │ REMAINING │ RESET │ PACE │
├────────────────────┼──────┼─────────────────────┼─────────┼──────┼───────────┼─────────────────────────────────────┼────────────────────
│ person@example.com │ pro │ default │ primary │ 88% │ 12% │ 2026-08-11 09:24 KST (in 4d) │ RUNS OUT in 9h 49m │
│ │ │ GPT-5.3-Codex-Spark │ primary │ 0% │ 100% │ - │ - │
│ other@example.com │ - │ - │ - │ - │ - │ ERROR: ChatGPT usage is unavailable │ - │
└────────────────────┴──────┴─────────────────────┴─────────┴──────┴───────────┴─────────────────────────────────────┴────────────────────
`
if output.String() != want {
t.Fatalf("output =\n%s\nwant:\n%s", output.String(), want)
}
}

func TestUsagePaceProjectsQuotaAtReset(t *testing.T) {
now := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
windowSeconds := 7 * 24 * 60 * 60
resetAt := func(elapsedFraction float64) *string {
value := now.Add(time.Duration(float64(time.Duration(windowSeconds)*time.Second) * (1 - elapsedFraction))).Format(time.RFC3339)
return &value
}
window := func() *int {
value := windowSeconds
return &value
}

tests := []struct {
name string
limit api.UsageLimit
want string
}{
{
name: "healthy",
limit: api.UsageLimit{UsedPercent: 30, ResetAt: resetAt(0.5), WindowSeconds: window()},
want: "OK · ~40% left at reset",
},
{
name: "close",
limit: api.UsageLimit{UsedPercent: 46, ResetAt: resetAt(0.5), WindowSeconds: window()},
want: "CLOSE · ~8% spare at reset",
},
{
name: "running out",
limit: api.UsageLimit{UsedPercent: 60, ResetAt: resetAt(0.5), WindowSeconds: window()},
want: "RUNS OUT in 2d 8h",
},
{
name: "limit reached",
limit: api.UsageLimit{UsedPercent: 100, LimitReached: true, ResetAt: resetAt(0.5), WindowSeconds: window()},
want: "LIMIT REACHED",
},
{
name: "too early",
limit: api.UsageLimit{UsedPercent: 1, ResetAt: resetAt(0.005), WindowSeconds: window()},
want: "TOO EARLY",
},
{
name: "missing duration",
limit: api.UsageLimit{UsedPercent: 30, ResetAt: resetAt(0.5)},
want: "-",
},
{
name: "exactly at limit",
limit: api.UsageLimit{UsedPercent: 50, ResetAt: resetAt(0.5), WindowSeconds: window()},
want: "RUNS OUT at reset",
},
{
name: "unused",
limit: api.UsageLimit{UsedPercent: 0, ResetAt: resetAt(0.5), WindowSeconds: window()},
want: "OK · ~100% left at reset",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := usagePace(test.limit, now); got != test.want {
t.Fatalf("usagePace() = %q, want %q", got, test.want)
}
})
}
}

func TestUsageResetFormattingUsesLocalTimeAndFriendlyRemainingTime(t *testing.T) {
location := time.FixedZone("KST", 9*60*60)
now := time.Date(2026, 8, 7, 12, 0, 0, 0, location)
Expand Down
Loading