mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 15:38:22 +08:00
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.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user