mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 08:16:49 +00:00
fix(openai): evaluate retirement after public alias projection
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user