fix(openai): evaluate retirement after public alias projection

This commit is contained in:
xugao
2026-10-15 22:42:45 +08:00
parent 26aab6dc07
commit 86f8443045
5 changed files with 82 additions and 9 deletions
@@ -211,7 +211,7 @@ func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels(
}
changed = true
}
filtered, retired, err := filterRetiredOpenAIModelsBody(body, accounts, true)
filtered, retired, err := filterRetiredOpenAIPublicModelsBody(body, accounts, true)
if err != nil {
return fmt.Errorf("filter retired Codex models: %w", err)
}
@@ -47,9 +47,10 @@ func filterRetiredOpenAIModelIDs(accounts []Account, models []string) []string {
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) {
// 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"
@@ -115,6 +115,68 @@ func TestGPT55RetirementAccountProjection(t *testing.T) {
}
}
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,
+12 -5
View File
@@ -161,15 +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) {
filtered, _, err := filterRetiredOpenAIModelsBody(body, []Account{*account}, codex)
projected, err := projectAccountModelAliases(body, account, group, codex)
if err != nil {
return nil, err
}
body = filtered
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
}
+3
View File
@@ -16,6 +16,9 @@ clients to authenticate to Sub2API:
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.