Spaces:
Paused
Paused
| package cmd | |
| import ( | |
| "fmt" | |
| "os" | |
| "plandex-cli/api" | |
| "plandex-cli/auth" | |
| "plandex-cli/lib" | |
| "plandex-cli/term" | |
| shared "plandex-shared" | |
| "sort" | |
| "strconv" | |
| "strings" | |
| "time" | |
| "unicode" | |
| "github.com/eiannone/keyboard" | |
| "github.com/fatih/color" | |
| "github.com/olekukonko/tablewriter" | |
| "github.com/shopspring/decimal" | |
| "github.com/spf13/cobra" | |
| ) | |
| const MaxCreditsLogPageSize = 500 | |
| var logCreditsPageSize int | |
| var logCreditsPage int | |
| var logCreditsDebitsOnly bool | |
| var logCreditsCreditsOnly bool | |
| var showUsageLog bool | |
| var creditsSession bool | |
| var creditsToday bool | |
| var creditsMonth bool | |
| var creditsCurrentPlan bool | |
| var usageCmd = &cobra.Command{ | |
| Use: "usage", | |
| Short: "Display credits balance and usage report", | |
| Run: usage, | |
| } | |
| func init() { | |
| RootCmd.AddCommand(usageCmd) | |
| usageCmd.Flags().BoolVar(&showUsageLog, "log", false, "Show usage log") | |
| usageCmd.Flags().IntVarP(&logCreditsPageSize, "page-size", "s", 100, "Number of transactions to display per page") | |
| usageCmd.Flags().IntVarP(&logCreditsPage, "page", "p", 1, "Page number to display") | |
| usageCmd.Flags().BoolVar(&logCreditsDebitsOnly, "debits", false, "Show only debits in the log") | |
| usageCmd.Flags().BoolVar(&logCreditsCreditsOnly, "purchases", false, "Show only purchases in the log") | |
| usageCmd.Flags().BoolVar(&creditsToday, "today", false, "Show usage for today") | |
| usageCmd.Flags().BoolVar(&creditsMonth, "month", false, "Show usage for current billing month") | |
| usageCmd.Flags().BoolVar(&creditsCurrentPlan, "plan", false, "Show usage for the current plan") | |
| } | |
| func usage(cmd *cobra.Command, args []string) { | |
| if showUsageLog { | |
| showLog(cmd, args) | |
| } else { | |
| showUsage() | |
| } | |
| } | |
| func showUsage() { | |
| auth.MustResolveAuthWithOrg() | |
| term.StartSpinner("") | |
| if !(creditsSession || creditsToday || creditsMonth || creditsCurrentPlan) { | |
| if os.Getenv("PLANDEX_REPL_SESSION_ID") != "" { | |
| creditsSession = true | |
| } else { | |
| creditsToday = true | |
| } | |
| } | |
| var sessionId string | |
| if creditsSession { | |
| sessionId = os.Getenv("PLANDEX_REPL_SESSION_ID") | |
| if sessionId == "" { | |
| term.OutputErrorAndExit("Session ID is not set. The --session flag should be used in the Plandex REPL.") | |
| } | |
| } | |
| var dayStart *time.Time | |
| if creditsToday { | |
| now := time.Now() | |
| midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) | |
| dayStart = &midnight | |
| } | |
| var planId string | |
| var currentPlanName string | |
| if creditsCurrentPlan { | |
| lib.MustResolveProject() | |
| planId = lib.CurrentPlanId | |
| plan, apiErr := api.Client.GetPlan(planId) | |
| if apiErr != nil { | |
| term.OutputErrorAndExit("Error getting plan: %v", apiErr) | |
| } | |
| currentPlanName = plan.Name | |
| } | |
| req := shared.CreditsLogRequest{ | |
| SessionId: sessionId, | |
| DayStart: dayStart, | |
| Month: creditsMonth, | |
| PlanId: planId, | |
| } | |
| res, apiErr := api.Client.GetCreditsSummary(req) | |
| term.StopSpinner() | |
| if apiErr != nil { | |
| term.OutputErrorAndExit("Error getting credits summary: %v", apiErr) | |
| } | |
| builder := strings.Builder{} | |
| balance := res.Balance | |
| balanceStr := formatSpend(balance) | |
| spendLbl := "💸 Spent" | |
| if creditsSession { | |
| spendLbl += " This Session" | |
| } else if creditsToday { | |
| spendLbl += " Today" | |
| } else if creditsMonth { | |
| spendLbl += " This Billing Month" | |
| spendLbl += fmt.Sprintf(" (since %s)", res.MonthStart.Format("Jan 2")) | |
| } else if creditsCurrentPlan { | |
| spendLbl += fmt.Sprintf(" On Plan 📋 %s", currentPlanName) | |
| } | |
| var spendStr string | |
| if res.TotalSpend.IsZero() { | |
| spendStr = "$0.00" | |
| } else { | |
| spendStr = formatSpend(res.TotalSpend) | |
| } | |
| table := tablewriter.NewWriter(&builder) | |
| table.SetAutoWrapText(false) | |
| table.SetHeader([]string{"💰 Current Balance", spendLbl}) | |
| table.Append([]string{balanceStr, spendStr}) | |
| table.Render() | |
| fmt.Fprintln(&builder) | |
| if !res.CacheSavings.IsZero() { | |
| table := tablewriter.NewWriter(&builder) | |
| table.SetAutoWrapText(false) | |
| table.SetHeader([]string{"🎯 Cache Savings"}) | |
| table.Append([]string{formatSpend(res.CacheSavings)}) | |
| table.Render() | |
| fmt.Fprintln(&builder) | |
| } | |
| amountByStr := map[string]float64{} | |
| if len(res.ByPlanId) > 0 { | |
| if !creditsCurrentPlan { | |
| table := tablewriter.NewWriter(&builder) | |
| table.SetAutoWrapText(false) | |
| table.SetHeader([]string{"📋 Plan", "💸 Spent"}) | |
| rows := [][]string{} | |
| for id, spend := range res.ByPlanId { | |
| name := res.PlanNamesById[id] | |
| spendStr := formatSpend(spend) | |
| rows = append(rows, []string{name, spendStr}) | |
| amountByStr[spendStr] = spend.InexactFloat64() | |
| } | |
| sort.Slice(rows, func(i, j int) bool { | |
| return amountByStr[rows[i][1]] > amountByStr[rows[j][1]] | |
| }) | |
| for _, row := range rows { | |
| table.Append(row) | |
| } | |
| table.Render() | |
| fmt.Fprintln(&builder) | |
| } | |
| } | |
| if len(res.ByPurpose) > 0 { | |
| table = tablewriter.NewWriter(&builder) | |
| table.SetAutoWrapText(false) | |
| table.SetHeader([]string{"⚡️ Purpose", "💸 Spent"}) | |
| rows := [][]string{} | |
| for name, spend := range res.ByPurpose { | |
| spendStr := formatSpend(spend) | |
| rows = append(rows, []string{name, spendStr}) | |
| amountByStr[spendStr] = spend.InexactFloat64() | |
| } | |
| sort.Slice(rows, func(i, j int) bool { | |
| return amountByStr[rows[i][1]] > amountByStr[rows[j][1]] | |
| }) | |
| for _, row := range rows { | |
| table.Append(row) | |
| } | |
| table.Render() | |
| fmt.Fprintln(&builder) | |
| } | |
| if len(res.ByModelName) > 0 { | |
| table = tablewriter.NewWriter(&builder) | |
| table.SetAutoWrapText(false) | |
| table.SetHeader([]string{"🤖 Model", "💸 Spent"}) | |
| rows := [][]string{} | |
| for name, spend := range res.ByModelName { | |
| spendStr := formatSpend(spend) | |
| rows = append(rows, []string{name, spendStr}) | |
| amountByStr[spendStr] = spend.InexactFloat64() | |
| } | |
| sort.Slice(rows, func(i, j int) bool { | |
| return amountByStr[rows[i][1]] > amountByStr[rows[j][1]] | |
| }) | |
| for _, row := range rows { | |
| table.Append(row) | |
| } | |
| table.Render() | |
| fmt.Fprintln(&builder) | |
| } | |
| term.PageOutput(builder.String()) | |
| term.PrintCmds("", "usage", "billing") | |
| } | |
| func showLog(cmd *cobra.Command, args []string) { | |
| auth.MustResolveAuthWithOrg() | |
| if !(creditsSession || creditsToday || creditsMonth || creditsCurrentPlan) { | |
| if os.Getenv("PLANDEX_REPL_SESSION_ID") != "" { | |
| creditsSession = true | |
| } else { | |
| creditsToday = true | |
| } | |
| } | |
| term.StartSpinner("") | |
| var transactionType shared.CreditsTransactionType | |
| if logCreditsDebitsOnly { | |
| transactionType = shared.CreditsTransactionTypeDebit | |
| } else if logCreditsCreditsOnly { | |
| transactionType = shared.CreditsTransactionTypeCredit | |
| } | |
| var sessionId string | |
| if creditsSession { | |
| sessionId = os.Getenv("PLANDEX_REPL_SESSION_ID") | |
| if sessionId == "" { | |
| term.OutputErrorAndExit("Session ID is not set. The --session flag should be used in the Plandex REPL.") | |
| } | |
| } | |
| var dayStart *time.Time | |
| if creditsToday { | |
| now := time.Now() | |
| midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) | |
| dayStart = &midnight | |
| } | |
| var planId string | |
| var planName string | |
| if creditsCurrentPlan { | |
| lib.MustResolveProject() | |
| planId = lib.CurrentPlanId | |
| plan, err := api.Client.GetPlan(planId) | |
| if err != nil { | |
| term.OutputErrorAndExit("Error getting plan: %v", err) | |
| return | |
| } | |
| planName = plan.Name | |
| } | |
| req := shared.CreditsLogRequest{ | |
| TransactionType: transactionType, | |
| SessionId: sessionId, | |
| DayStart: dayStart, | |
| Month: creditsMonth, | |
| PlanId: planId, | |
| } | |
| res, apiErr := api.Client.GetCreditsTransactions(logCreditsPageSize, logCreditsPage, req) | |
| term.StopSpinner() | |
| if apiErr != nil { | |
| term.OutputErrorAndExit("Error getting credits transactions: %v", apiErr) | |
| return | |
| } | |
| transactions := res.Transactions | |
| if len(transactions) == 0 { | |
| lbl := "🤷♂️ No usage" | |
| if sessionId != "" { | |
| lbl = "🤷♂️ No usage so far this session" | |
| } else if creditsToday { | |
| tz, _ := time.Now().Zone() | |
| lbl = fmt.Sprintf("🤷♂️ No usage so far today (since midnight %s)", tz) | |
| } else if creditsMonth { | |
| lbl = fmt.Sprintf("🤷♂️ No usage so far this billing month (since %s)", res.MonthStart.Format("Jan 2")) | |
| } else if creditsCurrentPlan { | |
| lbl = "🤷♂️ No usage so far for current plan 👉 " + planName | |
| } | |
| fmt.Println(lbl) | |
| return | |
| } | |
| tableString := &strings.Builder{} | |
| table := tablewriter.NewWriter(tableString) | |
| table.SetAutoWrapText(false) | |
| table.SetHeader([]string{"Amount", "Balance", "Transaction"}) | |
| for _, transaction := range transactions { | |
| var sign string | |
| var c color.Attribute | |
| desc := transaction.CreatedAt.Local().Format("2006-01-02 15:04:05.000 EST") + "\n" | |
| if transaction.TransactionType == "debit" { | |
| sign = "-" | |
| c = term.ColorHiRed | |
| if transaction.DebitPlanId != nil { | |
| planName := res.PlanNamesById[*transaction.DebitPlanId] | |
| desc += fmt.Sprintf("Plan → %s\n", planName) | |
| } | |
| surchargePct := transaction.DebitSurcharge.Div(*transaction.DebitBaseAmount) | |
| inputPrice := transaction.DebitModelInputPricePerToken.Mul(decimal.NewFromInt(1000000)).Mul(surchargePct.Add(decimal.NewFromInt(1))).StringFixed(4) | |
| outputPrice := transaction.DebitModelOutputPricePerToken.Mul(decimal.NewFromInt(1000000)).Mul(surchargePct.Add(decimal.NewFromInt(1))).StringFixed(4) | |
| var cacheDiscountStr string | |
| var cacheDiscountPct float64 | |
| if transaction.DebitCacheDiscount != nil { | |
| cacheDiscountStr = transaction.DebitCacheDiscount.StringFixed(4) | |
| totalAmount := transaction.DebitBaseAmount.Add(*transaction.DebitCacheDiscount) | |
| cacheDiscountPct = transaction.DebitCacheDiscount.Div(totalAmount).Mul(decimal.NewFromInt(100)).InexactFloat64() | |
| } | |
| for i := 0; i < 2; i++ { | |
| inputPrice = strings.TrimSuffix(inputPrice, "0") | |
| outputPrice = strings.TrimSuffix(outputPrice, "0") | |
| cacheDiscountStr = strings.TrimSuffix(cacheDiscountStr, "0") | |
| } | |
| desc += fmt.Sprintf("⚡️ %s\n", *transaction.DebitPurpose) | |
| desc += fmt.Sprintf("🧠 %s\n", transaction.ModelString()) | |
| desc += fmt.Sprintf("💳 Price → $%s input / $%s output per 1M\n", inputPrice, outputPrice) | |
| desc += fmt.Sprintf("🪙 Used → %d input / %d output\n", *transaction.DebitInputTokens, *transaction.DebitOutputTokens) | |
| if cacheDiscountStr != "" { | |
| desc += fmt.Sprintf("🎯 Cache discount → $%s (%d%%)\n", cacheDiscountStr, int(cacheDiscountPct)) | |
| } | |
| } else { | |
| sign = "+" | |
| c = term.ColorHiGreen | |
| switch *transaction.CreditType { | |
| case shared.CreditTypeGrant: | |
| desc += "Monthly subscription payment" | |
| case shared.CreditTypeTrial: | |
| desc += "Started trial" | |
| case shared.CreditTypePurchase: | |
| desc += "Purchased credits" | |
| case shared.CreditTypeSwitch: | |
| desc += "Switched to Integrated Models mode" | |
| } | |
| desc += "\n" | |
| } | |
| amountStr := transaction.Amount.StringFixed(6) | |
| for i := 0; i < 4; i++ { | |
| amountStr = strings.TrimSuffix(amountStr, "0") | |
| } | |
| balanceStr := transaction.EndBalance.StringFixed(4) | |
| for i := 0; i < 2; i++ { | |
| balanceStr = strings.TrimSuffix(balanceStr, "0") | |
| } | |
| table.Append([]string{ | |
| color.New(c).Sprint(sign + "$" + amountStr), | |
| "$" + balanceStr, | |
| desc, | |
| }) | |
| } | |
| table.Render() | |
| var output string | |
| var pageLine string | |
| if res.NumPages > 1 { | |
| pageLine = fmt.Sprintf("Page size %d. Showing page %d of %d", logCreditsPageSize, logCreditsPage, res.NumPages) | |
| if res.NumPagesMax { | |
| pageLine = "+" | |
| } | |
| output = pageLine + "\n\n" + tableString.String() | |
| } else { | |
| output = tableString.String() | |
| } | |
| term.PageOutput(output) | |
| var inputFn func() | |
| inputFn = func() { | |
| fmt.Println("\n" + pageLine) | |
| prompts := []string{} | |
| if res.NumPages > 1 && logCreditsPage < res.NumPages { | |
| prompts = append(prompts, "Press 'n' for next page") | |
| } | |
| if logCreditsPage > 1 { | |
| prompts = append(prompts, "Press 'p' for previous page") | |
| } | |
| prompts = append(prompts, "Type any number and press enter to jump to a page") | |
| prompts = append(prompts, "Press 'q' to quit") | |
| color.New(term.ColorHiMagenta, color.Bold).Println(strings.Join(prompts, "\n")) | |
| color.New(term.ColorHiMagenta, color.Bold).Print("> ") | |
| char, _, err := term.GetUserKeyInput() | |
| if err != nil { | |
| term.OutputErrorAndExit("Failed to get user input: %v", err) | |
| } | |
| // Check if the input is a digit | |
| if unicode.IsDigit(char) { | |
| var numberInput strings.Builder | |
| numberInput.WriteRune(char) | |
| fmt.Print(string(char)) // Show the initial digit | |
| for { | |
| char, key, err := term.GetUserKeyInput() | |
| if err != nil { | |
| term.OutputErrorAndExit("Failed to get user input: %v", err) | |
| } | |
| // If Enter is pressed, commit the input | |
| if key == keyboard.KeyEnter { | |
| pageNumber, err := strconv.Atoi(numberInput.String()) | |
| if err != nil { | |
| fmt.Println("Invalid page number.") | |
| return | |
| } | |
| // Check if the page number is valid | |
| if pageNumber >= 1 && (pageNumber <= res.NumPages || res.NumPagesMax) { | |
| logCreditsPage = pageNumber | |
| showLog(cmd, args) // Re-run the log command with the new page | |
| } else { | |
| fmt.Println() | |
| fmt.Println("Invalid page number.") | |
| inputFn() | |
| } | |
| return | |
| } | |
| // If another digit is pressed, add it to the input | |
| if unicode.IsDigit(char) { | |
| numberInput.WriteRune(char) | |
| fmt.Print(string(char)) // Show the digit | |
| } else if key == keyboard.KeyBackspace || key == keyboard.KeyBackspace2 { | |
| // Handle backspace | |
| if numberInput.Len() > 0 { | |
| // Remove the last rune | |
| input := numberInput.String() | |
| numberInput.Reset() | |
| numberInput.WriteString(input[:len(input)-1]) | |
| fmt.Print("\b \b") // Erase the digit | |
| } | |
| } else { | |
| // Handle invalid input while typing a number | |
| fmt.Println() | |
| fmt.Println("\nInvalid input. Please enter a valid page number.") | |
| inputFn() | |
| return | |
| } | |
| } | |
| } | |
| // Handle non-digit hotkeys | |
| fmt.Print(string(char)) | |
| switch char { | |
| case 'n': | |
| if logCreditsPage < res.NumPages || res.NumPagesMax { | |
| logCreditsPage++ | |
| showLog(cmd, args) | |
| } else { | |
| fmt.Println() | |
| fmt.Println("Already on last page.") | |
| inputFn() | |
| } | |
| case 'p': | |
| if logCreditsPage > 1 { | |
| logCreditsPage-- | |
| showLog(cmd, args) | |
| } else { | |
| fmt.Println() | |
| fmt.Println("Already on first page.") | |
| inputFn() | |
| } | |
| case 'q': | |
| fmt.Println() | |
| return | |
| default: | |
| fmt.Println() | |
| fmt.Println("Invalid input.") | |
| inputFn() | |
| } | |
| } | |
| if res.NumPages > 1 { | |
| inputFn() | |
| } | |
| } | |
| func formatSpend(spend decimal.Decimal) string { | |
| if spend.IsZero() { | |
| return "$0.00" | |
| } | |
| spendStr := fmt.Sprintf("$%s", spend.StringFixed(4)) | |
| for i := 0; i < 2; i++ { | |
| if strings.HasSuffix(spendStr, "0") { | |
| spendStr = spendStr[:len(spendStr)-1] | |
| } | |
| } | |
| if spendStr == "$0.00" { | |
| return "<$0.0001" | |
| } | |
| return spendStr | |
| } | |