Merge pull request #4169 from bestony/fix/openai-oauth-model-capability-cooldown

fix(scheduler): cool down Codex plan-gated models per account
This commit is contained in:
Wesley Liddick
2026-07-13 17:19:59 +08:00
committed by GitHub
4 changed files with 168 additions and 5 deletions
@@ -22,6 +22,30 @@ func isModelNotFoundError(statusCode int, body []byte) bool {
return isUpstreamModelNotFoundError(statusCode, body) || statusCode == http.StatusNotFound
}
// openAICodexPlanGatedModelPhrase matches the deterministic Codex 400 returned
// when a ChatGPT OAuth account's plan cannot serve the requested model, e.g.
// {"detail":"The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account."}
// The phrase is compared against the normalized body (lowercased, "_"/"-"
// folded to spaces), so it also matches the same message embedded in
// error.message-style payloads.
const openAICodexPlanGatedModelPhrase = "model is not supported when using codex"
// isOpenAICodexPlanGatedModelError reports whether the upstream response is the
// deterministic Codex rejection of a plan-gated model on a ChatGPT account.
// Unlike transient failures, retrying the same account cannot succeed until the
// account's plan changes, so callers should treat it like model-not-found and
// cool the (account, model) pair down instead of re-selecting the account.
func isOpenAICodexPlanGatedModelError(statusCode int, body []byte) bool {
if statusCode != http.StatusBadRequest {
return false
}
normalized := normalizeModelNotFoundBody(body)
if normalized == "" {
return false
}
return strings.Contains(normalized, openAICodexPlanGatedModelPhrase)
}
func containsModelNotFoundKeyword(normalizedBody string) bool {
if normalizedBody == "" {
return false
@@ -64,3 +64,51 @@ func TestAntigravityModelNotFoundKeepsBare404Fallback(t *testing.T) {
t.Fatal("antigravity model-not-found helper should keep bare 404 fallback")
}
}
func TestIsOpenAICodexPlanGatedModelError(t *testing.T) {
tests := []struct {
name string
statusCode int
body []byte
want bool
}{
{
name: "400 codex plan gated detail payload",
statusCode: http.StatusBadRequest,
body: []byte(`{"detail":"The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account."}`),
want: true,
},
{
name: "400 codex plan gated error message payload",
statusCode: http.StatusBadRequest,
body: []byte(`{"error":{"message":"The 'gpt-5.4' model is not supported when using Codex with a ChatGPT account."}}`),
want: true,
},
{
name: "400 unrelated invalid request does not match",
statusCode: http.StatusBadRequest,
body: []byte(`{"error":{"message":"Invalid schema for response_format 'agentic_plan'"}}`),
want: false,
},
{
name: "404 with plan gated message does not match",
statusCode: http.StatusNotFound,
body: []byte(`{"detail":"The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account."}`),
want: false,
},
{
name: "400 empty body does not match",
statusCode: http.StatusBadRequest,
body: nil,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isOpenAICodexPlanGatedModelError(tt.statusCode, tt.body); got != tt.want {
t.Fatalf("isOpenAICodexPlanGatedModelError() = %v, want %v", got, tt.want)
}
})
}
}
+22 -5
View File
@@ -2006,9 +2006,19 @@ func parseOpenAIImageTryAgainCooldown(body []byte) time.Duration {
const upstreamModelNotFoundCooldown = 30 * time.Minute
const upstreamModelNotFoundReason = "upstream_404_model_not_found"
const upstreamCodexPlanGatedModelCooldown = 30 * time.Minute
const upstreamCodexPlanGatedModelReason = "upstream_400_codex_plan_gated_model"
const tempUnschedBodyMaxBytes = 64 << 10
const tempUnschedMessageMaxBytes = 2048
// HandleUpstreamModelNotFound marks the requested model as temporarily
// unavailable on the account when the upstream deterministically reports it
// cannot serve that model: a 404 model-not-found, or the Codex 400 rejecting a
// plan-gated model on a ChatGPT OAuth account. Returning true tells the caller
// to fail the current attempt over to another account; the scheduler skips the
// (account, model) pair via IsSchedulableForModelWithContext until the
// cooldown expires, instead of re-selecting an account that can never serve
// the model.
func (s *RateLimitService) HandleUpstreamModelNotFound(ctx context.Context, account *Account, requestedModel string, statusCode int, responseBody []byte) bool {
if s == nil || account == nil || s.accountRepo == nil {
return false
@@ -2016,19 +2026,26 @@ func (s *RateLimitService) HandleUpstreamModelNotFound(ctx context.Context, acco
if !account.ShouldHandleErrorCode(statusCode) {
return false
}
if !isUpstreamModelNotFoundError(statusCode, responseBody) {
var cooldown time.Duration
var reason string
switch {
case isUpstreamModelNotFoundError(statusCode, responseBody):
cooldown, reason = upstreamModelNotFoundCooldown, upstreamModelNotFoundReason
case isOpenAIOAuthAccount(account) && isOpenAICodexPlanGatedModelError(statusCode, responseBody):
cooldown, reason = upstreamCodexPlanGatedModelCooldown, upstreamCodexPlanGatedModelReason
default:
return false
}
modelKey := modelRateLimitKeyForUpstreamModelNotFound(ctx, account, requestedModel)
if modelKey == "" {
return false
}
resetAt := time.Now().Add(upstreamModelNotFoundCooldown)
if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelKey, resetAt, upstreamModelNotFoundReason); err != nil {
slog.Warn("upstream_model_not_found_set_model_rate_limit_failed", "account_id", account.ID, "model", modelKey, "error", err)
resetAt := time.Now().Add(cooldown)
if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelKey, resetAt, reason); err != nil {
slog.Warn("upstream_model_not_found_set_model_rate_limit_failed", "account_id", account.ID, "model", modelKey, "reason", reason, "error", err)
return true
}
slog.Info("upstream_model_not_found_model_rate_limited", "account_id", account.ID, "model", modelKey, "reset_at", resetAt)
slog.Info("upstream_model_not_found_model_rate_limited", "account_id", account.ID, "model", modelKey, "reason", reason, "reset_at", resetAt)
return true
}
@@ -125,3 +125,77 @@ func openAIModelNotFoundTempAccount() *Account {
},
}
}
func TestRateLimitService_HandleUpstreamError_CodexPlanGatedModelUsesModelRateLimit(t *testing.T) {
repo := &modelNotFoundAccountRepoStub{}
svc := &RateLimitService{accountRepo: repo}
account := openAICodexPlanGatedOAuthAccount()
handled := svc.HandleUpstreamError(
context.Background(),
account,
http.StatusBadRequest,
http.Header{},
[]byte(`{"detail":"The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account."}`),
"gpt-5.6-sol",
)
require.True(t, handled)
require.Zero(t, repo.tempCalls)
require.Len(t, repo.modelRateLimitCalls, 1)
call := repo.modelRateLimitCalls[0]
require.Equal(t, account.ID, call.accountID)
require.Equal(t, "gpt-5.6-sol", call.scope)
require.Equal(t, upstreamCodexPlanGatedModelReason, call.reason)
require.WithinDuration(t, time.Now().Add(upstreamCodexPlanGatedModelCooldown), call.resetAt, 5*time.Second)
}
func TestRateLimitService_HandleUpstreamError_CodexPlanGatedModelRespectsModelMapping(t *testing.T) {
repo := &modelNotFoundAccountRepoStub{}
svc := &RateLimitService{accountRepo: repo}
account := openAICodexPlanGatedOAuthAccount()
account.Credentials["model_mapping"] = map[string]any{"gpt-5.6-sol": "gpt-5.6-sol-upstream"}
handled := svc.HandleUpstreamError(
context.Background(),
account,
http.StatusBadRequest,
http.Header{},
[]byte(`{"detail":"The 'gpt-5.6-sol-upstream' model is not supported when using Codex with a ChatGPT account."}`),
"gpt-5.6-sol",
)
require.True(t, handled)
require.Len(t, repo.modelRateLimitCalls, 1)
require.Equal(t, "gpt-5.6-sol-upstream", repo.modelRateLimitCalls[0].scope)
}
func TestRateLimitService_HandleUpstreamError_CodexPlanGatedModelIgnoresAPIKeyAccount(t *testing.T) {
repo := &modelNotFoundAccountRepoStub{}
svc := &RateLimitService{accountRepo: repo}
account := openAICodexPlanGatedOAuthAccount()
account.Type = AccountTypeAPIKey
handled := svc.HandleUpstreamError(
context.Background(),
account,
http.StatusBadRequest,
http.Header{},
[]byte(`{"detail":"The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account."}`),
"gpt-5.6-sol",
)
require.False(t, handled)
require.Empty(t, repo.modelRateLimitCalls)
}
func openAICodexPlanGatedOAuthAccount() *Account {
return &Account{
ID: 202,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Schedulable: true,
Credentials: map[string]any{},
}
}