diff --git a/internal/provider/migrate_zcode.go b/internal/provider/migrate_zcode.go index 79ee11fc..63db3f50 100644 --- a/internal/provider/migrate_zcode.go +++ b/internal/provider/migrate_zcode.go @@ -21,7 +21,7 @@ import ( func init() { movers["zcode"] = &mover{ pkg: "@magpie-community/opencode-zcode-auth", - min: "0.1.9", // the gift's models are trial entries (GLM-5.3-Flash-Trial) the user picks; a plain model spends the GLM Coding Plan (plugins #21) + min: "0.1.10", // a gift-only account's spent bucket (the Start Plan's GLM-5.3-Flash) doesn't hold up a model another plan (Trust Build) still has; 0.1.9's trial entries (plugins #21) kept agents: []string{"zcode"}, // a Start Plan account was never served GLM-5.3, by the built-in // or by ZCode, and the plugin lists it no more than they do; its diff --git a/internal/provider/zcode_start.go b/internal/provider/zcode_start.go index 9ca8807c..aa4852f2 100644 --- a/internal/provider/zcode_start.go +++ b/internal/provider/zcode_start.go @@ -487,6 +487,7 @@ func zcodeStartQuota(ctx context.Context, l Login, jwt string) SubscriptionQuota if until != nil { q.Until, q.Renew = until, "off" } + var buckets []zcodeBucket for _, x := range b.Balances { total, hasTotal := zcodeNum(x.Total) used, hasUsed := zcodeNum(x.Used) @@ -529,17 +530,58 @@ func zcodeStartQuota(ctx context.Context, l Login, jwt string) SubscriptionQuota w.Span = time.Duration(e-s) * time.Second } } - if len(models) > 0 { - w.matches = func(model string) bool { - for _, m := range models { - if strings.EqualFold(m, model) { - return true - } - } - return false - } - } q.Windows = append(q.Windows, w) + buckets = append(buckets, zcodeBucket{models, hasLeft && left <= 0 || hasTotal && total > 0 && used >= total}) } + zcodeAlternatives(q.Windows, buckets) return q } + +// zcodeBucket is what zcodeAlternatives reads of a window's bucket. +type zcodeBucket struct { + models []string + spent bool +} + +// zcodeAlternatives scopes each bucket's window to the models a request +// spends it on. Two plans can give one model (ZCode Trust Build's and the +// Start Plan's GLM-5.3-Flash), and the server picks the bucket — ZCode +// sends no plan with a request — so a model counts on one bucket: a live +// one before a spent one, the least used of those. A bucket that is no +// model's stays on the card, Aside, so a spent one doesn't hold up the +// model, or the account, another still has (ganlerk on Discord). +func zcodeAlternatives(ws []QuotaWindow, bs []zcodeBucket) { + best := map[string]int{} + for i, b := range bs { + for _, m := range b.models { + k := strings.ToLower(m) + j, ok := best[k] + if !ok || bs[j].spent && !b.spent || bs[j].spent == b.spent && ws[i].Used < ws[j].Used { + best[k] = i + } + } + } + for i, b := range bs { + if len(b.models) == 0 { + continue + } + var mine []string + for _, m := range b.models { + if best[strings.ToLower(m)] == i { + mine = append(mine, m) + } + } + if len(mine) == 0 { + ws[i].Aside = true + continue + } + ws[i].matches = func(model string) bool { + for _, m := range mine { + if strings.EqualFold(m, model) { + return true + } + } + return false + } + } +} diff --git a/internal/provider/zcode_start_test.go b/internal/provider/zcode_start_test.go index feb34b1b..6a741d98 100644 --- a/internal/provider/zcode_start_test.go +++ b/internal/provider/zcode_start_test.go @@ -303,3 +303,44 @@ func TestZCodeStartHTTP1(t *testing.T) { t.Fatalf("sent:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) } } + +// ganlerk (Discord): an account with ZCode Trust Build and the Start Plan, +// both giving GLM-5.3-Flash, the Start Plan's all used and Trust Build's +// 14.5%. The server spends whichever has some, so GLM-5.3-Flash counts on +// Trust Build's bucket and the spent one is set aside: neither the model +// nor the account reads as used up. +func TestZCodeStartSpentSiblingAside(t *testing.T) { + signIn(t) + jwt := zcodeTestJWT(time.Now().Add(24 * time.Hour)) + u := newZCodeStartUpstream(t, jwt) + now := time.Now() + u.balance = map[string]any{ + "server_time": now.Unix(), + "plans": []any{ + map[string]any{"plan_id": "zai-start-plan", "user_plan_id": "s1", "name": "ZCode Start Plan", "status": "active", "ends_at": now.Add(7 * 24 * time.Hour).Unix()}, + map[string]any{"plan_id": "zai-trust-build", "user_plan_id": "t1", "name": "ZCode Trust Build", "status": "active", "ends_at": now.Add(24 * time.Hour).Unix()}, + }, + "balances": []any{ + map[string]any{"plan_id": "zai-start-plan", "user_plan_id": "s1", "show_name": "GLM-5.3", "capabilities": []any{"model:GLM-5.3"}, + "total_units": 3000000, "used_units": 0, "remaining_units": 3000000}, + map[string]any{"plan_id": "zai-start-plan", "user_plan_id": "s1", "show_name": "GLM-5.3-Flash", "capabilities": []any{"model:GLM-5.3-Flash"}, + "total_units": 5000000, "used_units": 5000000, "remaining_units": 0}, + map[string]any{"plan_id": "zai-trust-build", "user_plan_id": "t1", "show_name": "GLM-5.3-Flash", "capabilities": []any{"model:GLM-5.3-Flash"}, + "total_units": 100000000, "used_units": 14501196, "remaining_units": 85498804, "expires_at": now.Add(time.Hour).Unix()}, + }, + } + q := zcodeStartQuota(context.Background(), Login{User: "u@example.com"}, jwt) + if q.Error != "" || len(q.Windows) != 3 { + t.Fatalf("usage: %+v", q) + } + if w := q.Windows[1]; w.Used != 100 || !w.Aside { + t.Fatalf("the spent Start Plan bucket isn't set aside: %+v", w) + } + a := allowanceOf(q.Windows, now) + if used, _ := a.For("glm-5.3-flash", now); used < 14 || used > 15 { + t.Fatalf("GLM-5.3-Flash counts %v%% used, not Trust Build's 14.5%%", used) + } + if used, _ := a.For("glm-5.3", now); used != 0 { + t.Fatalf("GLM-5.3: %v", used) + } +}