Skip to content

Commit 39bb212

Browse files
committed
Don't mark Claude accounts cooked when only a per-model pool is out
A per-model quota pool (e.g. Fable) hitting 0% was marking the whole Claude account "cooked, cannot switch" in sr status, even though Opus/Sonnet and the base weekly quota were still available. The scheduler already scores per-model pools separately (Feature-tagged windows are excluded from the base score), so this was purely a misleading display. cookedFromWindows and the long-quota saturation helpers now skip Feature-tagged (model-scoped) windows, so the account stays usable in the display. The Use column instead appends a note like "(Fable out)" so it's clear which model's pool is exhausted while the account still works for the others.
1 parent 72f2272 commit 39bb212

2 files changed

Lines changed: 64 additions & 7 deletions

File tree

cmd/subrouter/sr.go

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,9 +1130,17 @@ func scoreFromWindows(accountID string, windows []accounts.UsageWindow) selectac
11301130
return selectacct.ScoreFromLimitWindows(accountID, 0, limitWindows)
11311131
}
11321132

1133+
// isModelScopedWindow reports whether a window is a per-model quota pool (e.g.
1134+
// Fable), identified by a non-empty Feature. Such a pool being exhausted must
1135+
// not cook the whole account: the scheduler already scores it as its own pool,
1136+
// and the account stays usable for other models (Opus/Sonnet).
1137+
func isModelScopedWindow(window accounts.UsageWindow) bool {
1138+
return strings.TrimSpace(window.Feature) != ""
1139+
}
1140+
11331141
func cookedFromWindows(windows []accounts.UsageWindow) (bool, string) {
11341142
for _, window := range windows {
1135-
if !isLongQuotaWindow(window) || clampUsagePercent(window.UsedPercent) < 100 {
1143+
if isModelScopedWindow(window) || !isLongQuotaWindow(window) || clampUsagePercent(window.UsedPercent) < 100 {
11361144
continue
11371145
}
11381146
if window.ResetAfterSeconds > 0 {
@@ -1948,16 +1956,56 @@ func compactPickReason(row srUsageRow) string {
19481956
return "Claude profile"
19491957
}
19501958
left := fmt.Sprintf("%d%% left", int(row.score.Headroom*100+0.5))
1959+
suffix := exhaustedModelSuffix(row.windows)
19511960
if !usableForNewSession(row.score) {
1952-
return fmt.Sprintf("%s, protected < %d%%", left, int(selectacct.MinNewSessionHeadroom*100))
1961+
return fmt.Sprintf("%s, protected < %d%%%s", left, int(selectacct.MinNewSessionHeadroom*100), suffix)
19531962
}
19541963
if row.score.ShortResetAfterSeconds > 0 {
19551964
if usageProvider(row) == accounts.ProviderClaude {
1956-
return fmt.Sprintf("%s, session reset %s", left, formatDuration(row.score.ShortResetAfterSeconds))
1965+
return fmt.Sprintf("%s, session reset %s%s", left, formatDuration(row.score.ShortResetAfterSeconds), suffix)
19571966
}
1958-
return fmt.Sprintf("%s, 5h reset %s", left, formatDuration(row.score.ShortResetAfterSeconds))
1967+
return fmt.Sprintf("%s, 5h reset %s%s", left, formatDuration(row.score.ShortResetAfterSeconds), suffix)
1968+
}
1969+
return left + suffix
1970+
}
1971+
1972+
// exhaustedModelSuffix names any per-model quota pools that are fully consumed
1973+
// (e.g. Fable) so the Use column makes clear the account still works for other
1974+
// models even when one model's weekly pool is out.
1975+
func exhaustedModelSuffix(windows []accounts.UsageWindow) string {
1976+
var out []string
1977+
seen := map[string]bool{}
1978+
for _, window := range windows {
1979+
if !isModelScopedWindow(window) || clampUsagePercent(window.UsedPercent) < 100 {
1980+
continue
1981+
}
1982+
label := modelScopedWindowLabel(window)
1983+
if label == "" || seen[label] {
1984+
continue
1985+
}
1986+
seen[label] = true
1987+
out = append(out, label)
1988+
}
1989+
if len(out) == 0 {
1990+
return ""
1991+
}
1992+
return " (" + strings.Join(out, "/") + " out)"
1993+
}
1994+
1995+
func modelScopedWindowLabel(window accounts.UsageWindow) string {
1996+
f := strings.ToLower(window.Feature)
1997+
switch {
1998+
case strings.Contains(f, "fable"):
1999+
return "Fable"
2000+
case strings.Contains(f, "opus"):
2001+
return "Opus"
2002+
case strings.Contains(f, "sonnet"):
2003+
return "Sonnet"
2004+
case strings.Contains(f, "haiku"):
2005+
return "Haiku"
2006+
default:
2007+
return window.Feature
19592008
}
1960-
return left
19612009
}
19622010

19632011
func usageGridShortWindowCell(row srUsageRow) usageGridCell {
@@ -1978,6 +2026,9 @@ func usageGridShortNamedWindowCell(row srUsageRow) usageGridCell {
19782026

19792027
func longQuotaSaturatedMatching(windows []accounts.UsageWindow, match func(accounts.UsageWindow) bool) bool {
19802028
for _, window := range windows {
2029+
if isModelScopedWindow(window) {
2030+
continue
2031+
}
19812032
if match(window) && isLongQuotaWindow(window) && clampUsagePercent(window.UsedPercent) >= 100 {
19822033
return true
19832034
}

cmd/subrouter/sr_test.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,9 +1145,15 @@ func TestClaudeUsageWindowsIncludeOAuthAppsWeekly(t *testing.T) {
11451145
if fableScore.Headroom != 0 {
11461146
t.Fatalf("Fable headroom = %.2f, want 0 from saturated oauth app weekly bucket", fableScore.Headroom)
11471147
}
1148+
// A saturated Fable (per-model) pool must NOT cook the account: Opus/Sonnet
1149+
// and the base weekly quota remain usable. The Use column instead notes that
1150+
// Fable specifically is out.
11481151
cooked, reason := cookedFromWindows(windows)
1149-
if !cooked || !strings.Contains(reason, "Fable") {
1150-
t.Fatalf("cooked=%v reason=%q, want Fable weekly cooked", cooked, reason)
1152+
if cooked {
1153+
t.Fatalf("account should not be cooked when only the Fable pool is exhausted (reason=%q)", reason)
1154+
}
1155+
if suffix := exhaustedModelSuffix(windows); !strings.Contains(suffix, "Fable") {
1156+
t.Fatalf("Use suffix = %q, want it to note Fable is out", suffix)
11511157
}
11521158
}
11531159

0 commit comments

Comments
 (0)