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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user