fix(routing): classify OpenCode period usage limits so dynamic routing can advance (#3320)

Co-authored-by: Artem Shtanov <artemshtanov@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Apfelbaum
2026-09-04 00:11:22 +00:00
committed by GitHub
parent e8956840e0
commit 66d110b509
6 changed files with 130 additions and 9 deletions
@@ -194,6 +194,33 @@ func TestClassify_OpenCodeUsageLimitIsHighConfidenceQuota(t *testing.T) {
}
}
func TestClassify_OpenCodePeriodUsageLimitsAreHighConfidenceQuota(t *testing.T) {
resetInjection()
for _, stderr := range []string{
"AI_APICallError: Weekly usage limit reached. Resets in 3 days. To continue using this model now, enable usage from your available balance",
"AI_APICallError: Daily usage limit reached. Resets in 4hr 19min.",
"AI_APICallError: monthly usage limit reached.",
} {
e := Classify(Input{
Phase: PhaseStreaming,
ProviderID: "opencode-acp",
Stderr: stderr,
})
if e.Code != CodeQuotaLimited || e.Confidence != ConfHigh {
t.Fatalf("classification of %q = %+v, want high-confidence quota_limited", stderr, e)
}
}
}
func TestHasProviderRules(t *testing.T) {
if !HasProviderRules("opencode-acp") {
t.Fatal("opencode-acp should have provider rules")
}
if HasProviderRules("opencode-go") {
t.Fatal("model-provider ID opencode-go must not resolve to provider rules")
}
}
func TestClassify_InjectionShortCircuit(t *testing.T) {
resetInjection()
t.Setenv("KANDEV_PROVIDER_FAILURES", "claude-acp:quota_limited,codex-acp:auth_required")
@@ -25,7 +25,7 @@ var providerRules = map[string][]rule{
mustRule("codex.stderr.model.v1", `(?i)model_not_found`, CodeModelUnavailable, ConfHigh),
},
"opencode-acp": {
mustRule("opencode.stderr.usage_limit.v1", `(?i)\b\d+[- ]hour(?:s)?\s+usage\s+limit\s+reached\b`, CodeQuotaLimited, ConfHigh),
mustRule("opencode.stderr.usage_limit.v1", `(?i)\b(?:\d+[- ]hour(?:s)?|daily|weekly|monthly)\s+usage\s+limit\s+reached\b`, CodeQuotaLimited, ConfHigh),
mustRule("opencode.stderr.quota.v1", `(?i)quota`, CodeQuotaLimited, ConfMedium),
mustRule("opencode.stderr.rate.v1", `(?i)rate.?limit`, CodeRateLimited, ConfHigh),
mustRule("opencode.stderr.auth.v1", `(?i)unauthorized|invalid token`, CodeAuthRequired, ConfHigh),
@@ -46,6 +46,15 @@ func mustRule(id, pat string, code Code, conf Confidence) rule {
return rule{id: id, pattern: regexp.MustCompile(pat), code: code, confidence: conf}
}
// HasProviderRules reports whether providerID is a rules catalogue key (an
// agent ID such as "opencode-acp"). Diagnostics can carry a different
// provider identity, e.g. OpenCode's model-provider ID "opencode-go", which
// must not be used for rule lookup.
func HasProviderRules(providerID string) bool {
_, ok := providerRules[providerID]
return ok
}
func matchProviderRules(providerID, text string) (*Error, bool) {
rules, ok := providerRules[providerID]
if !ok || text == "" {
@@ -20,7 +20,7 @@ const (
var (
openCodeIdentifierPattern = regexp.MustCompile(`\b(?:wrk|ses|run)_[A-Za-z0-9_-]+\b`)
openCodeURLPattern = regexp.MustCompile(`https?://[^\s]+`)
openCodeResetPattern = regexp.MustCompile(`(?i)\bresets?\s+in\s+(?:(\d+)\s*(?:hours?|hrs?|h))?\s*(?:(\d+)\s*(?:minutes?|mins?|m|min))?`)
openCodeResetPattern = regexp.MustCompile(`(?i)\bresets?\s+in\s+(?:(\d+)\s*(?:days?|d)\b)?\s*(?:(\d+)\s*(?:hours?|hrs?|h)\b)?\s*(?:(\d+)\s*(?:minutes?|mins?|m|min)\b)?`)
)
type openCodeStderrDiagnostic struct {
@@ -267,14 +267,15 @@ func safeOpenCodeField(value string) string {
func openCodeResetAt(message string, occurredAt time.Time) *time.Time {
matches := openCodeResetPattern.FindStringSubmatch(message)
if len(matches) != 3 || (matches[1] == "" && matches[2] == "") {
if len(matches) != 4 || (matches[1] == "" && matches[2] == "" && matches[3] == "") {
return nil
}
hours, _ := strconv.Atoi(matches[1])
minutes, _ := strconv.Atoi(matches[2])
if hours == 0 && minutes == 0 {
days, _ := strconv.Atoi(matches[1])
hours, _ := strconv.Atoi(matches[2])
minutes, _ := strconv.Atoi(matches[3])
if days == 0 && hours == 0 && minutes == 0 {
return nil
}
resetAt := occurredAt.Add(time.Duration(hours)*time.Hour + time.Duration(minutes)*time.Minute)
resetAt := occurredAt.Add(time.Duration(days)*24*time.Hour + time.Duration(hours)*time.Hour + time.Duration(minutes)*time.Minute)
return &resetAt
}
@@ -139,6 +139,32 @@ func TestProviderErrorFromACPRequestErrorReadsOnlyStructuredActionURL(t *testing
})
}
func TestParseOpenCodeStderrLineWeeklyLimitResetInDays(t *testing.T) {
line := `timestamp=2026-09-03T07:19:45.000Z level=ERROR run=4120ce87 message="stream error" providerID=opencode-go modelID=deepseek-v4-flash session.id=ses_f99491a97ffeeWlCMErV9o00vr small=false agent=build mode=primary error.error="AI_APICallError: Weekly usage limit reached. Resets in 3 days. To continue using this model now, enable usage from your available balance: https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go"`
diagnostic, ok := parseOpenCodeStderrLine(line)
if !ok {
t.Fatal("parseOpenCodeStderrLine() rejected a weekly usage-limit stream error")
}
wantOccurred := time.Date(2026, 9, 3, 7, 19, 45, 0, time.UTC)
wantReset := wantOccurred.Add(3 * 24 * time.Hour)
if diagnostic.ProviderError.ResetAt == nil || !diagnostic.ProviderError.ResetAt.Equal(wantReset) {
t.Fatalf("reset at = %v, want %s", diagnostic.ProviderError.ResetAt, wantReset)
}
t.Run("mixed units", func(t *testing.T) {
line := `timestamp=2026-09-03T07:19:45.000Z level=ERROR run=4120ce87 message="stream error" providerID=opencode-go modelID=deepseek-v4-flash session.id=ses_f99491a97ffeeWlCMErV9o00vr small=false agent=build mode=primary error.error="AI_APICallError: Weekly usage limit reached. Resets in 3 days 4 hours 19 min."`
diagnostic, ok := parseOpenCodeStderrLine(line)
if !ok {
t.Fatal("parseOpenCodeStderrLine() rejected a mixed-unit weekly usage-limit stream error")
}
wantReset := wantOccurred.Add(3*24*time.Hour + 4*time.Hour + 19*time.Minute)
if diagnostic.ProviderError.ResetAt == nil || !diagnostic.ProviderError.ResetAt.Equal(wantReset) {
t.Fatalf("reset at = %v, want %s", diagnostic.ProviderError.ResetAt, wantReset)
}
})
}
func TestParseOpenCodeStderrLineAcceptsForegroundStreamError(t *testing.T) {
line := `timestamp=2026-08-02T15:15:44.504Z level=ERROR run=a93db686 message="stream error" providerID=opencode-go modelID=kimi-k3 session.id=ses_03d1ac324ffeA2eAGeBfuq80dq small=false agent=build mode=primary error.error="AI_APICallError: 5-hour usage limit reached. Resets in 4hr 19min. To continue using this model now, enable usage from your available balance: https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go"`
@@ -395,8 +395,13 @@ func classifyKanbanFailure(data watcher.AgentEventData) *routingerr.Error {
message := data.ErrorMessage
var resetHint *time.Time
if providerError := data.ProviderError; providerError != nil {
if providerError.ProviderID != "" {
providerID = providerError.ProviderID
// Provider rules are keyed by agent ID. OpenCode diagnostics carry the
// model-provider ID instead ("opencode-go"), which has no rules; keeping
// the agent ID there lets the OpenCode usage-limit rule classify the
// failure so dynamic routing can advance to the next candidate.
if id := providerError.ProviderID; id != "" &&
!routingerr.HasProviderRules(providerID) && routingerr.HasProviderRules(id) {
providerID = id
}
if providerError.Message != "" {
message = providerError.Message
@@ -0,0 +1,53 @@
package orchestrator
import (
"testing"
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/orchestrator/watcher"
)
// OpenCode stderr diagnostics carry the model-provider ID ("opencode-go"),
// not the agent ID the provider rules are keyed by. The Kanban classifier
// must keep the agent ID so a usage-limit error is still quota_limited and
// dynamic routing can advance past the exhausted candidate.
func TestClassifyKanbanFailure_OpenCodeModelProviderIDKeepsAgentRules(t *testing.T) {
classified := classifyKanbanFailure(watcher.AgentEventData{
AgentID: "opencode-acp",
ErrorMessage: "AI_APICallError: Weekly usage limit reached. Resets in 3 days.",
ProviderError: &streams.ProviderError{
Source: streams.ProviderErrorSourceOpenCodeStderr,
ProviderID: "opencode-go",
ModelID: "deepseek-v4-flash",
Message: "AI_APICallError: Weekly usage limit reached. Resets in 3 days.",
},
})
if classified.Code != routingerr.CodeQuotaLimited || classified.Confidence != routingerr.ConfHigh {
t.Fatalf("classification = %+v, want high-confidence quota_limited", classified)
}
if !classified.FallbackAllowed {
t.Fatalf("quota_limited must allow dynamic fallback: %+v", classified)
}
}
func TestClassifyKanbanFailure_OpenCodeModelProviderCollisionKeepsAgentRules(t *testing.T) {
classified := classifyKanbanFailure(watcher.AgentEventData{
AgentID: "opencode-acp",
ErrorMessage: "AI_APICallError: Weekly usage limit reached. Resets in 3 days.",
ProviderError: &streams.ProviderError{
Source: streams.ProviderErrorSourceOpenCodeStderr,
ProviderID: "claude-acp",
Message: "AI_APICallError: Weekly usage limit reached. Resets in 3 days.",
},
})
if classified == nil {
t.Fatal("classification = nil, want high-confidence quota_limited")
}
if classified.Code != routingerr.CodeQuotaLimited || classified.Confidence != routingerr.ConfHigh {
t.Fatalf("classification = %+v, want high-confidence quota_limited", classified)
}
if !classified.FallbackAllowed {
t.Fatalf("quota_limited must allow dynamic fallback: %+v", classified)
}
}