mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
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:
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user