mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 15:43:13 +08:00
Merge pull request #8121 from Larry-Labs/fix/gpt55-subscription-retirement
fix(openai): respect GPT-5.5 retirement on ChatGPT upstreams
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 := filterRetiredOpenAIPublicModelsBody(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,91 @@
|
||||
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
|
||||
}
|
||||
|
||||
// Entries must already use client-visible names, not raw upstream IDs. Resolve
|
||||
// each public name exactly as routing does, after projection and before the
|
||||
// final client ETag is calculated. Preserve unknown fields.
|
||||
func filterRetiredOpenAIPublicModelsBody(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,236 @@
|
||||
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 TestGPT55RetirementProjectionUsesPublicNames(t *testing.T) {
|
||||
for _, codex := range []bool{false, true} {
|
||||
field, idField := "data", "id"
|
||||
if codex {
|
||||
field, idField = "models", "slug"
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mapping map[string]any
|
||||
passthrough bool
|
||||
api bool
|
||||
want map[string]string
|
||||
}{
|
||||
{"one-hop", map[string]any{"legacy": "gpt-5.5", "gpt-5.5": "gpt-6.1-sol"}, false, false, map[string]string{"gpt-5.5": "live"}},
|
||||
{"crossed", map[string]any{"gpt-6.1-sol": "gpt-5.5", "gpt-5.5": "gpt-6.1-sol"}, false, false, map[string]string{"gpt-5.5": "live"}},
|
||||
{"wildcard", map[string]any{"legacy*": "gpt-5.5", "gpt-5.5": "gpt-6.1-sol"}, false, false, map[string]string{"gpt-5.5": "live"}},
|
||||
{"passthrough", map[string]any{"gpt-6.1-sol": "gpt-5.5", "gpt-5.5": "gpt-6.1-sol"}, true, false, map[string]string{"gpt-6.1-sol": "live"}},
|
||||
{"api", map[string]any{"legacy": "gpt-5.5", "gpt-5.5": "gpt-6.1-sol"}, false, true, map[string]string{"legacy": "retired", "gpt-5.5": "live"}},
|
||||
} {
|
||||
t.Run(field+"/"+tc.name, func(t *testing.T) {
|
||||
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{"model_mapping": tc.mapping}, Extra: map[string]any{"openai_passthrough": tc.passthrough}}
|
||||
if tc.api {
|
||||
account.Type = AccountTypeAPIKey
|
||||
}
|
||||
group := &Group{Platform: PlatformOpenAI, ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"legacy", "gpt-5.5", "gpt-6.1-sol"}}}
|
||||
body, err := json.Marshal(map[string]any{field: []map[string]any{
|
||||
{idField: "gpt-5.5", "source": "retired"}, {idField: "gpt-6.1-sol", "source": "live"},
|
||||
}, "unknown_top": true})
|
||||
require.NoError(t, err)
|
||||
original := string(body)
|
||||
got, err := projectAccountModelsBody(body, account, group, codex)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, original, string(body), "shared upstream cache must remain unchanged")
|
||||
require.True(t, gjson.GetBytes(got, "unknown_top").Bool())
|
||||
entries := gjson.GetBytes(got, field).Array()
|
||||
require.Len(t, entries, len(tc.want), string(got))
|
||||
for _, entry := range entries {
|
||||
id := entry.Get(idField).String()
|
||||
require.Contains(t, tc.want, id)
|
||||
require.Equal(t, tc.want[id], entry.Get("source").String(), "metadata must come from the one-hop upstream target")
|
||||
require.True(t, account.IsModelSupported(id), "catalog and routing must agree for %s", id)
|
||||
}
|
||||
if codex {
|
||||
response := &OpenAIModelsResponse{Body: body, ETag: CodexModelsManifestETag(body)}
|
||||
require.NoError(t, ApplyPinnedCodexModelsMapping(response, account, group))
|
||||
require.JSONEq(t, string(got), string(response.Body))
|
||||
require.Equal(t, codexModelsManifestBodyETag(response.Body), response.ETag)
|
||||
group.ID = 1
|
||||
group.CodexModelsManifestConfig.Enabled = true
|
||||
service := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{1: {*account}}}}
|
||||
require.NoError(t, service.MergeGroupConfiguredCodexModels(context.Background(), group, response, CodexModelsManifestETag(body)))
|
||||
require.False(t, response.NotModified, "an upstream ETag must not hide the projected catalog")
|
||||
require.Equal(t, codexModelsManifestBodyETag(response.Body), response.ETag)
|
||||
require.NoError(t, service.MergeGroupConfiguredCodexModels(context.Background(), group, response, response.ETag))
|
||||
require.True(t, response.NotModified, "the final public ETag must support conditional requests")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
@@ -161,10 +161,22 @@ func standardOpenAIModelsBody(body []byte, fromManifest bool) ([]byte, error) {
|
||||
}{Object: "list", Data: models})
|
||||
}
|
||||
|
||||
// projectAccountModelsBody applies the same public-name mapping to both model
|
||||
// representations while retaining the source entry's metadata. It never changes
|
||||
// the shared response and never synthesizes models absent from this account.
|
||||
// projectAccountModelsBody projects upstream entries to public names before
|
||||
// checking their routing eligibility. Applying account mappings to source IDs
|
||||
// would interpret upstream names as aliases and can retain retired targets or
|
||||
// discard metadata needed by a valid replacement alias.
|
||||
func projectAccountModelsBody(body []byte, account *Account, group *Group, codex bool) ([]byte, error) {
|
||||
projected, err := projectAccountModelAliases(body, account, group, codex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered, _, err := filterRetiredOpenAIPublicModelsBody(projected, []Account{*account}, codex)
|
||||
return filtered, err
|
||||
}
|
||||
|
||||
// projectAccountModelAliases retains the source entry's metadata, never changes
|
||||
// the shared response, and never synthesizes models absent from this account.
|
||||
func projectAccountModelAliases(body []byte, account *Account, group *Group, codex bool) ([]byte, error) {
|
||||
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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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.
|
||||
Remote catalogs are projected to public aliases before retirement filtering,
|
||||
using the same single mapping step as routing. Source metadata and shared
|
||||
upstream caches are preserved, including when source IDs are also alias keys.
|
||||
- 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.
|
||||
Reference in New Issue
Block a user