fix(openai): respect GPT-5.5 retirement on ChatGPT upstreams

This commit is contained in:
xugao
2026-10-15 20:53:15 +08:00
parent 698d142468
commit 26aab6dc07
19 changed files with 418 additions and 30 deletions
+5 -2
View File
@@ -1193,7 +1193,7 @@ func (h *GatewayHandler) Models(c *gin.Context) {
return
}
if len(availableModels) > 0 {
if len(availableModels) > 0 || (platform == service.PlatformOpenAI && availableModels != nil) {
writeModelsList(c, platform, availableModels)
return
}
@@ -1308,7 +1308,7 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
continue
}
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
if len(platformModels) == 0 {
if len(platformModels) == 0 && (platform != service.PlatformOpenAI || platformModels == nil) {
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
// default 分支是 Claude 列表),composite 下只暴露账号映射键。
if _, ok := schedulablePlatforms[platform]; ok && !service.IsMultiProtocolAPIKeyProvider(platform) {
@@ -1457,6 +1457,9 @@ func writeOpenAIModelsList(c *gin.Context, modelIDs []string) {
// 与平台默认列表(fallbackModels)。账号映射为空时回落默认列表;Anthropic
// 平台两者取并集,其余平台以账号映射键为准。
func modelListingSource(platform string, availableModels, fallbackModels []string) []string {
if platform == service.PlatformOpenAI && availableModels != nil {
return availableModels
}
if len(availableModels) == 0 {
return fallbackModels
}
@@ -324,6 +324,12 @@ func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T
Credentials: map[string]any{"model_mapping": map[string]any{alias: "gpt-5.6-sol"}},
},
}
subscriptionDefaults := make([]string, 0)
for _, model := range openai.DefaultModelIDs() {
if model != "gpt-5.5" {
subscriptionDefaults = append(subscriptionDefaults, model)
}
}
tests := []struct {
name string
accounts []service.Account
@@ -333,7 +339,7 @@ func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T
{
name: "unmapped parent and Spark shadow retain defaults and aliases",
accounts: accounts,
want: append(openai.DefaultModelIDs(), alias),
want: append(append([]string{}, subscriptionDefaults...), alias),
},
{
name: "unmapped API key account also contributes defaults",
@@ -343,7 +349,7 @@ func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T
{
name: "unmapped accounts alone retain default response shape",
accounts: accounts[:1],
want: openai.DefaultModelIDs(),
want: subscriptionDefaults,
},
{
name: "custom list can select defaults and aliases",
@@ -372,7 +378,7 @@ func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T
Credentials: map[string]any{"model_mapping": map[string]any{"stale-model": "stale-model"}},
Extra: map[string]any{"openai_passthrough": true},
}}, accounts[1:]...),
want: append(openai.DefaultModelIDs(), alias),
want: append(append([]string{}, subscriptionDefaults...), alias),
},
{
name: "unmapped accounts from another platform do not add defaults",
+5 -1
View File
@@ -123,10 +123,14 @@ func classifyNoAccountError(
result := diag.DiagnoseModelAvailabilityForPlatform(ctx, apiKey.GroupID, routingModel, platform)
if result.HasAccountsInPool && !result.HasModelSupport {
message := fmt.Sprintf("Model %q is not supported by any configured account in this group", displayModel)
if result.HasRetiredSubscriptionModel {
message = fmt.Sprintf("Model %q targets GPT-5.5, which retired from Codex with ChatGPT sign-in on October 14, 2026. Select an available replacement such as GPT-6.1 Sol or GPT-6 Luna, or configure an API-key upstream that supports GPT-5.5.", displayModel)
}
return noAccountErrorClassification{
Status: http.StatusNotFound,
ErrType: "model_not_found",
Message: fmt.Sprintf("Model %q is not supported by any configured account in this group", displayModel),
Message: message,
ModelNotFound: true,
}
}
@@ -303,3 +303,22 @@ func TestClassifySelectionFailureError_StillUpgradesNonModelNotFoundFallback(t *
require.Equal(t, "rate_limit_error", got.ErrType)
require.False(t, got.ModelNotFound)
}
func TestClassifyNoAccountError_GPT55Retirement(t *testing.T) {
groupID := int64(1)
apiKey := &service.APIKey{GroupID: &groupID}
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasRetiredSubscriptionModel: true}}
cls := classifyNoAccountError(context.Background(), fd, apiKey, "gpt-5.5", "legacy", service.PlatformOpenAI)
require.Equal(t, http.StatusNotFound, cls.Status)
require.Equal(t, "model_not_found", cls.ErrType)
require.Contains(t, cls.Message, "retired")
require.Contains(t, cls.Message, "API-key")
fd.resp.HasModelSupport = true
cls = classifyNoAccountError(context.Background(), fd, apiKey, "gpt-5.5", "legacy", service.PlatformOpenAI)
require.Equal(t, http.StatusServiceUnavailable, cls.Status, "a temporarily unavailable API pool is not a retirement error")
}
func TestGPT55RetirementEmptyListingDoesNotUseDefaults(t *testing.T) {
require.Empty(t, modelListingSource(service.PlatformOpenAI, []string{}, []string{"gpt-5.5"}))
require.Equal(t, []string{"gpt-5.5"}, modelListingSource(service.PlatformOpenAI, nil, []string{"gpt-5.5"}))
}
@@ -27,6 +27,14 @@ func (r *pinnedModelsRoutesRepository) ListByGroup(context.Context, int64) ([]se
return []service.Account{r.account}, nil
}
func (r *pinnedModelsRoutesRepository) ListSchedulableByGroupID(context.Context, int64) ([]service.Account, error) {
return []service.Account{r.account}, nil
}
func (r *pinnedModelsRoutesRepository) ListModelAvailabilityCandidates(context.Context, *int64, []string, bool) ([]service.Account, error) {
return []service.Account{r.account}, nil
}
type pinnedModelsRoutesUpstream struct {
service.HTTPUpstream
ordinaryCalls atomic.Int32
+4
View File
@@ -914,6 +914,10 @@ func resolveRequestedModelInMapping(mapping map[string]string, requestedModel st
// (isDeepseekServableModel)——未知模型名透传上游只会得到 404/400,并误触发
// per-(账号,模型) 30 分钟冷却;带 [1m] 上下文后缀的写法先归一化再比对。
func (a *Account) IsModelSupported(requestedModel string) bool {
return supportsOpenAIRequestModel(a, requestedModel, false)
}
func (a *Account) isModelMappingSupported(requestedModel string) bool {
// 透传模式仅替换认证、模型语义完全交由上游决定,因此放行所有模型。
// 该短路必须在 model_mapping 判定之前:账号从"白名单模式"切换到透传后,
// credentials 里常残留旧的非空 model_mapping,若不在此放行,透传账号会被
@@ -21,6 +21,9 @@ type ModelAvailabilityDiagnosis struct {
// HasModelSupport is true if at least one account's model mapping admits
// the requested model.
HasModelSupport bool
// HasRetiredSubscriptionModel records a ChatGPT upstream blocked by model
// retirement, so an unsupported request can include migration guidance.
HasRetiredSubscriptionModel bool
}
// ModelAvailabilityDiagnoser is implemented by gateway services that can
@@ -22,6 +22,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/cespare/xxhash/v2"
@@ -1461,6 +1462,12 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64,
}
// If no account has model_mapping, return nil (use default)
if !hasAnyMapping && platform == PlatformOpenAI {
models := filterRetiredOpenAIModelIDs(accounts, openai.DefaultModelIDs())
if len(models) != len(openai.DefaultModels) {
return models
}
}
if !hasAnyMapping {
if s.modelsListCache != nil {
s.modelsListCache.Set(cacheKey, []string(nil), s.modelsListCacheTTL)
@@ -1478,6 +1485,12 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64,
if platform == PlatformOpenAI {
models = supplementUnmappedOpenAIModels(accounts, models)
models = filterRetiredOpenAIModelIDs(accounts, models)
// nil means "use defaults" to listing callers; an empty filtered catalog
// must remain authoritative instead of resurrecting retired defaults.
if len(models) == 0 {
return models
}
}
if s.modelsListCache != nil {
@@ -1812,7 +1812,7 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatibleReason(ctx con
}) {
return false, "shadow_parent_unhealthy"
}
if req.RequestedModel != "" && !account.IsModelSupported(req.RequestedModel) {
if req.RequestedModel != "" && !supportsOpenAIRequestModel(account, req.RequestedModel, req.RequireCompact) {
return false, "model_not_supported"
}
if req.GroupID != nil && s != nil && s.service != nil &&
@@ -187,13 +187,13 @@ func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels(
return nil
}
visible, accounts, err := loadCodexGroupCatalogAccounts(ctx, s.accountRepo, group.ID)
if err != nil {
return fmt.Errorf("load group configured Codex models: %w", err)
}
var configuredModels []string
if !group.CodexModelsManifestConfig.Enabled {
var err error
configuredModels, err = s.groupConfiguredCodexModelIDs(ctx, group)
if err != nil {
return fmt.Errorf("load group configured Codex models: %w", err)
}
configuredModels = openAIConfiguredCodexModelIDsForGroup(visible, group)
}
body, changed, err := mergeConfiguredCodexModelsManifest(
manifest.Body,
@@ -211,7 +211,12 @@ func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels(
}
changed = true
}
if changed {
filtered, retired, err := filterRetiredOpenAIModelsBody(body, accounts, true)
if err != nil {
return fmt.Errorf("filter retired Codex models: %w", err)
}
body = filtered
if changed || retired {
manifest.Body = body
manifest.ETag = codexModelsManifestBodyETag(body)
}
@@ -222,17 +227,6 @@ func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels(
return nil
}
func (s *OpenAIGatewayService) groupConfiguredCodexModelIDs(ctx context.Context, group *Group) ([]string, error) {
if group == nil {
return nil, nil
}
accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, group.ID)
if err != nil {
return nil, err
}
return openAIConfiguredCodexModelIDsForGroup(accounts, group), nil
}
// loadCodexGroupCatalogAccounts separates picker membership from capability
// intersection. visible accounts are currently schedulable and decide which
// public aliases appear. catalog accounts are persistently enabled group
@@ -300,7 +294,7 @@ func openAIConfiguredCodexModelIDs(accounts []Account) []string {
func openAIConfiguredCodexModelIDsForGroup(accounts []Account, group *Group) []string {
models := supplementUnmappedOpenAIModels(accounts, openAIConfiguredCodexModelIDs(accounts))
if group == nil || !group.ModelAllowlistEnabled() {
return models
return filterRetiredOpenAIModelIDs(accounts, models)
}
seen := make(map[string]struct{}, len(models)+len(group.ModelAllowlist.Models))
@@ -329,7 +323,7 @@ func openAIConfiguredCodexModelIDsForGroup(accounts []Account, group *Group) []s
}
}
sort.Strings(models)
return models
return filterRetiredOpenAIModelIDs(accounts, models)
}
const (
@@ -908,6 +902,19 @@ func buildCodexModelsManifestForAccounts(
compositeRoutes []CompositeModelRoute,
compositeRoutesAvailable bool,
) ([]byte, error) {
if effectivePlatform == PlatformOpenAI {
modelIDs = filterRetiredOpenAIModelIDs(accounts, modelIDs)
} else if effectivePlatform == PlatformComposite {
filtered := make([]string, 0, len(modelIDs))
for _, modelID := range modelIDs {
platform, target, resolved := resolveCodexCompositeModelTarget(modelID, accounts, compositeRoutes, compositeRoutesAvailable)
if resolved && platform == PlatformOpenAI && openAIModelRetiredForAccounts(accounts, target) {
continue
}
filtered = append(filtered, modelID)
}
modelIDs = filtered
}
imageInputModels := make(map[string]bool, len(modelIDs))
searchToolModels := make(map[string]bool, len(modelIDs))
metadataModels := codexCatalogMetadataModels(
@@ -57,6 +57,9 @@ func (s *OpenAIGatewayService) resolveOpenAICompactFallbackModel(account *Accoun
if account != nil {
if mapped, matched := account.ResolveCompactMappedModel(requestedModel); matched {
if mapped = strings.TrimSpace(mapped); mapped != "" {
if isRetiredOpenAISubscriptionModel(account, mapped) {
return ""
}
return mapped
}
}
@@ -68,7 +71,11 @@ func (s *OpenAIGatewayService) resolveOpenAICompactFallbackModel(account *Accoun
if fallback == "" {
return ""
}
return strings.TrimSpace(resolveOpenAIAccountUpstreamModelForRequest(account, fallback, false))
resolved := strings.TrimSpace(resolveOpenAIAccountUpstreamModelForRequest(account, fallback, false))
if isRetiredOpenAISubscriptionModel(account, resolved) {
return ""
}
return resolved
}
func isOpenAICompactModelFailure(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
@@ -56,6 +56,9 @@ func (s *OpenAIGatewayService) DiagnoseModelAvailabilityForPlatform(
diag := ModelAvailabilityDiagnosis{}
for i := range accounts {
diag.HasAccountsInPool = true
if isRetiredOpenAISubscriptionModel(&accounts[i], resolveOpenAIAccountUpstreamModelForRequest(&accounts[i], requestedModel, false)) {
diag.HasRetiredSubscriptionModel = true
}
// Mirrors the per-candidate filter used during account selection
// (openai_account_scheduler.isAccountRequestCompatible): empty
// model_mapping accepts everything; otherwise the explicit / wildcard
@@ -440,7 +440,7 @@ func openAICompatibleAccountEligibilityFailureReasonBeforeProfit(ctx context.Con
return "quota_auto_pause"
}
}
if requestedModel != "" && !account.IsModelSupported(requestedModel) {
if requestedModel != "" && !supportsOpenAIRequestModel(account, requestedModel, requireCompact) {
return "model_not_supported"
}
if !account.SupportsOpenAIEndpointCapability(requiredCapability) {
@@ -0,0 +1,90 @@
package service
import (
"encoding/json"
"strings"
)
// GPT-5.5 retired from Codex with ChatGPT sign-in on 2026-10-14 at
// 19:00 UTC. API-key upstreams remain supported. Do not infer upstream
// eligibility from the API key a client uses to authenticate to this gateway.
// https://learn.chatgpt.com/docs/models#gpt-55-retirement
func isRetiredOpenAISubscriptionModel(account *Account, upstreamModel string) bool {
if account == nil || !account.IsOpenAI() || (!account.IsOpenAIOAuthLike() && !account.IsShadow()) {
return false
}
model := canonicalizeOpenAIModelAliasSpelling(upstreamModel)
return model == "gpt-5.5" || strings.HasPrefix(model, "gpt-5.5-")
}
// Only remove entries blocked by retirement. Unknown models and other platforms
// retain their existing listing policy. An explicitly mapped replacement is
// allowed, but a public alias targeting retired GPT-5.5 is not.
func openAIModelRetiredForAccounts(accounts []Account, modelID string) bool {
retired := false
for i := range accounts {
account := &accounts[i]
if !account.IsOpenAI() {
continue
}
target := resolveOpenAIAccountUpstreamModelForRequest(account, modelID, false)
if isRetiredOpenAISubscriptionModel(account, target) {
retired = true
} else if account.IsModelSupported(modelID) {
return false
}
}
return retired
}
func filterRetiredOpenAIModelIDs(accounts []Account, models []string) []string {
filtered := make([]string, 0, len(models))
for _, model := range models {
if !openAIModelRetiredForAccounts(accounts, model) {
filtered = append(filtered, model)
}
}
return filtered
}
// Apply after account/group mappings, including on cached upstream catalogs,
// and before the final client ETag is calculated. Preserve unknown fields.
func filterRetiredOpenAIModelsBody(body []byte, accounts []Account, codex bool) ([]byte, bool, error) {
field, idField := "data", "id"
if codex {
field, idField = "models", "slug"
}
envelope, entries, err := modelCatalogEntries(body, field)
if err != nil {
return nil, false, err
}
filtered := make([]json.RawMessage, 0, len(entries))
for _, raw := range entries {
var entry map[string]json.RawMessage
var id string
if json.Unmarshal(raw, &entry) == nil {
_ = json.Unmarshal(entry[idField], &id)
}
if !openAIModelRetiredForAccounts(accounts, id) {
filtered = append(filtered, raw)
}
}
if len(filtered) == len(entries) {
return body, false, nil
}
envelope[field], err = json.Marshal(filtered)
if err != nil {
return nil, false, err
}
encoded, err := json.Marshal(envelope)
return encoded, true, err
}
// Compact-only administrator mappings are evaluated against their actual
// target, without treating an old client-visible name as a retired target.
func supportsOpenAIRequestModel(account *Account, requestedModel string, requireCompact bool) bool {
if isRetiredOpenAISubscriptionModel(account, resolveOpenAIAccountUpstreamModelForRequest(account, requestedModel, requireCompact)) {
return false
}
return account.isModelMappingSupported(requestedModel)
}
@@ -0,0 +1,174 @@
package service
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestGPT55RetirementModelSupport(t *testing.T) {
for _, typ := range []string{AccountTypeOAuth, AccountTypeSetupToken, AccountTypeAPIKey} {
for _, passthrough := range []bool{false, true} {
t.Run(typ+"/passthrough="+map[bool]string{true: "true", false: "false"}[passthrough], func(t *testing.T) {
account := &Account{Platform: PlatformOpenAI, Type: typ, Extra: map[string]any{"openai_passthrough": passthrough}}
for _, model := range []string{"gpt-5.5", "openai/gpt-5.5-high", "gpt-5.5-2026-04-23", "GPT_5.5", "gpt-5.5-openai-compact"} {
require.Equal(t, typ == AccountTypeAPIKey, account.IsModelSupported(model), model)
}
require.True(t, account.IsModelSupported("gpt-6.1-sol"))
})
}
}
for _, mapping := range []map[string]any{
{"legacy": "gpt-5.5"}, {"legacy*": "gpt-5.5"},
} {
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"model_mapping": mapping}}
require.False(t, account.IsModelSupported("legacy"))
}
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.5": "gpt-6.1-sol"}}}
require.True(t, account.IsModelSupported("gpt-5.5"), "explicit administrator replacement remains authoritative")
account.Type = AccountTypeAPIKey
require.True(t, account.IsModelSupported("gpt-5.5"))
parent := int64(3)
shadow := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, ParentAccountID: &parent}
require.False(t, shadow.IsModelSupported("gpt-5.5"))
require.False(t, isRetiredOpenAISubscriptionModel(&Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}, "gpt-5.5"))
require.False(t, isRetiredOpenAISubscriptionModel(account, "gpt-5.50"))
}
func TestGPT55RetirementCatalogs(t *testing.T) {
oauth := Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
api := Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
group := &Group{ID: 1, Platform: PlatformOpenAI}
for _, tc := range []struct {
name string
accounts []Account
wantGPT55 bool
}{
{"oauth", []Account{oauth}, false}, {"api", []Account{api}, true}, {"mixed", []Account{oauth, api}, true},
} {
t.Run(tc.name, func(t *testing.T) {
repo := codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{1: tc.accounts}}
gateway := &GatewayService{accountRepo: repo}
models := gateway.GetAvailableModels(context.Background(), &group.ID, PlatformOpenAI)
if models != nil {
require.Equal(t, tc.wantGPT55, containsModelID(models, "gpt-5.5"))
}
body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"gpt-5.5", "gpt-6.1-sol"}, tc.accounts, group, nil, true)
require.NoError(t, err)
require.Equal(t, tc.wantGPT55, containsManifestModel(body, "gpt-5.5"))
// A stale/pinned upstream body must be filtered before evaluating its old ETag.
original := []byte(`{"models":[{"slug":"gpt-5.5","unknown":1},{"slug":"gpt-6.1-sol","unknown":2}],"unknown_top":3}`)
etag := CodexModelsManifestETag(original)
manifest := &OpenAIModelsResponse{Body: original, ETag: etag}
service := &OpenAIGatewayService{accountRepo: repo}
require.NoError(t, service.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, etag))
if !tc.wantGPT55 {
require.False(t, manifest.NotModified)
require.NotEqual(t, etag, manifest.ETag)
require.False(t, containsManifestModel(manifest.Body, "gpt-5.5"))
require.Equal(t, int64(3), gjson.GetBytes(manifest.Body, "unknown_top").Int())
require.Equal(t, int64(2), gjson.GetBytes(manifest.Body, "models.0.unknown").Int())
}
})
}
// All configured entries retired: plain discovery must remain an authoritative empty list.
oauth.Credentials = map[string]any{"model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"}}
repo := codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{1: {oauth}}}
models := (&GatewayService{accountRepo: repo}).GetAvailableModels(context.Background(), &group.ID, PlatformOpenAI)
require.NotNil(t, models)
require.Empty(t, models)
}
func containsModelID(models []string, id string) bool {
for _, model := range models {
if model == id {
return true
}
}
return false
}
func containsManifestModel(body []byte, id string) bool {
for _, m := range gjson.GetBytes(body, "models").Array() {
if m.Get("slug").String() == id {
return true
}
}
return false
}
func TestGPT55RetirementAccountProjection(t *testing.T) {
body := []byte(`{"object":"list","data":[{"id":"gpt-5.5"},{"id":"gpt-6.1-sol","unknown":true}],"unknown_top":true}`)
for _, typ := range []string{AccountTypeOAuth, AccountTypeAPIKey} {
account := &Account{Platform: PlatformOpenAI, Type: typ}
got, err := projectAccountModelsBody(body, account, nil, false)
require.NoError(t, err)
require.Equal(t, typ == AccountTypeAPIKey, strings.Contains(string(got), "gpt-5.5"))
require.True(t, gjson.GetBytes(got, "unknown_top").Bool())
require.True(t, json.Valid(got))
}
}
func TestGPT55RetirementCompactFallbackKeepsSelectedModel(t *testing.T) {
for _, passthrough := range []bool{false, true} {
account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
Credentials: map[string]any{"access_token": "test-token"}, Extra: map[string]any{"openai_passthrough": passthrough}}
upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"id":"compact","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`))}}
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.5"}}, httpUpstream: upstream}
require.Empty(t, svc.resolveOpenAICompactFallbackModel(account, "gpt-6.1-sol"))
c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
_, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-6.1-sol","input":[],"instructions":"compact","stream":false}`))
require.NoError(t, err)
require.Equal(t, "gpt-6.1-sol", gjson.GetBytes(upstream.lastBody, "model").String())
account.Type = AccountTypeAPIKey
require.Equal(t, "gpt-5.5", svc.resolveOpenAICompactFallbackModel(account, "gpt-6.1-sol"))
}
}
func TestGPT55RetirementSchedulerAndDiagnosis(t *testing.T) {
ctx := context.Background()
groupID := int64(1)
oauth := Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true}
api := Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true}
for _, typ := range []string{AccountTypeOAuth, AccountTypeSetupToken, AccountTypeAPIKey} {
account := oauth
account.Type = typ
scheduler := &defaultOpenAIAccountScheduler{}
ok, _ := scheduler.isAccountRequestCompatibleReason(ctx, &account, OpenAIAccountScheduleRequest{RequestedModel: "gpt-5.5"})
require.Equal(t, typ == AccountTypeAPIKey, ok)
reason := openAICompatibleAccountEligibilityFailureReasonBeforeProfit(ctx, &account, PlatformOpenAI, "gpt-5.5", false, "")
require.Equal(t, typ == AccountTypeAPIKey, reason == "")
}
oauth.Credentials = map[string]any{"compact_model_mapping": map[string]any{"gpt-6.1-sol": "gpt-5.5"}}
scheduler := &defaultOpenAIAccountScheduler{}
ok, reason := scheduler.isAccountRequestCompatibleReason(ctx, &oauth, OpenAIAccountScheduleRequest{RequestedModel: "gpt-6.1-sol", RequireCompact: true})
require.False(t, ok)
require.Equal(t, "model_not_supported", reason)
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-6.1-sol"}}}
require.Empty(t, svc.resolveOpenAICompactFallbackModel(&oauth, "gpt-6.1-sol"), "native compact retries must not use a retired explicit target")
oauth.Credentials = map[string]any{"compact_model_mapping": map[string]any{"gpt-5.5": "gpt-6.1-sol"}}
ok, _ = scheduler.isAccountRequestCompatibleReason(ctx, &oauth, OpenAIAccountScheduleRequest{RequestedModel: "gpt-5.5", RequireCompact: true})
require.True(t, ok, "an explicit compact replacement remains usable")
for _, accounts := range [][]Account{{oauth}, {oauth, api}} {
svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{groupID: accounts}}}
diagnosis := svc.DiagnoseModelAvailabilityForPlatform(ctx, &groupID, "gpt-5.5", PlatformOpenAI)
require.True(t, diagnosis.HasAccountsInPool)
require.True(t, diagnosis.HasRetiredSubscriptionModel)
require.Equal(t, len(accounts) == 2, diagnosis.HasModelSupport)
}
}
func TestGPT55RetirementEmptyListingSource(t *testing.T) {
// A configured alias targeting retired GPT-5.5 must not reappear in a generated catalog.
account := Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"model_mapping": map[string]any{"legacy": "gpt-5.5"}}}
require.Empty(t, openAIConfiguredCodexModelIDsForGroup([]Account{account}, nil))
body, err := buildCodexModelsManifestForAccounts(PlatformComposite, []string{"gpt-5.5", "gpt-6.1-sol"}, []Account{account}, nil, nil, true)
require.NoError(t, err)
require.False(t, containsManifestModel(body, "gpt-5.5"))
}
@@ -165,6 +165,11 @@ func standardOpenAIModelsBody(body []byte, fromManifest bool) ([]byte, error) {
// representations while retaining the source entry's metadata. It never changes
// the shared response and never synthesizes models absent from this account.
func projectAccountModelsBody(body []byte, account *Account, group *Group, codex bool) ([]byte, error) {
filtered, _, err := filterRetiredOpenAIModelsBody(body, []Account{*account}, codex)
if err != nil {
return nil, err
}
body = filtered
if account.IsOpenAIPassthroughEnabled() || len(account.GetModelMapping()) == 0 {
return body, nil
}
@@ -571,7 +571,7 @@ func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability(
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
return 0, nil, "", nil
}
if requestedModel != "" && !account.IsModelSupported(requestedModel) {
if requestedModel != "" && !supportsOpenAIRequestModel(account, requestedModel, requireCompact) {
return 0, nil, "", nil
}
if !account.SupportsOpenAIEndpointCapability(requiredCapability) {
@@ -610,7 +610,7 @@ func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability(
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
return 0, nil, "", nil
}
if requestedModel != "" && !latest.IsModelSupported(requestedModel) {
if requestedModel != "" && !supportsOpenAIRequestModel(latest, requestedModel, requireCompact) {
return 0, nil, "", nil
}
if !latest.SupportsOpenAIEndpointCapability(requiredCapability) {
+2
View File
@@ -324,6 +324,8 @@ gateway:
# OpenAI /responses/compact 上游模型(默认 gpt-5.5)。
# Use this to avoid compact failures when newer models are not yet supported by the compact endpoint.
# 当 compact 端点暂未支持更新模型时,可通过这里降级规避失败。
# GPT-5.5 is API-only after its ChatGPT Codex retirement; subscription
# requests skip this retired fallback and keep their selected model.
openai_compact_model: "gpt-5.5"
# ChatGPT Frameless Live 单会话硬上限(秒)。
live:
+40
View File
@@ -0,0 +1,40 @@
# GPT-5.5 retirement on ChatGPT-backed Codex accounts
OpenAI retired GPT-5.5 from Codex with ChatGPT sign-in on October 14,
2026 at 19:00 UTC (October 15 at 03:00 in Asia/Shanghai). Official API access
is unaffected. See the [official migration notice](https://learn.chatgpt.com/docs/models#gpt-55-retirement).
Sub2API applies this distinction to the upstream account, not the key used by
clients to authenticate to Sub2API:
- OpenAI OAuth and SetupToken accounts, including credential shadows, cannot
serve GPT-5.5. Normal, sticky, and compact scheduling exclude these targets.
- API-key accounts retain GPT-5.5 support. In mixed groups, only eligible API-key
upstreams can serve it. Their actual model availability remains authoritative.
- Public model lists and Codex catalogs omit models whose targets are retired on
the group's ChatGPT accounts and have no supporting API-key account. This also
filters stale/pinned catalog entries before calculating the client ETag.
- Explicit administrator mappings to a supported replacement remain valid.
Aliases mapped *to* GPT-5.5 are excluded from ChatGPT account routing.
- Unsupported requests return the existing `model_not_found` error with migration
guidance when the availability diagnosis identifies a retired subscription
target. Client chat requests are not silently rewritten to a replacement.
## Administrator migration
Review group/account mappings, client defaults, custom agents and scheduled tasks
that select GPT-5.5. OpenAI recommends GPT-6.1 Sol for eligible paid plans or
GPT-6 Luna for Free/Go in the desktop app, subject to model availability. Check
availability on the upstream account before selecting a replacement.
The global `gateway.openai_compact_model` setting remains compatible with API-key
upstreams. If that fallback resolves to retired GPT-5.5 on a ChatGPT account, it
is not applied; compact requests keep their selected model. A compact-only
account mapping targeting retired GPT-5.5 is ineligible for scheduling: update
that explicit mapping. No stored settings are automatically rewritten.
Changing the public registry, deleting GPT-5.5 pricing/history, or globally
mapping GPT-5.5 to a new model is unnecessary and would affect valid API usage.
Regression tests use local fixtures and stub upstream responses. They do not
establish real subscription access to a replacement model.