From a6b11ccced401129c69aa5455996e365db6e4096 Mon Sep 17 00:00:00 2001 From: William Drake Date: Fri, 14 Aug 2026 20:01:27 -0400 Subject: [PATCH] fix(openai): honor OpenCode Go usage reset durations Recognize GoUsageLimitError responses and parse both single and composite reset durations from their human-readable messages. Keep API-key accounts rate-limited until the parsed reset instead of falling back to the short default cooldown. --- ...nai_account_runtime_block_fastpath_test.go | 27 +++++++ backend/internal/service/ratelimit_service.go | 77 ++++++++++++++++++- .../service/ratelimit_service_openai_test.go | 43 +++++++++++ 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/openai_account_runtime_block_fastpath_test.go b/backend/internal/service/openai_account_runtime_block_fastpath_test.go index db620cc5b8..fcda6d6a05 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath_test.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath_test.go @@ -114,6 +114,33 @@ func TestOpenAI429RetryDelayHonorsBoundedRetryAfter(t *testing.T) { require.Equal(t, openAIOAuth429MaxRetryDelay, openAIOAuth429SameAccountRetryDelay(http.Header{"Retry-After": []string{"90"}}, deadline)) } +func TestOpenAI429FastPath_OpenCodeGoUsageLimitUsesMessageResetDuration(t *testing.T) { + repo := &rateLimit429AccountRepoStub{} + rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + svc := &OpenAIGatewayService{rateLimitService: rateLimitService} + rateLimitService.SetAccountRuntimeBlocker(svc) + account := &Account{ID: 44, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + body := []byte(`{"type":"error","error":{"type":"GoUsageLimitError","message":"5-hour usage limit reached. Resets in 4hr 59min. To continue using this model now, enable usage from your available balance: https://opencode.ai/workspace/wrk_test/go"},"metadata":{"workspace":"wrk_test","limitName":"5 hour"}}`) + + before := time.Now() + shouldDisable := svc.handleOpenAIAccountUpstreamError( + context.Background(), + account, + http.StatusTooManyRequests, + http.Header{}, + body, + ) + after := time.Now() + + require.False(t, shouldDisable) + require.Equal(t, 1, repo.rateLimitCalls) + require.Equal(t, account.ID, repo.lastRateLimitID) + expectedResetAfter := 4*time.Hour + 59*time.Minute + require.False(t, repo.lastRateLimitReset.Before(before.Add(expectedResetAfter-time.Second))) + require.False(t, repo.lastRateLimitReset.After(after.Add(expectedResetAfter))) + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + // TestOpenAI429FastPath_SkipsSparkShadow 外审第8轮 P1:spark 影子被选中后若 /responses 返回 429, // 不得按 global x-codex-* 信号写内存运行时熔断(否则 spark 被冷却到 global reset、单影子场景无可用账号)。 func TestOpenAI429FastPath_SkipsSparkShadow(t *testing.T) { diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 2c42b810b6..0823c85e4e 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -79,6 +79,10 @@ const ( var openAIImageTryAgainPattern = regexp.MustCompile(`(?i)try again in\s+([0-9]+(?:\.[0-9]+)?)\s*(ms|s|sec|secs|second|seconds|m|min|mins|minute|minutes)`) +var openCodeGoUsageLimitResetPattern = regexp.MustCompile(`(?i)\bresets\s+in\s+`) + +var openCodeGoUsageLimitDurationPartPattern = regexp.MustCompile(`(?i)^([0-9]+(?:\.[0-9]+)?)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks)\b`) + const ( openAI403CooldownMinutesDefault = 10 openAI403DisableThreshold = 3 @@ -1623,7 +1627,7 @@ func (s *RateLimitService) persistOpenAICodexSnapshot(ctx context.Context, accou } } -// parseOpenAIRateLimitResetTime 解析 OpenAI 格式的 429 响应,返回重置时间的 Unix 时间戳 +// parseOpenAIRateLimitResetTime 解析 OpenAI 兼容格式的 429 响应,返回重置时间的 Unix 时间戳 // OpenAI 的 usage_limit_reached 错误格式: // // { @@ -1645,9 +1649,9 @@ func parseOpenAIRateLimitResetTime(body []byte) *int64 { return nil } - // 检查是否为 usage_limit_reached 或 rate_limit_exceeded 类型 + // 检查是否为已知的账号用量限制类型。 errType, _ := errObj["type"].(string) - if errType != "usage_limit_reached" && errType != "rate_limit_exceeded" { + if errType != "usage_limit_reached" && errType != "rate_limit_exceeded" && errType != "GoUsageLimitError" { return nil } @@ -1674,9 +1678,76 @@ func parseOpenAIRateLimitResetTime(body []byte) *int64 { } } + // OpenCode Go subscriptions expose the reset only in a human-readable message, + // for example: "Weekly usage limit reached. Resets in 2 days." + if errType == "GoUsageLimitError" { + message, _ := errObj["message"].(string) + if resetAfter := parseOpenCodeGoUsageLimitResetDuration(message); resetAfter > 0 { + ts := time.Now().Add(resetAfter).Unix() + return &ts + } + } + return nil } +func parseOpenCodeGoUsageLimitResetDuration(message string) time.Duration { + resetPrefix := openCodeGoUsageLimitResetPattern.FindStringIndex(message) + if resetPrefix == nil { + return 0 + } + + remainder := message[resetPrefix[1]:] + var total time.Duration + for { + remainder = strings.TrimSpace(remainder) + matches := openCodeGoUsageLimitDurationPartPattern.FindStringSubmatchIndex(remainder) + if matches == nil { + break + } + + value, err := strconv.ParseFloat(remainder[matches[2]:matches[3]], 64) + if err != nil || value <= 0 { + return 0 + } + + unit := openCodeGoUsageLimitDurationUnit(remainder[matches[4]:matches[5]]) + if unit <= 0 { + return 0 + } + + const maxDuration = time.Duration(1<<63 - 1) + if value >= float64(maxDuration)/float64(unit) { + return 0 + } + part := time.Duration(value * float64(unit)) + if part <= 0 || total > maxDuration-part { + return 0 + } + total += part + remainder = remainder[matches[1]:] + } + + return total +} + +func openCodeGoUsageLimitDurationUnit(raw string) time.Duration { + switch strings.ToLower(raw) { + case "s", "sec", "secs", "second", "seconds": + return time.Second + case "m", "min", "mins", "minute", "minutes": + return time.Minute + case "h", "hr", "hrs", "hour", "hours": + return time.Hour + case "d", "day", "days": + return 24 * time.Hour + case "w", "week", "weeks": + return 7 * 24 * time.Hour + default: + return 0 + } +} + func parseOpenAIRateLimitPlanType(body []byte) string { var parsed map[string]any if err := json.Unmarshal(body, &parsed); err != nil { diff --git a/backend/internal/service/ratelimit_service_openai_test.go b/backend/internal/service/ratelimit_service_openai_test.go index 4b619e15b2..8969f7e622 100644 --- a/backend/internal/service/ratelimit_service_openai_test.go +++ b/backend/internal/service/ratelimit_service_openai_test.go @@ -117,6 +117,49 @@ func TestCalculateOpenAI429ResetTime_NoCodexHeaders(t *testing.T) { } } +func TestParseOpenAIRateLimitResetTime_OpenCodeGoUsageLimit(t *testing.T) { + tests := []struct { + name string + body string + want time.Duration + }{ + { + name: "days", + body: `{"type":"error","error":{"type":"GoUsageLimitError","message":"Weekly usage limit reached. Resets in 2 days."}}`, + want: 48 * time.Hour, + }, + { + name: "hours", + body: `{"type":"error","error":{"type":"GoUsageLimitError","message":"Weekly usage limit reached. Resets in 18 hours."}}`, + want: 18 * time.Hour, + }, + { + name: "hours and minutes", + body: `{"type":"error","error":{"type":"GoUsageLimitError","message":"5-hour usage limit reached. Resets in 4hr 59min."}}`, + want: 4*time.Hour + 59*time.Minute, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + before := time.Now() + resetAt := parseOpenAIRateLimitResetTime([]byte(tt.body)) + after := time.Now() + + require.NotNil(t, resetAt) + actual := time.Unix(*resetAt, 0) + require.False(t, actual.Before(before.Add(tt.want).Truncate(time.Second))) + require.False(t, actual.After(after.Add(tt.want))) + }) + } +} + +func TestParseOpenAIRateLimitResetTime_DoesNotParseUnknownErrorMessage(t *testing.T) { + body := []byte(`{"error":{"type":"rate_limit_error","message":"Resets in 2 days."}}`) + + require.Nil(t, parseOpenAIRateLimitResetTime(body)) +} + func TestCalculateOpenAI429ResetTime_ReversedWindowOrder(t *testing.T) { svc := &RateLimitService{}