From 66d110b50960115e4c0308f6277f3e4640cab2a5 Mon Sep 17 00:00:00 2001 From: Apfelbaum <110834187+iamapfelbaum@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:11:22 +0000 Subject: [PATCH] fix(routing): classify OpenCode period usage limits so dynamic routing can advance (#3320) Co-authored-by: Artem Shtanov Co-authored-by: Claude Fable 5.1 Co-authored-by: Carlos Florencio --- .../agent/runtime/routingerr/classify_test.go | 27 ++++++++++ .../agent/runtime/routingerr/rules.go | 11 +++- .../adapter/transport/acp/opencode_stderr.go | 13 ++--- .../transport/acp/opencode_stderr_test.go | 26 +++++++++ .../orchestrator/event_handlers_transient.go | 9 +++- .../event_handlers_transient_opencode_test.go | 53 +++++++++++++++++++ 6 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 apps/backend/internal/orchestrator/event_handlers_transient_opencode_test.go diff --git a/apps/backend/internal/agent/runtime/routingerr/classify_test.go b/apps/backend/internal/agent/runtime/routingerr/classify_test.go index b6c348458a..6e2a229352 100644 --- a/apps/backend/internal/agent/runtime/routingerr/classify_test.go +++ b/apps/backend/internal/agent/runtime/routingerr/classify_test.go @@ -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") diff --git a/apps/backend/internal/agent/runtime/routingerr/rules.go b/apps/backend/internal/agent/runtime/routingerr/rules.go index 2a450cfaca..ce5f3dd69a 100644 --- a/apps/backend/internal/agent/runtime/routingerr/rules.go +++ b/apps/backend/internal/agent/runtime/routingerr/rules.go @@ -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 == "" { diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr.go index 7e722c2fb2..5d0c3df9f7 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr.go @@ -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 } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr_test.go index 42a6fd25db..34f419ef3d 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr_test.go @@ -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"` diff --git a/apps/backend/internal/orchestrator/event_handlers_transient.go b/apps/backend/internal/orchestrator/event_handlers_transient.go index 3817ca1ca5..c760287479 100644 --- a/apps/backend/internal/orchestrator/event_handlers_transient.go +++ b/apps/backend/internal/orchestrator/event_handlers_transient.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 diff --git a/apps/backend/internal/orchestrator/event_handlers_transient_opencode_test.go b/apps/backend/internal/orchestrator/event_handlers_transient_opencode_test.go new file mode 100644 index 0000000000..51fd0294c8 --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_transient_opencode_test.go @@ -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) + } +}