Skip to content
Open
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
7 changes: 7 additions & 0 deletions changelog/unreleased/qoder-quota-sum-buckets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Qoder accounts now show their real remaining credits: the account card's headline sums the plan quota, the add-on pack, and the organization resource package instead of reading only the plan quota. Daily check-in rewards are credited to the add-on pack upstream, so before this a plan-only account's card never moved after a check-in (it stayed at e.g. 300 while the reward sat unshown). This matches how the Qoder client itself totals the buckets.

### 中文

- Qoder 账号现在显示真实剩余额度:账号卡片的主数值改为把「套餐额度 + 加量包 + 组织资源包」三桶相加,而不是只读套餐额度。每日签到奖励在上游是记入加量包的,因此此前仅靠套餐额度的账号在签到后卡片数字始终不动(例如一直停在 300,奖励金额不显示)。这与 Qoder 客户端自身的合计口径一致。
96 changes: 74 additions & 22 deletions internal/providers/qoder/quota.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package qoder

import "github.com/caigee-cmd/cli2api/internal/accounts"
import (
"math"

"github.com/caigee-cmd/cli2api/internal/accounts"
)

type workerQuota struct {
UserQuota *workerQuotaBlock `json:"userQuota"`
Expand All @@ -19,50 +23,98 @@ type workerQuotaBlock struct {
Available *bool `json:"available"`
}

// available reports whether the bucket may be spent. A nil Available means the
// upstream did not flag it unavailable.
func (w *workerQuotaBlock) available() bool {
return w != nil && (w.Available == nil || *w.Available)
}

func (w *workerQuotaBlock) hasRemaining() bool {
return w != nil && w.Remaining > 0 && (w.Available == nil || *w.Available)
return w.available() && w.Remaining > 0
}

// snapshot projects the upstream quota onto the console/routing model.
//
// Qoder reports credits in up to three buckets: the plan's monthly user quota,
// the add-on pack, and an organization resource package. The upstream CLI sums
// them (its remaining = userQuota + addOnQuota + orgResourcePackage) and its
// totalUsagePercentage is the same aggregate ratio, so the headline must be the
// sum of the available buckets. Daily check-in rewards land in the add-on
// bucket, which is exactly why a base-only headline never moved after a
// check-in (issue #229). Each bucket's detail fields are still reported.
func (w *workerQuota) snapshot() *accounts.QuotaSnapshot {
if w == nil || w.UserQuota == nil {
if w == nil {
return nil
}
if w.UserQuota == nil && w.AddOnQuota == nil && w.OrgResourcePackage == nil {
// No bucket reported at all: unknown, not a fabricated zero.
return nil
}

var used, total, remaining float64
for _, bucket := range []*workerQuotaBlock{w.UserQuota, w.AddOnQuota, w.OrgResourcePackage} {
if !bucket.available() {
continue
}
used += bucket.Used
total += bucket.Total
remaining += bucket.Remaining
}
percentage := 0.0
if total > 0 {
percentage = math.Min(used/total*100, 100)
}

snapshot := &accounts.QuotaSnapshot{
Used: w.UserQuota.Used,
Total: w.UserQuota.Total,
Remaining: w.UserQuota.Remaining,
Percentage: w.UserQuota.Percentage,
Unit: w.UserQuota.Unit,
Exceeded: w.IsQuotaExceeded || w.UserQuota.Percentage >= 100,
Used: used,
Total: total,
Remaining: remaining,
Percentage: percentage,
Unit: headlineUnit(w),
FetchedAt: w.FetchedAt,
}
if snapshot.Unit == "" {
snapshot.Unit = "credits"
// Exceeded keeps its previous meaning: the account is out of credits unless
// an available add-on or organization package still holds some.
exceeded := w.IsQuotaExceeded || (w.UserQuota.available() && w.UserQuota.Percentage >= 100)
if exceeded && (w.AddOnQuota.hasRemaining() || w.OrgResourcePackage.hasRemaining()) {
exceeded = false
}
snapshot.Exceeded = exceeded

if w.AddOnQuota != nil {
snapshot.HasAddOn = true
snapshot.AddOnUsed = w.AddOnQuota.Used
snapshot.AddOnTotal = w.AddOnQuota.Total
snapshot.AddOnRemaining = w.AddOnQuota.Remaining
snapshot.AddOnUnit = w.AddOnQuota.Unit
snapshot.AddOnUnit = blockUnit(w.AddOnQuota)
snapshot.AddOnAvailable = w.AddOnQuota.Available
if snapshot.AddOnUnit == "" {
snapshot.AddOnUnit = "credits"
}
}
if w.OrgResourcePackage != nil {
snapshot.HasResourcePackage = true
snapshot.ResourcePackageUsed = w.OrgResourcePackage.Used
snapshot.ResourcePackageTotal = w.OrgResourcePackage.Total
snapshot.ResourcePackageRemaining = w.OrgResourcePackage.Remaining
snapshot.ResourcePackageUnit = w.OrgResourcePackage.Unit
snapshot.ResourcePackageUnit = blockUnit(w.OrgResourcePackage)
snapshot.ResourcePackageAvailable = w.OrgResourcePackage.Available
if snapshot.ResourcePackageUnit == "" {
snapshot.ResourcePackageUnit = "credits"
}
}
if snapshot.Exceeded && (w.AddOnQuota.hasRemaining() || w.OrgResourcePackage.hasRemaining()) {
snapshot.Exceeded = false
}
return snapshot
}

func blockUnit(block *workerQuotaBlock) string {
if block == nil || block.Unit == "" {
return "credits"
}
return block.Unit
}

// headlineUnit picks the unit for the aggregate from the first available
// bucket; every Qoder bucket is credit-denominated, so this only guards against
// an empty upstream value.
func headlineUnit(w *workerQuota) string {
for _, bucket := range []*workerQuotaBlock{w.UserQuota, w.AddOnQuota, w.OrgResourcePackage} {
if bucket.available() && bucket.Unit != "" {
return bucket.Unit
}
}
return "credits"
}
86 changes: 82 additions & 4 deletions internal/providers/qoder/quota_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,90 @@ package qoder

import "testing"

func TestWorkerQuotaSnapshotUsesResourcePackage(t *testing.T) {
// A plan with a monthly quota plus a check-in add-on must sum both buckets;
// upstream reports totalUsagePercentage over the same aggregate, and the CLI
// sums the buckets too. This is issue #229: the add-on reward never moved the
// base-only headline.
func TestWorkerQuotaSnapshotSumsBuckets(t *testing.T) {
quota := (&workerQuota{
UserQuota: &workerQuotaBlock{Total: 100, Used: 100, Percentage: 100},
OrgResourcePackage: &workerQuotaBlock{Total: 50, Used: 10, Remaining: 40, Unit: "credits"},
UserQuota: &workerQuotaBlock{Total: 300, Used: 139, Remaining: 161, Percentage: 47, Unit: "credits"},
AddOnQuota: &workerQuotaBlock{Total: 400, Used: 0, Remaining: 400, Percentage: 0, Unit: "credits"},
IsQuotaExceeded: false,
FetchedAt: "now",
}).snapshot()

if quota == nil {
t.Fatal("snapshot is nil")
}
if quota.Total != 700 || quota.Used != 139 || quota.Remaining != 561 {
t.Fatalf("headline must sum buckets: %+v", quota)
}
// 139 / 700 = 19.857% -> matches upstream totalUsagePercentage ~= 20.
if quota.Percentage < 19.8 || quota.Percentage > 19.9 {
t.Fatalf("percentage = %v, want ~19.86", quota.Percentage)
}
if quota.Exceeded {
t.Fatalf("account with add-on remaining must not be exceeded: %+v", quota)
}
if !quota.HasAddOn || quota.AddOnRemaining != 400 {
t.Fatalf("add-on detail must be preserved: %+v", quota)
}
}

// A balance-only account (no subscription) reports userQuota 0/0/0 and keeps
// its credits entirely in the add-on bucket; the headline must show them.
func TestWorkerQuotaSnapshotBalanceOnlyAccount(t *testing.T) {
quota := (&workerQuota{
UserQuota: &workerQuotaBlock{Total: 0, Used: 0, Remaining: 0, Percentage: 0, Unit: "credits"},
AddOnQuota: &workerQuotaBlock{Total: 400, Used: 0, Remaining: 400, Percentage: 1, Unit: "credits"},
}).snapshot()

if quota == nil || quota.Total != 400 || quota.Remaining != 400 || quota.Used != 0 {
t.Fatalf("balance-only headline should follow the add-on: %+v", quota)
}
}

// A consumed base quota with an available add-on keeps the account routable and
// shows the combined remainder.
func TestWorkerQuotaSnapshotBaseExhaustedAddOnAvailable(t *testing.T) {
quota := (&workerQuota{
UserQuota: &workerQuotaBlock{Total: 100, Used: 100, Remaining: 0, Percentage: 100, Unit: "credits"},
AddOnQuota: &workerQuotaBlock{Total: 50, Used: 10, Remaining: 40, Unit: "credits"},
IsQuotaExceeded: true,
}).snapshot()

if quota == nil || quota.Exceeded {
t.Fatalf("add-on remaining must keep Exceeded false: %+v", quota)
}
if quota.Remaining != 40 || quota.Total != 150 || quota.Used != 110 {
t.Fatalf("headline should be the combined buckets: %+v", quota)
}
}

// An org resource package is included in the aggregate and can rescue an
// otherwise exhausted account.
func TestWorkerQuotaSnapshotIncludesResourcePackage(t *testing.T) {
available := true
quota := (&workerQuota{
UserQuota: &workerQuotaBlock{Total: 100, Used: 100, Remaining: 0, Percentage: 100},
OrgResourcePackage: &workerQuotaBlock{Total: 50, Used: 10, Remaining: 40, Percentage: 20, Available: &available},
IsQuotaExceeded: true,
}).snapshot()

if quota == nil || quota.Exceeded || !quota.HasResourcePackage || quota.ResourcePackageRemaining != 40 {
t.Fatalf("quota = %+v", quota)
}
if quota.Total != 150 || quota.Remaining != 40 {
t.Fatalf("resource package must join the aggregate: %+v", quota)
}
}

// An unavailable resource package is excluded from the aggregate and cannot
// rescue an exhausted account.
func TestWorkerQuotaSnapshotIgnoresUnavailableResourcePackage(t *testing.T) {
available := false
quota := (&workerQuota{
UserQuota: &workerQuotaBlock{Total: 100, Used: 100, Percentage: 100},
UserQuota: &workerQuotaBlock{Total: 100, Used: 100, Remaining: 0, Percentage: 100},
OrgResourcePackage: &workerQuotaBlock{
Total: 50, Remaining: 40, Available: &available,
},
Expand All @@ -27,4 +95,14 @@ func TestWorkerQuotaSnapshotIgnoresUnavailableResourcePackage(t *testing.T) {
if quota == nil || !quota.Exceeded || quota.ResourcePackageAvailable == nil || *quota.ResourcePackageAvailable {
t.Fatalf("quota = %+v", quota)
}
if quota.Total != 100 || quota.Remaining != 0 {
t.Fatalf("unavailable bucket must not count: %+v", quota)
}
}

// No bucket reported at all is "unknown", never a fabricated zero.
func TestWorkerQuotaSnapshotNoBucketsIsNil(t *testing.T) {
if quota := (&workerQuota{}).snapshot(); quota != nil {
t.Fatalf("quota = %+v, want nil", quota)
}
}
4 changes: 2 additions & 2 deletions internal/runtime/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@ func TestManagerRefreshFetchesQuotaWithoutAffectingHealth(t *testing.T) {
if item.Quota == nil {
t.Fatalf("expected quota snapshot on pool item, got %+v", item)
}
if item.Quota.Used != 150 || item.Quota.Total != 600 || item.Quota.Unit != "credits" || item.Quota.Exceeded {
if item.Quota.Used != 190 || item.Quota.Total != 700 || item.Quota.Unit != "credits" || item.Quota.Exceeded {
t.Fatalf("quota snapshot = %+v", item.Quota)
}
if !item.Quota.HasAddOn || item.Quota.AddOnTotal != 100 || item.Quota.AddOnUsed != 40 {
Expand All @@ -552,7 +552,7 @@ func TestManagerRefreshFetchesQuotaWithoutAffectingHealth(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(views) != 1 || views[0].Quota == nil || views[0].Quota.Remaining != 450 {
if len(views) != 1 || views[0].Quota == nil || views[0].Quota.Remaining != 510 {
t.Fatalf("account view quota = %+v", views)
}
}
Expand Down