Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
77 changes: 74 additions & 3 deletions backend/internal/service/ratelimit_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1623,7 +1627,7 @@ func (s *RateLimitService) persistOpenAICodexSnapshot(ctx context.Context, accou
}
}

// parseOpenAIRateLimitResetTime 解析 OpenAI 格式的 429 响应,返回重置时间的 Unix 时间戳
// parseOpenAIRateLimitResetTime 解析 OpenAI 兼容格式的 429 响应,返回重置时间的 Unix 时间戳
// OpenAI 的 usage_limit_reached 错误格式:
//
// {
Expand All @@ -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
}

Expand All @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions backend/internal/service/ratelimit_service_openai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}

Expand Down
Loading