diff --git a/server/pkg/taskfailure/classify.go b/server/pkg/taskfailure/classify.go index d0973d915..70a4221d5 100644 --- a/server/pkg/taskfailure/classify.go +++ b/server/pkg/taskfailure/classify.go @@ -43,6 +43,14 @@ var ( // member-facing recovery guidance describe the actual failure. const concurrentRequestLimitWitness = "concurrent request limit" +var httpForbiddenCodeRe = regexp.MustCompile(`(^|[^0-9])403([^0-9]|$)`) + +// isUsageLimit403 is shared by Classify and NormalizeDaemonReason so new and +// old daemons land on the same reason. +func isUsageLimit403(lower string) bool { + return httpForbiddenCodeRe.MatchString(lower) && strings.Contains(lower, "usage limit") +} + // Classify maps a free-form error string from the agent runtime / CLI // to one of the 14 agent_error.* sub-reasons. Always returns a valid // Reason; falls back to ReasonAgentUnknown when no rule matches and for @@ -85,6 +93,13 @@ func Classify(rawError string) Reason { case strings.Contains(lower, concurrentRequestLimitWitness): return ReasonAgentProviderCapacityOrRateLimit + // Some providers (e.g. Kimi Code) report an exhausted usage window as HTTP + // 403, and Claude Code may prefix it with an access-token failure. It must + // beat both the token-window rule (the text has "token" and "limit") and + // the bare 403 auth rule, or valid credentials get blamed. + case isUsageLimit403(lower): + return ReasonAgentProviderQuotaLimit + // 1. Context / token window overflow. Checked early so "token // limit" doesn't get swallowed by the broader "limit" / "quota" // rule below. @@ -495,6 +510,16 @@ var legacyConcurrentRequestLimitReasons = map[string]bool{ "agent_error": true, } +// legacyUsageLimit403Reasons are the stale buckets an older daemon lands a +// 403 usage-limit rejection in: the bare 403 auth rule, the token-window rule +// when an access-token prefix is present, or the catchalls. +var legacyUsageLimit403Reasons = map[string]bool{ + string(ReasonAgentContextOverflow): true, + string(ReasonAgentProviderAuthOrAccess): true, + string(ReasonAgentUnknown): true, + "agent_error": true, +} + // NormalizeDaemonReason upgrades a failure_reason reported by an older daemon // onto the taxonomy this server understands, using the raw error text as the // witness. It returns the reason unchanged when nothing applies. @@ -515,6 +540,9 @@ func NormalizeDaemonReason(reason, rawError string) Reason { strings.Contains(strings.ToLower(rawError), concurrentRequestLimitWitness) { return ReasonAgentProviderCapacityOrRateLimit } + if legacyUsageLimit403Reasons[reason] && isUsageLimit403(strings.ToLower(rawError)) { + return ReasonAgentProviderQuotaLimit + } if legacySkillBundleReasons[reason] && strings.HasPrefix(strings.TrimSpace(rawError), legacySkillBundlePrefix) { return ReasonSkillBundleUnavailable diff --git a/server/pkg/taskfailure/classify_test.go b/server/pkg/taskfailure/classify_test.go index 32d900df6..3aabb7f9f 100644 --- a/server/pkg/taskfailure/classify_test.go +++ b/server/pkg/taskfailure/classify_test.go @@ -228,6 +228,14 @@ func TestClassifyOrderingPriorities(t *testing.T) { {"403 concurrent request limit beats auth", "Failed to authenticate. API Error: 403 You've reached your concurrent request limit. Please wait for your ongoing requests to finish and try again.", ReasonAgentProviderCapacityOrRateLimit}, {"access token concurrent request limit beats context", "Failed to refresh access token. API Error: 403 You've reached your concurrent request limit.", ReasonAgentProviderCapacityOrRateLimit}, + // Kimi Code reports an exhausted subscription window as HTTP 403. The + // usage-limit witness must beat the bare 403 auth rule, while an + // unrelated 403 stays an auth failure. + {"403 usage limit beats auth", "API Error: 403 You've reached your 5-hour usage limit", ReasonAgentProviderQuotaLimit}, + {"403 usage limit with prefix beats auth", "Failed to authenticate. API Error: 403 {\"error\":{\"message\":\"You've reached your 5-hour usage limit\"}}", ReasonAgentProviderQuotaLimit}, + {"access token 403 usage limit beats context", "Failed to refresh access token. API Error: 403 You've reached your 5-hour usage limit", ReasonAgentProviderQuotaLimit}, + {"plain 403 stays auth", "API Error: 403 Forbidden", ReasonAgentProviderAuthOrAccess}, + // Both "429" and "rate limit" present — should still land in // the capacity bucket, not the quota bucket. {"429 rate limit", "API Error: 429 rate limit reached", ReasonAgentProviderCapacityOrRateLimit}, @@ -271,6 +279,30 @@ func TestNormalizeDaemonReasonUpgradesConcurrentRequestLimit(t *testing.T) { } } +func TestNormalizeDaemonReasonUpgradesUsageLimit403(t *testing.T) { + t.Parallel() + + for _, raw := range []string{ + "API Error: 403 You've reached your 5-hour usage limit", + "Failed to refresh access token. API Error: 403 You've reached your 5-hour usage limit", + } { + for _, reason := range []string{ + string(ReasonAgentContextOverflow), + string(ReasonAgentProviderAuthOrAccess), + string(ReasonAgentUnknown), + "agent_error", + } { + if got := NormalizeDaemonReason(reason, raw); got != ReasonAgentProviderQuotaLimit { + t.Errorf("NormalizeDaemonReason(%q, %q) = %q, want %q", reason, raw, got, ReasonAgentProviderQuotaLimit) + } + } + } + + if got := NormalizeDaemonReason(string(ReasonAgentProviderAuthOrAccess), "API Error: 403 Forbidden"); got != ReasonAgentProviderAuthOrAccess { + t.Errorf("plain 403 auth rejection changed to %q", got) + } +} + // TestClassify5xxRegex pins the boundary behavior of the 5xx HTTP // status detector. The SQL classifier uses an anchored regex // `(^|[^0-9])5[0-9][0-9]([^0-9]|$)`; this Go classifier mirrors it via