Merge pull request #6680 from cyhhao/fix/openai-mixed-account-model-list

fix(openai): retain default models in mixed account catalogs
This commit is contained in:
Wesley Liddick
2026-09-07 18:50:03 +08:00
committed by GitHub
7 changed files with 191 additions and 24 deletions
+6 -10
View File
@@ -1144,7 +1144,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 {
@@ -1160,7 +1160,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
}
@@ -1301,6 +1301,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
@@ -1320,14 +1324,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"`
+101 -2
View File
@@ -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) {
@@ -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) {
@@ -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)
+21 -1
View File
@@ -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}
@@ -111,8 +111,8 @@ type OpenAIModelsResponse 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(
@@ -290,7 +290,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
}
@@ -1109,7 +1109,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()
@@ -1154,8 +1154,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"])