mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user