Skip to content

Commit eb58a36

Browse files
Merge pull request #45 from manaflow-ai/fix/fable-headerless-probe
Show headerless Fable probe quota exhaustion
2 parents 523959d + 439cb2b commit eb58a36

5 files changed

Lines changed: 77 additions & 6 deletions

File tree

cmd/subrouter/sr.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1325,7 +1325,9 @@ func fetchClaudeUsageWindows(ctx context.Context, client *http.Client, accessTok
13251325
if !usageWindowNamed(windows, agentclaude.FableWindowName) {
13261326
fableWindows, probeErr := agentclaude.FetchFableUsageWindows(ctx, client, accessToken)
13271327
if probeErr == nil && len(fableWindows) > 0 {
1328-
windows = mergeUsageWindows(windows, fableWindows)
1328+
if err == nil || fableProbeHasPrimaryWindows(fableWindows) {
1329+
windows = mergeUsageWindows(windows, fableWindows)
1330+
}
13291331
} else if err != nil {
13301332
if probeErr != nil {
13311333
return nil, probeErr
@@ -1339,6 +1341,15 @@ func fetchClaudeUsageWindows(ctx context.Context, client *http.Client, accessTok
13391341
return windows, nil
13401342
}
13411343

1344+
func fableProbeHasPrimaryWindows(windows []accounts.UsageWindow) bool {
1345+
for _, window := range windows {
1346+
if window.Name == "5h" || window.Name == "7d" {
1347+
return true
1348+
}
1349+
}
1350+
return false
1351+
}
1352+
13421353
func mergeUsageWindows(base, extra []accounts.UsageWindow) []accounts.UsageWindow {
13431354
out := append([]accounts.UsageWindow(nil), base...)
13441355
index := make(map[string]int, len(out))

internal/agents/claude/store.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,14 @@ func FetchFableUsageWindows(ctx context.Context, client *http.Client, accessToke
749749
if len(windows) > 0 {
750750
return windows, nil
751751
}
752+
if res.StatusCode == http.StatusTooManyRequests {
753+
return []accounts.UsageWindow{{
754+
Name: FableWindowName,
755+
UsedPercent: 100,
756+
LimitWindowSeconds: int64(7 * 24 * time.Hour / time.Second),
757+
Feature: FableModel,
758+
}}, nil
759+
}
752760
if res.StatusCode == http.StatusUnauthorized {
753761
return nil, fmt.Errorf("fable probe failed: %s", res.Status)
754762
}

internal/proxy/claude_ratelimit_routing_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,41 @@ func TestClaudeFableProbeAddsHiddenOAuthAppsWindow(t *testing.T) {
423423
}
424424
}
425425

426+
func TestClaudeFableProbeTreatsHeaderless429AsFableExhausted(t *testing.T) {
427+
usageBody := `{"five_hour":{"utilization":10.0,"resets_at":"2030-01-01T00:00:00+00:00"},"seven_day":{"utilization":60.0,"resets_at":"2030-01-02T00:00:00+00:00"}}`
428+
client := &http.Client{Transport: proxyRoundTripFunc(func(req *http.Request) (*http.Response, error) {
429+
switch req.URL.Path {
430+
case "/api/oauth/usage":
431+
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(usageBody)), Header: http.Header{}}, nil
432+
case "/v1/messages":
433+
return &http.Response{StatusCode: http.StatusTooManyRequests, Body: io.NopCloser(strings.NewReader(`{"type":"error","error":{"type":"rate_limit_error","message":"Error"}}`)), Header: http.Header{}}, nil
434+
default:
435+
t.Fatalf("unexpected path %s", req.URL.Path)
436+
return nil, nil
437+
}
438+
})}
439+
windows, err := fetchAccountUsageWindowsLive(context.Background(), client, accounts.Account{
440+
ID: "acct",
441+
Provider: accounts.ProviderClaude,
442+
AuthMode: accounts.AuthModeOAuth,
443+
Token: "tok",
444+
})
445+
if err != nil {
446+
t.Fatal(err)
447+
}
448+
score := scoreFromUsageWindows(accounts.ProviderClaude, "acct", windows)
449+
if score.Headroom == 0 {
450+
t.Fatalf("base Headroom = %.3f, hidden Fable bucket should not exhaust non-Fable Claude models", score.Headroom)
451+
}
452+
fableScore, ok := score.ModelScores[selectacct.ModelKey(agentclaude.FableModel)]
453+
if !ok {
454+
t.Fatalf("missing Fable model score: %+v", score.ModelScores)
455+
}
456+
if fableScore.Headroom != 0 {
457+
t.Fatalf("Fable Headroom = %.3f, want 0 from headerless Fable probe 429", fableScore.Headroom)
458+
}
459+
}
460+
426461
// TestClaudeShortWindowDrivesRouting proves the routing calculation now prefers
427462
// the account with more 5h headroom even when both accounts have identical
428463
// account-wide (7d) headroom. Before the fix both scored equal short headroom.

internal/proxy/proxy.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -993,8 +993,10 @@ func (s Server) withRequestTimeExhaustionWindows(statuses []AccountUsageStatus)
993993
}
994994
name := "request-limit"
995995
windowSeconds := int64(7 * 24 * 60 * 60)
996+
feature := ""
996997
if status.Provider == accounts.ProviderClaude {
997-
name = "oauth-apps-weekly"
998+
name = agentclaude.FableWindowName
999+
feature = agentclaude.FableModel
9981000
}
9991001
if usageWindowNamed(status.Windows, name) {
10001002
continue
@@ -1004,6 +1006,7 @@ func (s Server) withRequestTimeExhaustionWindows(statuses []AccountUsageStatus)
10041006
UsedPercent: 100,
10051007
LimitWindowSeconds: windowSeconds,
10061008
ResetAfterSeconds: resetAfter,
1009+
Feature: feature,
10071010
})
10081011
}
10091012
return out
@@ -1374,7 +1377,9 @@ func fetchAccountUsageWindowsLive(ctx context.Context, client *http.Client, acco
13741377
windows := claudeUsageWindows(usage)
13751378
if !usageWindowNamed(windows, agentclaude.FableWindowName) {
13761379
if fableWindows, probeErr := agentclaude.FetchFableUsageWindows(ctx, client, account.Token); probeErr == nil && len(fableWindows) > 0 {
1377-
windows = mergeUsageWindows(windows, fableWindows)
1380+
if err == nil || fableProbeHasPrimaryWindows(fableWindows) {
1381+
windows = mergeUsageWindows(windows, fableWindows)
1382+
}
13781383
} else if err != nil {
13791384
if probeErr != nil {
13801385
return nil, probeErr
@@ -1390,6 +1395,15 @@ func fetchAccountUsageWindowsLive(ctx context.Context, client *http.Client, acco
13901395
return accounts.FetchCodexUsage(ctx, client, account)
13911396
}
13921397

1398+
func fableProbeHasPrimaryWindows(windows []accounts.UsageWindow) bool {
1399+
for _, window := range windows {
1400+
if window.Name == "5h" || window.Name == "7d" {
1401+
return true
1402+
}
1403+
}
1404+
return false
1405+
}
1406+
13931407
func mergeUsageWindows(base, extra []accounts.UsageWindow) []accounts.UsageWindow {
13941408
out := append([]accounts.UsageWindow(nil), base...)
13951409
index := make(map[string]int, len(out))

internal/proxy/proxy_websocket_test.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1096,14 +1096,17 @@ func TestUsageStatusEndpointIncludesClaudeRequestTimeExhaustion(t *testing.T) {
10961096
t.Fatalf("statuses = %+v, want one", statuses)
10971097
}
10981098
for _, window := range statuses[0].Windows {
1099-
if window.Name == "oauth-apps-weekly" {
1099+
if window.Name == agentclaude.FableWindowName {
11001100
if window.UsedPercent != 100 || window.ResetAfterSeconds <= 0 {
1101-
t.Fatalf("oauth-apps-weekly = %+v, want saturated with reset", window)
1101+
t.Fatalf("Fable window = %+v, want saturated with reset", window)
1102+
}
1103+
if window.Feature != agentclaude.FableModel {
1104+
t.Fatalf("Fable Feature = %q, want %q", window.Feature, agentclaude.FableModel)
11021105
}
11031106
return
11041107
}
11051108
}
1106-
t.Fatalf("missing oauth-apps-weekly window: %+v", statuses[0].Windows)
1109+
t.Fatalf("missing Fable window: %+v", statuses[0].Windows)
11071110
}
11081111

11091112
func TestReloadAccountsHotLoadsNewAccountWithoutRestart(t *testing.T) {

0 commit comments

Comments
 (0)