From 17312e287131ee762621432c27909f30c6181522 Mon Sep 17 00:00:00 2001 From: YG Park Date: Sun, 9 Aug 2026 20:17:40 +0900 Subject: [PATCH] feat: show ChatGPT usage pace --- internal/api/client.go | 1 + internal/api/client_test.go | 7 ++- internal/cli/run.go | 63 ++++++++++++++++++++++++++- internal/cli/run_test.go | 85 +++++++++++++++++++++++++++++++++---- 4 files changed, 144 insertions(+), 12 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index 7f2150f..c25cc14 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -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 { diff --git a/internal/api/client_test.go b/internal/api/client_test.go index d0f4834..a631ea1 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -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) @@ -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) } } diff --git a/internal/cli/run.go b/internal/cli/run.go index 69782d9..edeb5aa 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "math" "net/url" "os" "strings" @@ -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 { @@ -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, @@ -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 { diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index aa086a5..d270e1b 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -37,6 +37,7 @@ 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", @@ -44,7 +45,7 @@ func TestChatGPTUsageOutputShowsEveryLimitAndPartialErrors(t *testing.T) { 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, @@ -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)