From f49a3935667e1a081cd0d2f5b9669cd429c2a597 Mon Sep 17 00:00:00 2001 From: cyh Date: Sun, 6 Sep 2026 02:02:49 +0800 Subject: [PATCH] fix(openai): retain default models in mixed account catalogs --- backend/internal/handler/gateway_handler.go | 16 +-- .../internal/handler/gateway_models_test.go | 103 +++++++++++++++++- .../openai_codex_models_handler_test.go | 58 +++++++++- backend/internal/service/gateway_service.go | 4 + backend/internal/service/group_models_list.go | 22 +++- .../service/openai_codex_models_service.go | 6 +- .../openai_codex_models_service_test.go | 6 +- 7 files changed, 191 insertions(+), 24 deletions(-) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 941a73f53c..a513fff8c9 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1138,7 +1138,7 @@ func (h *GatewayHandler) Models(c *gin.Context) { availableModels := h.compositeAvailableModels(c.Request.Context(), groupID) if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() { availableModels = filterModelsByCustomList(availableModels, defaultModelIDsForPlatform(service.PlatformComposite), apiKey.Group.ModelsListConfig.Models) - writeCustomModelsList(c, service.PlatformComposite, availableModels) + writeModelsList(c, service.PlatformComposite, availableModels) return } if len(availableModels) > 0 { @@ -1154,7 +1154,7 @@ func (h *GatewayHandler) Models(c *gin.Context) { if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() { fallbackModels := defaultModelIDsForPlatform(platform) availableModels = filterModelsByCustomList(customModelsListSource(platform, availableModels, fallbackModels), fallbackModels, apiKey.Group.ModelsListConfig.Models) - writeCustomModelsList(c, platform, availableModels) + writeModelsList(c, platform, availableModels) return } @@ -1295,6 +1295,10 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID * } func writeModelsList(c *gin.Context, platform string, modelIDs []string) { + if platform == service.PlatformOpenAI { + writeOpenAIModelsList(c, modelIDs) + return + } if platform == service.PlatformGrok { writeGrokModelsList(c, modelIDs) return @@ -1314,14 +1318,6 @@ func writeModelsList(c *gin.Context, platform string, modelIDs []string) { }) } -func writeCustomModelsList(c *gin.Context, platform string, modelIDs []string) { - if platform == service.PlatformOpenAI { - writeOpenAIModelsList(c, modelIDs) - return - } - writeModelsList(c, platform, modelIDs) -} - type grokReasoningEffortOption struct { Value string `json:"value"` Label string `json:"label"` diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 386954a17e..a840d6dc0b 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -181,7 +182,7 @@ func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) { } } -// Scenario: Composite manifests aggregate only administrator-configured models. +// Composite manifests include defaults from unmapped accounts and explicit mappings. func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) { gin.SetMode(gin.TestMode) const groupID int64 = 120 @@ -225,7 +226,105 @@ func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T require.Equal(t, http.StatusOK, rec.Code) var got codexModelsResponseForTest require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) - require.Equal(t, []string{"gpt-5.5", "grok-4.6"}, codexModelSlugsForTest(got.Models)) + want := service.FilterCodexModelIDsForGroup(openai.DefaultModelIDs(), nil) + require.ElementsMatch(t, append(want, "grok-4.6"), codexModelSlugsForTest(got.Models)) +} + +func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 28 + const sparkModel = "gpt-5.3-codex-spark" + const alias = "team-coder" + parentID := int64(1) + accounts := []service.Account{ + {ID: parentID, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, + { + ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + ParentAccountID: &parentID, QuotaDimension: "spark", + Credentials: map[string]any{"model_mapping": map[string]any{sparkModel: sparkModel}}, + }, + { + ID: 3, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"model_mapping": map[string]any{alias: "gpt-5.6-sol"}}, + }, + } + tests := []struct { + name string + accounts []service.Account + config service.GroupModelsListConfig + want []string + }{ + { + name: "unmapped parent and Spark shadow retain defaults and aliases", + accounts: accounts, + want: append(openai.DefaultModelIDs(), alias), + }, + { + name: "unmapped API key account also contributes defaults", + accounts: append([]service.Account{{ID: 4, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey}}, accounts[1:]...), + want: append(openai.DefaultModelIDs(), alias), + }, + { + name: "unmapped accounts alone retain default response shape", + accounts: accounts[:1], + want: openai.DefaultModelIDs(), + }, + { + name: "custom list can select defaults and aliases", + accounts: accounts, + config: service.GroupModelsListConfig{Enabled: true, Models: []string{alias, "gpt-5.6-sol", sparkModel, "unknown-model"}}, + want: []string{alias, "gpt-5.6-sol", sparkModel}, + }, + { + name: "unavailable custom selection remains empty", + accounts: accounts, + config: service.GroupModelsListConfig{Enabled: true, Models: []string{"unknown-model"}}, + want: []string{}, + }, + { + name: "mapped accounts alone do not gain defaults", + accounts: accounts[1:], + want: []string{sparkModel, alias}, + }, + { + name: "unmapped accounts from another platform do not add defaults", + accounts: append([]service.Account{{ID: 4, Platform: service.PlatformAnthropic}}, accounts[1:]...), + want: []string{sparkModel, alias}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{groupID: tt.accounts}, + }) + for range 2 { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, ModelsListConfig: tt.config}, + }) + h.Models(c) + require.Equal(t, http.StatusOK, rec.Code) + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, "list", got.Object) + require.ElementsMatch(t, tt.want, modelIDsForTest(got.Data)) + for _, model := range got.Data { + require.Equal(t, "model", model.Object, model.ID) + require.Positive(t, model.Created, model.ID) + require.Equal(t, "openai", model.OwnedBy, model.ID) + require.Empty(t, model.CreatedAt, model.ID) + } + if tt.config.Enabled { + require.Equal(t, tt.want, modelIDsForTest(got.Data)) + } + } + }) + } + require.Empty(t, accounts[0].GetModelMapping()) + require.True(t, accounts[0].IsModelSupported("gpt-future-model")) + require.False(t, accounts[1].IsModelSupported("gpt-5.6-sol")) } func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) { diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index 89d3763602..b0bf5e3094 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -292,8 +292,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) { require.True(t, sawGroupB) } -// Scenario: OpenAI 分组内混用 OAuth 和第三方 API Key 时,管理员模型配置优先。 -func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) { +func TestCodexModelsSupplementsConfiguredModelsWithUnmappedAccountDefaults(t *testing.T) { gin.SetMode(gin.TestMode) groupID := int64(44) repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{ @@ -354,12 +353,59 @@ func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) { if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String()) } - if len(envelope.Models) != 1 || envelope.Models[0]["slug"] != "glm-5.3" { - t.Fatalf("models: got %v, want only glm-5.3", envelope.Models) + require.Contains(t, codexHandlerManifestSlugs(t, recorder), "gpt-5.6-sol") + require.Contains(t, codexHandlerManifestSlugs(t, recorder), "glm-5.3") + for _, model := range envelope.Models { + require.Contains(t, model, "supported_reasoning_levels") } - if _, ok := envelope.Models[0]["supported_reasoning_levels"]; !ok { - t.Fatalf("configured model is missing the Codex descriptor contract: %v", envelope.Models[0]) +} + +func TestCodexModelsUnmappedParentAndSparkShadowHonorCustomListAndETag(t *testing.T) { + gin.SetMode(gin.TestMode) + const sparkModel = "gpt-5.3-codex-spark" + parentID := int64(1) + repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{ + { + ID: parentID, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + Status: service.StatusActive, Schedulable: true, + }, + { + ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + Status: service.StatusActive, Schedulable: true, + ParentAccountID: &parentID, QuotaDimension: "spark", + Credentials: map[string]any{"model_mapping": map[string]any{sparkModel: sparkModel}}, + }, + }} + upstream := &codexModelsFailoverHTTPUpstream{firstStatus: http.StatusNotFound} + gatewayService := service.NewOpenAIGatewayService( + repo, + nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil, + upstream, + nil, nil, nil, nil, nil, nil, nil, nil, + ) + handler := &OpenAIGatewayHandler{gatewayService: gatewayService} + group := &service.Group{ID: 45, Platform: service.PlatformOpenAI} + first := performCodexModelsRequestForGroup(t, handler, group, "") + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + slugs := codexHandlerManifestSlugs(t, first) + require.Contains(t, slugs, "gpt-5.6-sol") + require.Contains(t, slugs, sparkModel) + require.NotContains(t, slugs, "gpt-image-2") + require.NotContains(t, slugs, "codex-auto-review") + firstETag := first.Header().Get("ETag") + require.NotEmpty(t, firstETag) + + group.ModelsListConfig = service.GroupModelsListConfig{ + Enabled: true, Models: []string{"gpt-5.6-sol", sparkModel, "unknown-model"}, } + second := performCodexModelsRequestForGroup(t, handler, group, firstETag) + require.Equal(t, http.StatusOK, second.Code, second.Body.String()) + require.ElementsMatch(t, []string{"gpt-5.6-sol", sparkModel}, codexHandlerManifestSlugs(t, second)) + require.NotEqual(t, firstETag, second.Header().Get("ETag")) + third := performCodexModelsRequestForGroup(t, handler, group, second.Header().Get("ETag")) + require.Equal(t, http.StatusNotModified, third.Code) + require.Empty(t, third.Body.Bytes()) + require.Empty(t, upstream.calls()) } func TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) { diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 54ebcfde18..97e35fdb1b 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1452,6 +1452,10 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, } sort.Strings(models) + if platform == PlatformOpenAI { + models = supplementUnmappedOpenAIModels(accounts, models) + } + if s.modelsListCache != nil { s.modelsListCache.Set(cacheKey, cloneStringSlice(models), s.modelsListCacheTTL) modelsListCacheStoreTotal.Add(1) diff --git a/backend/internal/service/group_models_list.go b/backend/internal/service/group_models_list.go index b10de724d9..640a71d6fa 100644 --- a/backend/internal/service/group_models_list.go +++ b/backend/internal/service/group_models_list.go @@ -1,6 +1,26 @@ package service -import "strings" +import ( + "slices" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" +) + +// A partial mapping catalog must not hide models from unmapped OpenAI accounts. +// Keep an empty catalog unchanged so callers retain their existing discovery fallback. +func supplementUnmappedOpenAIModels(accounts []Account, models []string) []string { + if len(models) == 0 { + return models + } + for i := range accounts { + account := &accounts[i] + if account.Platform == PlatformOpenAI && len(account.GetModelMapping()) == 0 { + return dedupeAndSortModelIDs(slices.Concat(models, openai.DefaultModelIDs())) + } + } + return models +} func normalizeGroupModelsListConfig(cfg GroupModelsListConfig) GroupModelsListConfig { out := GroupModelsListConfig{Enabled: cfg.Enabled} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 9e537a9c78..57ae14582a 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -110,8 +110,8 @@ type CodexModelsManifest struct { NotModified bool } -// BuildGroupConfiguredCodexModelsManifest builds a Codex catalog exclusively -// from the public model names configured on accounts in an OpenAI group. The +// BuildGroupConfiguredCodexModelsManifest builds a Codex catalog from configured +// public model names, supplemented by defaults for unmapped OpenAI accounts. The // boolean result distinguishes "no explicit configuration" from a configured // catalog that becomes empty after group-level filtering. func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( @@ -277,7 +277,7 @@ func openAIConfiguredCodexModelIDs(accounts []Account) []string { } func openAIConfiguredCodexModelIDsForGroup(accounts []Account, group *Group) []string { - models := openAIConfiguredCodexModelIDs(accounts) + models := supplementUnmappedOpenAIModels(accounts, openAIConfiguredCodexModelIDs(accounts)) if group == nil || !group.CustomModelsListEnabled() { return models } diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index f6e51d3453..594a0fe7cb 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -1105,7 +1105,7 @@ func TestMergeGroupConfiguredCodexModelsInjectsCurrentGroupAliases(t *testing.T) require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) } -// Scenario: OpenAI 分组存在账号模型配置时直接生成本地 Codex 清单。 +// Mixed groups retain configured metadata alongside defaults for unmapped accounts. func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t *testing.T) { t.Parallel() @@ -1150,8 +1150,10 @@ func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t require.NoError(t, err) require.True(t, configured) models := decodeCodexManifestModels(t, manifest.Body) - require.Len(t, models, 1) require.Equal(t, "glm-5.3", models[0]["slug"]) + require.Contains(t, codexManifestModelSlugs(t, manifest.Body), "gpt-5.6-sol") + require.NotContains(t, codexManifestModelSlugs(t, manifest.Body), "gpt-image-2") + require.NotContains(t, codexManifestModelSlugs(t, manifest.Body), "codex-auto-review") require.Equal(t, "GLM 5.3", models[0]["display_name"]) require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0])) require.Equal(t, "medium", models[0]["default_reasoning_level"])