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
117 changes: 76 additions & 41 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,23 +184,24 @@ func runCombinedUsage(ctx context.Context, args []string, output io.Writer) erro
result usageResult
}{
{name: "ChatGPT", result: <-chatGPTResults},
{name: "OpenCode Go", result: <-openCodeResults},
{name: "OpenCode", result: <-openCodeResults},
}
succeeded := 0
var failures []string
for index, provider := range results {
if index > 0 {
fmt.Fprintln(output)
}
fmt.Fprintln(output, provider.name)
providers := make([]usageTableProvider, 0, len(results))
for _, provider := range results {
providers = append(providers, usageTableProvider{
name: provider.name,
usage: provider.result.usage,
err: provider.result.err,
})
if provider.result.err != nil {
fmt.Fprintf(output, "ERROR: %s\n", provider.result.err)
failures = append(failures, provider.name)
continue
}
printUsage(output, provider.result.usage)
succeeded++
}
printUsageTableAt(output, providers, time.Now(), true)
if succeeded == 0 {
return fmt.Errorf("usage is unavailable for %s", strings.Join(failures, " and "))
}
Expand Down Expand Up @@ -257,50 +258,84 @@ func printUsage(output io.Writer, usage api.ProviderUsage) {
printUsageAt(output, usage, time.Now())
}

type usageTableProvider struct {
name string
usage api.ProviderUsage
err error
}

func printUsageAt(output io.Writer, usage api.ProviderUsage, now time.Time) {
if len(usage.Accounts) == 0 {
fmt.Fprintf(output, "No %s credentials are registered.\n", usage.Provider)
return
}
rows := [][]string{{"NAME", "PLAN", "LIMIT", "WINDOW", "USED", "REMAINING", "RESET", "PACE"}}
printUsageTableAt(output, []usageTableProvider{{usage: usage}}, now, false)
}

func printUsageTableAt(output io.Writer, providers []usageTableProvider, now time.Time, showProvider bool) {
rows := [][]string{{"PROVIDER", "ACCOUNT", "PLAN", "LIMIT", "WINDOW", "USED", "REMAINING", "RESET", "PACE"}}
if !showProvider {
rows[0] = rows[0][1:]
rows[0][0] = "NAME"
}
accountSeparators := map[int]bool{}
for _, account := range usage.Accounts {
if len(rows) > 1 && (account.Error != nil || len(account.Limits) > 0) {
accountSeparators[len(rows)] = true
}
plan := "-"
if account.Plan != nil && *account.Plan != "" {
plan = *account.Plan
for _, provider := range providers {
providerName := provider.name
if provider.err != nil {
if len(rows) > 1 {
accountSeparators[len(rows)] = true
}
rows = appendUsageRow(rows, showProvider, providerName, "-", "-", "-", "-", "-", "-", "ERROR: "+provider.err.Error(), "-")
continue
}
if account.Error != nil {
rows = append(rows, []string{account.Name, plan, "-", "-", "-", "-", "ERROR: " + account.Error.Message, "-"})
if len(provider.usage.Accounts) == 0 {
if len(rows) > 1 {
accountSeparators[len(rows)] = true
}
rows = appendUsageRow(rows, showProvider, providerName, "-", "-", "-", "-", "-", "-", "No accounts", "-")
continue
}
for index, limit := range account.Limits {
name := account.Name
rowPlan := plan
if index > 0 {
name = ""
rowPlan = ""
for _, account := range provider.usage.Accounts {
if len(rows) > 1 && (account.Error != nil || len(account.Limits) > 0) {
accountSeparators[len(rows)] = true
}
plan := "-"
if account.Plan != nil && *account.Plan != "" {
plan = *account.Plan
}
if account.Error != nil {
rows = appendUsageRow(rows, showProvider, providerName, account.Name, plan, "-", "-", "-", "-", "ERROR: "+account.Error.Message, "-")
providerName = ""
continue
}
reset := "-"
if limit.ResetAt != nil {
reset = formatUsageReset(*limit.ResetAt, now)
for index, limit := range account.Limits {
name := account.Name
rowPlan := plan
if index > 0 {
name = ""
rowPlan = ""
}
reset := "-"
if limit.ResetAt != nil {
reset = formatUsageReset(*limit.ResetAt, now)
}
rows = appendUsageRow(rows, showProvider, providerName, name, rowPlan, limit.Name, limit.Window, fmt.Sprintf("%g%%", limit.UsedPercent), fmt.Sprintf("%g%%", limit.RemainingPercent), reset, usagePace(limit, now))
providerName = ""
}
pace := usagePace(limit, now)
rows = append(rows, []string{
name,
rowPlan,
limit.Name,
limit.Window,
fmt.Sprintf("%g%%", limit.UsedPercent),
fmt.Sprintf("%g%%", limit.RemainingPercent),
reset,
pace,
})
}
}
printTable(output, rows, map[int]bool{4: true, 5: true}, accountSeparators)
}
}
usedColumn := 4
if showProvider {
usedColumn++
}
printTable(output, rows, map[int]bool{usedColumn: true, usedColumn + 1: true}, accountSeparators)
}

func appendUsageRow(rows [][]string, showProvider bool, values ...string) [][]string {
if !showProvider {
values = values[1:]
}
return append(rows, values)
}

func usagePace(limit api.UsageLimit, now time.Time) string {
Expand Down
16 changes: 7 additions & 9 deletions internal/cli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func TestCombinedUsageShowsChatGPTAndOpenCodeGo(t *testing.T) {
}
fetchOpenCodeGoUsage = func(context.Context) (api.ProviderUsage, error) {
return api.ProviderUsage{Provider: "opencode-go", Accounts: []api.UsageAccount{{
Name: "OpenCode workspace", Limits: []api.UsageLimit{{Name: "rolling", Window: "5h", UsedPercent: 2, RemainingPercent: 98}},
Name: "-", Limits: []api.UsageLimit{{Name: "rolling", Window: "5h", UsedPercent: 2, RemainingPercent: 98}},
}}}, nil
}

Expand All @@ -65,9 +65,7 @@ func TestCombinedUsageShowsChatGPTAndOpenCodeGo(t *testing.T) {
t.Fatalf("ChatGPT options = %+v", chatGPTOptions)
}
text := output.String()
chatGPT := strings.Index(text, "ChatGPT\n")
openCode := strings.Index(text, "OpenCode Go\n")
if chatGPT < 0 || openCode < chatGPT || !strings.Contains(text, "person@example.com") || !strings.Contains(text, "OpenCode workspace") {
if strings.Count(text, "┌") != 1 || !strings.Contains(text, "│ PROVIDER │ ACCOUNT") || !strings.Contains(text, "ChatGPT") || !strings.Contains(text, "OpenCode") || !strings.Contains(text, "person@example.com") || strings.Contains(text, "OpenCode workspace") {
t.Fatalf("output = %q", text)
}
}
Expand All @@ -84,15 +82,15 @@ func TestCombinedUsageKeepsPartialResults(t *testing.T) {
}
fetchOpenCodeGoUsage = func(context.Context) (api.ProviderUsage, error) {
return api.ProviderUsage{Provider: "opencode-go", Accounts: []api.UsageAccount{{
Name: "OpenCode workspace", Limits: []api.UsageLimit{{Name: "weekly", Window: "7d"}},
Name: "-", Limits: []api.UsageLimit{{Name: "weekly", Window: "7d"}},
}}}, nil
}

var output bytes.Buffer
if err := Run(context.Background(), []string{"usage"}, &output, &bytes.Buffer{}, "test"); err != nil {
t.Fatal(err)
}
if !strings.Contains(output.String(), "ERROR: ChatGPT login unavailable") || !strings.Contains(output.String(), "OpenCode workspace") {
if !strings.Contains(output.String(), "ERROR: ChatGPT login unavailable") || !strings.Contains(output.String(), "OpenCode") {
t.Fatalf("output = %q", output.String())
}
}
Expand All @@ -113,7 +111,7 @@ func TestCombinedUsageFailsOnlyWhenEveryProviderFails(t *testing.T) {

var output bytes.Buffer
err := Run(context.Background(), []string{"usage"}, &output, &bytes.Buffer{}, "test")
if err == nil || err.Error() != "usage is unavailable for ChatGPT and OpenCode Go" {
if err == nil || err.Error() != "usage is unavailable for ChatGPT and OpenCode" {
t.Fatalf("error = %v", err)
}
}
Expand All @@ -126,7 +124,7 @@ func TestOpenCodeGoUsageUsesTheLocalBrowserWithoutCirclesCredentials(t *testing.
fetchOpenCodeGoUsage = func(context.Context) (api.ProviderUsage, error) {
called = true
return api.ProviderUsage{Provider: "opencode-go", Accounts: []api.UsageAccount{{
ID: "wrk_EXAMPLE", Name: "OpenCode workspace", Plan: &plan,
ID: "wrk_EXAMPLE", Name: "-", Plan: &plan,
Limits: []api.UsageLimit{{Name: "rolling", Window: "5h", UsedPercent: 2, RemainingPercent: 98}},
}}}, nil
}
Expand All @@ -136,7 +134,7 @@ func TestOpenCodeGoUsageUsesTheLocalBrowserWithoutCirclesCredentials(t *testing.
if err := Run(context.Background(), []string{"opencode-go", "usage"}, &output, &bytes.Buffer{}, "test"); err != nil {
t.Fatal(err)
}
if !called || !strings.Contains(output.String(), "OpenCode workspace") || !strings.Contains(output.String(), "rolling") {
if !called || !strings.Contains(output.String(), "rolling") {
t.Fatalf("called = %v, output = %q", called, output.String())
}
}
Expand Down
4 changes: 2 additions & 2 deletions internal/opencodego/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,9 @@ func fetchFromSessions(
break
}

name := "OpenCode workspace"
name := "-"
if len(workspaceIDs) > 1 {
name = fmt.Sprintf("OpenCode workspace %d", index+1)
name = fmt.Sprintf("%d", index+1)
}
plan := "Go"
account := api.UsageAccount{
Expand Down
2 changes: 1 addition & 1 deletion internal/opencodego/usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ monthlyUsage:{status:"active",resetInSec:180,usagePercent:3}}
if zenRequests != 2 || len(usage.Accounts) != 1 || len(usage.Accounts[0].Limits) != 3 {
t.Fatalf("zen requests = %d, usage = %#v", zenRequests, usage)
}
if usage.Provider != "opencode-go" || usage.Accounts[0].ID != "wrk_EXAMPLE" || usage.Accounts[0].Name != "OpenCode workspace" {
if usage.Provider != "opencode-go" || usage.Accounts[0].ID != "wrk_EXAMPLE" || usage.Accounts[0].Name != "-" {
t.Fatalf("usage = %#v", usage)
}
}
Expand Down
Loading