fix(groups): merge main and resolve model allowlist conflicts

Resolve three conflicts from main's #6680 (supplementUnmappedOpenAIModels):

1. gateway_handler.go — keep PR's allowlist refactoring (ModelAllowlistEnabled,
   writeAllowlistedModelsList, modelListingSource); main's OpenAI branch in
   writeModelsList was auto-merged.
2. group_models_list.go (modify/delete) — delete file per PR intent; relocate
   supplementUnmappedOpenAIModels to group_model_allowlist.go where gateway_service
   and openai_codex_models_service can still call it.
3. openai_codex_models_service.go — combine main's supplementUnmappedOpenAIModels
   wrapping with PR's ModelAllowlistEnabled() guard.

Also fix two auto-merged test files that still referenced the old
GroupModelsListConfig / ModelsListConfig types.
This commit is contained in:
shaw
2026-09-07 19:44:03 +08:00
92 changed files with 3056 additions and 299 deletions
@@ -81,15 +81,19 @@ type userSupportedModelPricing struct {
// userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。
type userPricingIntervalDTO struct {
MinTokens int `json:"min_tokens"`
MaxTokens *int `json:"max_tokens"`
TierLabel string `json:"tier_label,omitempty"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
PerRequestPrice *float64 `json:"per_request_price"`
MinTokens int `json:"min_tokens"`
MaxTokens *int `json:"max_tokens"`
TierLabel string `json:"tier_label,omitempty"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
InputMultiplier *float64 `json:"input_multiplier"`
OutputMultiplier *float64 `json:"output_multiplier"`
CacheWriteMultiplier *float64 `json:"cache_write_multiplier"`
CacheReadMultiplier *float64 `json:"cache_read_multiplier"`
PerRequestPrice *float64 `json:"per_request_price"`
}
// userSupportedModel 用户可见的支持模型条目。
@@ -295,15 +299,19 @@ func toUserPricingIntervals(src []service.PricingInterval) []userPricingInterval
intervals := make([]userPricingIntervalDTO, 0, len(src))
for _, iv := range src {
intervals = append(intervals, userPricingIntervalDTO{
MinTokens: iv.MinTokens,
MaxTokens: iv.MaxTokens,
TierLabel: iv.TierLabel,
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheWrite1hPrice: iv.CacheWrite1hPrice,
CacheReadPrice: iv.CacheReadPrice,
PerRequestPrice: iv.PerRequestPrice,
MinTokens: iv.MinTokens,
MaxTokens: iv.MaxTokens,
TierLabel: iv.TierLabel,
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheWrite1hPrice: iv.CacheWrite1hPrice,
CacheReadPrice: iv.CacheReadPrice,
InputMultiplier: iv.InputMultiplier,
OutputMultiplier: iv.OutputMultiplier,
CacheWriteMultiplier: iv.CacheWriteMultiplier,
CacheReadMultiplier: iv.CacheReadMultiplier,
PerRequestPrice: iv.PerRequestPrice,
})
}
return intervals
@@ -113,10 +113,18 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
}
// pricing interval 白名单:不应暴露 id / sort_order。
inputMultiplier := 2.0
outputMultiplier := 1.5
cacheWriteMultiplier := 2.0
cacheReadMultiplier := 2.0
pricing := toUserPricing(&service.ChannelModelPricing{
BillingMode: service.BillingModeToken,
Intervals: []service.PricingInterval{
{ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3},
{
ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3,
InputMultiplier: &inputMultiplier, OutputMultiplier: &outputMultiplier,
CacheWriteMultiplier: &cacheWriteMultiplier, CacheReadMultiplier: &cacheReadMultiplier,
},
},
})
require.NotNil(t, pricing)
@@ -129,6 +137,14 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
_, exists := ivDecoded[key]
require.Falsef(t, exists, "user pricing interval must not expose %q", key)
}
for key, want := range map[string]float64{
"input_multiplier": inputMultiplier, "output_multiplier": outputMultiplier,
"cache_write_multiplier": cacheWriteMultiplier, "cache_read_multiplier": cacheReadMultiplier,
} {
got, exists := ivDecoded[key]
require.Truef(t, exists, "user pricing interval must expose %q", key)
require.InDelta(t, want, got.(float64), 1e-12)
}
}
func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) {
@@ -152,6 +152,23 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
var overLimit *service.ReasoningEffortOverLimitError
require.ErrorAs(t, err, &overLimit)
mappingDenyGroup := *group
mappingDenyGroup.MaxReasoningEffort = ""
mappingDenyGroup.ReasoningEffortMappings = []service.ReasoningEffortMapping{
{From: "max", To: service.ReasoningEffortMappingDeny},
}
mappingDenyAPIKey := &service.APIKey{Group: &mappingDenyGroup}
mappingDenyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
mappingDenyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
mappingDenyCtx.Request = mappingDenyCtx.Request.WithContext(service.WithResolvedTargetPlatform(mappingDenyCtx.Request.Context(), service.PlatformOpenAI))
_, changed, err = applyOpenAIReasoningEffortPolicyForRequest(mappingDenyCtx, mappingDenyAPIKey, body)
require.Error(t, err)
require.False(t, changed)
var mappingDenied *service.ReasoningEffortMappingDeniedError
require.ErrorAs(t, err, &mappingDenied)
require.Equal(t, "max", mappingDenied.Requested)
require.Contains(t, mappingDenied.Error(), "denied by this group's mapping policy")
grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok))
@@ -1303,6 +1303,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
+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.GroupModelAllowlist
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.GroupModelAllowlist{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.GroupModelAllowlist{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, ModelAllowlist: 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.ModelAllowlist = service.GroupModelAllowlist{
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) {