Merge pull request #5658 from william-drakemond/fix/opencode-go-usage-limit-reset

fix(openai): honor OpenCode Go usage reset durations
This commit is contained in:
Wesley Liddick
2026-08-25 11:05:15 +08:00
committed by GitHub
3 changed files with 144 additions and 3 deletions
@@ -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) {
+74 -3
View File
@@ -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
@@ -1629,7 +1633,7 @@ func (s *RateLimitService) persistOpenAICodexSnapshot(ctx context.Context, accou
notifyOpenAIAutoReset(account.ID)
}
// parseOpenAIRateLimitResetTime 解析 OpenAI 格式的 429 响应,返回重置时间的 Unix 时间戳
// parseOpenAIRateLimitResetTime 解析 OpenAI 兼容格式的 429 响应,返回重置时间的 Unix 时间戳
// OpenAI 的 usage_limit_reached 错误格式:
//
// {
@@ -1651,9 +1655,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
}
@@ -1680,9 +1684,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 {
@@ -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{}