mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 08:22:34 +00:00
Merge upstream/main into feat/antigravity-gemini-3.8-flash
Resolve comment-wording conflict in normalizeGeminiThinkingTierAlias by keeping upstream's phrasing. Function body and behavior are unchanged.
This commit is contained in:
@@ -1 +1 @@
|
||||
0.2.0
|
||||
0.2.3
|
||||
|
||||
@@ -137,7 +137,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient)
|
||||
tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache)
|
||||
channelRepository := repository.NewChannelRepository(db)
|
||||
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
|
||||
channelCachePubSub := repository.NewChannelCache(redisClient)
|
||||
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService, channelCachePubSub)
|
||||
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
|
||||
compositeModelRouteRepository := repository.NewCompositeModelRouteRepository(client)
|
||||
compositeRouteResolver := service.NewCompositeRouteResolver(compositeModelRouteRepository)
|
||||
@@ -189,10 +190,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache)
|
||||
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
|
||||
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
|
||||
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService)
|
||||
adminService := service.NewAdminService(configConfig, userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService)
|
||||
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService, settingService)
|
||||
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
|
||||
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
|
||||
groupHandler := admin.NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, configConfig)
|
||||
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
|
||||
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig)
|
||||
grokQuotaFetcher := service.NewGrokQuotaFetcher()
|
||||
@@ -205,12 +206,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo)
|
||||
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||
accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
|
||||
accountHandler := admin.ProvideAccountHandler(configConfig, adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
|
||||
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
||||
dataManagementService := service.NewDataManagementService()
|
||||
dataManagementHandler := admin.NewDataManagementHandler(dataManagementService)
|
||||
backupObjectStoreFactory := repository.NewS3BackupStoreFactory()
|
||||
dbDumper := repository.NewPgDumper(configConfig)
|
||||
dbDumper := repository.NewPgDumper(configConfig, db)
|
||||
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper, leaderLockCache, db)
|
||||
imageStorageFactory := repository.ProvideImageStorageFactory()
|
||||
imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig)
|
||||
|
||||
@@ -182,6 +182,7 @@ const (
|
||||
ProviderKimi Provider = "kimi"
|
||||
ProviderZhipu Provider = "zhipu"
|
||||
ProviderDeepseek Provider = "deepseek"
|
||||
ProviderMinimax Provider = "minimax"
|
||||
)
|
||||
|
||||
func (pr Provider) String() string {
|
||||
@@ -191,7 +192,7 @@ func (pr Provider) String() string {
|
||||
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
|
||||
func ProviderValidator(pr Provider) error {
|
||||
switch pr {
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr)
|
||||
|
||||
@@ -108,6 +108,7 @@ const (
|
||||
ProviderKimi Provider = "kimi"
|
||||
ProviderZhipu Provider = "zhipu"
|
||||
ProviderDeepseek Provider = "deepseek"
|
||||
ProviderMinimax Provider = "minimax"
|
||||
)
|
||||
|
||||
func (pr Provider) String() string {
|
||||
@@ -117,7 +118,7 @@ func (pr Provider) String() string {
|
||||
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
|
||||
func ProviderValidator(pr Provider) error {
|
||||
switch pr {
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr)
|
||||
|
||||
+17
-1
@@ -4595,6 +4595,22 @@ func (c *ProxyClient) QueryAccounts(_m *Proxy) *AccountQuery {
|
||||
return query
|
||||
}
|
||||
|
||||
// QueryPrimaryProxies queries the primary_proxies edge of a Proxy.
|
||||
func (c *ProxyClient) QueryPrimaryProxies(_m *Proxy) *ProxyQuery {
|
||||
query := (&ProxyClient{config: c.config}).Query()
|
||||
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
|
||||
id := _m.ID
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(proxy.Table, proxy.FieldID, id),
|
||||
sqlgraph.To(proxy.Table, proxy.FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2M, true, proxy.PrimaryProxiesTable, proxy.PrimaryProxiesColumn),
|
||||
)
|
||||
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
|
||||
return fromV, nil
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// QueryBackupProxy queries the backup_proxy edge of a Proxy.
|
||||
func (c *ProxyClient) QueryBackupProxy(_m *Proxy) *ProxyQuery {
|
||||
query := (&ProxyClient{config: c.config}).Query()
|
||||
@@ -4603,7 +4619,7 @@ func (c *ProxyClient) QueryBackupProxy(_m *Proxy) *ProxyQuery {
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(proxy.Table, proxy.FieldID, id),
|
||||
sqlgraph.To(proxy.Table, proxy.FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
|
||||
sqlgraph.Edge(sqlgraph.M2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
|
||||
)
|
||||
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
|
||||
return fromV, nil
|
||||
|
||||
+23
-10
@@ -134,15 +134,17 @@ type Group struct {
|
||||
DefaultMappedModel string `json:"default_mapped_model,omitempty"`
|
||||
// OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型
|
||||
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"`
|
||||
// 自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度
|
||||
ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config,omitempty"`
|
||||
// 分组模型白名单:同时约束模型列表接口与请求准入
|
||||
ModelAllowlist domain.GroupModelAllowlist `json:"model_allowlist,omitempty"`
|
||||
// 固定账号获取 Codex Model Manifest 配置;开启后 /models 请求只用选定账号拉取(仅 openai 平台)
|
||||
CodexModelsManifestConfig domain.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config,omitempty"`
|
||||
// 分组 RPM 上限,0 表示不限制;设置后接管该分组用户的限流
|
||||
RpmLimit int `json:"rpm_limit,omitempty"`
|
||||
// OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"`
|
||||
// 超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"`
|
||||
// OpenAI reasoning effort 自定义精确映射;先映射再应用上限
|
||||
// OpenAI reasoning effort 自定义映射;可按模型精确名、前缀或后缀限定,先映射再应用上限
|
||||
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings,omitempty"`
|
||||
// 是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池
|
||||
ProfitControlEnabled bool `json:"profit_control_enabled,omitempty"`
|
||||
@@ -256,7 +258,7 @@ func (*Group) scanValues(columns []string) ([]any, error) {
|
||||
values := make([]any, len(columns))
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings:
|
||||
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelAllowlist, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings:
|
||||
values[i] = new([]byte)
|
||||
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
|
||||
values[i] = new(sql.NullBool)
|
||||
@@ -660,12 +662,20 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
return fmt.Errorf("unmarshal field messages_dispatch_model_config: %w", err)
|
||||
}
|
||||
}
|
||||
case group.FieldModelsListConfig:
|
||||
case group.FieldModelAllowlist:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field models_list_config", values[i])
|
||||
return fmt.Errorf("unexpected type %T for field model_allowlist", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.ModelsListConfig); err != nil {
|
||||
return fmt.Errorf("unmarshal field models_list_config: %w", err)
|
||||
if err := json.Unmarshal(*value, &_m.ModelAllowlist); err != nil {
|
||||
return fmt.Errorf("unmarshal field model_allowlist: %w", err)
|
||||
}
|
||||
}
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field codex_models_manifest_config", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.CodexModelsManifestConfig); err != nil {
|
||||
return fmt.Errorf("unmarshal field codex_models_manifest_config: %w", err)
|
||||
}
|
||||
}
|
||||
case group.FieldRpmLimit:
|
||||
@@ -997,8 +1007,11 @@ func (_m *Group) String() string {
|
||||
builder.WriteString("messages_dispatch_model_config=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.MessagesDispatchModelConfig))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("models_list_config=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ModelsListConfig))
|
||||
builder.WriteString("model_allowlist=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ModelAllowlist))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("codex_models_manifest_config=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.CodexModelsManifestConfig))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("rpm_limit=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.RpmLimit))
|
||||
|
||||
@@ -130,8 +130,10 @@ const (
|
||||
FieldDefaultMappedModel = "default_mapped_model"
|
||||
// FieldMessagesDispatchModelConfig holds the string denoting the messages_dispatch_model_config field in the database.
|
||||
FieldMessagesDispatchModelConfig = "messages_dispatch_model_config"
|
||||
// FieldModelsListConfig holds the string denoting the models_list_config field in the database.
|
||||
FieldModelsListConfig = "models_list_config"
|
||||
// FieldModelAllowlist holds the string denoting the model_allowlist field in the database.
|
||||
FieldModelAllowlist = "model_allowlist"
|
||||
// FieldCodexModelsManifestConfig holds the string denoting the codex_models_manifest_config field in the database.
|
||||
FieldCodexModelsManifestConfig = "codex_models_manifest_config"
|
||||
// FieldRpmLimit holds the string denoting the rpm_limit field in the database.
|
||||
FieldRpmLimit = "rpm_limit"
|
||||
// FieldMaxReasoningEffort holds the string denoting the max_reasoning_effort field in the database.
|
||||
@@ -278,7 +280,8 @@ var Columns = []string{
|
||||
FieldRequirePrivacySet,
|
||||
FieldDefaultMappedModel,
|
||||
FieldMessagesDispatchModelConfig,
|
||||
FieldModelsListConfig,
|
||||
FieldModelAllowlist,
|
||||
FieldCodexModelsManifestConfig,
|
||||
FieldRpmLimit,
|
||||
FieldMaxReasoningEffort,
|
||||
FieldMaxReasoningEffortOverLimit,
|
||||
@@ -409,8 +412,10 @@ var (
|
||||
DefaultMappedModelValidator func(string) error
|
||||
// DefaultMessagesDispatchModelConfig holds the default value on creation for the "messages_dispatch_model_config" field.
|
||||
DefaultMessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig
|
||||
// DefaultModelsListConfig holds the default value on creation for the "models_list_config" field.
|
||||
DefaultModelsListConfig domain.GroupModelsListConfig
|
||||
// DefaultModelAllowlist holds the default value on creation for the "model_allowlist" field.
|
||||
DefaultModelAllowlist domain.GroupModelAllowlist
|
||||
// DefaultCodexModelsManifestConfig holds the default value on creation for the "codex_models_manifest_config" field.
|
||||
DefaultCodexModelsManifestConfig domain.GroupCodexModelsManifestConfig
|
||||
// DefaultRpmLimit holds the default value on creation for the "rpm_limit" field.
|
||||
DefaultRpmLimit int
|
||||
// DefaultMaxReasoningEffort holds the default value on creation for the "max_reasoning_effort" field.
|
||||
|
||||
+97
-32
@@ -788,16 +788,30 @@ func (_c *GroupCreate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMe
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (_c *GroupCreate) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupCreate {
|
||||
_c.mutation.SetModelsListConfig(v)
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (_c *GroupCreate) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupCreate {
|
||||
_c.mutation.SetModelAllowlist(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupCreate {
|
||||
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetModelsListConfig(*v)
|
||||
_c.SetModelAllowlist(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (_c *GroupCreate) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupCreate {
|
||||
_c.mutation.SetCodexModelsManifestConfig(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetCodexModelsManifestConfig(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
@@ -1161,9 +1175,13 @@ func (_c *GroupCreate) defaults() error {
|
||||
v := group.DefaultMessagesDispatchModelConfig
|
||||
_c.mutation.SetMessagesDispatchModelConfig(v)
|
||||
}
|
||||
if _, ok := _c.mutation.ModelsListConfig(); !ok {
|
||||
v := group.DefaultModelsListConfig
|
||||
_c.mutation.SetModelsListConfig(v)
|
||||
if _, ok := _c.mutation.ModelAllowlist(); !ok {
|
||||
v := group.DefaultModelAllowlist
|
||||
_c.mutation.SetModelAllowlist(v)
|
||||
}
|
||||
if _, ok := _c.mutation.CodexModelsManifestConfig(); !ok {
|
||||
v := group.DefaultCodexModelsManifestConfig
|
||||
_c.mutation.SetCodexModelsManifestConfig(v)
|
||||
}
|
||||
if _, ok := _c.mutation.RpmLimit(); !ok {
|
||||
v := group.DefaultRpmLimit
|
||||
@@ -1363,8 +1381,11 @@ func (_c *GroupCreate) check() error {
|
||||
if _, ok := _c.mutation.MessagesDispatchModelConfig(); !ok {
|
||||
return &ValidationError{Name: "messages_dispatch_model_config", err: errors.New(`ent: missing required field "Group.messages_dispatch_model_config"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.ModelsListConfig(); !ok {
|
||||
return &ValidationError{Name: "models_list_config", err: errors.New(`ent: missing required field "Group.models_list_config"`)}
|
||||
if _, ok := _c.mutation.ModelAllowlist(); !ok {
|
||||
return &ValidationError{Name: "model_allowlist", err: errors.New(`ent: missing required field "Group.model_allowlist"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.CodexModelsManifestConfig(); !ok {
|
||||
return &ValidationError{Name: "codex_models_manifest_config", err: errors.New(`ent: missing required field "Group.codex_models_manifest_config"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.RpmLimit(); !ok {
|
||||
return &ValidationError{Name: "rpm_limit", err: errors.New(`ent: missing required field "Group.rpm_limit"`)}
|
||||
@@ -1652,9 +1673,13 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
|
||||
_node.MessagesDispatchModelConfig = value
|
||||
}
|
||||
if value, ok := _c.mutation.ModelsListConfig(); ok {
|
||||
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
|
||||
_node.ModelsListConfig = value
|
||||
if value, ok := _c.mutation.ModelAllowlist(); ok {
|
||||
_spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value)
|
||||
_node.ModelAllowlist = value
|
||||
}
|
||||
if value, ok := _c.mutation.CodexModelsManifestConfig(); ok {
|
||||
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
|
||||
_node.CodexModelsManifestConfig = value
|
||||
}
|
||||
if value, ok := _c.mutation.RpmLimit(); ok {
|
||||
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
|
||||
@@ -2770,15 +2795,27 @@ func (u *GroupUpsert) UpdateMessagesDispatchModelConfig() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (u *GroupUpsert) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsert {
|
||||
u.Set(group.FieldModelsListConfig, v)
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (u *GroupUpsert) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsert {
|
||||
u.Set(group.FieldModelAllowlist, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateModelsListConfig() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldModelsListConfig)
|
||||
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateModelAllowlist() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldModelAllowlist)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (u *GroupUpsert) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsert {
|
||||
u.Set(group.FieldCodexModelsManifestConfig, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateCodexModelsManifestConfig() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldCodexModelsManifestConfig)
|
||||
return u
|
||||
}
|
||||
|
||||
@@ -4017,17 +4054,31 @@ func (u *GroupUpsertOne) UpdateMessagesDispatchModelConfig() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (u *GroupUpsertOne) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsertOne {
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (u *GroupUpsertOne) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetModelsListConfig(v)
|
||||
s.SetModelAllowlist(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateModelsListConfig() *GroupUpsertOne {
|
||||
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateModelAllowlist() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateModelsListConfig()
|
||||
s.UpdateModelAllowlist()
|
||||
})
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (u *GroupUpsertOne) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetCodexModelsManifestConfig(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateCodexModelsManifestConfig() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateCodexModelsManifestConfig()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5449,17 +5500,31 @@ func (u *GroupUpsertBulk) UpdateMessagesDispatchModelConfig() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (u *GroupUpsertBulk) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsertBulk {
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (u *GroupUpsertBulk) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetModelsListConfig(v)
|
||||
s.SetModelAllowlist(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateModelsListConfig() *GroupUpsertBulk {
|
||||
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateModelAllowlist() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateModelsListConfig()
|
||||
s.UpdateModelAllowlist()
|
||||
})
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (u *GroupUpsertBulk) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetCodexModelsManifestConfig(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateCodexModelsManifestConfig() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateCodexModelsManifestConfig()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+50
-16
@@ -1073,16 +1073,30 @@ func (_u *GroupUpdate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMe
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (_u *GroupUpdate) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpdate {
|
||||
_u.mutation.SetModelsListConfig(v)
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (_u *GroupUpdate) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpdate {
|
||||
_u.mutation.SetModelAllowlist(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupUpdate {
|
||||
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetModelsListConfig(*v)
|
||||
_u.SetModelAllowlist(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (_u *GroupUpdate) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpdate {
|
||||
_u.mutation.SetCodexModelsManifestConfig(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetCodexModelsManifestConfig(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
@@ -1862,8 +1876,11 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
|
||||
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ModelsListConfig(); ok {
|
||||
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
|
||||
if value, ok := _u.mutation.ModelAllowlist(); ok {
|
||||
_spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.CodexModelsManifestConfig(); ok {
|
||||
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.RpmLimit(); ok {
|
||||
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
|
||||
@@ -3250,16 +3267,30 @@ func (_u *GroupUpdateOne) SetNillableMessagesDispatchModelConfig(v *domain.OpenA
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (_u *GroupUpdateOne) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpdateOne {
|
||||
_u.mutation.SetModelsListConfig(v)
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (_u *GroupUpdateOne) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpdateOne {
|
||||
_u.mutation.SetModelAllowlist(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupUpdateOne {
|
||||
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetModelsListConfig(*v)
|
||||
_u.SetModelAllowlist(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (_u *GroupUpdateOne) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpdateOne {
|
||||
_u.mutation.SetCodexModelsManifestConfig(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetCodexModelsManifestConfig(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
@@ -4069,8 +4100,11 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
|
||||
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ModelsListConfig(); ok {
|
||||
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
|
||||
if value, ok := _u.mutation.ModelAllowlist(); ok {
|
||||
_spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.CodexModelsManifestConfig(); ok {
|
||||
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.RpmLimit(); ok {
|
||||
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
|
||||
|
||||
@@ -623,7 +623,7 @@ var (
|
||||
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "name", Type: field.TypeString, Size: 100},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax"}},
|
||||
{Name: "check_mode", Type: field.TypeString, Size: 32, Default: "probe"},
|
||||
{Name: "account_id", Type: field.TypeInt64, Nullable: true},
|
||||
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
|
||||
@@ -776,7 +776,7 @@ var (
|
||||
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "name", Type: field.TypeString, Size: 100},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax"}},
|
||||
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
|
||||
{Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
|
||||
{Name: "extra_headers", Type: field.TypeJSON},
|
||||
@@ -960,7 +960,8 @@ var (
|
||||
{Name: "require_privacy_set", Type: field.TypeBool, Default: false},
|
||||
{Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""},
|
||||
{Name: "messages_dispatch_model_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "models_list_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "model_allowlist", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "codex_models_manifest_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "rpm_limit", Type: field.TypeInt, Default: 0},
|
||||
{Name: "max_reasoning_effort", Type: field.TypeString, Size: 20, Default: ""},
|
||||
{Name: "max_reasoning_effort_over_limit", Type: field.TypeString, Size: 20, Default: "downgrade"},
|
||||
@@ -1412,7 +1413,7 @@ var (
|
||||
{Name: "expires_at", Type: field.TypeTime, Nullable: true},
|
||||
{Name: "fallback_mode", Type: field.TypeString, Size: 20, Default: "none"},
|
||||
{Name: "expiry_warn_days", Type: field.TypeInt, Default: 7},
|
||||
{Name: "backup_proxy_id", Type: field.TypeInt64, Unique: true, Nullable: true},
|
||||
{Name: "backup_proxy_id", Type: field.TypeInt64, Nullable: true},
|
||||
}
|
||||
// ProxiesTable holds the schema information for the "proxies" table.
|
||||
ProxiesTable = &schema.Table{
|
||||
|
||||
+195
-58
@@ -22164,7 +22164,8 @@ type GroupMutation struct {
|
||||
require_privacy_set *bool
|
||||
default_mapped_model *string
|
||||
messages_dispatch_model_config *domain.OpenAIMessagesDispatchModelConfig
|
||||
models_list_config *domain.GroupModelsListConfig
|
||||
model_allowlist *domain.GroupModelAllowlist
|
||||
codex_models_manifest_config *domain.GroupCodexModelsManifestConfig
|
||||
rpm_limit *int
|
||||
addrpm_limit *int
|
||||
max_reasoning_effort *string
|
||||
@@ -25163,40 +25164,76 @@ func (m *GroupMutation) ResetMessagesDispatchModelConfig() {
|
||||
m.messages_dispatch_model_config = nil
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (m *GroupMutation) SetModelsListConfig(dmlc domain.GroupModelsListConfig) {
|
||||
m.models_list_config = &dmlc
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (m *GroupMutation) SetModelAllowlist(dma domain.GroupModelAllowlist) {
|
||||
m.model_allowlist = &dma
|
||||
}
|
||||
|
||||
// ModelsListConfig returns the value of the "models_list_config" field in the mutation.
|
||||
func (m *GroupMutation) ModelsListConfig() (r domain.GroupModelsListConfig, exists bool) {
|
||||
v := m.models_list_config
|
||||
// ModelAllowlist returns the value of the "model_allowlist" field in the mutation.
|
||||
func (m *GroupMutation) ModelAllowlist() (r domain.GroupModelAllowlist, exists bool) {
|
||||
v := m.model_allowlist
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldModelsListConfig returns the old "models_list_config" field's value of the Group entity.
|
||||
// OldModelAllowlist returns the old "model_allowlist" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldModelsListConfig(ctx context.Context) (v domain.GroupModelsListConfig, err error) {
|
||||
func (m *GroupMutation) OldModelAllowlist(ctx context.Context) (v domain.GroupModelAllowlist, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldModelsListConfig is only allowed on UpdateOne operations")
|
||||
return v, errors.New("OldModelAllowlist is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldModelsListConfig requires an ID field in the mutation")
|
||||
return v, errors.New("OldModelAllowlist requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldModelsListConfig: %w", err)
|
||||
return v, fmt.Errorf("querying old value for OldModelAllowlist: %w", err)
|
||||
}
|
||||
return oldValue.ModelsListConfig, nil
|
||||
return oldValue.ModelAllowlist, nil
|
||||
}
|
||||
|
||||
// ResetModelsListConfig resets all changes to the "models_list_config" field.
|
||||
func (m *GroupMutation) ResetModelsListConfig() {
|
||||
m.models_list_config = nil
|
||||
// ResetModelAllowlist resets all changes to the "model_allowlist" field.
|
||||
func (m *GroupMutation) ResetModelAllowlist() {
|
||||
m.model_allowlist = nil
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (m *GroupMutation) SetCodexModelsManifestConfig(dcmmc domain.GroupCodexModelsManifestConfig) {
|
||||
m.codex_models_manifest_config = &dcmmc
|
||||
}
|
||||
|
||||
// CodexModelsManifestConfig returns the value of the "codex_models_manifest_config" field in the mutation.
|
||||
func (m *GroupMutation) CodexModelsManifestConfig() (r domain.GroupCodexModelsManifestConfig, exists bool) {
|
||||
v := m.codex_models_manifest_config
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldCodexModelsManifestConfig returns the old "codex_models_manifest_config" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldCodexModelsManifestConfig(ctx context.Context) (v domain.GroupCodexModelsManifestConfig, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldCodexModelsManifestConfig is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldCodexModelsManifestConfig requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldCodexModelsManifestConfig: %w", err)
|
||||
}
|
||||
return oldValue.CodexModelsManifestConfig, nil
|
||||
}
|
||||
|
||||
// ResetCodexModelsManifestConfig resets all changes to the "codex_models_manifest_config" field.
|
||||
func (m *GroupMutation) ResetCodexModelsManifestConfig() {
|
||||
m.codex_models_manifest_config = nil
|
||||
}
|
||||
|
||||
// SetRpmLimit sets the "rpm_limit" field.
|
||||
@@ -25884,7 +25921,7 @@ func (m *GroupMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *GroupMutation) Fields() []string {
|
||||
fields := make([]string, 0, 65)
|
||||
fields := make([]string, 0, 66)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, group.FieldCreatedAt)
|
||||
}
|
||||
@@ -26056,8 +26093,11 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.messages_dispatch_model_config != nil {
|
||||
fields = append(fields, group.FieldMessagesDispatchModelConfig)
|
||||
}
|
||||
if m.models_list_config != nil {
|
||||
fields = append(fields, group.FieldModelsListConfig)
|
||||
if m.model_allowlist != nil {
|
||||
fields = append(fields, group.FieldModelAllowlist)
|
||||
}
|
||||
if m.codex_models_manifest_config != nil {
|
||||
fields = append(fields, group.FieldCodexModelsManifestConfig)
|
||||
}
|
||||
if m.rpm_limit != nil {
|
||||
fields = append(fields, group.FieldRpmLimit)
|
||||
@@ -26202,8 +26242,10 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.DefaultMappedModel()
|
||||
case group.FieldMessagesDispatchModelConfig:
|
||||
return m.MessagesDispatchModelConfig()
|
||||
case group.FieldModelsListConfig:
|
||||
return m.ModelsListConfig()
|
||||
case group.FieldModelAllowlist:
|
||||
return m.ModelAllowlist()
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
return m.CodexModelsManifestConfig()
|
||||
case group.FieldRpmLimit:
|
||||
return m.RpmLimit()
|
||||
case group.FieldMaxReasoningEffort:
|
||||
@@ -26341,8 +26383,10 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldDefaultMappedModel(ctx)
|
||||
case group.FieldMessagesDispatchModelConfig:
|
||||
return m.OldMessagesDispatchModelConfig(ctx)
|
||||
case group.FieldModelsListConfig:
|
||||
return m.OldModelsListConfig(ctx)
|
||||
case group.FieldModelAllowlist:
|
||||
return m.OldModelAllowlist(ctx)
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
return m.OldCodexModelsManifestConfig(ctx)
|
||||
case group.FieldRpmLimit:
|
||||
return m.OldRpmLimit(ctx)
|
||||
case group.FieldMaxReasoningEffort:
|
||||
@@ -26765,12 +26809,19 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetMessagesDispatchModelConfig(v)
|
||||
return nil
|
||||
case group.FieldModelsListConfig:
|
||||
v, ok := value.(domain.GroupModelsListConfig)
|
||||
case group.FieldModelAllowlist:
|
||||
v, ok := value.(domain.GroupModelAllowlist)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetModelsListConfig(v)
|
||||
m.SetModelAllowlist(v)
|
||||
return nil
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
v, ok := value.(domain.GroupCodexModelsManifestConfig)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetCodexModelsManifestConfig(v)
|
||||
return nil
|
||||
case group.FieldRpmLimit:
|
||||
v, ok := value.(int)
|
||||
@@ -27503,8 +27554,11 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldMessagesDispatchModelConfig:
|
||||
m.ResetMessagesDispatchModelConfig()
|
||||
return nil
|
||||
case group.FieldModelsListConfig:
|
||||
m.ResetModelsListConfig()
|
||||
case group.FieldModelAllowlist:
|
||||
m.ResetModelAllowlist()
|
||||
return nil
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
m.ResetCodexModelsManifestConfig()
|
||||
return nil
|
||||
case group.FieldRpmLimit:
|
||||
m.ResetRpmLimit()
|
||||
@@ -37312,33 +37366,36 @@ func (m *PromoCodeUsageMutation) ResetEdge(name string) error {
|
||||
// ProxyMutation represents an operation that mutates the Proxy nodes in the graph.
|
||||
type ProxyMutation struct {
|
||||
config
|
||||
op Op
|
||||
typ string
|
||||
id *int64
|
||||
created_at *time.Time
|
||||
updated_at *time.Time
|
||||
deleted_at *time.Time
|
||||
name *string
|
||||
protocol *string
|
||||
host *string
|
||||
port *int
|
||||
addport *int
|
||||
username *string
|
||||
password *string
|
||||
status *string
|
||||
expires_at *time.Time
|
||||
fallback_mode *string
|
||||
expiry_warn_days *int
|
||||
addexpiry_warn_days *int
|
||||
clearedFields map[string]struct{}
|
||||
accounts map[int64]struct{}
|
||||
removedaccounts map[int64]struct{}
|
||||
clearedaccounts bool
|
||||
backup_proxy *int64
|
||||
clearedbackup_proxy bool
|
||||
done bool
|
||||
oldValue func(context.Context) (*Proxy, error)
|
||||
predicates []predicate.Proxy
|
||||
op Op
|
||||
typ string
|
||||
id *int64
|
||||
created_at *time.Time
|
||||
updated_at *time.Time
|
||||
deleted_at *time.Time
|
||||
name *string
|
||||
protocol *string
|
||||
host *string
|
||||
port *int
|
||||
addport *int
|
||||
username *string
|
||||
password *string
|
||||
status *string
|
||||
expires_at *time.Time
|
||||
fallback_mode *string
|
||||
expiry_warn_days *int
|
||||
addexpiry_warn_days *int
|
||||
clearedFields map[string]struct{}
|
||||
accounts map[int64]struct{}
|
||||
removedaccounts map[int64]struct{}
|
||||
clearedaccounts bool
|
||||
primary_proxies map[int64]struct{}
|
||||
removedprimary_proxies map[int64]struct{}
|
||||
clearedprimary_proxies bool
|
||||
backup_proxy *int64
|
||||
clearedbackup_proxy bool
|
||||
done bool
|
||||
oldValue func(context.Context) (*Proxy, error)
|
||||
predicates []predicate.Proxy
|
||||
}
|
||||
|
||||
var _ ent.Mutation = (*ProxyMutation)(nil)
|
||||
@@ -38102,6 +38159,60 @@ func (m *ProxyMutation) ResetAccounts() {
|
||||
m.removedaccounts = nil
|
||||
}
|
||||
|
||||
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by ids.
|
||||
func (m *ProxyMutation) AddPrimaryProxyIDs(ids ...int64) {
|
||||
if m.primary_proxies == nil {
|
||||
m.primary_proxies = make(map[int64]struct{})
|
||||
}
|
||||
for i := range ids {
|
||||
m.primary_proxies[ids[i]] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// ClearPrimaryProxies clears the "primary_proxies" edge to the Proxy entity.
|
||||
func (m *ProxyMutation) ClearPrimaryProxies() {
|
||||
m.clearedprimary_proxies = true
|
||||
}
|
||||
|
||||
// PrimaryProxiesCleared reports if the "primary_proxies" edge to the Proxy entity was cleared.
|
||||
func (m *ProxyMutation) PrimaryProxiesCleared() bool {
|
||||
return m.clearedprimary_proxies
|
||||
}
|
||||
|
||||
// RemovePrimaryProxyIDs removes the "primary_proxies" edge to the Proxy entity by IDs.
|
||||
func (m *ProxyMutation) RemovePrimaryProxyIDs(ids ...int64) {
|
||||
if m.removedprimary_proxies == nil {
|
||||
m.removedprimary_proxies = make(map[int64]struct{})
|
||||
}
|
||||
for i := range ids {
|
||||
delete(m.primary_proxies, ids[i])
|
||||
m.removedprimary_proxies[ids[i]] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// RemovedPrimaryProxies returns the removed IDs of the "primary_proxies" edge to the Proxy entity.
|
||||
func (m *ProxyMutation) RemovedPrimaryProxiesIDs() (ids []int64) {
|
||||
for id := range m.removedprimary_proxies {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// PrimaryProxiesIDs returns the "primary_proxies" edge IDs in the mutation.
|
||||
func (m *ProxyMutation) PrimaryProxiesIDs() (ids []int64) {
|
||||
for id := range m.primary_proxies {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ResetPrimaryProxies resets all changes to the "primary_proxies" edge.
|
||||
func (m *ProxyMutation) ResetPrimaryProxies() {
|
||||
m.primary_proxies = nil
|
||||
m.clearedprimary_proxies = false
|
||||
m.removedprimary_proxies = nil
|
||||
}
|
||||
|
||||
// ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity.
|
||||
func (m *ProxyMutation) ClearBackupProxy() {
|
||||
m.clearedbackup_proxy = true
|
||||
@@ -38543,10 +38654,13 @@ func (m *ProxyMutation) ResetField(name string) error {
|
||||
|
||||
// AddedEdges returns all edge names that were set/added in this mutation.
|
||||
func (m *ProxyMutation) AddedEdges() []string {
|
||||
edges := make([]string, 0, 2)
|
||||
edges := make([]string, 0, 3)
|
||||
if m.accounts != nil {
|
||||
edges = append(edges, proxy.EdgeAccounts)
|
||||
}
|
||||
if m.primary_proxies != nil {
|
||||
edges = append(edges, proxy.EdgePrimaryProxies)
|
||||
}
|
||||
if m.backup_proxy != nil {
|
||||
edges = append(edges, proxy.EdgeBackupProxy)
|
||||
}
|
||||
@@ -38563,6 +38677,12 @@ func (m *ProxyMutation) AddedIDs(name string) []ent.Value {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
case proxy.EdgePrimaryProxies:
|
||||
ids := make([]ent.Value, 0, len(m.primary_proxies))
|
||||
for id := range m.primary_proxies {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
case proxy.EdgeBackupProxy:
|
||||
if id := m.backup_proxy; id != nil {
|
||||
return []ent.Value{*id}
|
||||
@@ -38573,10 +38693,13 @@ func (m *ProxyMutation) AddedIDs(name string) []ent.Value {
|
||||
|
||||
// RemovedEdges returns all edge names that were removed in this mutation.
|
||||
func (m *ProxyMutation) RemovedEdges() []string {
|
||||
edges := make([]string, 0, 2)
|
||||
edges := make([]string, 0, 3)
|
||||
if m.removedaccounts != nil {
|
||||
edges = append(edges, proxy.EdgeAccounts)
|
||||
}
|
||||
if m.removedprimary_proxies != nil {
|
||||
edges = append(edges, proxy.EdgePrimaryProxies)
|
||||
}
|
||||
return edges
|
||||
}
|
||||
|
||||
@@ -38590,16 +38713,25 @@ func (m *ProxyMutation) RemovedIDs(name string) []ent.Value {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
case proxy.EdgePrimaryProxies:
|
||||
ids := make([]ent.Value, 0, len(m.removedprimary_proxies))
|
||||
for id := range m.removedprimary_proxies {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearedEdges returns all edge names that were cleared in this mutation.
|
||||
func (m *ProxyMutation) ClearedEdges() []string {
|
||||
edges := make([]string, 0, 2)
|
||||
edges := make([]string, 0, 3)
|
||||
if m.clearedaccounts {
|
||||
edges = append(edges, proxy.EdgeAccounts)
|
||||
}
|
||||
if m.clearedprimary_proxies {
|
||||
edges = append(edges, proxy.EdgePrimaryProxies)
|
||||
}
|
||||
if m.clearedbackup_proxy {
|
||||
edges = append(edges, proxy.EdgeBackupProxy)
|
||||
}
|
||||
@@ -38612,6 +38744,8 @@ func (m *ProxyMutation) EdgeCleared(name string) bool {
|
||||
switch name {
|
||||
case proxy.EdgeAccounts:
|
||||
return m.clearedaccounts
|
||||
case proxy.EdgePrimaryProxies:
|
||||
return m.clearedprimary_proxies
|
||||
case proxy.EdgeBackupProxy:
|
||||
return m.clearedbackup_proxy
|
||||
}
|
||||
@@ -38636,6 +38770,9 @@ func (m *ProxyMutation) ResetEdge(name string) error {
|
||||
case proxy.EdgeAccounts:
|
||||
m.ResetAccounts()
|
||||
return nil
|
||||
case proxy.EdgePrimaryProxies:
|
||||
m.ResetPrimaryProxies()
|
||||
return nil
|
||||
case proxy.EdgeBackupProxy:
|
||||
m.ResetBackupProxy()
|
||||
return nil
|
||||
|
||||
+18
-2
@@ -55,11 +55,13 @@ type Proxy struct {
|
||||
type ProxyEdges struct {
|
||||
// Accounts holds the value of the accounts edge.
|
||||
Accounts []*Account `json:"accounts,omitempty"`
|
||||
// PrimaryProxies holds the value of the primary_proxies edge.
|
||||
PrimaryProxies []*Proxy `json:"primary_proxies,omitempty"`
|
||||
// BackupProxy holds the value of the backup_proxy edge.
|
||||
BackupProxy *Proxy `json:"backup_proxy,omitempty"`
|
||||
// loadedTypes holds the information for reporting if a
|
||||
// type was loaded (or requested) in eager-loading or not.
|
||||
loadedTypes [2]bool
|
||||
loadedTypes [3]bool
|
||||
}
|
||||
|
||||
// AccountsOrErr returns the Accounts value or an error if the edge
|
||||
@@ -71,12 +73,21 @@ func (e ProxyEdges) AccountsOrErr() ([]*Account, error) {
|
||||
return nil, &NotLoadedError{edge: "accounts"}
|
||||
}
|
||||
|
||||
// PrimaryProxiesOrErr returns the PrimaryProxies value or an error if the edge
|
||||
// was not loaded in eager-loading.
|
||||
func (e ProxyEdges) PrimaryProxiesOrErr() ([]*Proxy, error) {
|
||||
if e.loadedTypes[1] {
|
||||
return e.PrimaryProxies, nil
|
||||
}
|
||||
return nil, &NotLoadedError{edge: "primary_proxies"}
|
||||
}
|
||||
|
||||
// BackupProxyOrErr returns the BackupProxy value or an error if the edge
|
||||
// was not loaded in eager-loading, or loaded but was not found.
|
||||
func (e ProxyEdges) BackupProxyOrErr() (*Proxy, error) {
|
||||
if e.BackupProxy != nil {
|
||||
return e.BackupProxy, nil
|
||||
} else if e.loadedTypes[1] {
|
||||
} else if e.loadedTypes[2] {
|
||||
return nil, &NotFoundError{label: proxy.Label}
|
||||
}
|
||||
return nil, &NotLoadedError{edge: "backup_proxy"}
|
||||
@@ -221,6 +232,11 @@ func (_m *Proxy) QueryAccounts() *AccountQuery {
|
||||
return NewProxyClient(_m.config).QueryAccounts(_m)
|
||||
}
|
||||
|
||||
// QueryPrimaryProxies queries the "primary_proxies" edge of the Proxy entity.
|
||||
func (_m *Proxy) QueryPrimaryProxies() *ProxyQuery {
|
||||
return NewProxyClient(_m.config).QueryPrimaryProxies(_m)
|
||||
}
|
||||
|
||||
// QueryBackupProxy queries the "backup_proxy" edge of the Proxy entity.
|
||||
func (_m *Proxy) QueryBackupProxy() *ProxyQuery {
|
||||
return NewProxyClient(_m.config).QueryBackupProxy(_m)
|
||||
|
||||
@@ -45,6 +45,8 @@ const (
|
||||
FieldExpiryWarnDays = "expiry_warn_days"
|
||||
// EdgeAccounts holds the string denoting the accounts edge name in mutations.
|
||||
EdgeAccounts = "accounts"
|
||||
// EdgePrimaryProxies holds the string denoting the primary_proxies edge name in mutations.
|
||||
EdgePrimaryProxies = "primary_proxies"
|
||||
// EdgeBackupProxy holds the string denoting the backup_proxy edge name in mutations.
|
||||
EdgeBackupProxy = "backup_proxy"
|
||||
// Table holds the table name of the proxy in the database.
|
||||
@@ -56,6 +58,10 @@ const (
|
||||
AccountsInverseTable = "accounts"
|
||||
// AccountsColumn is the table column denoting the accounts relation/edge.
|
||||
AccountsColumn = "proxy_id"
|
||||
// PrimaryProxiesTable is the table that holds the primary_proxies relation/edge.
|
||||
PrimaryProxiesTable = "proxies"
|
||||
// PrimaryProxiesColumn is the table column denoting the primary_proxies relation/edge.
|
||||
PrimaryProxiesColumn = "backup_proxy_id"
|
||||
// BackupProxyTable is the table that holds the backup_proxy relation/edge.
|
||||
BackupProxyTable = "proxies"
|
||||
// BackupProxyColumn is the table column denoting the backup_proxy relation/edge.
|
||||
@@ -219,6 +225,20 @@ func ByAccounts(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
|
||||
}
|
||||
}
|
||||
|
||||
// ByPrimaryProxiesCount orders the results by primary_proxies count.
|
||||
func ByPrimaryProxiesCount(opts ...sql.OrderTermOption) OrderOption {
|
||||
return func(s *sql.Selector) {
|
||||
sqlgraph.OrderByNeighborsCount(s, newPrimaryProxiesStep(), opts...)
|
||||
}
|
||||
}
|
||||
|
||||
// ByPrimaryProxies orders the results by primary_proxies terms.
|
||||
func ByPrimaryProxies(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
|
||||
return func(s *sql.Selector) {
|
||||
sqlgraph.OrderByNeighborTerms(s, newPrimaryProxiesStep(), append([]sql.OrderTerm{term}, terms...)...)
|
||||
}
|
||||
}
|
||||
|
||||
// ByBackupProxyField orders the results by backup_proxy field.
|
||||
func ByBackupProxyField(field string, opts ...sql.OrderTermOption) OrderOption {
|
||||
return func(s *sql.Selector) {
|
||||
@@ -232,10 +252,17 @@ func newAccountsStep() *sqlgraph.Step {
|
||||
sqlgraph.Edge(sqlgraph.O2M, true, AccountsTable, AccountsColumn),
|
||||
)
|
||||
}
|
||||
func newPrimaryProxiesStep() *sqlgraph.Step {
|
||||
return sqlgraph.NewStep(
|
||||
sqlgraph.From(Table, FieldID),
|
||||
sqlgraph.To(Table, FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2M, true, PrimaryProxiesTable, PrimaryProxiesColumn),
|
||||
)
|
||||
}
|
||||
func newBackupProxyStep() *sqlgraph.Step {
|
||||
return sqlgraph.NewStep(
|
||||
sqlgraph.From(Table, FieldID),
|
||||
sqlgraph.To(Table, FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2O, false, BackupProxyTable, BackupProxyColumn),
|
||||
sqlgraph.Edge(sqlgraph.M2O, false, BackupProxyTable, BackupProxyColumn),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -913,12 +913,35 @@ func HasAccountsWith(preds ...predicate.Account) predicate.Proxy {
|
||||
})
|
||||
}
|
||||
|
||||
// HasPrimaryProxies applies the HasEdge predicate on the "primary_proxies" edge.
|
||||
func HasPrimaryProxies() predicate.Proxy {
|
||||
return predicate.Proxy(func(s *sql.Selector) {
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(Table, FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2M, true, PrimaryProxiesTable, PrimaryProxiesColumn),
|
||||
)
|
||||
sqlgraph.HasNeighbors(s, step)
|
||||
})
|
||||
}
|
||||
|
||||
// HasPrimaryProxiesWith applies the HasEdge predicate on the "primary_proxies" edge with a given conditions (other predicates).
|
||||
func HasPrimaryProxiesWith(preds ...predicate.Proxy) predicate.Proxy {
|
||||
return predicate.Proxy(func(s *sql.Selector) {
|
||||
step := newPrimaryProxiesStep()
|
||||
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
|
||||
for _, p := range preds {
|
||||
p(s)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// HasBackupProxy applies the HasEdge predicate on the "backup_proxy" edge.
|
||||
func HasBackupProxy() predicate.Proxy {
|
||||
return predicate.Proxy(func(s *sql.Selector) {
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(Table, FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2O, false, BackupProxyTable, BackupProxyColumn),
|
||||
sqlgraph.Edge(sqlgraph.M2O, false, BackupProxyTable, BackupProxyColumn),
|
||||
)
|
||||
sqlgraph.HasNeighbors(s, step)
|
||||
})
|
||||
|
||||
@@ -202,6 +202,21 @@ func (_c *ProxyCreate) AddAccounts(v ...*Account) *ProxyCreate {
|
||||
return _c.AddAccountIDs(ids...)
|
||||
}
|
||||
|
||||
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs.
|
||||
func (_c *ProxyCreate) AddPrimaryProxyIDs(ids ...int64) *ProxyCreate {
|
||||
_c.mutation.AddPrimaryProxyIDs(ids...)
|
||||
return _c
|
||||
}
|
||||
|
||||
// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity.
|
||||
func (_c *ProxyCreate) AddPrimaryProxies(v ...*Proxy) *ProxyCreate {
|
||||
ids := make([]int64, len(v))
|
||||
for i := range v {
|
||||
ids[i] = v[i].ID
|
||||
}
|
||||
return _c.AddPrimaryProxyIDs(ids...)
|
||||
}
|
||||
|
||||
// SetBackupProxy sets the "backup_proxy" edge to the Proxy entity.
|
||||
func (_c *ProxyCreate) SetBackupProxy(v *Proxy) *ProxyCreate {
|
||||
return _c.SetBackupProxyID(v.ID)
|
||||
@@ -432,13 +447,29 @@ func (_c *ProxyCreate) createSpec() (*Proxy, *sqlgraph.CreateSpec) {
|
||||
}
|
||||
_spec.Edges = append(_spec.Edges, edge)
|
||||
}
|
||||
if nodes := _c.mutation.PrimaryProxiesIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: true,
|
||||
Table: proxy.PrimaryProxiesTable,
|
||||
Columns: []string{proxy.PrimaryProxiesColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges = append(_spec.Edges, edge)
|
||||
}
|
||||
if nodes := _c.mutation.BackupProxyIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2O,
|
||||
Rel: sqlgraph.M2O,
|
||||
Inverse: false,
|
||||
Table: proxy.BackupProxyTable,
|
||||
Columns: []string{proxy.BackupProxyColumn},
|
||||
Bidi: true,
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
|
||||
+92
-16
@@ -21,13 +21,14 @@ import (
|
||||
// ProxyQuery is the builder for querying Proxy entities.
|
||||
type ProxyQuery struct {
|
||||
config
|
||||
ctx *QueryContext
|
||||
order []proxy.OrderOption
|
||||
inters []Interceptor
|
||||
predicates []predicate.Proxy
|
||||
withAccounts *AccountQuery
|
||||
withBackupProxy *ProxyQuery
|
||||
modifiers []func(*sql.Selector)
|
||||
ctx *QueryContext
|
||||
order []proxy.OrderOption
|
||||
inters []Interceptor
|
||||
predicates []predicate.Proxy
|
||||
withAccounts *AccountQuery
|
||||
withPrimaryProxies *ProxyQuery
|
||||
withBackupProxy *ProxyQuery
|
||||
modifiers []func(*sql.Selector)
|
||||
// intermediate query (i.e. traversal path).
|
||||
sql *sql.Selector
|
||||
path func(context.Context) (*sql.Selector, error)
|
||||
@@ -86,6 +87,28 @@ func (_q *ProxyQuery) QueryAccounts() *AccountQuery {
|
||||
return query
|
||||
}
|
||||
|
||||
// QueryPrimaryProxies chains the current query on the "primary_proxies" edge.
|
||||
func (_q *ProxyQuery) QueryPrimaryProxies() *ProxyQuery {
|
||||
query := (&ProxyClient{config: _q.config}).Query()
|
||||
query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
|
||||
if err := _q.prepareQuery(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selector := _q.sqlQuery(ctx)
|
||||
if err := selector.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(proxy.Table, proxy.FieldID, selector),
|
||||
sqlgraph.To(proxy.Table, proxy.FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2M, true, proxy.PrimaryProxiesTable, proxy.PrimaryProxiesColumn),
|
||||
)
|
||||
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
|
||||
return fromU, nil
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// QueryBackupProxy chains the current query on the "backup_proxy" edge.
|
||||
func (_q *ProxyQuery) QueryBackupProxy() *ProxyQuery {
|
||||
query := (&ProxyClient{config: _q.config}).Query()
|
||||
@@ -100,7 +123,7 @@ func (_q *ProxyQuery) QueryBackupProxy() *ProxyQuery {
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(proxy.Table, proxy.FieldID, selector),
|
||||
sqlgraph.To(proxy.Table, proxy.FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
|
||||
sqlgraph.Edge(sqlgraph.M2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
|
||||
)
|
||||
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
|
||||
return fromU, nil
|
||||
@@ -295,13 +318,14 @@ func (_q *ProxyQuery) Clone() *ProxyQuery {
|
||||
return nil
|
||||
}
|
||||
return &ProxyQuery{
|
||||
config: _q.config,
|
||||
ctx: _q.ctx.Clone(),
|
||||
order: append([]proxy.OrderOption{}, _q.order...),
|
||||
inters: append([]Interceptor{}, _q.inters...),
|
||||
predicates: append([]predicate.Proxy{}, _q.predicates...),
|
||||
withAccounts: _q.withAccounts.Clone(),
|
||||
withBackupProxy: _q.withBackupProxy.Clone(),
|
||||
config: _q.config,
|
||||
ctx: _q.ctx.Clone(),
|
||||
order: append([]proxy.OrderOption{}, _q.order...),
|
||||
inters: append([]Interceptor{}, _q.inters...),
|
||||
predicates: append([]predicate.Proxy{}, _q.predicates...),
|
||||
withAccounts: _q.withAccounts.Clone(),
|
||||
withPrimaryProxies: _q.withPrimaryProxies.Clone(),
|
||||
withBackupProxy: _q.withBackupProxy.Clone(),
|
||||
// clone intermediate query.
|
||||
sql: _q.sql.Clone(),
|
||||
path: _q.path,
|
||||
@@ -319,6 +343,17 @@ func (_q *ProxyQuery) WithAccounts(opts ...func(*AccountQuery)) *ProxyQuery {
|
||||
return _q
|
||||
}
|
||||
|
||||
// WithPrimaryProxies tells the query-builder to eager-load the nodes that are connected to
|
||||
// the "primary_proxies" edge. The optional arguments are used to configure the query builder of the edge.
|
||||
func (_q *ProxyQuery) WithPrimaryProxies(opts ...func(*ProxyQuery)) *ProxyQuery {
|
||||
query := (&ProxyClient{config: _q.config}).Query()
|
||||
for _, opt := range opts {
|
||||
opt(query)
|
||||
}
|
||||
_q.withPrimaryProxies = query
|
||||
return _q
|
||||
}
|
||||
|
||||
// WithBackupProxy tells the query-builder to eager-load the nodes that are connected to
|
||||
// the "backup_proxy" edge. The optional arguments are used to configure the query builder of the edge.
|
||||
func (_q *ProxyQuery) WithBackupProxy(opts ...func(*ProxyQuery)) *ProxyQuery {
|
||||
@@ -408,8 +443,9 @@ func (_q *ProxyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Proxy,
|
||||
var (
|
||||
nodes = []*Proxy{}
|
||||
_spec = _q.querySpec()
|
||||
loadedTypes = [2]bool{
|
||||
loadedTypes = [3]bool{
|
||||
_q.withAccounts != nil,
|
||||
_q.withPrimaryProxies != nil,
|
||||
_q.withBackupProxy != nil,
|
||||
}
|
||||
)
|
||||
@@ -441,6 +477,13 @@ func (_q *ProxyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Proxy,
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if query := _q.withPrimaryProxies; query != nil {
|
||||
if err := _q.loadPrimaryProxies(ctx, query, nodes,
|
||||
func(n *Proxy) { n.Edges.PrimaryProxies = []*Proxy{} },
|
||||
func(n *Proxy, e *Proxy) { n.Edges.PrimaryProxies = append(n.Edges.PrimaryProxies, e) }); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if query := _q.withBackupProxy; query != nil {
|
||||
if err := _q.loadBackupProxy(ctx, query, nodes, nil,
|
||||
func(n *Proxy, e *Proxy) { n.Edges.BackupProxy = e }); err != nil {
|
||||
@@ -483,6 +526,39 @@ func (_q *ProxyQuery) loadAccounts(ctx context.Context, query *AccountQuery, nod
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (_q *ProxyQuery) loadPrimaryProxies(ctx context.Context, query *ProxyQuery, nodes []*Proxy, init func(*Proxy), assign func(*Proxy, *Proxy)) error {
|
||||
fks := make([]driver.Value, 0, len(nodes))
|
||||
nodeids := make(map[int64]*Proxy)
|
||||
for i := range nodes {
|
||||
fks = append(fks, nodes[i].ID)
|
||||
nodeids[nodes[i].ID] = nodes[i]
|
||||
if init != nil {
|
||||
init(nodes[i])
|
||||
}
|
||||
}
|
||||
if len(query.ctx.Fields) > 0 {
|
||||
query.ctx.AppendFieldOnce(proxy.FieldBackupProxyID)
|
||||
}
|
||||
query.Where(predicate.Proxy(func(s *sql.Selector) {
|
||||
s.Where(sql.InValues(s.C(proxy.PrimaryProxiesColumn), fks...))
|
||||
}))
|
||||
neighbors, err := query.All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, n := range neighbors {
|
||||
fk := n.BackupProxyID
|
||||
if fk == nil {
|
||||
return fmt.Errorf(`foreign-key "backup_proxy_id" is nil for node %v`, n.ID)
|
||||
}
|
||||
node, ok := nodeids[*fk]
|
||||
if !ok {
|
||||
return fmt.Errorf(`unexpected referenced foreign-key "backup_proxy_id" returned %v for node %v`, *fk, n.ID)
|
||||
}
|
||||
assign(node, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (_q *ProxyQuery) loadBackupProxy(ctx context.Context, query *ProxyQuery, nodes []*Proxy, init func(*Proxy), assign func(*Proxy, *Proxy)) error {
|
||||
ids := make([]int64, 0, len(nodes))
|
||||
nodeids := make(map[int64][]*Proxy)
|
||||
|
||||
+170
-8
@@ -262,6 +262,21 @@ func (_u *ProxyUpdate) AddAccounts(v ...*Account) *ProxyUpdate {
|
||||
return _u.AddAccountIDs(ids...)
|
||||
}
|
||||
|
||||
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs.
|
||||
func (_u *ProxyUpdate) AddPrimaryProxyIDs(ids ...int64) *ProxyUpdate {
|
||||
_u.mutation.AddPrimaryProxyIDs(ids...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity.
|
||||
func (_u *ProxyUpdate) AddPrimaryProxies(v ...*Proxy) *ProxyUpdate {
|
||||
ids := make([]int64, len(v))
|
||||
for i := range v {
|
||||
ids[i] = v[i].ID
|
||||
}
|
||||
return _u.AddPrimaryProxyIDs(ids...)
|
||||
}
|
||||
|
||||
// SetBackupProxy sets the "backup_proxy" edge to the Proxy entity.
|
||||
func (_u *ProxyUpdate) SetBackupProxy(v *Proxy) *ProxyUpdate {
|
||||
return _u.SetBackupProxyID(v.ID)
|
||||
@@ -293,6 +308,27 @@ func (_u *ProxyUpdate) RemoveAccounts(v ...*Account) *ProxyUpdate {
|
||||
return _u.RemoveAccountIDs(ids...)
|
||||
}
|
||||
|
||||
// ClearPrimaryProxies clears all "primary_proxies" edges to the Proxy entity.
|
||||
func (_u *ProxyUpdate) ClearPrimaryProxies() *ProxyUpdate {
|
||||
_u.mutation.ClearPrimaryProxies()
|
||||
return _u
|
||||
}
|
||||
|
||||
// RemovePrimaryProxyIDs removes the "primary_proxies" edge to Proxy entities by IDs.
|
||||
func (_u *ProxyUpdate) RemovePrimaryProxyIDs(ids ...int64) *ProxyUpdate {
|
||||
_u.mutation.RemovePrimaryProxyIDs(ids...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// RemovePrimaryProxies removes "primary_proxies" edges to Proxy entities.
|
||||
func (_u *ProxyUpdate) RemovePrimaryProxies(v ...*Proxy) *ProxyUpdate {
|
||||
ids := make([]int64, len(v))
|
||||
for i := range v {
|
||||
ids[i] = v[i].ID
|
||||
}
|
||||
return _u.RemovePrimaryProxyIDs(ids...)
|
||||
}
|
||||
|
||||
// ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity.
|
||||
func (_u *ProxyUpdate) ClearBackupProxy() *ProxyUpdate {
|
||||
_u.mutation.ClearBackupProxy()
|
||||
@@ -492,13 +528,58 @@ func (_u *ProxyUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
}
|
||||
_spec.Edges.Add = append(_spec.Edges.Add, edge)
|
||||
}
|
||||
if _u.mutation.PrimaryProxiesCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: true,
|
||||
Table: proxy.PrimaryProxiesTable,
|
||||
Columns: []string{proxy.PrimaryProxiesColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
|
||||
}
|
||||
if nodes := _u.mutation.RemovedPrimaryProxiesIDs(); len(nodes) > 0 && !_u.mutation.PrimaryProxiesCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: true,
|
||||
Table: proxy.PrimaryProxiesTable,
|
||||
Columns: []string{proxy.PrimaryProxiesColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
|
||||
}
|
||||
if nodes := _u.mutation.PrimaryProxiesIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: true,
|
||||
Table: proxy.PrimaryProxiesTable,
|
||||
Columns: []string{proxy.PrimaryProxiesColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges.Add = append(_spec.Edges.Add, edge)
|
||||
}
|
||||
if _u.mutation.BackupProxyCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2O,
|
||||
Rel: sqlgraph.M2O,
|
||||
Inverse: false,
|
||||
Table: proxy.BackupProxyTable,
|
||||
Columns: []string{proxy.BackupProxyColumn},
|
||||
Bidi: true,
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
@@ -507,11 +588,11 @@ func (_u *ProxyUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
}
|
||||
if nodes := _u.mutation.BackupProxyIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2O,
|
||||
Rel: sqlgraph.M2O,
|
||||
Inverse: false,
|
||||
Table: proxy.BackupProxyTable,
|
||||
Columns: []string{proxy.BackupProxyColumn},
|
||||
Bidi: true,
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
@@ -774,6 +855,21 @@ func (_u *ProxyUpdateOne) AddAccounts(v ...*Account) *ProxyUpdateOne {
|
||||
return _u.AddAccountIDs(ids...)
|
||||
}
|
||||
|
||||
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs.
|
||||
func (_u *ProxyUpdateOne) AddPrimaryProxyIDs(ids ...int64) *ProxyUpdateOne {
|
||||
_u.mutation.AddPrimaryProxyIDs(ids...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity.
|
||||
func (_u *ProxyUpdateOne) AddPrimaryProxies(v ...*Proxy) *ProxyUpdateOne {
|
||||
ids := make([]int64, len(v))
|
||||
for i := range v {
|
||||
ids[i] = v[i].ID
|
||||
}
|
||||
return _u.AddPrimaryProxyIDs(ids...)
|
||||
}
|
||||
|
||||
// SetBackupProxy sets the "backup_proxy" edge to the Proxy entity.
|
||||
func (_u *ProxyUpdateOne) SetBackupProxy(v *Proxy) *ProxyUpdateOne {
|
||||
return _u.SetBackupProxyID(v.ID)
|
||||
@@ -805,6 +901,27 @@ func (_u *ProxyUpdateOne) RemoveAccounts(v ...*Account) *ProxyUpdateOne {
|
||||
return _u.RemoveAccountIDs(ids...)
|
||||
}
|
||||
|
||||
// ClearPrimaryProxies clears all "primary_proxies" edges to the Proxy entity.
|
||||
func (_u *ProxyUpdateOne) ClearPrimaryProxies() *ProxyUpdateOne {
|
||||
_u.mutation.ClearPrimaryProxies()
|
||||
return _u
|
||||
}
|
||||
|
||||
// RemovePrimaryProxyIDs removes the "primary_proxies" edge to Proxy entities by IDs.
|
||||
func (_u *ProxyUpdateOne) RemovePrimaryProxyIDs(ids ...int64) *ProxyUpdateOne {
|
||||
_u.mutation.RemovePrimaryProxyIDs(ids...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// RemovePrimaryProxies removes "primary_proxies" edges to Proxy entities.
|
||||
func (_u *ProxyUpdateOne) RemovePrimaryProxies(v ...*Proxy) *ProxyUpdateOne {
|
||||
ids := make([]int64, len(v))
|
||||
for i := range v {
|
||||
ids[i] = v[i].ID
|
||||
}
|
||||
return _u.RemovePrimaryProxyIDs(ids...)
|
||||
}
|
||||
|
||||
// ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity.
|
||||
func (_u *ProxyUpdateOne) ClearBackupProxy() *ProxyUpdateOne {
|
||||
_u.mutation.ClearBackupProxy()
|
||||
@@ -1034,13 +1151,58 @@ func (_u *ProxyUpdateOne) sqlSave(ctx context.Context) (_node *Proxy, err error)
|
||||
}
|
||||
_spec.Edges.Add = append(_spec.Edges.Add, edge)
|
||||
}
|
||||
if _u.mutation.PrimaryProxiesCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: true,
|
||||
Table: proxy.PrimaryProxiesTable,
|
||||
Columns: []string{proxy.PrimaryProxiesColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
|
||||
}
|
||||
if nodes := _u.mutation.RemovedPrimaryProxiesIDs(); len(nodes) > 0 && !_u.mutation.PrimaryProxiesCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: true,
|
||||
Table: proxy.PrimaryProxiesTable,
|
||||
Columns: []string{proxy.PrimaryProxiesColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
|
||||
}
|
||||
if nodes := _u.mutation.PrimaryProxiesIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: true,
|
||||
Table: proxy.PrimaryProxiesTable,
|
||||
Columns: []string{proxy.PrimaryProxiesColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges.Add = append(_spec.Edges.Add, edge)
|
||||
}
|
||||
if _u.mutation.BackupProxyCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2O,
|
||||
Rel: sqlgraph.M2O,
|
||||
Inverse: false,
|
||||
Table: proxy.BackupProxyTable,
|
||||
Columns: []string{proxy.BackupProxyColumn},
|
||||
Bidi: true,
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
@@ -1049,11 +1211,11 @@ func (_u *ProxyUpdateOne) sqlSave(ctx context.Context) (_node *Proxy, err error)
|
||||
}
|
||||
if nodes := _u.mutation.BackupProxyIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2O,
|
||||
Rel: sqlgraph.M2O,
|
||||
Inverse: false,
|
||||
Table: proxy.BackupProxyTable,
|
||||
Columns: []string{proxy.BackupProxyColumn},
|
||||
Bidi: true,
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
|
||||
@@ -1183,40 +1183,44 @@ func init() {
|
||||
groupDescMessagesDispatchModelConfig := groupFields[53].Descriptor()
|
||||
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
|
||||
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
|
||||
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
|
||||
groupDescModelsListConfig := groupFields[54].Descriptor()
|
||||
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
|
||||
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
|
||||
// groupDescModelAllowlist is the schema descriptor for model_allowlist field.
|
||||
groupDescModelAllowlist := groupFields[54].Descriptor()
|
||||
// group.DefaultModelAllowlist holds the default value on creation for the model_allowlist field.
|
||||
group.DefaultModelAllowlist = groupDescModelAllowlist.Default.(domain.GroupModelAllowlist)
|
||||
// groupDescCodexModelsManifestConfig is the schema descriptor for codex_models_manifest_config field.
|
||||
groupDescCodexModelsManifestConfig := groupFields[55].Descriptor()
|
||||
// group.DefaultCodexModelsManifestConfig holds the default value on creation for the codex_models_manifest_config field.
|
||||
group.DefaultCodexModelsManifestConfig = groupDescCodexModelsManifestConfig.Default.(domain.GroupCodexModelsManifestConfig)
|
||||
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
|
||||
groupDescRpmLimit := groupFields[55].Descriptor()
|
||||
groupDescRpmLimit := groupFields[56].Descriptor()
|
||||
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
|
||||
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
|
||||
// groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field.
|
||||
groupDescMaxReasoningEffort := groupFields[56].Descriptor()
|
||||
groupDescMaxReasoningEffort := groupFields[57].Descriptor()
|
||||
// group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field.
|
||||
group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string)
|
||||
// group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
|
||||
group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error)
|
||||
// groupDescMaxReasoningEffortOverLimit is the schema descriptor for max_reasoning_effort_over_limit field.
|
||||
groupDescMaxReasoningEffortOverLimit := groupFields[57].Descriptor()
|
||||
groupDescMaxReasoningEffortOverLimit := groupFields[58].Descriptor()
|
||||
// group.DefaultMaxReasoningEffortOverLimit holds the default value on creation for the max_reasoning_effort_over_limit field.
|
||||
group.DefaultMaxReasoningEffortOverLimit = groupDescMaxReasoningEffortOverLimit.Default.(string)
|
||||
// group.MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save.
|
||||
group.MaxReasoningEffortOverLimitValidator = groupDescMaxReasoningEffortOverLimit.Validators[0].(func(string) error)
|
||||
// groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field.
|
||||
groupDescReasoningEffortMappings := groupFields[58].Descriptor()
|
||||
groupDescReasoningEffortMappings := groupFields[59].Descriptor()
|
||||
// group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field.
|
||||
group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping)
|
||||
// groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field.
|
||||
groupDescProfitControlEnabled := groupFields[59].Descriptor()
|
||||
groupDescProfitControlEnabled := groupFields[60].Descriptor()
|
||||
// group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field.
|
||||
group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool)
|
||||
// groupDescProfitMinMargin is the schema descriptor for profit_min_margin field.
|
||||
groupDescProfitMinMargin := groupFields[60].Descriptor()
|
||||
groupDescProfitMinMargin := groupFields[61].Descriptor()
|
||||
// group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field.
|
||||
group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64)
|
||||
// groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field.
|
||||
groupDescProfitSafetyBuffer := groupFields[61].Descriptor()
|
||||
groupDescProfitSafetyBuffer := groupFields[62].Descriptor()
|
||||
// group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field.
|
||||
group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64)
|
||||
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
|
||||
|
||||
@@ -36,7 +36,7 @@ func (ChannelMonitor) Fields() []ent.Field {
|
||||
MaxLen(100),
|
||||
field.Enum("provider").
|
||||
Values("openai", "anthropic", "gemini", "grok",
|
||||
"antigravity", "kimi", "zhipu", "deepseek"),
|
||||
"antigravity", "kimi", "zhipu", "deepseek", "minimax"),
|
||||
// check_mode: 'probe' | 'quota' | 'quota_probe'
|
||||
// probe - LLM 探活(默认,原有行为)
|
||||
// quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空)
|
||||
|
||||
@@ -40,7 +40,7 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
|
||||
MaxLen(100),
|
||||
field.Enum("provider").
|
||||
Values("openai", "anthropic", "gemini", "grok",
|
||||
"antigravity", "kimi", "zhipu", "deepseek"),
|
||||
"antigravity", "kimi", "zhipu", "deepseek", "minimax"),
|
||||
field.String("api_mode").
|
||||
Default("chat_completions").
|
||||
MaxLen(32).
|
||||
|
||||
@@ -262,10 +262,14 @@ func (Group) Fields() []ent.Field {
|
||||
Default(domain.OpenAIMessagesDispatchModelConfig{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型"),
|
||||
field.JSON("models_list_config", domain.GroupModelsListConfig{}).
|
||||
Default(domain.GroupModelsListConfig{}).
|
||||
field.JSON("model_allowlist", domain.GroupModelAllowlist{}).
|
||||
Default(domain.GroupModelAllowlist{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度"),
|
||||
Comment("分组模型白名单:同时约束模型列表接口与请求准入"),
|
||||
field.JSON("codex_models_manifest_config", domain.GroupCodexModelsManifestConfig{}).
|
||||
Default(domain.GroupCodexModelsManifestConfig{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("固定账号获取 Codex Model Manifest 配置;开启后 /models 请求只用选定账号拉取(仅 openai 平台)"),
|
||||
|
||||
// 分组级每分钟请求数上限(0 = 不限制)。设置后优先于用户级兜底生效。
|
||||
field.Int("rpm_limit").
|
||||
|
||||
@@ -73,6 +73,10 @@ func (Proxy) Edges() []ent.Edge {
|
||||
// accounts: 使用此代理的账户(反向边)
|
||||
edge.From("accounts", Account.Type).
|
||||
Ref("proxy"),
|
||||
// Directed many-to-one: a backup can serve multiple primary proxies.
|
||||
// The inverse edge prevents Ent from treating this self-reference as symmetric.
|
||||
edge.From("primary_proxies", Proxy.Type).
|
||||
Ref("backup_proxy"),
|
||||
edge.To("backup_proxy", Proxy.Type).
|
||||
Field("backup_proxy_id").
|
||||
Unique(),
|
||||
|
||||
@@ -42,7 +42,7 @@ func (UserPlatformQuota) Fields() []ent.Field {
|
||||
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
|
||||
switch s {
|
||||
case "anthropic", "openai", "gemini", "antigravity", "grok",
|
||||
"kimi", "zhipu", "deepseek":
|
||||
"kimi", "zhipu", "deepseek", "minimax":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("platform %q is not allowed", s)
|
||||
|
||||
+3
-4
@@ -31,7 +31,7 @@ require (
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/redis/go-redis/v9 v9.17.2
|
||||
github.com/redis/go-redis/v9 v9.22.0
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/shirou/gopsutil/v4 v4.25.6
|
||||
@@ -101,7 +101,6 @@ require (
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.1.0 // indirect
|
||||
github.com/docker/docker v28.5.1+incompatible // indirect
|
||||
@@ -135,7 +134,7 @@ require (
|
||||
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||
github.com/icholy/digest v1.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
@@ -197,7 +196,7 @@ require (
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.uber.org/atomic v1.10.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
|
||||
+16
-4
@@ -159,8 +159,6 @@ github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dr
|
||||
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dlclark/regexp2/v2 v2.1.0 h1:jHXRmHRZGbuQzDZjMlCAXOvQb75iv3HyLDzXGj5H1AY=
|
||||
@@ -255,6 +253,8 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
@@ -302,6 +302,8 @@ github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxh
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
@@ -327,6 +329,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
@@ -365,6 +369,8 @@ github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
|
||||
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -393,12 +399,14 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
|
||||
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -434,6 +442,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
@@ -535,6 +545,8 @@ go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeX
|
||||
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
||||
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
|
||||
@@ -1613,10 +1613,10 @@ type OpsCleanupConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Schedule string `mapstructure:"schedule"`
|
||||
|
||||
// Retention days (0 disables that cleanup target).
|
||||
//
|
||||
// vNext requirement: default 30 days across ops datasets.
|
||||
// Retention days. Error and metrics targets accept 0 as an explicit truncate;
|
||||
// system logs require a positive value because their runtime setting is bounded.
|
||||
ErrorLogRetentionDays int `mapstructure:"error_log_retention_days"`
|
||||
SystemLogRetentionDays int `mapstructure:"system_log_retention_days"`
|
||||
MinuteMetricsRetentionDays int `mapstructure:"minute_metrics_retention_days"`
|
||||
HourlyMetricsRetentionDays int `mapstructure:"hourly_metrics_retention_days"`
|
||||
}
|
||||
@@ -2046,7 +2046,8 @@ func setDefaults() {
|
||||
"api.moonshot.ai",
|
||||
"api.moonshot.cn",
|
||||
"open.bigmodel.cn",
|
||||
"api.minimaxi.com",
|
||||
"api.minimaxi.com", // MiniMax CN quota + inference
|
||||
"api.minimax.io", // MiniMax intl; frozen allowlists must add this host to use the intl site
|
||||
"generativelanguage.googleapis.com",
|
||||
"cloudcode-pa.googleapis.com",
|
||||
"*.openai.azure.com",
|
||||
@@ -2250,6 +2251,7 @@ func setDefaults() {
|
||||
viper.SetDefault("ops.cleanup.schedule", "0 2 * * *")
|
||||
// Retention days: vNext defaults to 30 days across ops datasets.
|
||||
viper.SetDefault("ops.cleanup.error_log_retention_days", 30)
|
||||
viper.SetDefault("ops.cleanup.system_log_retention_days", 30)
|
||||
viper.SetDefault("ops.cleanup.minute_metrics_retention_days", 30)
|
||||
viper.SetDefault("ops.cleanup.hourly_metrics_retention_days", 30)
|
||||
viper.SetDefault("ops.aggregation.enabled", true)
|
||||
@@ -3673,6 +3675,9 @@ func (c *Config) Validate() error {
|
||||
if c.Ops.Cleanup.ErrorLogRetentionDays < 0 {
|
||||
return fmt.Errorf("ops.cleanup.error_log_retention_days must be non-negative")
|
||||
}
|
||||
if c.Ops.Cleanup.Enabled && c.Ops.Cleanup.SystemLogRetentionDays <= 0 {
|
||||
return fmt.Errorf("ops.cleanup.system_log_retention_days must be positive when ops cleanup is enabled")
|
||||
}
|
||||
if c.Ops.Cleanup.MinuteMetricsRetentionDays < 0 {
|
||||
return fmt.Errorf("ops.cleanup.minute_metrics_retention_days must be non-negative")
|
||||
}
|
||||
|
||||
@@ -1191,6 +1191,21 @@ func TestLoadDefaultUsageCleanupConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultOpsCleanupConfig(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if !cfg.Ops.Cleanup.Enabled {
|
||||
t.Fatal("Ops.Cleanup.Enabled = false, want true")
|
||||
}
|
||||
if cfg.Ops.Cleanup.SystemLogRetentionDays != 30 {
|
||||
t.Fatalf("Ops.Cleanup.SystemLogRetentionDays = %d, want 30", cfg.Ops.Cleanup.SystemLogRetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUsageCleanupConfigEnabled(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
@@ -2137,6 +2152,11 @@ func TestValidateConfigErrors(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Ops.Cleanup.ErrorLogRetentionDays = -1 },
|
||||
wantErr: "ops.cleanup.error_log_retention_days",
|
||||
},
|
||||
{
|
||||
name: "ops cleanup system log retention",
|
||||
mutate: func(c *Config) { c.Ops.Cleanup.SystemLogRetentionDays = 0 },
|
||||
wantErr: "ops.cleanup.system_log_retention_days",
|
||||
},
|
||||
{
|
||||
name: "ops cleanup minute retention",
|
||||
mutate: func(c *Config) { c.Ops.Cleanup.MinuteMetricsRetentionDays = -1 },
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package domain
|
||||
|
||||
// GroupCodexModelsManifestConfig controls which accounts fetch the Codex
|
||||
// models manifest for an OpenAI group. When enabled, the group's Codex
|
||||
// /models requests bypass the scheduler and only use the pinned accounts.
|
||||
type GroupCodexModelsManifestConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// AccountIDs is the ordered list of pinned account IDs. Order decides
|
||||
// merge precedence for duplicate model slugs.
|
||||
AccountIDs []int64 `json:"account_ids,omitempty"`
|
||||
// FallbackToScheduler controls the behavior when no pinned account is
|
||||
// usable or all upstream fetches fail: fall back to the scheduler loop
|
||||
// (true) or return the upstream error / 503 (false, default).
|
||||
FallbackToScheduler bool `json:"fallback_to_scheduler,omitempty"`
|
||||
}
|
||||
@@ -27,6 +27,7 @@ const (
|
||||
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
|
||||
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
|
||||
PlatformDeepseek = "deepseek" // DeepSeek
|
||||
PlatformMiniMax = "minimax" // MiniMax (M 系列)
|
||||
PlatformComposite = "composite"
|
||||
)
|
||||
|
||||
@@ -43,7 +44,7 @@ const (
|
||||
const (
|
||||
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
|
||||
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
|
||||
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi 原生端点,适配 Codex)
|
||||
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi / minimax 原生端点,适配 Codex)
|
||||
APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package domain
|
||||
|
||||
// GroupModelAllowlist 是分组级模型白名单:开启后既过滤模型列表类接口的返回内容,
|
||||
// 也约束分组实际可调用的模型(网关准入在合成路由改写与调度之前完成)。
|
||||
type GroupModelAllowlist struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package domain
|
||||
|
||||
// GroupModelsListConfig controls the optional custom /v1/models response list.
|
||||
type GroupModelsListConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
}
|
||||
@@ -7,7 +7,8 @@ const (
|
||||
)
|
||||
|
||||
// ReasoningEffortMapping rewrites one explicit OpenAI/Codex reasoning effort
|
||||
// value to another before the group ceiling is applied.
|
||||
// value to another before the group ceiling is applied. To may also be "deny"
|
||||
// to reject the request when the matching source value is present.
|
||||
//
|
||||
// Model and MatchType optionally scope the rewrite to a request model:
|
||||
// exact matches the full model id, prefix/suffix match a model-id affix.
|
||||
|
||||
@@ -309,9 +309,11 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
|
||||
_, _ = h.adminService.UpdateProxy(ctx, existingID, &service.UpdateProxyInput{
|
||||
Status: normalizedStatus,
|
||||
ExpiresAt: existingExpiresAt,
|
||||
ClearExpiresAt: existingExpiresAt == nil,
|
||||
FallbackMode: existingFallbackMode,
|
||||
BackupProxyID: existingBackupProxyID,
|
||||
ExpiryWarnDays: item.ExpiryWarnDays,
|
||||
ClearBackupID: existingBackupProxyID == nil,
|
||||
ExpiryWarnDays: &item.ExpiryWarnDays,
|
||||
Name: proxy.Name,
|
||||
Protocol: proxy.Protocol,
|
||||
Host: proxy.Host,
|
||||
@@ -383,9 +385,11 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
|
||||
_, _ = h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{
|
||||
Status: normalizedStatus,
|
||||
ExpiresAt: expiresAt,
|
||||
ClearExpiresAt: expiresAt == nil,
|
||||
FallbackMode: fallbackMode,
|
||||
BackupProxyID: backupProxyID,
|
||||
ExpiryWarnDays: item.ExpiryWarnDays,
|
||||
ClearBackupID: backupProxyID == nil,
|
||||
ExpiryWarnDays: &item.ExpiryWarnDays,
|
||||
Name: created.Name,
|
||||
Protocol: created.Protocol,
|
||||
Host: created.Host,
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
@@ -64,6 +65,7 @@ type AccountHandler struct {
|
||||
grokImportProber grokImportProber
|
||||
upstreamBillingProbe *service.UpstreamBillingProbeService
|
||||
ollamaCloudUsage *service.OllamaCloudUsageService
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// SetUpstreamBillingProbeService attaches the optional remote billing probe service.
|
||||
@@ -190,6 +192,7 @@ type CheckMixedChannelRequest struct {
|
||||
// AccountWithConcurrency extends Account with real-time concurrency info
|
||||
type AccountWithConcurrency struct {
|
||||
*dto.Account
|
||||
simpleMode bool `json:"-"`
|
||||
CurrentConcurrency int `json:"current_concurrency"`
|
||||
SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
|
||||
SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
|
||||
@@ -199,6 +202,123 @@ type AccountWithConcurrency struct {
|
||||
CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数
|
||||
}
|
||||
|
||||
// AccountListItemWithConcurrency is the compact account-list envelope used
|
||||
// for lite=1. It embeds dto.AccountListItem instead of the full dto.Account,
|
||||
// so groups/account_groups never appear in the list payload.
|
||||
type AccountListItemWithConcurrency struct {
|
||||
*dto.AccountListItem
|
||||
CurrentConcurrency int `json:"current_concurrency"`
|
||||
SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
|
||||
SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
|
||||
CurrentWindowCost *float64 `json:"current_window_cost,omitempty"`
|
||||
ActiveSessions *int `json:"active_sessions,omitempty"`
|
||||
CurrentRPM *int `json:"current_rpm,omitempty"`
|
||||
}
|
||||
|
||||
type simpleModeGroupReference struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type simpleModeAccountGroupReference struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Priority int `json:"priority"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Group *simpleModeGroupReference `json:"group,omitempty"`
|
||||
}
|
||||
|
||||
func simpleModeGroupReferenceFromDTO(group *dto.Group) *simpleModeGroupReference {
|
||||
if group == nil {
|
||||
return nil
|
||||
}
|
||||
return &simpleModeGroupReference{ID: group.ID, Name: group.Name, Platform: group.Platform, Status: group.Status}
|
||||
}
|
||||
|
||||
func simpleModeCompositeGroupIDs(account *dto.Account) map[int64]struct{} {
|
||||
hidden := make(map[int64]struct{})
|
||||
if account == nil {
|
||||
return hidden
|
||||
}
|
||||
for _, group := range account.Groups {
|
||||
if group != nil && group.Platform == service.PlatformComposite {
|
||||
hidden[group.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, accountGroup := range account.AccountGroups {
|
||||
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
|
||||
hidden[accountGroup.GroupID] = struct{}{}
|
||||
}
|
||||
}
|
||||
return hidden
|
||||
}
|
||||
|
||||
func filterSimpleModeGroupIDs(groupIDs []int64, hidden map[int64]struct{}) []int64 {
|
||||
visible := make([]int64, 0, len(groupIDs))
|
||||
for _, groupID := range groupIDs {
|
||||
if _, ok := hidden[groupID]; !ok {
|
||||
visible = append(visible, groupID)
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
func simpleModeCompositeServiceGroupIDs(account *service.Account) map[int64]struct{} {
|
||||
hidden := make(map[int64]struct{})
|
||||
if account == nil {
|
||||
return hidden
|
||||
}
|
||||
for _, group := range account.Groups {
|
||||
if group != nil && group.Platform == service.PlatformComposite {
|
||||
hidden[group.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, accountGroup := range account.AccountGroups {
|
||||
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
|
||||
hidden[accountGroup.GroupID] = struct{}{}
|
||||
}
|
||||
}
|
||||
return hidden
|
||||
}
|
||||
|
||||
func (a AccountWithConcurrency) MarshalJSON() ([]byte, error) {
|
||||
type alias AccountWithConcurrency
|
||||
if !a.simpleMode || a.Account == nil {
|
||||
return json.Marshal(alias(a))
|
||||
}
|
||||
groups := make([]simpleModeGroupReference, 0, len(a.Groups))
|
||||
compositeIDs := simpleModeCompositeGroupIDs(a.Account)
|
||||
for _, group := range a.Groups {
|
||||
if group != nil && group.Platform == service.PlatformComposite {
|
||||
continue
|
||||
}
|
||||
if ref := simpleModeGroupReferenceFromDTO(group); ref != nil {
|
||||
groups = append(groups, *ref)
|
||||
}
|
||||
}
|
||||
accountGroups := make([]simpleModeAccountGroupReference, 0, len(a.AccountGroups))
|
||||
for _, accountGroup := range a.AccountGroups {
|
||||
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
|
||||
continue
|
||||
}
|
||||
if _, hidden := compositeIDs[accountGroup.GroupID]; hidden {
|
||||
continue
|
||||
}
|
||||
accountGroups = append(accountGroups, simpleModeAccountGroupReference{
|
||||
AccountID: accountGroup.AccountID, GroupID: accountGroup.GroupID, Priority: accountGroup.Priority,
|
||||
CreatedAt: accountGroup.CreatedAt, Group: simpleModeGroupReferenceFromDTO(accountGroup.Group),
|
||||
})
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
alias
|
||||
GroupIDs []int64 `json:"group_ids,omitempty"`
|
||||
Groups []simpleModeGroupReference `json:"groups"`
|
||||
AccountGroups []simpleModeAccountGroupReference `json:"account_groups"`
|
||||
}{alias: alias(a), GroupIDs: filterSimpleModeGroupIDs(a.GroupIDs, compositeIDs), Groups: groups, AccountGroups: accountGroups})
|
||||
}
|
||||
|
||||
type AccountSchedulerScore struct {
|
||||
BaseScore float64 `json:"base_score"`
|
||||
StickyScore float64 `json:"sticky_score"`
|
||||
@@ -223,9 +343,25 @@ func (h *AccountHandler) accountResponseFromService(account *service.Account) *d
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *AccountHandler) accountListResponseFromService(account *service.Account) *dto.Account {
|
||||
out := dto.AccountFromServiceShallow(account)
|
||||
if out != nil && account != nil {
|
||||
out.Proxy = dto.ProxyFromService(account.Proxy)
|
||||
}
|
||||
if h != nil && h.ollamaCloudUsage != nil && out != nil {
|
||||
h.ollamaCloudUsage.EnrichState(out.OllamaCloudUsage)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *AccountHandler) isSimpleMode() bool {
|
||||
return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple
|
||||
}
|
||||
|
||||
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
|
||||
item := AccountWithConcurrency{
|
||||
Account: h.accountResponseFromService(account),
|
||||
simpleMode: h.isSimpleMode(),
|
||||
CurrentConcurrency: 0,
|
||||
}
|
||||
if account == nil {
|
||||
@@ -650,8 +786,16 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
result := make([]AccountWithConcurrency, len(accounts))
|
||||
for i := range accounts {
|
||||
acc := &accounts[i]
|
||||
accountResponse := h.accountResponseFromService(acc)
|
||||
if lite {
|
||||
accountResponse = h.accountListResponseFromService(acc)
|
||||
if h.isSimpleMode() {
|
||||
accountResponse.GroupIDs = filterSimpleModeGroupIDs(accountResponse.GroupIDs, simpleModeCompositeServiceGroupIDs(acc))
|
||||
}
|
||||
}
|
||||
item := AccountWithConcurrency{
|
||||
Account: h.accountResponseFromService(acc),
|
||||
Account: accountResponse,
|
||||
simpleMode: h.isSimpleMode(),
|
||||
CurrentConcurrency: concurrencyCounts[acc.ID],
|
||||
SchedulerScore: schedulerScores[acc.ID],
|
||||
SchedulerScores: schedulerGroupScores[acc.ID],
|
||||
@@ -683,7 +827,34 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
|
||||
h.enrichShadowParents(c.Request.Context(), result)
|
||||
|
||||
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, lite)
|
||||
if lite {
|
||||
compact := make([]AccountListItemWithConcurrency, len(result))
|
||||
for i := range result {
|
||||
item := result[i]
|
||||
compact[i] = AccountListItemWithConcurrency{
|
||||
AccountListItem: dto.AccountListItemFromAccount(item.Account),
|
||||
CurrentConcurrency: item.CurrentConcurrency,
|
||||
SchedulerScore: item.SchedulerScore,
|
||||
SchedulerScores: item.SchedulerScores,
|
||||
CurrentWindowCost: item.CurrentWindowCost,
|
||||
ActiveSessions: item.ActiveSessions,
|
||||
CurrentRPM: item.CurrentRPM,
|
||||
}
|
||||
}
|
||||
etag := buildAccountsListETag(compact, total, page, pageSize, platform, accountType, status, search, true)
|
||||
if etag != "" {
|
||||
c.Header("ETag", etag)
|
||||
c.Header("Vary", "If-None-Match")
|
||||
if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
|
||||
c.Status(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
response.Paginated(c, compact, total, page, pageSize)
|
||||
return
|
||||
}
|
||||
|
||||
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, false)
|
||||
if etag != "" {
|
||||
c.Header("ETag", etag)
|
||||
c.Header("Vary", "If-None-Match")
|
||||
@@ -696,23 +867,23 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
response.Paginated(c, result, total, page, pageSize)
|
||||
}
|
||||
|
||||
func buildAccountsListETag(
|
||||
items []AccountWithConcurrency,
|
||||
func buildAccountsListETag[T any](
|
||||
items []T,
|
||||
total int64,
|
||||
page, pageSize int,
|
||||
platform, accountType, status, search string,
|
||||
lite bool,
|
||||
) string {
|
||||
payload := struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Platform string `json:"platform"`
|
||||
AccountType string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Search string `json:"search"`
|
||||
Lite bool `json:"lite"`
|
||||
Items []AccountWithConcurrency `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Platform string `json:"platform"`
|
||||
AccountType string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Search string `json:"search"`
|
||||
Lite bool `json:"lite"`
|
||||
Items []T `json:"items"`
|
||||
}{
|
||||
Total: total,
|
||||
Page: page,
|
||||
@@ -837,6 +1008,10 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
||||
}
|
||||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||||
sanitizeExtraBaseRPM(req.Extra)
|
||||
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 确定是否跳过混合渠道检查
|
||||
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||||
@@ -970,6 +1145,10 @@ func (h *AccountHandler) Update(c *gin.Context) {
|
||||
}
|
||||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||||
sanitizeExtraBaseRPM(req.Extra)
|
||||
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 确定是否跳过混合渠道检查
|
||||
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||||
@@ -1424,6 +1603,10 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Drop SSO/password residue; re-auth must leave only OAuth tokens on disk.
|
||||
req.Credentials = service.SanitizeStoredCredentials(existing.Platform, req.Credentials)
|
||||
@@ -1875,6 +2058,14 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
groupIDs := make([]int64, 0)
|
||||
for _, item := range req.Accounts {
|
||||
groupIDs = append(groupIDs, item.GroupIDs...)
|
||||
}
|
||||
if err := h.adminService.ValidateAccountGroupBindings(c.Request.Context(), groupIDs); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
executeAdminIdempotentJSON(c, "admin.accounts.batch_create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
|
||||
success := 0
|
||||
@@ -1897,6 +2088,15 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||
|
||||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||||
sanitizeExtraBaseRPM(item.Extra)
|
||||
if err := service.ValidateUpstreamRequestIDHeaderExtra(item.Extra); err != nil {
|
||||
failed++
|
||||
results = append(results, gin.H{
|
||||
"name": item.Name,
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
skipCheck := item.ConfirmMixedChannelRisk != nil && *item.ConfirmMixedChannelRisk
|
||||
|
||||
@@ -2091,6 +2291,10 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
|
||||
}
|
||||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||||
sanitizeExtraBaseRPM(req.Extra)
|
||||
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 确定是否跳过混合渠道检查
|
||||
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||||
@@ -2576,6 +2780,14 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
||||
|
||||
// Handle OpenAI accounts
|
||||
if account.IsOpenAI() {
|
||||
// Prefer the shared, account-keyed upstream catalog. If discovery fails,
|
||||
// retain the legacy local catalog below so the test dialog remains usable.
|
||||
if h.accountTestService != nil {
|
||||
if models, fetchErr := h.accountTestService.FetchOpenAIAccountModels(c.Request.Context(), account); fetchErr == nil {
|
||||
response.Success(c, models)
|
||||
return
|
||||
}
|
||||
}
|
||||
// OpenAI 自动透传会绕过常规模型改写,测试/模型列表也应回落到默认模型集。
|
||||
if account.IsOpenAIPassthroughEnabled() {
|
||||
response.Success(c, openai.DefaultModels)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -13,6 +16,101 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccountHandlerListLiteUsesCompactDTOAndETag(t *testing.T) {
|
||||
router, adminSvc := setupAccountListRouter()
|
||||
now := time.Now().UTC()
|
||||
groupID := int64(77)
|
||||
adminSvc.accounts = []service.Account{{
|
||||
ID: 501, Name: "compact-account", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{"email": "compact@example.com", "access_token": strings.Repeat("x", 4096)},
|
||||
Extra: map[string]any{"privacy_mode": "training_off"}, Status: service.StatusActive,
|
||||
Schedulable: true, Concurrency: 4, GroupIDs: []int64{groupID},
|
||||
Groups: []*service.Group{{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}},
|
||||
AccountGroups: []service.AccountGroup{{AccountID: 501, GroupID: groupID, Priority: 2, Group: &service.Group{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}}},
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.NotEmpty(t, rec.Header().Get("ETag"))
|
||||
|
||||
var litePayload struct {
|
||||
Data struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &litePayload))
|
||||
require.Len(t, litePayload.Data.Items, 1)
|
||||
liteItem := litePayload.Data.Items[0]
|
||||
require.Equal(t, float64(501), liteItem["id"])
|
||||
require.Equal(t, []any{float64(groupID)}, liteItem["group_ids"])
|
||||
require.Equal(t, true, liteItem["schedulable"])
|
||||
require.NotContains(t, liteItem, "groups")
|
||||
require.NotContains(t, liteItem, "account_groups")
|
||||
credentials, ok := liteItem["credentials"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "compact@example.com", credentials["email"])
|
||||
require.NotContains(t, credentials, "access_token")
|
||||
credentialsStatus, ok := liteItem["credentials_status"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, true, credentialsStatus["has_access_token"])
|
||||
|
||||
// The ETag must represent the same compact body and return 304 on refresh.
|
||||
rec304 := httptest.NewRecorder()
|
||||
req304 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
|
||||
req304.Header.Set("If-None-Match", rec.Header().Get("ETag"))
|
||||
router.ServeHTTP(rec304, req304)
|
||||
require.Equal(t, http.StatusNotModified, rec304.Code)
|
||||
|
||||
// Omitting lite preserves the legacy full response shape.
|
||||
recFull := httptest.NewRecorder()
|
||||
reqFull := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20", nil)
|
||||
router.ServeHTTP(recFull, reqFull)
|
||||
require.Equal(t, http.StatusOK, recFull.Code)
|
||||
var fullPayload struct {
|
||||
Data struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recFull.Body.Bytes(), &fullPayload))
|
||||
require.Contains(t, fullPayload.Data.Items[0], "groups")
|
||||
require.Contains(t, fullPayload.Data.Items[0], "account_groups")
|
||||
}
|
||||
|
||||
func TestAccountHandlerListLiteStaysBelowResponseBudget(t *testing.T) {
|
||||
router, adminSvc := setupAccountListRouter()
|
||||
now := time.Now().UTC()
|
||||
accounts := make([]service.Account, 20)
|
||||
for i := range accounts {
|
||||
id := int64(600 + i)
|
||||
groupID := int64(800 + i)
|
||||
accounts[i] = service.Account{
|
||||
ID: id, Name: "account-" + strconv.Itoa(i), Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth, Status: service.StatusActive, Schedulable: true,
|
||||
Concurrency: 4, GroupIDs: []int64{groupID},
|
||||
Groups: []*service.Group{{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}},
|
||||
AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID, Group: &service.Group{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}}},
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
adminSvc.accounts = accounts
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Less(t, rec.Body.Len(), 80*1024)
|
||||
|
||||
var compressed bytes.Buffer
|
||||
zw := gzip.NewWriter(&compressed)
|
||||
_, err := zw.Write(rec.Body.Bytes())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, zw.Close())
|
||||
require.Less(t, compressed.Len(), 15*1024)
|
||||
}
|
||||
|
||||
func setupAccountListRouter() (*gin.Engine, *stubAdminService) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type simpleModeAccountService struct {
|
||||
*stubAdminService
|
||||
account service.Account
|
||||
createCalls int
|
||||
updateCalls int
|
||||
bulkCalls int
|
||||
}
|
||||
|
||||
func (s *simpleModeAccountService) GetAccount(context.Context, int64) (*service.Account, error) {
|
||||
return &s.account, nil
|
||||
}
|
||||
|
||||
func (s *simpleModeAccountService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) {
|
||||
if err := s.ValidateAccountGroupBindings(ctx, input.GroupIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.createCalls++
|
||||
return &s.account, nil
|
||||
}
|
||||
|
||||
func (s *simpleModeAccountService) UpdateAccount(ctx context.Context, _ int64, input *service.UpdateAccountInput) (*service.Account, error) {
|
||||
if input.GroupIDs != nil {
|
||||
if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.updateCalls++
|
||||
return &s.account, nil
|
||||
}
|
||||
|
||||
func (s *simpleModeAccountService) BulkUpdateAccounts(ctx context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) {
|
||||
if input.GroupIDs != nil {
|
||||
if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.bulkCalls++
|
||||
return &service.BulkUpdateAccountsResult{}, nil
|
||||
}
|
||||
|
||||
func (s *simpleModeAccountService) ValidateAccountGroupBindings(_ context.Context, groupIDs []int64) error {
|
||||
for _, id := range groupIDs {
|
||||
for i := range s.groups {
|
||||
if s.groups[i].ID == id && s.groups[i].Platform == service.PlatformComposite {
|
||||
return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups cannot be bound in simple mode")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAccountHandlerSimpleModeUsesMinimalGroupReferences(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9, IsExclusive: true, SubscriptionType: service.SubscriptionTypeSubscription, RPMLimit: 99}
|
||||
historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive}
|
||||
account := service.Account{
|
||||
ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive,
|
||||
GroupIDs: []int64{7, 9},
|
||||
Groups: []*service.Group{richGroup, historicalComposite},
|
||||
AccountGroups: []service.AccountGroup{
|
||||
{AccountID: 3, GroupID: 7, Priority: 2, Group: richGroup, Account: &service.Account{ID: 3, Extra: map[string]any{"secret": true}}},
|
||||
{AccountID: 3, GroupID: 9, Priority: 3, Group: historicalComposite},
|
||||
},
|
||||
}
|
||||
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account}
|
||||
svc.accounts = []service.Account{account}
|
||||
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h.cfg = &config.Config{RunMode: config.RunModeSimple}
|
||||
r := gin.New()
|
||||
r.GET("/accounts", h.List)
|
||||
r.GET("/accounts/:id", h.GetByID)
|
||||
r.POST("/accounts", h.Create)
|
||||
r.PUT("/accounts/:id", h.Update)
|
||||
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodGet, "/accounts", ""},
|
||||
{http.MethodGet, "/accounts/3", ""},
|
||||
{http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"}}`},
|
||||
{http.MethodPut, "/accounts/3", `{"name":"account"}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method+tt.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
|
||||
data, ok := payload["data"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
if items, ok := data["items"].([]any); ok {
|
||||
require.Len(t, items, 1)
|
||||
data, ok = items[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
}
|
||||
groups, ok := data["groups"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, groups, 1)
|
||||
require.Equal(t, []any{float64(7)}, data["group_ids"])
|
||||
group, ok := groups[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(group))
|
||||
accountGroups, ok := data["account_groups"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, accountGroups, 1)
|
||||
accountGroup, ok := accountGroups[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.ElementsMatch(t, []string{"account_id", "group_id", "priority", "created_at", "group"}, mapKeys(accountGroup))
|
||||
nestedGroup, ok := accountGroup["group"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(nestedGroup))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountHandlerSimpleModeLitePreservesCompactShapeAndETag(t *testing.T) {
|
||||
richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9}
|
||||
historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive}
|
||||
account := service.Account{
|
||||
ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive,
|
||||
GroupIDs: []int64{7, 9}, Groups: []*service.Group{richGroup, historicalComposite},
|
||||
AccountGroups: []service.AccountGroup{{AccountID: 3, GroupID: 7, Group: richGroup}, {AccountID: 3, GroupID: 9, Group: historicalComposite}},
|
||||
}
|
||||
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account}
|
||||
svc.accounts = []service.Account{account}
|
||||
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h.cfg = &config.Config{RunMode: config.RunModeSimple}
|
||||
r := gin.New()
|
||||
r.GET("/accounts", h.List)
|
||||
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil))
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
require.NotEmpty(t, res.Header().Get("ETag"))
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
|
||||
require.Len(t, payload.Data.Items, 1)
|
||||
require.Equal(t, []any{float64(7)}, payload.Data.Items[0]["group_ids"])
|
||||
require.NotContains(t, payload.Data.Items[0], "groups")
|
||||
require.NotContains(t, payload.Data.Items[0], "account_groups")
|
||||
|
||||
notModified := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil)
|
||||
req.Header.Set("If-None-Match", res.Header().Get("ETag"))
|
||||
r.ServeHTTP(notModified, req)
|
||||
require.Equal(t, http.StatusNotModified, notModified.Code)
|
||||
}
|
||||
|
||||
func TestAccountHandlerSimpleModeRejectsCompositeGroupBindingsBeforeWrites(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, tt := range []struct{ name, method, path, body string }{
|
||||
{"create", http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[9]}`},
|
||||
{"update", http.MethodPut, "/accounts/3", `{"group_ids":[9]}`},
|
||||
{"bulk", http.MethodPost, "/accounts/bulk-update", `{"account_ids":[3],"group_ids":[9]}`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := &simpleModeAccountService{stubAdminService: newStubAdminService()}
|
||||
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}}
|
||||
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h.cfg = &config.Config{RunMode: config.RunModeSimple}
|
||||
r := gin.New()
|
||||
r.POST("/accounts", h.Create)
|
||||
r.PUT("/accounts/:id", h.Update)
|
||||
r.POST("/accounts/bulk-update", h.BulkUpdate)
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String())
|
||||
require.Zero(t, svc.createCalls)
|
||||
require.Zero(t, svc.updateCalls)
|
||||
require.Zero(t, svc.bulkCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountHandlerSimpleModePreservesBasicGroupBinding(t *testing.T) {
|
||||
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: service.Account{ID: 3}}
|
||||
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}}
|
||||
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h.cfg = &config.Config{RunMode: config.RunModeSimple}
|
||||
r := gin.New()
|
||||
r.POST("/accounts", h.Create)
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/accounts", bytes.NewBufferString(`{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
require.Equal(t, 1, svc.createCalls)
|
||||
}
|
||||
|
||||
func TestAccountHandlerSimpleModeBatchPrevalidatesAllGroupsAtomically(t *testing.T) {
|
||||
svc := &simpleModeAccountService{stubAdminService: newStubAdminService()}
|
||||
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}}
|
||||
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h.cfg = &config.Config{RunMode: config.RunModeSimple}
|
||||
r := gin.New()
|
||||
r.POST("/accounts/batch", h.BatchCreate)
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/accounts/batch", bytes.NewBufferString(`{"accounts":[{"name":"first","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]},{"name":"second","platform":"anthropic","type":"apikey","credentials":{"key":"y"},"group_ids":[9]}]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String())
|
||||
require.Zero(t, svc.createCalls)
|
||||
}
|
||||
|
||||
func TestAccountHandlerAdvancedModeKeepsFullGroupReferences(t *testing.T) {
|
||||
group := &service.Group{ID: 7, Name: "advanced", RateMultiplier: 9, SubscriptionType: service.SubscriptionTypeSubscription}
|
||||
h := NewAccountHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
raw, err := json.Marshal(h.buildAccountResponseWithRuntime(context.Background(), &service.Account{Groups: []*service.Group{group}}))
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(raw), `"rate_multiplier":9`)
|
||||
require.Contains(t, string(raw), `"subscription_type":"subscription"`)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
|
||||
|
||||
router.GET("/api/v1/admin/groups", groupHandler.List)
|
||||
router.GET("/api/v1/admin/groups/all", groupHandler.GetAll)
|
||||
router.GET("/api/v1/admin/groups/:id/models-list-candidates", groupHandler.GetModelsListCandidates)
|
||||
router.GET("/api/v1/admin/groups/:id/model-allowlist-candidates", groupHandler.GetGroupModelAllowlistCandidates)
|
||||
router.GET("/api/v1/admin/groups/:id/composite-routes", groupHandler.ListCompositeRoutes)
|
||||
router.POST("/api/v1/admin/groups/:id/composite-routes", groupHandler.CreateCompositeRoute)
|
||||
router.POST("/api/v1/admin/groups/:id/composite-routes/preview", groupHandler.PreviewCompositeRoute)
|
||||
@@ -184,7 +184,7 @@ func TestGroupHandlerEndpoints(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/groups/0/models-list-candidates?platform=openai", nil)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/groups/0/model-allowlist-candidates?platform=openai", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "gpt-5.5")
|
||||
|
||||
@@ -278,13 +278,15 @@ func TestOpenAIFastPolicySettingsFromDTO_NormalizesServiceTier(t *testing.T) {
|
||||
Rules: []dto.OpenAIFastPolicyRule{
|
||||
{ServiceTier: "priority", Action: "filter", Scope: "all"},
|
||||
{ServiceTier: "flex", Action: "block", Scope: "oauth"},
|
||||
{ServiceTier: "ultrafast", Action: "pass", Scope: "all"},
|
||||
{ServiceTier: "all", Action: "pass", Scope: "apikey"},
|
||||
},
|
||||
}
|
||||
out := openaiFastPolicySettingsFromDTO(in)
|
||||
require.Len(t, out.Rules, 3)
|
||||
require.Len(t, out.Rules, 4)
|
||||
require.Equal(t, service.OpenAIFastTierPriority, out.Rules[0].ServiceTier)
|
||||
require.Equal(t, service.OpenAIFastTierFlex, out.Rules[1].ServiceTier)
|
||||
require.Equal(t, service.OpenAIFastTierAny, out.Rules[2].ServiceTier)
|
||||
require.Equal(t, service.OpenAIFastTierUltrafast, out.Rules[2].ServiceTier)
|
||||
require.Equal(t, service.OpenAIFastTierAny, out.Rules[3].ServiceTier)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,6 +24,13 @@ type stubAdminService struct {
|
||||
boundAuthIdentity *service.AdminBindAuthIdentityInput
|
||||
boundAuthIdentityFor int64
|
||||
createdAccounts []*service.CreateAccountInput
|
||||
createdGroups []*service.CreateGroupInput
|
||||
updatedGroups []*service.UpdateGroupInput
|
||||
deletedGroupIDs []int64
|
||||
guardedDeletedGroupIDs []int64
|
||||
deleteGroupIfEmptyErr error
|
||||
advancedGroupOperationCalls int
|
||||
lastListGroupsIsExclusive *bool
|
||||
createdProxies []*service.CreateProxyInput
|
||||
updatedProxyIDs []int64
|
||||
updatedProxies []*service.UpdateProxyInput
|
||||
@@ -267,6 +274,7 @@ func (s *stubAdminService) BindUserAuthIdentity(ctx context.Context, userID int6
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]service.Group, int64, error) {
|
||||
s.lastListGroupsIsExclusive = isExclusive
|
||||
return s.groups, int64(len(s.groups)), nil
|
||||
}
|
||||
|
||||
@@ -295,6 +303,7 @@ func (s *stubAdminService) GetGroupModelsListCandidates(ctx context.Context, id
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int64) ([]service.CompositeModelRoute, error) {
|
||||
s.advancedGroupOperationCalls++
|
||||
return []service.CompositeModelRoute{
|
||||
{
|
||||
ID: 1,
|
||||
@@ -311,6 +320,7 @@ func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int6
|
||||
}
|
||||
|
||||
func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) {
|
||||
s.advancedGroupOperationCalls++
|
||||
return &service.CompositeModelRoute{
|
||||
ID: 1,
|
||||
GroupID: groupID,
|
||||
@@ -326,6 +336,7 @@ func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int
|
||||
}
|
||||
|
||||
func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) {
|
||||
s.advancedGroupOperationCalls++
|
||||
return &service.CompositeModelRoute{
|
||||
ID: routeID,
|
||||
GroupID: groupID,
|
||||
@@ -341,10 +352,12 @@ func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, ro
|
||||
}
|
||||
|
||||
func (s *stubAdminService) DeleteCompositeRoute(ctx context.Context, groupID, routeID int64) error {
|
||||
s.advancedGroupOperationCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRoutePreviewRequest) (*service.CompositeRouteDecision, error) {
|
||||
s.advancedGroupOperationCalls++
|
||||
decision, err := service.NewCompositeRouteResolver(nil).Resolve(ctx, groupID, input.Model, input.Endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -353,11 +366,13 @@ func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID in
|
||||
}
|
||||
|
||||
func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.CreateGroupInput) (*service.Group, error) {
|
||||
s.createdGroups = append(s.createdGroups, input)
|
||||
group := service.Group{ID: 200, Name: input.Name, Status: service.StatusActive}
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) {
|
||||
s.advancedGroupOperationCalls++
|
||||
group := service.Group{ID: 201, Name: "group (Copy)", Status: "inactive"}
|
||||
return &group, nil
|
||||
}
|
||||
@@ -367,14 +382,21 @@ func (s *stubAdminService) RecoverDuplicateGroup(ctx context.Context, id int64,
|
||||
}
|
||||
|
||||
func (s *stubAdminService) UpdateGroup(ctx context.Context, id int64, input *service.UpdateGroupInput) (*service.Group, error) {
|
||||
s.updatedGroups = append(s.updatedGroups, input)
|
||||
group := service.Group{ID: id, Name: input.Name, Status: service.StatusActive}
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) DeleteGroup(ctx context.Context, id int64) error {
|
||||
s.deletedGroupIDs = append(s.deletedGroupIDs, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) DeleteGroupIfEmpty(ctx context.Context, id int64) error {
|
||||
s.guardedDeletedGroupIDs = append(s.guardedDeletedGroupIDs, id)
|
||||
return s.deleteGroupIfEmptyErr
|
||||
}
|
||||
|
||||
func (s *stubAdminService) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]service.APIKey, int64, error) {
|
||||
return s.apiKeys, int64(len(s.apiKeys)), nil
|
||||
}
|
||||
@@ -384,10 +406,12 @@ func (s *stubAdminService) GetGroupRateMultipliers(_ context.Context, _ int64) (
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ClearGroupRateMultipliers(_ context.Context, _ int64) error {
|
||||
s.advancedGroupOperationCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) BatchSetGroupRateMultipliers(_ context.Context, _ int64, _ []service.GroupRateMultiplierInput) error {
|
||||
s.advancedGroupOperationCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -396,6 +420,7 @@ func (s *stubAdminService) ClearGroupRPMOverrides(_ context.Context, _ int64) er
|
||||
}
|
||||
|
||||
func (s *stubAdminService) BatchSetGroupRPMOverrides(_ context.Context, _ int64, _ []service.GroupRPMOverrideInput) error {
|
||||
s.advancedGroupOperationCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -491,6 +516,8 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ValidateAccountGroupBindings(context.Context, []int64) error { return nil }
|
||||
|
||||
func (s *stubAdminService) DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*service.Account, error) {
|
||||
account := service.Account{ID: 301, Name: "account (Copy)", Status: service.StatusActive, Schedulable: false}
|
||||
return &account, nil
|
||||
|
||||
@@ -57,21 +57,22 @@ type updateChannelRequest struct {
|
||||
}
|
||||
|
||||
type channelModelPricingRequest struct {
|
||||
Platform string `json:"platform" binding:"omitempty,max=50"`
|
||||
Models []string `json:"models" binding:"required,min=1,max=100"`
|
||||
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
|
||||
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
|
||||
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
|
||||
FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"`
|
||||
FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"`
|
||||
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
|
||||
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
|
||||
Intervals []pricingIntervalRequest `json:"intervals"`
|
||||
TimePricing *channelTimePricingRequest `json:"time_pricing"`
|
||||
Platform string `json:"platform" binding:"omitempty,max=50"`
|
||||
Models []string `json:"models" binding:"required,min=1,max=100"`
|
||||
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
|
||||
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
|
||||
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
|
||||
FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"`
|
||||
FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"`
|
||||
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier" binding:"omitempty,gt=0"`
|
||||
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
|
||||
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
|
||||
Intervals []pricingIntervalRequest `json:"intervals"`
|
||||
TimePricing *channelTimePricingRequest `json:"time_pricing"`
|
||||
}
|
||||
|
||||
type channelTimePricingRequest struct {
|
||||
@@ -129,22 +130,23 @@ type channelResponse struct {
|
||||
}
|
||||
|
||||
type channelModelPricingResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
Models []string `json:"models"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
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"`
|
||||
FastMultiplier *float64 `json:"fast_multiplier"`
|
||||
FlexMultiplier *float64 `json:"flex_multiplier"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []pricingIntervalResponse `json:"intervals"`
|
||||
TimePricing *channelTimePricingResponse `json:"time_pricing"`
|
||||
ID int64 `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
Models []string `json:"models"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
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"`
|
||||
FastMultiplier *float64 `json:"fast_multiplier"`
|
||||
FlexMultiplier *float64 `json:"flex_multiplier"`
|
||||
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []pricingIntervalResponse `json:"intervals"`
|
||||
TimePricing *channelTimePricingResponse `json:"time_pricing"`
|
||||
}
|
||||
|
||||
type channelTimePricingResponse struct {
|
||||
@@ -258,22 +260,23 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon
|
||||
intervals = append(intervals, intervalToResponse(iv))
|
||||
}
|
||||
return channelModelPricingResponse{
|
||||
ID: p.ID,
|
||||
Platform: platform,
|
||||
Models: models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheWrite1hPrice: p.CacheWrite1hPrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
FastMultiplier: p.FastMultiplier,
|
||||
FlexMultiplier: p.FlexMultiplier,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingToResponse(p.TimePricing),
|
||||
ID: p.ID,
|
||||
Platform: platform,
|
||||
Models: models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheWrite1hPrice: p.CacheWrite1hPrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
FastMultiplier: p.FastMultiplier,
|
||||
FlexMultiplier: p.FlexMultiplier,
|
||||
MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingToResponse(p.TimePricing),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,27 +353,29 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult
|
||||
SortOrder: iv.SortOrder,
|
||||
})
|
||||
}
|
||||
var fastMultiplier, flexMultiplier *float64
|
||||
var fastMultiplier, flexMultiplier, maxReasoningEffortMultiplier *float64
|
||||
if allowChannelMultipliers {
|
||||
fastMultiplier = r.FastMultiplier
|
||||
flexMultiplier = r.FlexMultiplier
|
||||
maxReasoningEffortMultiplier = r.MaxReasoningEffortMultiplier
|
||||
}
|
||||
result = append(result, service.ChannelModelPricing{
|
||||
Platform: platform,
|
||||
Models: r.Models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: r.InputPrice,
|
||||
OutputPrice: r.OutputPrice,
|
||||
CacheWritePrice: r.CacheWritePrice,
|
||||
CacheWrite1hPrice: r.CacheWrite1hPrice,
|
||||
CacheReadPrice: r.CacheReadPrice,
|
||||
FastMultiplier: fastMultiplier,
|
||||
FlexMultiplier: flexMultiplier,
|
||||
ImageInputPrice: r.ImageInputPrice,
|
||||
ImageOutputPrice: r.ImageOutputPrice,
|
||||
PerRequestPrice: r.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingRequestToService(r.TimePricing),
|
||||
Platform: platform,
|
||||
Models: r.Models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: r.InputPrice,
|
||||
OutputPrice: r.OutputPrice,
|
||||
CacheWritePrice: r.CacheWritePrice,
|
||||
CacheWrite1hPrice: r.CacheWrite1hPrice,
|
||||
CacheReadPrice: r.CacheReadPrice,
|
||||
FastMultiplier: fastMultiplier,
|
||||
FlexMultiplier: flexMultiplier,
|
||||
MaxReasoningEffortMultiplier: maxReasoningEffortMultiplier,
|
||||
ImageInputPrice: r.ImageInputPrice,
|
||||
ImageOutputPrice: r.ImageOutputPrice,
|
||||
PerRequestPrice: r.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingRequestToService(r.TimePricing),
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -615,14 +620,15 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"found": true,
|
||||
"input_price": pricing.InputPricePerToken,
|
||||
"output_price": pricing.OutputPricePerToken,
|
||||
"cache_write_price": cacheWritePrice,
|
||||
"cache_write_1h_price": cacheWrite1hPrice,
|
||||
"cache_read_price": pricing.CacheReadPricePerToken,
|
||||
"image_input_price": pricing.ImageInputPricePerToken,
|
||||
"image_output_price": pricing.ImageOutputPricePerToken,
|
||||
"found": true,
|
||||
"input_price": pricing.InputPricePerToken,
|
||||
"output_price": pricing.OutputPricePerToken,
|
||||
"cache_write_price": cacheWritePrice,
|
||||
"cache_write_1h_price": cacheWrite1hPrice,
|
||||
"cache_read_price": pricing.CacheReadPricePerToken,
|
||||
"max_reasoning_effort_multiplier": pricing.MaxReasoningEffortMultiplier,
|
||||
"image_input_price": pricing.ImageInputPricePerToken,
|
||||
"image_output_price": pricing.ImageOutputPricePerToken,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -637,6 +643,7 @@ var platformToLiteLLMProvider = map[string]string{
|
||||
service.PlatformKimi: "moonshot",
|
||||
service.PlatformZhipu: "zhipu",
|
||||
service.PlatformDeepseek: "deepseek",
|
||||
service.PlatformMiniMax: "minimax",
|
||||
}
|
||||
|
||||
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
|
||||
|
||||
@@ -453,10 +453,11 @@ func TestPricingRequestToService_TimePricingNil(t *testing.T) {
|
||||
// 避免渠道倍率意外污染账号成本口径。
|
||||
func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
|
||||
req := channelModelPricingRequest{
|
||||
Models: []string{"gpt-5"},
|
||||
BillingMode: "token",
|
||||
FastMultiplier: float64Ptr(2.5),
|
||||
FlexMultiplier: float64Ptr(0.5),
|
||||
Models: []string{"gpt-5"},
|
||||
BillingMode: "token",
|
||||
FastMultiplier: float64Ptr(2.5),
|
||||
FlexMultiplier: float64Ptr(0.5),
|
||||
MaxReasoningEffortMultiplier: float64Ptr(3),
|
||||
Intervals: []pricingIntervalRequest{{
|
||||
MinTokens: 272000,
|
||||
InputMultiplier: float64Ptr(2),
|
||||
@@ -469,6 +470,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
|
||||
allowed := pricingRequestToService([]channelModelPricingRequest{req}, true)
|
||||
require.Equal(t, float64Ptr(2.5), allowed[0].FastMultiplier)
|
||||
require.Equal(t, float64Ptr(0.5), allowed[0].FlexMultiplier)
|
||||
require.Equal(t, float64Ptr(3), allowed[0].MaxReasoningEffortMultiplier)
|
||||
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].InputMultiplier)
|
||||
require.Equal(t, float64Ptr(1.5), allowed[0].Intervals[0].OutputMultiplier)
|
||||
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheWriteMultiplier)
|
||||
@@ -477,6 +479,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
|
||||
dropped := pricingRequestToService([]channelModelPricingRequest{req}, false)
|
||||
require.Nil(t, dropped[0].FastMultiplier)
|
||||
require.Nil(t, dropped[0].FlexMultiplier)
|
||||
require.Nil(t, dropped[0].MaxReasoningEffortMultiplier)
|
||||
require.Nil(t, dropped[0].Intervals[0].InputMultiplier)
|
||||
require.Nil(t, dropped[0].Intervals[0].OutputMultiplier)
|
||||
require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier)
|
||||
@@ -546,7 +549,7 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
|
||||
svc := service.NewPricingService(nil, nil)
|
||||
router := setupSyncPricingModelsRouter(svc)
|
||||
|
||||
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek"} {
|
||||
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek", "minimax"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/pricing/sync-models?platform="+platform, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
@@ -580,9 +583,10 @@ func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var body struct {
|
||||
Data struct {
|
||||
Found bool `json:"found"`
|
||||
CacheWritePrice float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
Found bool `json:"found"`
|
||||
CacheWritePrice float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
@@ -590,6 +594,8 @@ func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) {
|
||||
require.InDelta(t, 12.5e-6, body.Data.CacheWritePrice, 1e-12)
|
||||
require.NotNil(t, body.Data.CacheWrite1hPrice)
|
||||
require.InDelta(t, 20e-6, *body.Data.CacheWrite1hPrice, 1e-12)
|
||||
require.NotNil(t, body.Data.MaxReasoningEffortMultiplier)
|
||||
require.Equal(t, 3.0, *body.Data.MaxReasoningEffortMultiplier)
|
||||
}
|
||||
|
||||
func TestGetModelDefaultPricing_OmitsUnsupportedCache1hPrice(t *testing.T) {
|
||||
|
||||
@@ -40,7 +40,7 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
|
||||
|
||||
type channelMonitorCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"`
|
||||
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
|
||||
Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
|
||||
APIKey string `json:"api_key" binding:"omitempty,max=2000"`
|
||||
@@ -64,7 +64,7 @@ type channelMonitorCreateRequest struct {
|
||||
|
||||
type channelMonitorUpdateRequest struct {
|
||||
Name *string `json:"name" binding:"omitempty,max=100"`
|
||||
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
|
||||
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"`
|
||||
APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
|
||||
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
|
||||
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
@@ -194,6 +195,7 @@ func (h *GrokOAuthHandler) scheduleGrokImportProbe(account *service.Account) {
|
||||
// ProvideAccountHandler injects the Grok active prober for production while
|
||||
// keeping NewAccountHandler convenient for focused unit tests.
|
||||
func ProvideAccountHandler(
|
||||
cfg *config.Config,
|
||||
adminService service.AdminService,
|
||||
oauthService *service.OAuthService,
|
||||
openaiOAuthService *service.OpenAIOAuthService,
|
||||
@@ -227,5 +229,6 @@ func ProvideAccountHandler(
|
||||
tokenCacheInvalidator,
|
||||
)
|
||||
handler.grokImportProber = grokQuotaService
|
||||
handler.cfg = cfg
|
||||
return handler
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
grokMediaEligibilityModeAuto = "auto"
|
||||
grokMediaEligibilityModeEnabled = "enabled"
|
||||
grokMediaEligibilityModeDisabled = "disabled"
|
||||
)
|
||||
|
||||
type grokMediaEligibilityResponse struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
Mode string `json:"mode"`
|
||||
Eligible bool `json:"eligible"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type updateGrokMediaEligibilityRequest struct {
|
||||
Mode string `json:"mode" binding:"required"`
|
||||
}
|
||||
|
||||
// GetGrokMediaEligibility returns the current manual override and evaluated
|
||||
// media-routing decision for a Grok OAuth account.
|
||||
// GET /api/v1/admin/accounts/:id/grok-media-eligibility
|
||||
func (h *AccountHandler) GetGrokMediaEligibility(c *gin.Context) {
|
||||
account, err := h.getGrokOAuthAccount(c)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, buildGrokMediaEligibilityResponse(account))
|
||||
}
|
||||
|
||||
// UpdateGrokMediaEligibility updates only the manual media eligibility
|
||||
// override. It deliberately uses UpdateAccountExtra so unrelated account
|
||||
// extra fields are preserved.
|
||||
// PUT /api/v1/admin/accounts/:id/grok-media-eligibility
|
||||
func (h *AccountHandler) UpdateGrokMediaEligibility(c *gin.Context) {
|
||||
account, err := h.getGrokOAuthAccount(c)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req updateGrokMediaEligibilityRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var value any
|
||||
switch req.Mode {
|
||||
case grokMediaEligibilityModeAuto:
|
||||
value = nil
|
||||
case grokMediaEligibilityModeEnabled:
|
||||
value = true
|
||||
case grokMediaEligibilityModeDisabled:
|
||||
value = false
|
||||
default:
|
||||
response.ErrorFrom(c, infraerrors.BadRequest(
|
||||
"GROK_MEDIA_ELIGIBILITY_INVALID_MODE",
|
||||
"mode must be one of auto, enabled, disabled",
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]any{service.GrokMediaEligibleExtraKey: value}
|
||||
if err := service.ValidateGrokMediaEligibilityExtra(account.Platform, updates); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.adminService.UpdateAccountExtra(c.Request.Context(), account.ID, updates); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.adminService.GetAccount(c.Request.Context(), account.ID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err := ensureGrokOAuthAccount(updated); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, buildGrokMediaEligibilityResponse(updated))
|
||||
}
|
||||
|
||||
func (h *AccountHandler) getGrokOAuthAccount(c *gin.Context) (*service.Account, error) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return nil, infraerrors.BadRequest("INVALID_ACCOUNT_ID", "Invalid account ID")
|
||||
}
|
||||
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureGrokOAuthAccount(account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func ensureGrokOAuthAccount(account *service.Account) error {
|
||||
if account == nil {
|
||||
return infraerrors.NotFound("ACCOUNT_NOT_FOUND", "account not found")
|
||||
}
|
||||
if account.Platform != service.PlatformGrok || account.Type != service.AccountTypeOAuth {
|
||||
return infraerrors.BadRequest(
|
||||
"GROK_MEDIA_ELIGIBILITY_UNSUPPORTED_ACCOUNT",
|
||||
"media eligibility is only supported for Grok OAuth accounts",
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildGrokMediaEligibilityResponse(account *service.Account) grokMediaEligibilityResponse {
|
||||
eligible, reason := account.GrokMediaGenerationEligibility()
|
||||
return grokMediaEligibilityResponse{
|
||||
AccountID: account.ID,
|
||||
Mode: grokMediaEligibilityModeFromExtra(account.Extra),
|
||||
Eligible: eligible,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
func grokMediaEligibilityModeFromExtra(extra map[string]any) string {
|
||||
if extra != nil {
|
||||
if enabled, ok := extra[service.GrokMediaEligibleExtraKey].(bool); ok {
|
||||
if enabled {
|
||||
return grokMediaEligibilityModeEnabled
|
||||
}
|
||||
return grokMediaEligibilityModeDisabled
|
||||
}
|
||||
}
|
||||
return grokMediaEligibilityModeAuto
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type grokMediaEligibilityAdminServiceStub struct {
|
||||
*stubAdminService
|
||||
account *service.Account
|
||||
}
|
||||
|
||||
func (s *grokMediaEligibilityAdminServiceStub) GetAccount(_ context.Context, _ int64) (*service.Account, error) {
|
||||
return s.account, nil
|
||||
}
|
||||
|
||||
func (s *grokMediaEligibilityAdminServiceStub) UpdateAccountExtra(_ context.Context, _ int64, updates map[string]any) error {
|
||||
if s.account.Extra == nil {
|
||||
s.account.Extra = map[string]any{}
|
||||
}
|
||||
for key, value := range updates {
|
||||
if value == nil {
|
||||
delete(s.account.Extra, key)
|
||||
} else {
|
||||
s.account.Extra[key] = value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newGrokMediaEligibilityRouter(account *service.Account) *gin.Engine {
|
||||
stub := &grokMediaEligibilityAdminServiceStub{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: account,
|
||||
}
|
||||
h := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.GET("/accounts/:id/grok-media-eligibility", h.GetGrokMediaEligibility)
|
||||
router.PUT("/accounts/:id/grok-media-eligibility", h.UpdateGrokMediaEligibility)
|
||||
return router
|
||||
}
|
||||
|
||||
func TestGrokMediaEligibilityHandlerGetReturnsModeAndReason(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := newGrokMediaEligibilityRouter(&service.Account{
|
||||
ID: 12,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"grok_billing_snapshot": map[string]any{"status_code": 200, "partial": true},
|
||||
"unrelated_setting": "preserve",
|
||||
},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/accounts/12/grok-media-eligibility", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var envelope struct {
|
||||
Data grokMediaEligibilityResponse `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
|
||||
require.Equal(t, int64(12), envelope.Data.AccountID)
|
||||
require.Equal(t, grokMediaEligibilityModeAuto, envelope.Data.Mode)
|
||||
require.Equal(t, "billing_inconclusive", envelope.Data.Reason)
|
||||
}
|
||||
|
||||
func TestGrokMediaEligibilityHandlerUpdateModesPreservesExtra(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
account := &service.Account{
|
||||
ID: 12,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Extra: map[string]any{"unrelated_setting": "preserve"},
|
||||
}
|
||||
router := newGrokMediaEligibilityRouter(account)
|
||||
|
||||
tests := []struct {
|
||||
mode string
|
||||
wantMode string
|
||||
wantExtra any
|
||||
wantEligible bool
|
||||
wantReason string
|
||||
}{
|
||||
{mode: "enabled", wantMode: grokMediaEligibilityModeEnabled, wantExtra: true, wantEligible: true, wantReason: "override_enabled"},
|
||||
{mode: "disabled", wantMode: grokMediaEligibilityModeDisabled, wantExtra: false, wantEligible: false, wantReason: "override_disabled"},
|
||||
{mode: "auto", wantMode: grokMediaEligibilityModeAuto, wantExtra: nil, wantEligible: false, wantReason: "billing_unobserved"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.mode, func(t *testing.T) {
|
||||
body, err := json.Marshal(map[string]string{"mode": tt.mode})
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPut, "/accounts/12/grok-media-eligibility", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var envelope struct {
|
||||
Data grokMediaEligibilityResponse `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
|
||||
require.Equal(t, tt.wantMode, envelope.Data.Mode)
|
||||
require.Equal(t, tt.wantEligible, envelope.Data.Eligible)
|
||||
require.Equal(t, tt.wantReason, envelope.Data.Reason)
|
||||
require.Equal(t, "preserve", account.Extra["unrelated_setting"])
|
||||
if tt.wantExtra == nil {
|
||||
_, exists := account.Extra[service.GrokMediaEligibleExtraKey]
|
||||
require.False(t, exists)
|
||||
} else {
|
||||
require.Equal(t, tt.wantExtra, account.Extra[service.GrokMediaEligibleExtraKey])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokMediaEligibilityHandlerRejectsUnsupportedAndInvalidModes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
account *service.Account
|
||||
body string
|
||||
}{
|
||||
{name: "unsupported account", account: &service.Account{ID: 1, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, body: `{"mode":"enabled"}`},
|
||||
{name: "invalid mode", account: &service.Account{ID: 1, Platform: service.PlatformGrok, Type: service.AccountTypeOAuth}, body: `{"mode":"force"}`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
router := newGrokMediaEligibilityRouter(tt.account)
|
||||
req := httptest.NewRequest(http.MethodPut, "/accounts/1/grok-media-eligibility", bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
@@ -24,10 +25,14 @@ type GroupHandler struct {
|
||||
adminService service.AdminService
|
||||
dashboardService *service.DashboardService
|
||||
groupCapacityService *service.GroupCapacityService
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// GetLiveCapability 返回当前服务端是否具备生成 Live attestation 的运行环境。
|
||||
func (h *GroupHandler) GetLiveCapability(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "live_capability") {
|
||||
return
|
||||
}
|
||||
err := liveattestation.NewProvider().Check(c.Request.Context())
|
||||
result := gin.H{"supported": err == nil}
|
||||
if err != nil {
|
||||
@@ -87,18 +92,99 @@ func (f optionalLimitField) ToServiceInput() *float64 {
|
||||
|
||||
// NewGroupHandler creates a new admin group handler
|
||||
func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler {
|
||||
return NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, nil)
|
||||
}
|
||||
|
||||
func NewGroupHandlerWithConfig(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService, cfg *config.Config) *GroupHandler {
|
||||
return &GroupHandler{
|
||||
adminService: adminService,
|
||||
dashboardService: dashboardService,
|
||||
groupCapacityService: groupCapacityService,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GroupHandler) isSimpleMode() bool {
|
||||
return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple
|
||||
}
|
||||
|
||||
type simpleModeGroupOperation string
|
||||
|
||||
const (
|
||||
simpleModeGroupList simpleModeGroupOperation = "list"
|
||||
simpleModeGroupGetAll simpleModeGroupOperation = "get_all"
|
||||
simpleModeGroupGet simpleModeGroupOperation = "get"
|
||||
simpleModeGroupCreate simpleModeGroupOperation = "create"
|
||||
simpleModeGroupUpdate simpleModeGroupOperation = "update"
|
||||
simpleModeGroupDelete simpleModeGroupOperation = "delete"
|
||||
)
|
||||
|
||||
var simpleModeGroupOperations = map[simpleModeGroupOperation]struct{}{
|
||||
simpleModeGroupList: {}, simpleModeGroupGetAll: {}, simpleModeGroupGet: {},
|
||||
simpleModeGroupCreate: {}, simpleModeGroupUpdate: {}, simpleModeGroupDelete: {},
|
||||
}
|
||||
|
||||
func (h *GroupHandler) rejectUnsupportedSimpleModeOperation(c *gin.Context, operation simpleModeGroupOperation) bool {
|
||||
if _, allowed := simpleModeGroupOperations[operation]; allowed {
|
||||
return false
|
||||
}
|
||||
if err := service.ValidateSimpleModeGroupOperation(h.cfg, service.AdminGroupOperation(operation)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type simpleModeGroupResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform"`
|
||||
Status string `json:"status"`
|
||||
|
||||
AccountCount int64 `json:"account_count,omitempty"`
|
||||
ActiveAccountCount int64 `json:"active_account_count,omitempty"`
|
||||
RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func groupForSimpleMode(group *service.Group) *simpleModeGroupResponse {
|
||||
if group == nil {
|
||||
return nil
|
||||
}
|
||||
return &simpleModeGroupResponse{
|
||||
ID: group.ID, Name: group.Name, Description: group.Description, Platform: group.Platform,
|
||||
Status: group.Status,
|
||||
AccountCount: group.AccountCount,
|
||||
ActiveAccountCount: group.ActiveAccountCount, RateLimitedAccountCount: group.RateLimitedAccountCount,
|
||||
SortOrder: group.SortOrder, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeCreateGroupRequestForSimpleMode(req *CreateGroupRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, Platform: req.Platform}
|
||||
allowed.RateMultiplier = 1
|
||||
allowed.SubscriptionType = service.SubscriptionTypeStandard
|
||||
*req = allowed
|
||||
}
|
||||
|
||||
func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
*req = UpdateGroupRequest{Name: req.Name, Description: req.Description}
|
||||
}
|
||||
|
||||
// CreateGroupRequest represents create group request
|
||||
type CreateGroupRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
|
||||
@@ -153,14 +239,16 @@ type CreateGroupRequest struct {
|
||||
RequirePrivacySet bool `json:"require_privacy_set"`
|
||||
DefaultMappedModel string `json:"default_mapped_model"`
|
||||
MessagesDispatchModelConfig service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
||||
ModelsListConfig service.GroupModelsListConfig `json:"models_list_config"`
|
||||
ModelAllowlist service.GroupModelAllowlist `json:"model_allowlist"`
|
||||
// 固定账号 manifest 配置;创建路径禁止开启,仅编辑可配置。
|
||||
CodexModelsManifestConfig service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
|
||||
// 分组 RPM 上限(0 = 不限制)
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
// OpenAI/Codex 请求推理强度上限,空字符串表示不限制。
|
||||
// Anthropic/OpenAI 请求推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort"`
|
||||
// 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
|
||||
// OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
// Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
// 从指定分组复制账号(创建后自动绑定)
|
||||
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
|
||||
@@ -170,7 +258,7 @@ type CreateGroupRequest struct {
|
||||
type UpdateGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"`
|
||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||
IsExclusive *bool `json:"is_exclusive"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
@@ -226,10 +314,12 @@ type UpdateGroupRequest struct {
|
||||
RequirePrivacySet *bool `json:"require_privacy_set"`
|
||||
DefaultMappedModel *string `json:"default_mapped_model"`
|
||||
MessagesDispatchModelConfig *service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
||||
ModelsListConfig *service.GroupModelsListConfig `json:"models_list_config"`
|
||||
ModelAllowlist *service.GroupModelAllowlist `json:"model_allowlist"`
|
||||
// 固定账号 manifest 配置;nil 表示不修改。
|
||||
CodexModelsManifestConfig *service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
|
||||
// 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动
|
||||
RPMLimit *int `json:"rpm_limit"`
|
||||
// OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。
|
||||
// Anthropic/OpenAI 请求推理强度上限;空字符串清除,nil 不修改。
|
||||
MaxReasoningEffort *string `json:"max_reasoning_effort"`
|
||||
// 超过上限时的访问控制;空字符串视为 downgrade,nil 不修改。
|
||||
MaxReasoningEffortOverLimit *string `json:"max_reasoning_effort_over_limit"`
|
||||
@@ -242,7 +332,7 @@ type UpdateGroupRequest struct {
|
||||
type CompositeRouteRequest struct {
|
||||
PublicModel string `json:"public_model" binding:"required"`
|
||||
MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
|
||||
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek"`
|
||||
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax"`
|
||||
UpstreamModel string `json:"upstream_model"`
|
||||
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
|
||||
Priority int `json:"priority"`
|
||||
@@ -258,6 +348,9 @@ type CompositeRoutePreviewRequest struct {
|
||||
// List handles listing all groups with pagination
|
||||
// GET /api/v1/admin/groups
|
||||
func (h *GroupHandler) List(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupList) {
|
||||
return
|
||||
}
|
||||
page, pageSize := response.ParsePagination(c)
|
||||
platform := c.Query("platform")
|
||||
status := c.Query("status")
|
||||
@@ -272,7 +365,7 @@ func (h *GroupHandler) List(c *gin.Context) {
|
||||
sortOrder := c.DefaultQuery("sort_order", "asc")
|
||||
|
||||
var isExclusive *bool
|
||||
if isExclusiveStr != "" {
|
||||
if !h.isSimpleMode() && isExclusiveStr != "" {
|
||||
val := isExclusiveStr == "true"
|
||||
isExclusive = &val
|
||||
}
|
||||
@@ -283,6 +376,16 @@ func (h *GroupHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isSimpleMode() {
|
||||
simpleGroups := make([]simpleModeGroupResponse, 0, len(groups))
|
||||
for i := range groups {
|
||||
if service.IsGroupBindableInSimpleMode(&groups[i]) {
|
||||
simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
|
||||
}
|
||||
}
|
||||
response.Paginated(c, simpleGroups, total, page, pageSize)
|
||||
return
|
||||
}
|
||||
outGroups := make([]dto.AdminGroup, 0, len(groups))
|
||||
for i := range groups {
|
||||
outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i]))
|
||||
@@ -293,6 +396,9 @@ func (h *GroupHandler) List(c *gin.Context) {
|
||||
// ListCompositeRoutes handles listing composite model routes for one group.
|
||||
// GET /api/v1/admin/groups/:id/composite-routes
|
||||
func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, ok := parsePositiveIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
@@ -308,6 +414,9 @@ func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) {
|
||||
// CreateCompositeRoute handles creating one composite model route.
|
||||
// POST /api/v1/admin/groups/:id/composite-routes
|
||||
func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, ok := parsePositiveIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
@@ -328,6 +437,9 @@ func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) {
|
||||
// UpdateCompositeRoute handles replacing one composite model route.
|
||||
// PUT /api/v1/admin/groups/:id/composite-routes/:route_id
|
||||
func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, ok := parsePositiveIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
@@ -352,6 +464,9 @@ func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) {
|
||||
// DeleteCompositeRoute handles deleting one composite model route.
|
||||
// DELETE /api/v1/admin/groups/:id/composite-routes/:route_id
|
||||
func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, ok := parsePositiveIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
@@ -370,6 +485,9 @@ func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) {
|
||||
// PreviewCompositeRoute resolves a model without mutating routes.
|
||||
// POST /api/v1/admin/groups/:id/composite-routes/preview
|
||||
func (h *GroupHandler) PreviewCompositeRoute(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, ok := parsePositiveIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
@@ -423,6 +541,9 @@ func parsePositiveIDParam(c *gin.Context, name string) (int64, bool) {
|
||||
// bound to them even after the group is disabled).
|
||||
// GET /api/v1/admin/groups/all
|
||||
func (h *GroupHandler) GetAll(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGetAll) {
|
||||
return
|
||||
}
|
||||
platform := c.Query("platform")
|
||||
includeInactive := c.Query("include_inactive") == "true"
|
||||
|
||||
@@ -442,6 +563,16 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isSimpleMode() {
|
||||
simpleGroups := make([]simpleModeGroupResponse, 0, len(groups))
|
||||
for i := range groups {
|
||||
if service.IsGroupBindableInSimpleMode(&groups[i]) {
|
||||
simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
|
||||
}
|
||||
}
|
||||
response.Success(c, simpleGroups)
|
||||
return
|
||||
}
|
||||
outGroups := make([]dto.AdminGroup, 0, len(groups))
|
||||
for i := range groups {
|
||||
outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i]))
|
||||
@@ -452,6 +583,9 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
|
||||
// GetByID handles getting a group by ID
|
||||
// GET /api/v1/admin/groups/:id
|
||||
func (h *GroupHandler) GetByID(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGet) {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -464,12 +598,19 @@ func (h *GroupHandler) GetByID(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isSimpleMode() {
|
||||
response.Success(c, groupForSimpleMode(group))
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.GroupFromServiceAdmin(group))
|
||||
}
|
||||
|
||||
// GetModelsListCandidates handles getting candidate model IDs for custom /v1/models list.
|
||||
// GET /api/v1/admin/groups/:id/models-list-candidates
|
||||
func (h *GroupHandler) GetModelsListCandidates(c *gin.Context) {
|
||||
// GetGroupModelAllowlistCandidates handles getting candidate model IDs for the group model allowlist.
|
||||
// GET /api/v1/admin/groups/:id/model-allowlist-candidates
|
||||
func (h *GroupHandler) GetGroupModelAllowlistCandidates(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || groupID < 0 {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -492,11 +633,21 @@ func (h *GroupHandler) GetModelsListCandidates(c *gin.Context) {
|
||||
// Create handles creating a new group
|
||||
// POST /api/v1/admin/groups
|
||||
func (h *GroupHandler) Create(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupCreate) {
|
||||
return
|
||||
}
|
||||
var req CreateGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if h.isSimpleMode() && req.Platform == service.PlatformComposite {
|
||||
response.BadRequest(c, "Platform is not supported in simple mode")
|
||||
return
|
||||
}
|
||||
if h.isSimpleMode() {
|
||||
sanitizeCreateGroupRequestForSimpleMode(&req)
|
||||
}
|
||||
|
||||
if err := service.ValidatePeakRateConfig(req.SubscriptionType, req.PeakRateEnabled, req.PeakStart, req.PeakEnd, float64ValueOrDefault(req.PeakRateMultiplier, 1.0)); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
@@ -564,7 +715,8 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
RequirePrivacySet: req.RequirePrivacySet,
|
||||
DefaultMappedModel: req.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: req.ModelsListConfig,
|
||||
ModelAllowlist: req.ModelAllowlist,
|
||||
CodexModelsManifestConfig: req.CodexModelsManifestConfig,
|
||||
RPMLimit: req.RPMLimit,
|
||||
MaxReasoningEffort: req.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
|
||||
@@ -576,12 +728,19 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isSimpleMode() {
|
||||
response.Success(c, groupForSimpleMode(group))
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.GroupFromServiceAdmin(group))
|
||||
}
|
||||
|
||||
// Duplicate handles creating an inactive group copy with the source account bindings.
|
||||
// POST /api/v1/admin/groups/:id/duplicate
|
||||
func (h *GroupHandler) Duplicate(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || groupID <= 0 {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -629,6 +788,9 @@ func (h *GroupHandler) Duplicate(c *gin.Context) {
|
||||
// Update handles updating a group
|
||||
// PUT /api/v1/admin/groups/:id
|
||||
func (h *GroupHandler) Update(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupUpdate) {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -640,6 +802,9 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if h.isSimpleMode() {
|
||||
sanitizeUpdateGroupRequestForSimpleMode(&req)
|
||||
}
|
||||
|
||||
group, err := h.adminService.UpdateGroup(c.Request.Context(), groupID, &service.UpdateGroupInput{
|
||||
Name: req.Name,
|
||||
@@ -696,7 +861,8 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
RequirePrivacySet: req.RequirePrivacySet,
|
||||
DefaultMappedModel: req.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: req.ModelsListConfig,
|
||||
ModelAllowlist: req.ModelAllowlist,
|
||||
CodexModelsManifestConfig: req.CodexModelsManifestConfig,
|
||||
RPMLimit: req.RPMLimit,
|
||||
MaxReasoningEffort: req.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
|
||||
@@ -708,19 +874,30 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isSimpleMode() {
|
||||
response.Success(c, groupForSimpleMode(group))
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.GroupFromServiceAdmin(group))
|
||||
}
|
||||
|
||||
// Delete handles deleting a group
|
||||
// DELETE /api/v1/admin/groups/:id
|
||||
func (h *GroupHandler) Delete(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupDelete) {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.adminService.DeleteGroup(c.Request.Context(), groupID)
|
||||
if h.isSimpleMode() {
|
||||
err = h.adminService.DeleteGroupIfEmpty(c.Request.Context(), groupID)
|
||||
} else {
|
||||
err = h.adminService.DeleteGroup(c.Request.Context(), groupID)
|
||||
}
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
@@ -732,6 +909,9 @@ func (h *GroupHandler) Delete(c *gin.Context) {
|
||||
// GetStats handles getting group statistics
|
||||
// GET /api/v1/admin/groups/:id/stats
|
||||
func (h *GroupHandler) GetStats(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "stats") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -751,6 +931,9 @@ func (h *GroupHandler) GetStats(c *gin.Context) {
|
||||
// GetUsageSummary returns today's, yesterday's, and cumulative cost for all groups.
|
||||
// GET /api/v1/admin/groups/usage-summary
|
||||
func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
todayStart := service.GroupUsageTodayStart(time.Now())
|
||||
|
||||
results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart)
|
||||
@@ -765,6 +948,9 @@ func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
|
||||
// GetCapacitySummary returns aggregated capacity (concurrency/sessions/RPM) for all active groups.
|
||||
// GET /api/v1/admin/groups/capacity-summary
|
||||
func (h *GroupHandler) GetCapacitySummary(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
results, err := h.groupCapacityService.GetAllGroupCapacity(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get group capacity summary")
|
||||
@@ -776,6 +962,9 @@ func (h *GroupHandler) GetCapacitySummary(c *gin.Context) {
|
||||
// GetGroupAPIKeys handles getting API keys in a group
|
||||
// GET /api/v1/admin/groups/:id/api-keys
|
||||
func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "api_keys") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -800,6 +989,9 @@ func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
|
||||
// GetGroupRateMultipliers handles getting rate multipliers for users in a group
|
||||
// GET /api/v1/admin/groups/:id/rate-multipliers
|
||||
func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -821,6 +1013,9 @@ func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
|
||||
// ClearGroupRateMultipliers handles clearing all rate multipliers for a group
|
||||
// DELETE /api/v1/admin/groups/:id/rate-multipliers
|
||||
func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -843,6 +1038,9 @@ type BatchSetGroupRateMultipliersRequest struct {
|
||||
// BatchSetGroupRateMultipliers handles batch setting rate multipliers for a group
|
||||
// PUT /api/v1/admin/groups/:id/rate-multipliers
|
||||
func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -871,6 +1069,9 @@ type BatchSetGroupRPMOverridesRequest struct {
|
||||
// BatchSetGroupRPMOverrides handles batch setting rpm_override for users in a group
|
||||
// PUT /api/v1/admin/groups/:id/rpm-overrides
|
||||
func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -894,6 +1095,9 @@ func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) {
|
||||
// ClearGroupRPMOverrides handles clearing all rpm_override for a group
|
||||
// DELETE /api/v1/admin/groups/:id/rpm-overrides
|
||||
func (h *GroupHandler) ClearGroupRPMOverrides(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -919,6 +1123,9 @@ type UpdateSortOrderRequest struct {
|
||||
// UpdateSortOrder handles updating group sort orders
|
||||
// PUT /api/v1/admin/groups/sort-order
|
||||
func (h *GroupHandler) UpdateSortOrder(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
var req UpdateSortOrderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
|
||||
@@ -27,7 +27,7 @@ func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
|
||||
func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
|
||||
allowed := []string{
|
||||
"anthropic", "openai", "gemini", "antigravity", "grok",
|
||||
"kimi", "zhipu", "deepseek", "composite",
|
||||
"kimi", "zhipu", "deepseek", "minimax", "composite",
|
||||
}
|
||||
for _, platform := range allowed {
|
||||
t.Run("create_"+platform, func(t *testing.T) {
|
||||
@@ -72,7 +72,7 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) {
|
||||
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
|
||||
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax"} {
|
||||
var req CompositeRouteRequest
|
||||
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
|
||||
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newSimpleModeGroupRouter(svc *stubAdminService) *gin.Engine {
|
||||
h := NewGroupHandlerWithConfig(svc, nil, nil, &config.Config{RunMode: config.RunModeSimple})
|
||||
r := gin.New()
|
||||
r.GET("/groups", h.List)
|
||||
r.GET("/groups/all", h.GetAll)
|
||||
r.GET("/groups/live-capability", h.GetLiveCapability)
|
||||
r.GET("/groups/usage-summary", h.GetUsageSummary)
|
||||
r.GET("/groups/capacity-summary", h.GetCapacitySummary)
|
||||
r.PUT("/groups/sort-order", h.UpdateSortOrder)
|
||||
r.GET("/groups/:id", h.GetByID)
|
||||
r.GET("/groups/:id/stats", h.GetStats)
|
||||
r.GET("/groups/:id/api-keys", h.GetGroupAPIKeys)
|
||||
r.GET("/groups/:id/model-allowlist-candidates", h.GetGroupModelAllowlistCandidates)
|
||||
r.POST("/groups", h.Create)
|
||||
r.PUT("/groups/:id", h.Update)
|
||||
r.POST("/groups/:id/duplicate", h.Duplicate)
|
||||
r.GET("/groups/:id/composite-routes", h.ListCompositeRoutes)
|
||||
r.POST("/groups/:id/composite-routes", h.CreateCompositeRoute)
|
||||
r.POST("/groups/:id/composite-routes/preview", h.PreviewCompositeRoute)
|
||||
r.PUT("/groups/:id/composite-routes/:route_id", h.UpdateCompositeRoute)
|
||||
r.DELETE("/groups/:id/composite-routes/:route_id", h.DeleteCompositeRoute)
|
||||
r.GET("/groups/:id/rate-multipliers", h.GetGroupRateMultipliers)
|
||||
r.PUT("/groups/:id/rate-multipliers", h.BatchSetGroupRateMultipliers)
|
||||
r.DELETE("/groups/:id/rate-multipliers", h.ClearGroupRateMultipliers)
|
||||
r.PUT("/groups/:id/rpm-overrides", h.BatchSetGroupRPMOverrides)
|
||||
r.DELETE("/groups/:id/rpm-overrides", h.ClearGroupRPMOverrides)
|
||||
r.DELETE("/groups/:id", h.Delete)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeSanitizesCommercialFields(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newStubAdminService()
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
|
||||
create := `{"name":"simple","description":"basic grouping","platform":"anthropic","rate_multiplier":7,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":10,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"rpm_limit":99}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(create))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Len(t, svc.createdGroups, 1)
|
||||
created := svc.createdGroups[0]
|
||||
require.Equal(t, "basic grouping", created.Description)
|
||||
require.Equal(t, 1.0, created.RateMultiplier)
|
||||
require.False(t, created.IsExclusive)
|
||||
require.Equal(t, service.SubscriptionTypeStandard, created.SubscriptionType)
|
||||
require.Nil(t, created.DailyLimitUSD)
|
||||
require.False(t, created.AllowImageGeneration)
|
||||
require.False(t, created.LongContextPricingEnabled)
|
||||
require.Empty(t, created.ModelPricing)
|
||||
require.False(t, created.AllowBatchImageGeneration)
|
||||
require.Nil(t, created.VideoPrice720P)
|
||||
require.Nil(t, created.WebSearchPricePerCall)
|
||||
require.Nil(t, created.AudioRealtimePricePerMin)
|
||||
require.Zero(t, created.RPMLimit)
|
||||
|
||||
update := `{"name":"renamed","description":"still basic","rate_multiplier":9,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":12,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"status":"inactive","rpm_limit":123}`
|
||||
req = httptest.NewRequest(http.MethodPut, "/groups/2", bytes.NewBufferString(update))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res = httptest.NewRecorder()
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Len(t, svc.updatedGroups, 1)
|
||||
updated := svc.updatedGroups[0]
|
||||
require.NotNil(t, updated.Description)
|
||||
require.Equal(t, "still basic", *updated.Description)
|
||||
require.Nil(t, updated.RateMultiplier)
|
||||
require.Nil(t, updated.IsExclusive)
|
||||
require.Empty(t, updated.SubscriptionType)
|
||||
require.Nil(t, updated.DailyLimitUSD)
|
||||
require.Nil(t, updated.AllowImageGeneration)
|
||||
require.Nil(t, updated.LongContextPricingEnabled)
|
||||
require.Nil(t, updated.ModelPricing)
|
||||
require.Nil(t, updated.AllowBatchImageGeneration)
|
||||
require.Nil(t, updated.VideoPrice720P)
|
||||
require.Nil(t, updated.WebSearchPricePerCall)
|
||||
require.Nil(t, updated.AudioRealtimePricePerMin)
|
||||
require.Empty(t, updated.Status)
|
||||
require.Nil(t, updated.RPMLimit)
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeRejectsCompositeBeforeService(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(`{"name":"composite","platform":"composite"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusBadRequest, res.Code)
|
||||
require.Empty(t, svc.createdGroups)
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeDeleteRejectsNonEmptyGroupBeforeCascade(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
svc.deleteGroupIfEmptyErr = service.ErrGroupNotEmpty
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/7", nil))
|
||||
|
||||
require.Equal(t, http.StatusConflict, res.Code)
|
||||
require.Equal(t, []int64{7}, svc.guardedDeletedGroupIDs)
|
||||
require.Empty(t, svc.deletedGroupIDs)
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeDeletePermitsEmptyGroup(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/8", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Equal(t, []int64{8}, svc.guardedDeletedGroupIDs)
|
||||
require.Empty(t, svc.deletedGroupIDs)
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeIgnoresExclusiveFilter(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups?is_exclusive=true", nil))
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Nil(t, svc.lastListGroupsIsExclusive)
|
||||
}
|
||||
|
||||
func float64PtrForSimpleModeTest(value float64) *float64 { return &value }
|
||||
|
||||
func TestGroupHandlerSimpleModeResponseUsesFieldAllowlist(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newStubAdminService()
|
||||
svc.groups = []service.Group{{
|
||||
ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic,
|
||||
AccountCount: 3, ActiveAccountCount: 2, RateLimitedAccountCount: 1,
|
||||
Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42,
|
||||
LongContextPricingEnabled: true,
|
||||
ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}},
|
||||
AllowBatchImageGeneration: true, VideoPrice720P: float64PtrForSimpleModeTest(2),
|
||||
WebSearchPricePerCall: float64PtrForSimpleModeTest(3), AudioRealtimePricePerMin: float64PtrForSimpleModeTest(4),
|
||||
ModelRouting: map[string][]int64{"claude": {2}},
|
||||
}}
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups", nil))
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
|
||||
require.Len(t, payload.Data.Items, 1)
|
||||
item := payload.Data.Items[0]
|
||||
require.Equal(t, "basic", item["name"])
|
||||
require.Equal(t, "allowed", item["description"])
|
||||
require.Equal(t, "anthropic", item["platform"])
|
||||
require.Equal(t, float64(3), item["account_count"])
|
||||
require.ElementsMatch(t, []string{
|
||||
"id", "name", "description", "platform", "status", "account_count",
|
||||
"active_account_count", "rate_limited_account_count", "sort_order", "created_at", "updated_at",
|
||||
}, mapKeys(item))
|
||||
for _, forbidden := range []string{
|
||||
"rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing",
|
||||
"allow_batch_image_generation", "video_price_720p", "web_search_price_per_call",
|
||||
"audio_realtime_price_per_min", "model_routing",
|
||||
} {
|
||||
_, exposed := item[forbidden]
|
||||
require.Falsef(t, exposed, "simple mode exposed %s", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeListsOnlyBindableGroups(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
svc.groups = []service.Group{{ID: 1, Name: "basic", Platform: service.PlatformAnthropic}, {ID: 2, Name: "composite", Platform: service.PlatformComposite}}
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
for _, path := range []string{"/groups", "/groups/all"} {
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Contains(t, res.Body.String(), `"name":"basic"`)
|
||||
require.NotContains(t, res.Body.String(), `"name":"composite"`)
|
||||
}
|
||||
}
|
||||
|
||||
func mapKeys(values map[string]any) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeAllReadAndWriteResponsesUseFieldAllowlist(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newStubAdminService()
|
||||
svc.groups = []service.Group{{
|
||||
ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic, AccountCount: 3,
|
||||
Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42,
|
||||
LongContextPricingEnabled: true,
|
||||
ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}},
|
||||
}}
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
|
||||
assertNoAdvancedFields := func(t *testing.T, item map[string]any) {
|
||||
t.Helper()
|
||||
for _, forbidden := range []string{
|
||||
"rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing",
|
||||
"allow_batch_image_generation", "video_price_720p", "web_search_price_per_call",
|
||||
"audio_realtime_price_per_min", "model_routing",
|
||||
} {
|
||||
_, exposed := item[forbidden]
|
||||
require.Falsef(t, exposed, "simple mode exposed %s", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
list bool
|
||||
}{
|
||||
{name: "get all", method: http.MethodGet, path: "/groups/all", list: true},
|
||||
{name: "get by id", method: http.MethodGet, path: "/groups/1"},
|
||||
{name: "create", method: http.MethodPost, path: "/groups", body: `{"name":"created","platform":"anthropic","model_pricing":[{"models":["claude"]}]}`},
|
||||
{name: "update", method: http.MethodPut, path: "/groups/1", body: `{"name":"updated","model_pricing":[{"models":["claude"]}]}`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
|
||||
var payload struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
|
||||
if tt.list {
|
||||
var items []map[string]any
|
||||
require.NoError(t, json.Unmarshal(payload.Data, &items))
|
||||
require.Len(t, items, 1)
|
||||
assertNoAdvancedFields(t, items[0])
|
||||
return
|
||||
}
|
||||
var item map[string]any
|
||||
require.NoError(t, json.Unmarshal(payload.Data, &item))
|
||||
assertNoAdvancedFields(t, item)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeBlocksAdvancedOperations(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodPost, "/groups/1/duplicate", ""},
|
||||
{http.MethodGet, "/groups/1/model-allowlist-candidates", ""},
|
||||
{http.MethodGet, "/groups/1/composite-routes", ""},
|
||||
{http.MethodPost, "/groups/1/composite-routes", `{"public_model":"x","target_platform":"openai"}`},
|
||||
{http.MethodPost, "/groups/1/composite-routes/preview", `{"model":"x"}`},
|
||||
{http.MethodPut, "/groups/1/composite-routes/2", `{"public_model":"x","target_platform":"openai"}`},
|
||||
{http.MethodDelete, "/groups/1/composite-routes/2", ""},
|
||||
{http.MethodGet, "/groups/1/rate-multipliers", ""},
|
||||
{http.MethodPut, "/groups/1/rate-multipliers", `{"entries":[]}`},
|
||||
{http.MethodDelete, "/groups/1/rate-multipliers", ""},
|
||||
{http.MethodPut, "/groups/1/rpm-overrides", `{"entries":[]}`},
|
||||
{http.MethodDelete, "/groups/1/rpm-overrides", ""},
|
||||
{http.MethodGet, "/groups/1/stats", ""},
|
||||
{http.MethodGet, "/groups/1/api-keys", ""},
|
||||
{http.MethodGet, "/groups/live-capability", ""},
|
||||
{http.MethodGet, "/groups/usage-summary", ""},
|
||||
{http.MethodGet, "/groups/capacity-summary", ""},
|
||||
{http.MethodPut, "/groups/sort-order", `{"updates":[{"id":1,"sort_order":1}]}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusForbidden, res.Code)
|
||||
require.Zero(t, svc.advancedGroupOperationCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -172,9 +172,11 @@ func (h *ProxyHandler) ImportData(c *gin.Context) {
|
||||
updateInput := &service.UpdateProxyInput{
|
||||
Status: normalizedStatus,
|
||||
ExpiresAt: existingExpiresAt,
|
||||
ClearExpiresAt: existingExpiresAt == nil,
|
||||
FallbackMode: existingFallbackMode,
|
||||
BackupProxyID: existingBackupProxyID,
|
||||
ExpiryWarnDays: item.ExpiryWarnDays,
|
||||
ClearBackupID: existingBackupProxyID == nil,
|
||||
ExpiryWarnDays: &item.ExpiryWarnDays,
|
||||
// 保留已存在代理的网络配置字段
|
||||
Name: existing.Name,
|
||||
Protocol: existing.Protocol,
|
||||
@@ -255,9 +257,11 @@ func (h *ProxyHandler) ImportData(c *gin.Context) {
|
||||
if _, err := h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{
|
||||
Status: normalizedStatus,
|
||||
ExpiresAt: expiresAt,
|
||||
ClearExpiresAt: expiresAt == nil,
|
||||
FallbackMode: fallbackMode,
|
||||
BackupProxyID: backupProxyID,
|
||||
ExpiryWarnDays: item.ExpiryWarnDays,
|
||||
ClearBackupID: backupProxyID == nil,
|
||||
ExpiryWarnDays: &item.ExpiryWarnDays,
|
||||
Name: created.Name,
|
||||
Protocol: created.Protocol,
|
||||
Host: created.Host,
|
||||
|
||||
@@ -41,17 +41,17 @@ type CreateProxyRequest struct {
|
||||
|
||||
// UpdateProxyRequest represents update proxy request
|
||||
type UpdateProxyRequest struct {
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
ExpiresAt *int64 `json:"expires_at"`
|
||||
FallbackMode string `json:"fallback_mode" binding:"omitempty,oneof=none proxy direct"`
|
||||
BackupProxyID *int64 `json:"backup_proxy_id"`
|
||||
ExpiryWarnDays int `json:"expiry_warn_days" binding:"omitempty,min=0"`
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
ExpiresAt dto.NullableInt64Field `json:"expires_at"`
|
||||
FallbackMode string `json:"fallback_mode" binding:"omitempty,oneof=none proxy direct"`
|
||||
BackupProxyID dto.NullableInt64Field `json:"backup_proxy_id"`
|
||||
ExpiryWarnDays *int `json:"expiry_warn_days" binding:"omitempty,min=0"`
|
||||
}
|
||||
|
||||
// List handles listing all proxies with pagination
|
||||
@@ -183,8 +183,8 @@ func (h *ProxyHandler) Update(c *gin.Context) {
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
if req.ExpiresAt != nil && *req.ExpiresAt > 0 {
|
||||
t := time.Unix(*req.ExpiresAt, 0).UTC()
|
||||
if req.ExpiresAt.Value != nil && *req.ExpiresAt.Value > 0 {
|
||||
t := time.Unix(*req.ExpiresAt.Value, 0).UTC()
|
||||
expiresAt = &t
|
||||
}
|
||||
proxy, err := h.adminService.UpdateProxy(c.Request.Context(), proxyID, &service.UpdateProxyInput{
|
||||
@@ -196,8 +196,10 @@ func (h *ProxyHandler) Update(c *gin.Context) {
|
||||
Password: strings.TrimSpace(req.Password),
|
||||
Status: strings.TrimSpace(req.Status),
|
||||
ExpiresAt: expiresAt,
|
||||
ClearExpiresAt: req.ExpiresAt.Set && expiresAt == nil,
|
||||
FallbackMode: strings.TrimSpace(req.FallbackMode),
|
||||
BackupProxyID: req.BackupProxyID,
|
||||
BackupProxyID: req.BackupProxyID.Value,
|
||||
ClearBackupID: req.BackupProxyID.Set && req.BackupProxyID.Value == nil,
|
||||
ExpiryWarnDays: req.ExpiryWarnDays,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type proxyPartialUpdateService struct {
|
||||
service.AdminService
|
||||
input *service.UpdateProxyInput
|
||||
}
|
||||
|
||||
func (s *proxyPartialUpdateService) UpdateProxy(_ context.Context, _ int64, input *service.UpdateProxyInput) (*service.Proxy, error) {
|
||||
s.input = input
|
||||
return &service.Proxy{ID: 9}, nil
|
||||
}
|
||||
|
||||
func TestProxyHandlerUpdatePreservesFieldPresence(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
set, clear, warn bool
|
||||
}{
|
||||
{name: "omitted", body: `{"status":"inactive"}`},
|
||||
{name: "null clears", body: `{"expires_at":null,"backup_proxy_id":null,"fallback_mode":"none","expiry_warn_days":0}`, set: true, clear: true, warn: true},
|
||||
{name: "zero clears expiry", body: `{"expires_at":0,"backup_proxy_id":null}`, set: true, clear: true},
|
||||
{name: "values", body: `{"expires_at":1800000000,"backup_proxy_id":10,"fallback_mode":"proxy","expiry_warn_days":7}`, set: true, warn: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := &proxyPartialUpdateService{}
|
||||
router := gin.New()
|
||||
router.PUT("/proxies/:id", NewProxyHandler(svc).Update)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/proxies/9", strings.NewReader(tc.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
require.NotNil(t, svc.input)
|
||||
require.Equal(t, tc.set, svc.input.ExpiresAt != nil || svc.input.ClearExpiresAt)
|
||||
require.Equal(t, tc.set, svc.input.BackupProxyID != nil || svc.input.ClearBackupID)
|
||||
require.Equal(t, tc.warn, svc.input.ExpiryWarnDays != nil)
|
||||
if !tc.set || tc.clear {
|
||||
require.Nil(t, svc.input.ExpiresAt)
|
||||
require.Nil(t, svc.input.BackupProxyID)
|
||||
} else {
|
||||
require.Equal(t, int64(1800000000), svc.input.ExpiresAt.Unix())
|
||||
require.Equal(t, int64(10), *svc.input.BackupProxyID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -228,7 +228,7 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
|
||||
return nil, createErr
|
||||
}
|
||||
|
||||
redeemed, redeemErr := h.redeemService.Redeem(ctx, req.UserID, req.Code)
|
||||
redeemed, redeemErr := h.redeemService.RedeemForAdminFulfillment(ctx, req.UserID, req.Code)
|
||||
if redeemErr != nil {
|
||||
return nil, redeemErr
|
||||
}
|
||||
@@ -246,7 +246,7 @@ func (h *RedeemHandler) resolveCreateAndRedeemExisting(ctx context.Context, exis
|
||||
return nil, service.ErrRedeemCodeExpired
|
||||
}
|
||||
if existing.CanUse() {
|
||||
redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code)
|
||||
redeemed, err := h.redeemService.RedeemForAdminFulfillment(ctx, userID, existing.Code)
|
||||
if err == nil {
|
||||
return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil
|
||||
}
|
||||
|
||||
@@ -376,6 +376,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
|
||||
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
|
||||
ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
|
||||
|
||||
GrokDefaultTextModel: settings.GrokDefaultTextModel,
|
||||
GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled,
|
||||
|
||||
@@ -334,6 +334,7 @@ type UpdateSettingsRequest struct {
|
||||
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
|
||||
ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"`
|
||||
ChannelMonitorShowQuota *bool `json:"channel_monitor_show_quota"`
|
||||
ChannelMonitorHideUserRanking *bool `json:"channel_monitor_hide_user_ranking"`
|
||||
|
||||
// Grok model mapping policy
|
||||
GrokDefaultTextModel *string `json:"grok_default_text_model"`
|
||||
@@ -1906,6 +1907,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.ChannelMonitorShowQuota
|
||||
}(),
|
||||
ChannelMonitorHideUserRanking: func() bool {
|
||||
if req.ChannelMonitorHideUserRanking != nil {
|
||||
return *req.ChannelMonitorHideUserRanking
|
||||
}
|
||||
return previousSettings.ChannelMonitorHideUserRanking
|
||||
}(),
|
||||
GrokDefaultTextModel: func() string {
|
||||
if req.GrokDefaultTextModel != nil {
|
||||
return *req.GrokDefaultTextModel
|
||||
@@ -2366,6 +2373,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
|
||||
ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput,
|
||||
ChannelMonitorShowQuota: updatedSettings.ChannelMonitorShowQuota,
|
||||
ChannelMonitorHideUserRanking: updatedSettings.ChannelMonitorHideUserRanking,
|
||||
|
||||
GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel,
|
||||
GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled,
|
||||
|
||||
@@ -29,19 +29,19 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T
|
||||
AvatarURL: "https://cdn.example.com/linuxdo.png",
|
||||
AvatarSource: "remote_url",
|
||||
},
|
||||
identities: []service.UserAuthIdentityRecord{
|
||||
{
|
||||
ProviderType: "linuxdo",
|
||||
ProviderKey: "linuxdo",
|
||||
ProviderSubject: "linuxdo-subject-31",
|
||||
VerifiedAt: &verifiedAt,
|
||||
Metadata: map[string]any{
|
||||
"username": "linuxdo-handle",
|
||||
"avatar_url": "https://cdn.example.com/linuxdo.png",
|
||||
},
|
||||
identities: []service.UserAuthIdentityRecord{
|
||||
{
|
||||
ProviderType: "linuxdo",
|
||||
ProviderKey: "linuxdo",
|
||||
ProviderSubject: "linuxdo-subject-31",
|
||||
VerifiedAt: &verifiedAt,
|
||||
Metadata: map[string]any{
|
||||
"username": "linuxdo-handle",
|
||||
"avatar_url": "https://cdn.example.com/linuxdo.png",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
handler := &AuthHandler{
|
||||
userService: service.NewUserService(repo, nil, nil, nil),
|
||||
|
||||
@@ -66,29 +66,34 @@ type userAvailableGroup struct {
|
||||
|
||||
// userSupportedModelPricing 用户可见的定价字段白名单。
|
||||
type userSupportedModelPricing struct {
|
||||
BillingMode string `json:"billing_mode"`
|
||||
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"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []userPricingIntervalDTO `json:"intervals"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
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"`
|
||||
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier,omitempty"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []userPricingIntervalDTO `json:"intervals"`
|
||||
}
|
||||
|
||||
// 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 用户可见的支持模型条目。
|
||||
@@ -294,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
|
||||
@@ -323,15 +332,16 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
|
||||
billingMode = string(service.BillingModeToken)
|
||||
}
|
||||
return &userSupportedModelPricing{
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheWrite1hPrice: p.CacheWrite1hPrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheWrite1hPrice: p.CacheWrite1hPrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: 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) {
|
||||
|
||||
@@ -139,9 +139,28 @@ func (h *BatchImageHandler) Models(c *gin.Context) {
|
||||
batchImageError(c, err)
|
||||
return
|
||||
}
|
||||
// 分组级模型白名单:ListModels 已按分组账号枚举,这里再按白名单过滤一次。
|
||||
if apiKey, ok := middleware.GetAPIKeyFromContext(c); ok && apiKey != nil && apiKey.Group != nil {
|
||||
got.Data = filterBatchImageModelsByAllowlist(got.Data, apiKey.Group.ModelAllowlist)
|
||||
}
|
||||
c.JSON(http.StatusOK, got)
|
||||
}
|
||||
|
||||
// filterBatchImageModelsByAllowlist 按分组模型白名单过滤批量生图模型列表。
|
||||
// 白名单未开启时原样返回。
|
||||
func filterBatchImageModelsByAllowlist(models []service.BatchImagePublicModel, allowlist service.GroupModelAllowlist) []service.BatchImagePublicModel {
|
||||
if !allowlist.Enabled {
|
||||
return models
|
||||
}
|
||||
filtered := make([]service.BatchImagePublicModel, 0, len(models))
|
||||
for _, model := range models {
|
||||
if allowlist.Allows(model.ID) {
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (h *BatchImageHandler) Items(c *gin.Context) {
|
||||
owner, ok := batchImageOwnerFromContext(c)
|
||||
if !ok {
|
||||
|
||||
@@ -64,13 +64,45 @@ func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKe
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return "", nil, "", false
|
||||
}
|
||||
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
|
||||
if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
|
||||
return "", nil, "", false
|
||||
}
|
||||
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI {
|
||||
effectivePlatform := effectiveAPIKeyPlatform(c, apiKey)
|
||||
if effectivePlatform != service.PlatformAnthropic && effectivePlatform != service.PlatformOpenAI {
|
||||
return "", nil, "", false
|
||||
}
|
||||
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, apiKey.Group.MaxReasoningEffortOverLimit, true
|
||||
maxEffort, mappings := apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings
|
||||
if effectivePlatform == service.PlatformAnthropic {
|
||||
maxEffort, mappings = anthropicCompatibleReasoningEffortPolicy(maxEffort, mappings)
|
||||
}
|
||||
return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true
|
||||
}
|
||||
|
||||
func anthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) {
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return "", nil, "", false
|
||||
}
|
||||
if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformComposite {
|
||||
return "", nil, "", false
|
||||
}
|
||||
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformAnthropic {
|
||||
return "", nil, "", false
|
||||
}
|
||||
maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy(apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings)
|
||||
return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true
|
||||
}
|
||||
|
||||
func anthropicCompatibleReasoningEffortPolicy(maxEffort string, mappings []service.ReasoningEffortMapping) (string, []service.ReasoningEffortMapping) {
|
||||
if service.NormalizeMaxReasoningEffort(maxEffort) == "minimal" {
|
||||
maxEffort = "low"
|
||||
}
|
||||
normalizedMappings := append([]service.ReasoningEffortMapping(nil), mappings...)
|
||||
for i := range normalizedMappings {
|
||||
if service.NormalizeMaxReasoningEffort(normalizedMappings[i].To) == "minimal" {
|
||||
normalizedMappings[i].To = "low"
|
||||
}
|
||||
}
|
||||
return maxEffort, normalizedMappings
|
||||
}
|
||||
|
||||
func bindRequestedReasoningEffort(c *gin.Context, body []byte, model string) {
|
||||
@@ -118,6 +150,14 @@ func respondOpenAIReasoningEffortPolicyError(c *gin.Context, err error, write fu
|
||||
write(c, http.StatusForbidden, "permission_error", err.Error())
|
||||
}
|
||||
|
||||
func applyAnthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) {
|
||||
maxEffort, mappings, overLimit, ok := anthropicReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return body, false, nil
|
||||
}
|
||||
return service.ApplyReasoningEffortPolicy(body, maxEffort, mappings, overLimit)
|
||||
}
|
||||
|
||||
func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) {
|
||||
if c == nil || c.Request == nil {
|
||||
return
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) {
|
||||
@@ -34,6 +35,7 @@ func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) {
|
||||
{model: "k3", platform: service.PlatformKimi},
|
||||
{model: "glm-5.2", platform: service.PlatformZhipu},
|
||||
{model: "deepseek-v3.2", platform: service.PlatformDeepseek},
|
||||
{model: "MiniMax-M3", platform: service.PlatformMiniMax},
|
||||
}
|
||||
for _, path := range []string{"/v1/messages", "/v1/chat/completions", "/v1/responses", "/v1/responses/input_tokens", "/v1/messages/count_tokens"} {
|
||||
for _, provider := range providers {
|
||||
@@ -55,7 +57,7 @@ func TestResponsesWebSocketCompositePlatformGuardKeepsOpenAIAndGrokOnly(t *testi
|
||||
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformOpenAI))
|
||||
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformGrok))
|
||||
for _, platform := range []string{
|
||||
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
|
||||
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax,
|
||||
service.PlatformAnthropic, service.PlatformGemini,
|
||||
} {
|
||||
require.False(t, isResponsesWebSocketCompositePlatform(platform), "platform=%s", platform)
|
||||
@@ -151,6 +153,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))
|
||||
@@ -160,6 +179,75 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
|
||||
func TestOpenAIShapedReasoningEffortPolicyForAnthropicGroup(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
apiKey := &service.APIKey{Group: &service.Group{
|
||||
Platform: service.PlatformAnthropic,
|
||||
MaxReasoningEffort: "xhigh",
|
||||
}}
|
||||
|
||||
got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, []byte(`{"reasoning":{"effort":"max"}}`))
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "xhigh", gjson.GetBytes(got, "reasoning.effort").String())
|
||||
}
|
||||
|
||||
func TestAnthropicReasoningEffortPolicyForConcreteAndCompositeTargets(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"claude-fable-5-1","output_config":{"effort":"max"}}`)
|
||||
policy := func(platform string) *service.APIKey {
|
||||
return &service.APIKey{Group: &service.Group{
|
||||
Platform: platform,
|
||||
MaxReasoningEffort: "xhigh",
|
||||
ReasoningEffortMappings: []service.ReasoningEffortMapping{
|
||||
{From: "max", To: "xhigh"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
concrete, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
concrete.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
got, changed, err := applyAnthropicReasoningEffortPolicyForRequest(concrete, policy(service.PlatformAnthropic), body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"model":"claude-fable-5-1","output_config":{"effort":"xhigh"}}`, string(got))
|
||||
|
||||
denyMax := policy(service.PlatformAnthropic)
|
||||
denyMax.Group.ReasoningEffortMappings = nil
|
||||
denyMax.Group.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
|
||||
_, changed, err = applyAnthropicReasoningEffortPolicyForRequest(concrete, denyMax, body)
|
||||
require.Error(t, err)
|
||||
require.False(t, changed)
|
||||
var overLimit *service.ReasoningEffortOverLimitError
|
||||
require.ErrorAs(t, err, &overLimit)
|
||||
require.Equal(t, "max", overLimit.Requested)
|
||||
require.Equal(t, "xhigh", overLimit.Max)
|
||||
|
||||
composite, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
composite.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
composite.Request = composite.Request.WithContext(service.WithResolvedTargetPlatform(composite.Request.Context(), service.PlatformAnthropic))
|
||||
got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(composite, policy(service.PlatformComposite), body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "xhigh", gjson.GetBytes(got, "output_config.effort").String())
|
||||
|
||||
openAI, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
openAI.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
openAI.Request = openAI.Request.WithContext(service.WithResolvedTargetPlatform(openAI.Request.Context(), service.PlatformOpenAI))
|
||||
got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(openAI, policy(service.PlatformComposite), body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
|
||||
func TestAnthropicCompositePolicyClampsUnsupportedMinimalToLow(t *testing.T) {
|
||||
maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy("minimal", []service.ReasoningEffortMapping{{From: "max", To: "minimal"}})
|
||||
require.Equal(t, "low", maxEffort)
|
||||
require.Equal(t, []service.ReasoningEffortMapping{{From: "max", To: "low"}}, mappings)
|
||||
}
|
||||
|
||||
func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
|
||||
@@ -9,10 +9,15 @@ import (
|
||||
|
||||
const statusClientClosedRequest = 499
|
||||
|
||||
func concurrencyErrorResponse(err error, slotType string) (int, string, string) {
|
||||
const (
|
||||
gatewayQueueFullCode = "gateway_queue_full"
|
||||
gatewayConcurrencyLimitCode = "gateway_concurrency_limit"
|
||||
)
|
||||
|
||||
func concurrencyErrorResponse(err error, slotType string) (int, string, string, string) {
|
||||
var waitQueueFullErr *WaitQueueFullError
|
||||
if errors.As(err, &waitQueueFullErr) {
|
||||
return http.StatusTooManyRequests, "rate_limit_error",
|
||||
return http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode,
|
||||
"Too many pending requests, please retry later"
|
||||
}
|
||||
|
||||
@@ -21,13 +26,13 @@ func concurrencyErrorResponse(err error, slotType string) (int, string, string)
|
||||
if concurrencyErr.SlotType != "" {
|
||||
slotType = concurrencyErr.SlotType
|
||||
}
|
||||
return http.StatusTooManyRequests, "rate_limit_error",
|
||||
return http.StatusTooManyRequests, "rate_limit_error", gatewayConcurrencyLimitCode,
|
||||
fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType)
|
||||
}
|
||||
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return statusClientClosedRequest, "api_error", "context canceled"
|
||||
return statusClientClosedRequest, "api_error", "", "context canceled"
|
||||
}
|
||||
|
||||
return http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable, please retry later"
|
||||
return http.StatusServiceUnavailable, "api_error", "", "Service temporarily unavailable, please retry later"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ func TestConcurrencyErrorResponse(t *testing.T) {
|
||||
slotType string
|
||||
wantStatus int
|
||||
wantType string
|
||||
wantCode string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
@@ -24,8 +25,18 @@ func TestConcurrencyErrorResponse(t *testing.T) {
|
||||
slotType: "user",
|
||||
wantStatus: http.StatusTooManyRequests,
|
||||
wantType: "rate_limit_error",
|
||||
wantCode: gatewayConcurrencyLimitCode,
|
||||
wantMessage: "Concurrency limit exceeded for account, please retry later",
|
||||
},
|
||||
{
|
||||
name: "full local wait queue has gateway code",
|
||||
err: &WaitQueueFullError{SlotType: "account"},
|
||||
slotType: "account",
|
||||
wantStatus: http.StatusTooManyRequests,
|
||||
wantType: "rate_limit_error",
|
||||
wantCode: gatewayQueueFullCode,
|
||||
wantMessage: "Too many pending requests, please retry later",
|
||||
},
|
||||
{
|
||||
name: "client cancellation is not classified as concurrency limit",
|
||||
err: context.Canceled,
|
||||
@@ -54,9 +65,10 @@ func TestConcurrencyErrorResponse(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
status, errType, message := concurrencyErrorResponse(tt.err, tt.slotType)
|
||||
status, errType, code, message := concurrencyErrorResponse(tt.err, tt.slotType)
|
||||
require.Equal(t, tt.wantStatus, status)
|
||||
require.Equal(t, tt.wantType, errType)
|
||||
require.Equal(t, tt.wantCode, code)
|
||||
require.Equal(t, tt.wantMessage, message)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 固定账号 manifest 配置必须在管理端 DTO 完整序列化:漏映射会让编辑对话框回显
|
||||
// 关闭状态,管理员保存无关字段时会把已开启的配置静默关掉。用户侧 DTO 不携带该字段。
|
||||
func TestGroupMapperRoundTripsCodexModelsManifestConfig(t *testing.T) {
|
||||
group := &service.Group{
|
||||
ID: 31, Name: "codex-manifest-dto", Platform: service.PlatformOpenAI, Status: service.StatusActive,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{101, 202},
|
||||
FallbackToScheduler: true,
|
||||
},
|
||||
}
|
||||
|
||||
adminJSON, err := json.Marshal(GroupFromServiceAdmin(group))
|
||||
require.NoError(t, err)
|
||||
var adminEnvelope struct {
|
||||
CodexModelsManifestConfig *struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AccountIDs []int64 `json:"account_ids"`
|
||||
FallbackToScheduler bool `json:"fallback_to_scheduler"`
|
||||
} `json:"codex_models_manifest_config"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(adminJSON, &adminEnvelope))
|
||||
require.NotNil(t, adminEnvelope.CodexModelsManifestConfig, "GroupFromServiceAdmin 漏映射 codex_models_manifest_config")
|
||||
require.True(t, adminEnvelope.CodexModelsManifestConfig.Enabled)
|
||||
require.Equal(t, []int64{101, 202}, adminEnvelope.CodexModelsManifestConfig.AccountIDs)
|
||||
require.True(t, adminEnvelope.CodexModelsManifestConfig.FallbackToScheduler)
|
||||
|
||||
userJSON, err := json.Marshal(GroupFromService(group))
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(userJSON), "codex_models_manifest_config", "用户侧分组 DTO 不得暴露管理端 manifest 配置")
|
||||
}
|
||||
@@ -159,7 +159,8 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
|
||||
MCPXMLInject: g.MCPXMLInject,
|
||||
DefaultMappedModel: g.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: g.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: g.ModelsListConfig,
|
||||
ModelAllowlist: g.ModelAllowlist,
|
||||
CodexModelsManifestConfig: g.CodexModelsManifestConfig,
|
||||
SupportedModelScopes: g.SupportedModelScopes,
|
||||
AccountCount: g.AccountCount,
|
||||
ActiveAccountCount: g.ActiveAccountCount,
|
||||
@@ -447,6 +448,47 @@ func AccountFromService(a *service.Account) *Account {
|
||||
return out
|
||||
}
|
||||
|
||||
// AccountListItemFromAccount projects a full account response into the
|
||||
// compact shape used by the paginated admin account list. Keeping this
|
||||
// projection separate from Account preserves the existing detail API.
|
||||
func AccountListItemFromAccount(a *Account) *AccountListItem {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
return &AccountListItem{
|
||||
ID: a.ID, Name: a.Name, Notes: a.Notes, Platform: a.Platform, Type: a.Type,
|
||||
Credentials: a.Credentials, CredentialsStatus: a.CredentialsStatus, Extra: a.Extra,
|
||||
OllamaCloudUsage: a.OllamaCloudUsage,
|
||||
ProxyID: a.ProxyID, ProxyFallbackOriginID: a.ProxyFallbackOriginID, ProxyFallbackOriginName: a.ProxyFallbackOriginName,
|
||||
Concurrency: a.Concurrency, LoadFactor: a.LoadFactor, Priority: a.Priority, RateMultiplier: a.RateMultiplier,
|
||||
Status: a.Status, ErrorMessage: a.ErrorMessage, LastUsedAt: a.LastUsedAt, ExpiresAt: a.ExpiresAt,
|
||||
AutoPauseOnExpired: a.AutoPauseOnExpired, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt,
|
||||
Schedulable: a.Schedulable, RateLimitedAt: a.RateLimitedAt, RateLimitResetAt: a.RateLimitResetAt,
|
||||
OverloadUntil: a.OverloadUntil, TempUnschedulableUntil: a.TempUnschedulableUntil,
|
||||
TempUnschedulableReason: a.TempUnschedulableReason, SessionWindowStart: a.SessionWindowStart,
|
||||
SessionWindowEnd: a.SessionWindowEnd, SessionWindowStatus: a.SessionWindowStatus,
|
||||
WindowCostLimit: a.WindowCostLimit, WindowCostStickyReserve: a.WindowCostStickyReserve,
|
||||
MaxSessions: a.MaxSessions, SessionIdleTimeoutMin: a.SessionIdleTimeoutMin, BaseRPM: a.BaseRPM,
|
||||
RPMStrategy: a.RPMStrategy, RPMStickyBuffer: a.RPMStickyBuffer, UserMsgQueueMode: a.UserMsgQueueMode,
|
||||
EnableTLSFingerprint: a.EnableTLSFingerprint, TLSFingerprintProfileID: a.TLSFingerprintProfileID,
|
||||
EnableSessionIDMasking: a.EnableSessionIDMasking, CacheTTLOverrideEnabled: a.CacheTTLOverrideEnabled,
|
||||
CacheTTLOverrideTarget: a.CacheTTLOverrideTarget, CustomBaseURLEnabled: a.CustomBaseURLEnabled,
|
||||
CustomBaseURL: a.CustomBaseURL, QuotaLimit: a.QuotaLimit, QuotaUsed: a.QuotaUsed,
|
||||
QuotaDailyLimit: a.QuotaDailyLimit, QuotaDailyUsed: a.QuotaDailyUsed, QuotaWeeklyLimit: a.QuotaWeeklyLimit,
|
||||
QuotaWeeklyUsed: a.QuotaWeeklyUsed, QuotaDailyResetMode: a.QuotaDailyResetMode,
|
||||
QuotaDailyResetHour: a.QuotaDailyResetHour, QuotaWeeklyResetMode: a.QuotaWeeklyResetMode,
|
||||
QuotaWeeklyResetDay: a.QuotaWeeklyResetDay, QuotaWeeklyResetHour: a.QuotaWeeklyResetHour,
|
||||
QuotaResetTimezone: a.QuotaResetTimezone, QuotaDailyResetAt: a.QuotaDailyResetAt,
|
||||
QuotaWeeklyResetAt: a.QuotaWeeklyResetAt, QuotaNotifyDailyEnabled: a.QuotaNotifyDailyEnabled,
|
||||
QuotaNotifyDailyThreshold: a.QuotaNotifyDailyThreshold, QuotaNotifyWeeklyEnabled: a.QuotaNotifyWeeklyEnabled,
|
||||
QuotaNotifyWeeklyThreshold: a.QuotaNotifyWeeklyThreshold, QuotaNotifyTotalEnabled: a.QuotaNotifyTotalEnabled,
|
||||
QuotaNotifyTotalThreshold: a.QuotaNotifyTotalThreshold, ParentAccountID: a.ParentAccountID,
|
||||
QuotaDimension: a.QuotaDimension, ParentEmail: a.ParentEmail, ParentPlanType: a.ParentPlanType,
|
||||
ParentPrivacyMode: a.ParentPrivacyMode, ParentSubscriptionExpiresAt: a.ParentSubscriptionExpiresAt,
|
||||
ParentChatGPTAccountID: a.ParentChatGPTAccountID, Proxy: a.Proxy, GroupIDs: a.GroupIDs,
|
||||
}
|
||||
}
|
||||
|
||||
func timeToUnixSeconds(value *time.Time) *int64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
@@ -723,6 +765,7 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog {
|
||||
UpstreamModelMismatch: l.UpstreamModelMismatch,
|
||||
ChannelID: l.ChannelID,
|
||||
ModelMappingChain: l.ModelMappingChain,
|
||||
UpstreamRequestID: l.UpstreamRequestID,
|
||||
BillingTier: l.BillingTier,
|
||||
AccountRateMultiplier: l.AccountRateMultiplier,
|
||||
AccountStatsCost: l.AccountStatsCost,
|
||||
|
||||
@@ -307,6 +307,7 @@ type SystemSettings struct {
|
||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
|
||||
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
|
||||
ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"`
|
||||
|
||||
// Grok model mapping policy (admin settings; empty account mapping falls back to these).
|
||||
GrokDefaultTextModel string `json:"grok_default_text_model"`
|
||||
@@ -417,6 +418,7 @@ type PublicSettings struct {
|
||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
|
||||
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
|
||||
ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"`
|
||||
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
|
||||
@@ -151,11 +151,11 @@ type Group struct {
|
||||
|
||||
// RPMLimit 分组级每分钟请求数上限(0 = 不限制),设置后覆盖用户级 rpm_limit。
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
|
||||
// MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort"`
|
||||
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
|
||||
// ReasoningEffortMappings OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
// ReasoningEffortMappings Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -189,7 +189,9 @@ type AdminGroup struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
DefaultMappedModel string `json:"default_mapped_model"`
|
||||
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
||||
ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config"`
|
||||
ModelAllowlist service.GroupModelAllowlist `json:"model_allowlist"`
|
||||
// 固定账号获取 Codex Model Manifest 配置(仅 openai 平台使用)。
|
||||
CodexModelsManifestConfig domain.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
|
||||
|
||||
// 支持的模型系列(仅 antigravity 平台使用)
|
||||
SupportedModelScopes []string `json:"supported_model_scopes"`
|
||||
@@ -322,6 +324,102 @@ type Account struct {
|
||||
Groups []*Group `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// AccountListItem is the compact representation returned by the admin account
|
||||
// list when the lite query parameter is enabled. It contains the fields used
|
||||
// by the account table and runtime indicators, but intentionally omits the
|
||||
// repeated account_groups and groups object graphs. Fetch /admin/accounts/:id
|
||||
// for the complete Account DTO when editing or inspecting an account.
|
||||
type AccountListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Notes *string `json:"notes"`
|
||||
Platform string `json:"platform"`
|
||||
Type string `json:"type"`
|
||||
|
||||
Credentials map[string]any `json:"credentials,omitempty"`
|
||||
CredentialsStatus map[string]bool `json:"credentials_status,omitempty"`
|
||||
Extra map[string]any `json:"extra,omitempty"`
|
||||
OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"`
|
||||
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"`
|
||||
ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
LoadFactor *int `json:"load_factor,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
ExpiresAt *int64 `json:"expires_at"`
|
||||
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
Schedulable bool `json:"schedulable"`
|
||||
|
||||
RateLimitedAt *time.Time `json:"rate_limited_at"`
|
||||
RateLimitResetAt *time.Time `json:"rate_limit_reset_at"`
|
||||
OverloadUntil *time.Time `json:"overload_until"`
|
||||
|
||||
TempUnschedulableUntil *time.Time `json:"temp_unschedulable_until"`
|
||||
TempUnschedulableReason string `json:"temp_unschedulable_reason"`
|
||||
|
||||
SessionWindowStart *time.Time `json:"session_window_start"`
|
||||
SessionWindowEnd *time.Time `json:"session_window_end"`
|
||||
SessionWindowStatus string `json:"session_window_status"`
|
||||
|
||||
WindowCostLimit *float64 `json:"window_cost_limit,omitempty"`
|
||||
WindowCostStickyReserve *float64 `json:"window_cost_sticky_reserve,omitempty"`
|
||||
MaxSessions *int `json:"max_sessions,omitempty"`
|
||||
SessionIdleTimeoutMin *int `json:"session_idle_timeout_minutes,omitempty"`
|
||||
BaseRPM *int `json:"base_rpm,omitempty"`
|
||||
RPMStrategy *string `json:"rpm_strategy,omitempty"`
|
||||
RPMStickyBuffer *int `json:"rpm_sticky_buffer,omitempty"`
|
||||
UserMsgQueueMode *string `json:"user_msg_queue_mode,omitempty"`
|
||||
EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"`
|
||||
TLSFingerprintProfileID *int64 `json:"tls_fingerprint_profile_id,omitempty"`
|
||||
EnableSessionIDMasking *bool `json:"session_id_masking_enabled,omitempty"`
|
||||
CacheTTLOverrideEnabled *bool `json:"cache_ttl_override_enabled,omitempty"`
|
||||
CacheTTLOverrideTarget *string `json:"cache_ttl_override_target,omitempty"`
|
||||
CustomBaseURLEnabled *bool `json:"custom_base_url_enabled,omitempty"`
|
||||
CustomBaseURL *string `json:"custom_base_url,omitempty"`
|
||||
|
||||
QuotaLimit *float64 `json:"quota_limit,omitempty"`
|
||||
QuotaUsed *float64 `json:"quota_used,omitempty"`
|
||||
QuotaDailyLimit *float64 `json:"quota_daily_limit,omitempty"`
|
||||
QuotaDailyUsed *float64 `json:"quota_daily_used,omitempty"`
|
||||
QuotaWeeklyLimit *float64 `json:"quota_weekly_limit,omitempty"`
|
||||
QuotaWeeklyUsed *float64 `json:"quota_weekly_used,omitempty"`
|
||||
|
||||
QuotaDailyResetMode *string `json:"quota_daily_reset_mode,omitempty"`
|
||||
QuotaDailyResetHour *int `json:"quota_daily_reset_hour,omitempty"`
|
||||
QuotaWeeklyResetMode *string `json:"quota_weekly_reset_mode,omitempty"`
|
||||
QuotaWeeklyResetDay *int `json:"quota_weekly_reset_day,omitempty"`
|
||||
QuotaWeeklyResetHour *int `json:"quota_weekly_reset_hour,omitempty"`
|
||||
QuotaResetTimezone *string `json:"quota_reset_timezone,omitempty"`
|
||||
QuotaDailyResetAt *string `json:"quota_daily_reset_at,omitempty"`
|
||||
QuotaWeeklyResetAt *string `json:"quota_weekly_reset_at,omitempty"`
|
||||
|
||||
QuotaNotifyDailyEnabled *bool `json:"quota_notify_daily_enabled,omitempty"`
|
||||
QuotaNotifyDailyThreshold *float64 `json:"quota_notify_daily_threshold,omitempty"`
|
||||
QuotaNotifyWeeklyEnabled *bool `json:"quota_notify_weekly_enabled,omitempty"`
|
||||
QuotaNotifyWeeklyThreshold *float64 `json:"quota_notify_weekly_threshold,omitempty"`
|
||||
QuotaNotifyTotalEnabled *bool `json:"quota_notify_total_enabled,omitempty"`
|
||||
QuotaNotifyTotalThreshold *float64 `json:"quota_notify_total_threshold,omitempty"`
|
||||
|
||||
ParentAccountID *int64 `json:"parent_account_id,omitempty"`
|
||||
QuotaDimension string `json:"quota_dimension,omitempty"`
|
||||
ParentEmail string `json:"parent_email,omitempty"`
|
||||
ParentPlanType string `json:"parent_plan_type,omitempty"`
|
||||
ParentPrivacyMode string `json:"parent_privacy_mode,omitempty"`
|
||||
ParentSubscriptionExpiresAt string `json:"parent_subscription_expires_at,omitempty"`
|
||||
ParentChatGPTAccountID string `json:"parent_chatgpt_account_id,omitempty"`
|
||||
|
||||
Proxy *Proxy `json:"proxy,omitempty"`
|
||||
GroupIDs []int64 `json:"group_ids,omitempty"`
|
||||
}
|
||||
|
||||
type AccountGroup struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
@@ -588,6 +686,8 @@ type AdminUsageLog struct {
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
// ModelMappingChain 模型映射链,如 "a→b→c"
|
||||
ModelMappingChain *string `json:"model_mapping_chain,omitempty"`
|
||||
// UpstreamRequestID 是直接上游声明的请求标识,仅管理端可见。
|
||||
UpstreamRequestID *string `json:"upstream_request_id,omitempty"`
|
||||
// BillingTier 计费层级标签(per_request/image 模式)
|
||||
BillingTier *string `json:"billing_tier,omitempty"`
|
||||
|
||||
|
||||
@@ -171,6 +171,19 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
reqStream := parsedReq.Stream
|
||||
bindRequestedReasoningEffort(c, body, reqModel)
|
||||
ensureCompositeTargetPlatform(c, apiKey, reqModel)
|
||||
if policyBody, changed, err := applyAnthropicReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
|
||||
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
|
||||
return
|
||||
} else if changed {
|
||||
if err := parsedReq.ReplaceBody(policyBody); err != nil {
|
||||
reqLog.Warn("gateway.reasoning_effort_policy_parse_failed", zap.Error(err))
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to apply reasoning effort policy")
|
||||
return
|
||||
}
|
||||
body = parsedReq.Body.Bytes()
|
||||
reqModel = parsedReq.Model
|
||||
reqStream = parsedReq.Stream
|
||||
}
|
||||
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
|
||||
|
||||
// 解析渠道级模型映射
|
||||
@@ -395,7 +408,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int("max_waiting", selection.WaitPlan.MaxWaiting),
|
||||
)
|
||||
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", streamStarted)
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later", streamStarted)
|
||||
return
|
||||
}
|
||||
if err == nil && canWait {
|
||||
@@ -610,6 +623,23 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
|
||||
// 会话槽失败释放:failover 链上每个选中的 Anthropic OAuth 账号都注册过同一会话
|
||||
// (checkAndRegisterSession)。若请求最终失败(转发失败/客户端中断/换号耗尽),
|
||||
// 须立即释放本次会话注册——上游从未真正服务该会话,继续占槽会让 max_sessions
|
||||
// 受限的账号被失败请求的 session hash 卡满整个空闲窗口,后续新会话全部被拒。
|
||||
// 成功请求保持既有空闲超时语义(会话按最后活动时间过期)。
|
||||
sessionSlotAccounts := make(map[int64]*service.Account)
|
||||
upstreamServedSession := false
|
||||
defer func() {
|
||||
if upstreamServedSession {
|
||||
return
|
||||
}
|
||||
// 客户端可能已断开、请求 ctx 已取消,用独立 ctx 执行释放
|
||||
for _, acc := range sessionSlotAccounts {
|
||||
h.gatewayService.ReleaseAccountSession(context.Background(), acc, sessionKey)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
fs := NewFailoverState(h.maxAccountSwitches, hasBoundSession)
|
||||
retryWithFallback := false
|
||||
@@ -719,7 +749,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int("max_waiting", selection.WaitPlan.MaxWaiting),
|
||||
)
|
||||
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", streamStarted)
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later", streamStarted)
|
||||
return
|
||||
}
|
||||
if err == nil && canWait {
|
||||
@@ -763,10 +793,15 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted)
|
||||
return
|
||||
}
|
||||
// 尝试被否决(从未转发),立即释放该账号的会话注册
|
||||
h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionKey)
|
||||
delete(sessionSlotAccounts, account.ID)
|
||||
continue
|
||||
}
|
||||
account = latest
|
||||
selection.Account = latest
|
||||
// 记录本请求注册过会话槽的账号(profit 准入后账号已定)
|
||||
sessionSlotAccounts[account.ID] = account
|
||||
// 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已
|
||||
// 抢槽的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。
|
||||
if selection.ProfitGateActive() || !selection.Acquired {
|
||||
@@ -983,6 +1018,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
currentSubscription = nil
|
||||
fallbackUsed = true
|
||||
retryWithFallback = true
|
||||
// 原分组账号已确定性失败(prompt too long),先释放其会话注册再走兜底分组
|
||||
for _, acc := range sessionSlotAccounts {
|
||||
h.gatewayService.ReleaseAccountSession(context.Background(), acc, sessionKey)
|
||||
}
|
||||
sessionSlotAccounts = make(map[int64]*service.Account)
|
||||
break
|
||||
}
|
||||
_ = h.antigravityGatewayService.WriteMappedClaudeError(c, account, promptTooLongErr.StatusCode, promptTooLongErr.RequestID, promptTooLongErr.Body)
|
||||
@@ -998,6 +1038,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr)
|
||||
switch action {
|
||||
case FailoverContinue:
|
||||
// 本次尝试已确定性失败,立即释放该账号的会话注册
|
||||
h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionKey)
|
||||
delete(sessionSlotAccounts, account.ID)
|
||||
continue
|
||||
case FailoverExhausted:
|
||||
h.handleFailoverExhausted(c, fs.LastFailoverErr, account.Platform, streamStarted)
|
||||
@@ -1036,6 +1079,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
// 不会走到这里重复计费。
|
||||
if result != nil {
|
||||
submitForwardUsage(result)
|
||||
// 上游已接受并计量本次会话(流中断),会话槽保持既有语义
|
||||
upstreamServedSession = true
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1061,6 +1106,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
|
||||
submitForwardUsage(result)
|
||||
// 转发成功,会话槽保持既有空闲超时语义
|
||||
upstreamServedSession = true
|
||||
return
|
||||
}
|
||||
if !retryWithFallback {
|
||||
@@ -1087,11 +1134,20 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
platform = forcedPlatform
|
||||
}
|
||||
|
||||
if platform == service.PlatformOpenAI && apiKey != nil && apiKey.Group != nil &&
|
||||
apiKey.Group.Platform == service.PlatformOpenAI && apiKey.Group.CodexModelsManifestConfig.Enabled {
|
||||
h.pinnedOpenAIModels(c, apiKey.Group)
|
||||
return
|
||||
}
|
||||
|
||||
if platform == service.PlatformComposite {
|
||||
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)
|
||||
if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
|
||||
source := availableModels
|
||||
if len(source) == 0 {
|
||||
source = defaultModelIDsForPlatform(service.PlatformComposite)
|
||||
}
|
||||
writeAllowlistedModelsList(c, service.PlatformComposite, apiKey.Group.ModelAllowlist.FilterForListing(source))
|
||||
return
|
||||
}
|
||||
if len(availableModels) > 0 {
|
||||
@@ -1104,10 +1160,9 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
|
||||
// Get available models from account configurations for the selected group platform.
|
||||
availableModels := h.gatewayService.GetAvailableModels(c.Request.Context(), groupID, platform)
|
||||
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)
|
||||
if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
|
||||
source := modelListingSource(platform, availableModels, defaultModelIDsForPlatform(platform))
|
||||
writeAllowlistedModelsList(c, platform, apiKey.Group.ModelAllowlist.FilterForListing(source))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1192,8 +1247,12 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi
|
||||
if platform == service.PlatformComposite {
|
||||
availableModels := h.compositeAvailableModels(ctx, groupID)
|
||||
fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite)
|
||||
if group.CustomModelsListEnabled() {
|
||||
return filterModelsByCustomList(availableModels, fallbackModels, group.ModelsListConfig.Models)
|
||||
if group.ModelAllowlistEnabled() {
|
||||
source := availableModels
|
||||
if len(source) == 0 {
|
||||
source = fallbackModels
|
||||
}
|
||||
return group.ModelAllowlist.FilterForListing(source)
|
||||
}
|
||||
if len(availableModels) > 0 {
|
||||
return availableModels
|
||||
@@ -1203,12 +1262,8 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi
|
||||
|
||||
availableModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
|
||||
fallbackModels := defaultCodexModelIDsForPlatform(platform)
|
||||
if group.CustomModelsListEnabled() {
|
||||
return filterModelsByCustomList(
|
||||
customModelsListSource(platform, availableModels, fallbackModels),
|
||||
fallbackModels,
|
||||
group.ModelsListConfig.Models,
|
||||
)
|
||||
if group.ModelAllowlistEnabled() {
|
||||
return group.ModelAllowlist.FilterForListing(modelListingSource(platform, availableModels, fallbackModels))
|
||||
}
|
||||
if len(availableModels) > 0 {
|
||||
return availableModels
|
||||
@@ -1223,7 +1278,7 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
|
||||
seen := make(map[string]struct{})
|
||||
models := make([]string, 0)
|
||||
schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID)
|
||||
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
|
||||
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
|
||||
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
|
||||
if len(platformModels) == 0 {
|
||||
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
|
||||
@@ -1248,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
|
||||
@@ -1267,7 +1326,7 @@ func writeModelsList(c *gin.Context, platform string, modelIDs []string) {
|
||||
})
|
||||
}
|
||||
|
||||
func writeCustomModelsList(c *gin.Context, platform string, modelIDs []string) {
|
||||
func writeAllowlistedModelsList(c *gin.Context, platform string, modelIDs []string) {
|
||||
if platform == service.PlatformOpenAI {
|
||||
writeOpenAIModelsList(c, modelIDs)
|
||||
return
|
||||
@@ -1365,76 +1424,25 @@ func writeOpenAIModelsList(c *gin.Context, modelIDs []string) {
|
||||
})
|
||||
}
|
||||
|
||||
func customModelsListSource(platform string, availableModels, fallbackModels []string) []string {
|
||||
if platform == service.PlatformAnthropic && len(availableModels) > 0 {
|
||||
// modelListingSource 汇总模型列表过滤的候选来源:账号映射键(availableModels)
|
||||
// 与平台默认列表(fallbackModels)。账号映射为空时回落默认列表;Anthropic
|
||||
// 平台两者取并集,其余平台以账号映射键为准。
|
||||
func modelListingSource(platform string, availableModels, fallbackModels []string) []string {
|
||||
if len(availableModels) == 0 {
|
||||
return fallbackModels
|
||||
}
|
||||
if platform == service.PlatformAnthropic {
|
||||
return mergeModelIDs(availableModels, fallbackModels)
|
||||
}
|
||||
return availableModels
|
||||
}
|
||||
|
||||
func filterModelsByCustomList(availableModels, fallbackModels, selectedModels []string) []string {
|
||||
if len(selectedModels) == 0 {
|
||||
return availableModels
|
||||
}
|
||||
source := availableModels
|
||||
if len(source) == 0 {
|
||||
source = fallbackModels
|
||||
}
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
allowed := make([]string, 0, len(source))
|
||||
for _, model := range source {
|
||||
model = strings.TrimSpace(model)
|
||||
if model != "" {
|
||||
allowed = append(allowed, model)
|
||||
}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(selectedModels))
|
||||
filtered := make([]string, 0, len(selectedModels))
|
||||
for _, model := range selectedModels {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
continue
|
||||
}
|
||||
if !customModelsListAllowsModel(allowed, model) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[model]; ok {
|
||||
continue
|
||||
}
|
||||
seen[model] = struct{}{}
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func customModelsListAllowsModel(availablePatterns []string, model string) bool {
|
||||
for _, pattern := range availablePatterns {
|
||||
if pattern == model {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(pattern, "*") && strings.HasPrefix(model, strings.TrimSuffix(pattern, "*")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
normalizedClaudeModel := claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking"))
|
||||
if normalizedClaudeModel != model {
|
||||
for _, pattern := range availablePatterns {
|
||||
if pattern == normalizedClaudeModel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultCodexModelIDsForPlatform(platform string) []string {
|
||||
switch platform {
|
||||
case service.PlatformDeepseek:
|
||||
return []string{"deepseek-v4-pro", "deepseek-v4-flash"}
|
||||
case service.PlatformMiniMax:
|
||||
return []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}
|
||||
default:
|
||||
return defaultModelIDsForPlatform(platform)
|
||||
}
|
||||
@@ -1464,7 +1472,7 @@ func defaultModelIDsForPlatform(platform string) []string {
|
||||
case service.PlatformComposite:
|
||||
ids := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
|
||||
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
|
||||
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
@@ -1504,10 +1512,21 @@ func mergeModelIDs(primary, secondary []string) []string {
|
||||
|
||||
// AntigravityModels 返回 Antigravity 支持的全部模型
|
||||
// GET /antigravity/models
|
||||
// 分组级模型白名单开启时按白名单过滤。
|
||||
func (h *GatewayHandler) AntigravityModels(c *gin.Context) {
|
||||
models := antigravity.DefaultModels()
|
||||
if apiKey, ok := middleware2.GetAPIKeyFromContext(c); ok && apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
|
||||
filtered := make([]antigravity.ClaudeModel, 0, len(models))
|
||||
for _, model := range models {
|
||||
if apiKey.Group.ModelAllowlist.Allows(model.ID) {
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
}
|
||||
models = filtered
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
"data": antigravity.DefaultModels(),
|
||||
"data": models,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1853,8 +1872,8 @@ func (h *GatewayHandler) calculateSubscriptionRemaining(group *service.Group, su
|
||||
|
||||
// handleConcurrencyError handles concurrency-related acquire errors.
|
||||
func (h *GatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) {
|
||||
status, errType, message := concurrencyErrorResponse(err, slotType)
|
||||
h.handleStreamingAwareError(c, status, errType, message, streamStarted)
|
||||
status, errType, code, message := concurrencyErrorResponse(err, slotType)
|
||||
h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, streamStarted)
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, platform string, streamStarted bool) {
|
||||
@@ -1925,6 +1944,10 @@ func (h *GatewayHandler) mapUpstreamError(statusCode int) (int, string, string)
|
||||
|
||||
// handleStreamingAwareError handles errors that may occur after streaming has started
|
||||
func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) {
|
||||
h.handleStreamingAwareErrorWithCode(c, status, errType, "", message, streamStarted)
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) handleStreamingAwareErrorWithCode(c *gin.Context, status int, errType, code, message string, streamStarted bool) {
|
||||
if streamStarted {
|
||||
// 响应状态码已固化为 200(ping/部分数据已 flush),错误只能就地以 SSE 帧回传。
|
||||
// 标记本次流内错误,供 ops_error_logger 补记——否则该中间件按 status>=400 采集,
|
||||
@@ -1935,7 +1958,7 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
|
||||
// response.completed/failed/incomplete/cancelled 集合。
|
||||
// Anthropic-backed Responses 路径同样会因为通用 error 帧被拒。
|
||||
if inboundIsResponses(c) {
|
||||
if writeResponsesFailedSSE(c, errType, message) {
|
||||
if writeResponsesFailedSSE(c, errType, code, message) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1943,7 +1966,11 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if ok {
|
||||
// SSE 错误事件固定 schema,使用 Quote 直拼可避免额外 Marshal 分配。
|
||||
errorEvent := `data: {"type":"error","error":{"type":` + strconv.Quote(errType) + `,"message":` + strconv.Quote(message) + `}}` + "\n\n"
|
||||
errorCode := ""
|
||||
if code != "" {
|
||||
errorCode = `,"code":` + strconv.Quote(code)
|
||||
}
|
||||
errorEvent := `data: {"type":"error","error":{"type":` + strconv.Quote(errType) + errorCode + `,"message":` + strconv.Quote(message) + `}}` + "\n\n"
|
||||
if _, err := fmt.Fprint(c.Writer, errorEvent); err != nil {
|
||||
_ = c.Error(err)
|
||||
}
|
||||
@@ -1953,7 +1980,7 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
|
||||
}
|
||||
|
||||
// Normal case: return JSON response with proper status code
|
||||
h.errorResponse(c, status, errType, message)
|
||||
h.errorResponseWithCode(c, status, errType, code, message)
|
||||
}
|
||||
|
||||
// ensureForwardErrorResponse 在 Forward 返回错误但尚未写响应时补写统一错误响应。
|
||||
@@ -2045,12 +2072,17 @@ func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool {
|
||||
|
||||
// errorResponse 返回Claude API格式的错误响应
|
||||
func (h *GatewayHandler) errorResponse(c *gin.Context, status int, errType, message string) {
|
||||
h.errorResponseWithCode(c, status, errType, "", message)
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) errorResponseWithCode(c *gin.Context, status int, errType, code, message string) {
|
||||
errorObject := gin.H{"type": errType, "message": message}
|
||||
if code != "" {
|
||||
errorObject["code"] = code
|
||||
}
|
||||
c.JSON(status, gin.H{
|
||||
"type": "error",
|
||||
"error": gin.H{
|
||||
"type": errType,
|
||||
"message": message,
|
||||
},
|
||||
"type": "error",
|
||||
"error": errorObject,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2163,6 +2195,8 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
|
||||
if err := h.gatewayService.ForwardCountTokens(c.Request.Context(), c, account, parsedReq); err != nil {
|
||||
reqLog.Error("gateway.count_tokens_forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
// 错误响应已在 ForwardCountTokens 中处理
|
||||
// 上游未服务该会话,立即释放选号时注册的会话槽(客户端可能已断开,用独立 ctx)
|
||||
h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionHash)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ func (h *GatewayHandler) handleResponsesFailoverExhausted(c *gin.Context, lastEr
|
||||
// generic response.failed.
|
||||
service.MarkOpsStreamError(c, code, message, status)
|
||||
if c != nil && c.Writer != nil && (c.Writer.Size() <= 0 || gatewayStreamHasOnlyHeartbeats(c)) {
|
||||
writeResponsesFailedSSE(c, code, message)
|
||||
writeResponsesFailedSSE(c, code, "", message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ func claudeCodeBodyMapFromParsedRequest(parsedReq *service.ParsedRequest) map[st
|
||||
bodyMap := map[string]any{
|
||||
"model": parsedReq.Model,
|
||||
}
|
||||
// 探测识别(max_tokens=1)需要看到该字段,复用已解析请求时一并带上。
|
||||
if parsedReq.MaxTokens > 0 {
|
||||
bodyMap["max_tokens"] = parsedReq.MaxTokens
|
||||
}
|
||||
if parsedReq.HasSystem {
|
||||
if system, ok := parsedReq.SystemValue(); ok {
|
||||
bodyMap["system"] = system
|
||||
|
||||
@@ -500,3 +500,19 @@ type helperConcurrencyCacheStubWithError struct {
|
||||
func (s *helperConcurrencyCacheStubWithError) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
|
||||
return false, s.err
|
||||
}
|
||||
|
||||
func TestSetClaudeCodeClientContext_ParsedRequestProbeWithoutSystemPrompt(t *testing.T) {
|
||||
c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
|
||||
c.Request.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)")
|
||||
|
||||
// The hot path reuses ParsedRequest instead of re-parsing the body; the probe
|
||||
// marker must survive that projection or the exemption only works cold.
|
||||
parsed := &service.ParsedRequest{Model: "claude-sonnet-4-5", MaxTokens: 1}
|
||||
SetClaudeCodeClientContext(c, nil, parsed)
|
||||
require.True(t, service.IsClaudeCodeClient(c.Request.Context()))
|
||||
|
||||
c2, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
|
||||
c2.Request.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)")
|
||||
SetClaudeCodeClientContext(c2, nil, &service.ParsedRequest{Model: "claude-sonnet-4-5", MaxTokens: 64})
|
||||
require.False(t, service.IsClaudeCodeClient(c2.Request.Context()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 通配条目在 /v1/models 中展开为候选来源中所有匹配项,保持来源顺序。
|
||||
func TestGatewayModels_ModelAllowlistWildcardExpandsAgainstSource(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(31)
|
||||
h := newGatewayModelsHandlerForTest(
|
||||
&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"gpt-5.4": "gpt-5.4",
|
||||
"gpt-5.5-codex": "gpt-5.5-codex",
|
||||
"gpt-5.5-mini": "gpt-5.5-mini",
|
||||
"other-foo": "other-foo",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
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: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gpt-5.5-*", "gpt-5.4"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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, []string{"gpt-5.5-codex", "gpt-5.5-mini", "gpt-5.4"}, modelIDsForTest(got.Data))
|
||||
}
|
||||
|
||||
// geminiAllowlistAccountRepoStub 在 gatewayModelsAccountRepoStub 之上补充
|
||||
// Gemini 兼容层用到的按平台过滤查询(分组内无任何账号,触发 fallback 列表)。
|
||||
type geminiAllowlistAccountRepoStub struct {
|
||||
gatewayModelsAccountRepoStub
|
||||
}
|
||||
|
||||
func (s *geminiAllowlistAccountRepoStub) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]service.Account, error) {
|
||||
allowed := make(map[string]struct{}, len(platforms))
|
||||
for _, platform := range platforms {
|
||||
allowed[platform] = struct{}{}
|
||||
}
|
||||
accounts := s.byGroup[groupID]
|
||||
filtered := make([]service.Account, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
if _, ok := allowed[account.Platform]; ok {
|
||||
filtered = append(filtered, account)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// Gemini 原生 /v1beta/models:白名单开启时过滤 fallback 列表的 models[].name。
|
||||
func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
// 只有 antigravity 账号:Gemini 选号失败后回落静态模型列表。
|
||||
41: {{ID: 2, Platform: service.PlatformAntigravity, Status: service.StatusActive, Schedulable: true}},
|
||||
},
|
||||
}}
|
||||
h := &GatewayHandler{
|
||||
geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
|
||||
geminiGroupID := int64(41)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
GroupID: &geminiGroupID,
|
||||
Group: &service.Group{
|
||||
ID: geminiGroupID,
|
||||
Platform: service.PlatformGemini,
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gemini-2.5-pro", "gemini-3-*"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
h.GeminiV1BetaListModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var got struct {
|
||||
Models []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"models"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
names := make([]string, 0, len(got.Models))
|
||||
for _, model := range got.Models {
|
||||
names = append(names, model.Name)
|
||||
}
|
||||
// models/ 前缀的候选形式也应命中条目。
|
||||
require.Contains(t, names, "models/gemini-2.5-pro")
|
||||
require.Contains(t, names, "models/gemini-3-pro-preview")
|
||||
require.NotContains(t, names, "models/gemini-2.5-flash")
|
||||
require.NotContains(t, names, "models/gemini-2.0-flash")
|
||||
}
|
||||
|
||||
// Antigravity /antigravity/models:白名单开启时按条目过滤静态列表。
|
||||
func TestAntigravityModels_FiltersByAllowlist(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &GatewayHandler{}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/antigravity/models", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{
|
||||
ID: 51,
|
||||
Platform: service.PlatformAntigravity,
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gemini-2.5-flash"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
h.AntigravityModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var got struct {
|
||||
Object string `json:"object"`
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, "list", got.Object)
|
||||
// -thinking 后缀的宽容规则:gemini-2.5-flash-thinking 也命中 gemini-2.5-flash 条目。
|
||||
require.Len(t, got.Data, 2)
|
||||
require.Equal(t, "gemini-2.5-flash", got.Data[0].ID)
|
||||
require.Equal(t, "gemini-2.5-flash-thinking", got.Data[1].ID)
|
||||
}
|
||||
|
||||
func TestFilterUpstreamGeminiModelsBody(t *testing.T) {
|
||||
allowlist := service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-2.5-pro"}}
|
||||
|
||||
t.Run("full hit returns original body with dropped=false", func(t *testing.T) {
|
||||
body := []byte(`{"models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-pro"}]}`)
|
||||
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
|
||||
require.True(t, ok)
|
||||
require.False(t, dropped, "full hit must keep the original response so headers pass through")
|
||||
require.Equal(t, string(body), string(filtered))
|
||||
})
|
||||
|
||||
t.Run("partial hit filters models and preserves envelope fields", func(t *testing.T) {
|
||||
body := []byte(`{"nextPageToken":"tok","models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-flash"}],"other":"kept"}`)
|
||||
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
|
||||
require.True(t, ok)
|
||||
require.True(t, dropped)
|
||||
require.JSONEq(t, `{"nextPageToken":"tok","other":"kept","models":[{"name":"models/gemini-2.5-pro"}]}`, string(filtered))
|
||||
})
|
||||
|
||||
t.Run("models prefix stripped candidate form matches entry", func(t *testing.T) {
|
||||
body := []byte(`{"models":[{"name":"gemini-2.5-pro"}]}`)
|
||||
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
|
||||
require.True(t, ok)
|
||||
require.False(t, dropped)
|
||||
require.Equal(t, string(body), string(filtered))
|
||||
})
|
||||
|
||||
t.Run("missing models field passes through", func(t *testing.T) {
|
||||
body := []byte(`{"error":"odd upstream"}`)
|
||||
_, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
|
||||
require.True(t, ok)
|
||||
require.False(t, dropped)
|
||||
})
|
||||
|
||||
t.Run("invalid JSON signals passthrough", func(t *testing.T) {
|
||||
_, dropped, ok := filterUpstreamGeminiModelsBody([]byte(`not-json`), allowlist)
|
||||
require.False(t, ok)
|
||||
require.False(t, dropped)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilterBatchImageModelsByAllowlist(t *testing.T) {
|
||||
models := []service.BatchImagePublicModel{
|
||||
{ID: "gemini-2.5-flash-image", Object: "image.batch.model", Provider: "gemini_api"},
|
||||
{ID: "gemini-3-pro-image", Object: "image.batch.model", Provider: "vertex"},
|
||||
{ID: "gpt-image-1", Object: "image.batch.model", Provider: "openai"},
|
||||
}
|
||||
|
||||
t.Run("disabled allowlist keeps everything", func(t *testing.T) {
|
||||
got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{Enabled: false})
|
||||
require.Equal(t, models, got)
|
||||
})
|
||||
|
||||
t.Run("exact and wildcard entries filter by model id", func(t *testing.T) {
|
||||
got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gemini-3-*", "gpt-image-1"},
|
||||
})
|
||||
require.Equal(t, []string{"gemini-3-pro-image", "gpt-image-1"}, []string{got[0].ID, got[1].ID})
|
||||
require.Len(t, got, 2)
|
||||
})
|
||||
|
||||
t.Run("no match yields empty list", func(t *testing.T) {
|
||||
got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"claude-*"},
|
||||
})
|
||||
require.Empty(t, got)
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -267,7 +366,7 @@ func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) {
|
||||
require.Equal(t, etag, second.Header().Get("ETag"))
|
||||
}
|
||||
|
||||
// Scenario: group models_list_config limits the generated Codex manifest.
|
||||
// Scenario: group model_allowlist limits the generated Codex manifest.
|
||||
func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 121
|
||||
@@ -299,7 +398,7 @@ func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformComposite,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"grok-4.6"},
|
||||
},
|
||||
@@ -572,7 +671,7 @@ func TestGatewayModels_CustomModelsListDisabledKeepsOriginalModels(t *testing.T)
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: false,
|
||||
Models: []string{"gpt-5.5"},
|
||||
},
|
||||
@@ -619,7 +718,7 @@ func TestGatewayModels_CustomModelsListFiltersAndOrdersMappedModels(t *testing.T
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gpt-5.5", "missing-model", "gpt-5.4"},
|
||||
},
|
||||
@@ -692,6 +791,13 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
|
||||
"model_mapping": map[string]any{"deepseek-custom": "deepseek-upstream"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 7,
|
||||
Platform: service.PlatformMiniMax,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"minimax-custom": "MiniMax-M3"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -704,9 +810,9 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformComposite,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"},
|
||||
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -717,7 +823,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
|
||||
|
||||
var got gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"}, modelIDsForTest(got.Data))
|
||||
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"}, modelIDsForTest(got.Data))
|
||||
}
|
||||
|
||||
func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t *testing.T) {
|
||||
@@ -770,6 +876,7 @@ func TestGatewayModels_CompositeUnmappedCNAccountsContributeNoDefaults(t *testin
|
||||
{ID: 2, Platform: service.PlatformKimi},
|
||||
{ID: 3, Platform: service.PlatformZhipu},
|
||||
{ID: 4, Platform: service.PlatformDeepseek},
|
||||
{ID: 5, Platform: service.PlatformMiniMax},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -801,13 +908,14 @@ func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T)
|
||||
for _, model := range claude.DefaultModels {
|
||||
want = append(want, model.ID)
|
||||
}
|
||||
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
|
||||
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
|
||||
require.Equal(t, want, defaultModelIDsForPlatform(platform), "platform=%s", platform)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T) {
|
||||
require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek))
|
||||
require.Equal(t, []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}, defaultCodexModelIDsForPlatform(service.PlatformMiniMax))
|
||||
require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic))
|
||||
}
|
||||
|
||||
@@ -918,7 +1026,7 @@ func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMappin
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformAnthropic,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"claude-sonnet-4-6"},
|
||||
},
|
||||
@@ -969,7 +1077,7 @@ func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeAndMappedDeep
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformAnthropic,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"claude-fable-5", "claude-opus-4-8", "deepseek-v4-pro"},
|
||||
},
|
||||
@@ -1020,7 +1128,7 @@ func TestGatewayModels_AnthropicCustomModelsListDisabledKeepsMappedModelList(t *
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformAnthropic,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: false,
|
||||
Models: []string{"claude-fable-5", "deepseek-v4-pro"},
|
||||
},
|
||||
@@ -1061,7 +1169,7 @@ func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeWithoutMappin
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformAnthropic,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"claude-opus-4-6-thinking", "claude-sonnet-4-5"},
|
||||
},
|
||||
@@ -1106,7 +1214,7 @@ func TestGatewayModels_CustomModelsListCanReturnEmptyWhenSelectionsUnavailable(t
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gpt-5.5"},
|
||||
},
|
||||
@@ -1143,7 +1251,7 @@ func TestGatewayModels_CustomModelsListFiltersDefaultFallbackModels(t *testing.T
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gpt-5.5", "legacy-gpt-2024", "gpt-5.4"},
|
||||
},
|
||||
@@ -1180,7 +1288,7 @@ func TestGatewayModels_OpenAICustomModelsListKeepsOpenAIResponseShapeForDefaultF
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gpt-5.5", "gpt-5.4"},
|
||||
},
|
||||
|
||||
@@ -46,8 +46,33 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 分组级模型白名单开启时过滤 models[].name(名字形如 models/xxx)。
|
||||
filterGeminiModels := func(models []gemini.Model) []gemini.Model {
|
||||
if apiKey.Group == nil || !apiKey.Group.ModelAllowlistEnabled() {
|
||||
return models
|
||||
}
|
||||
filtered := make([]gemini.Model, 0, len(models))
|
||||
for _, model := range models {
|
||||
if apiKey.Group.ModelAllowlist.Allows(model.Name) {
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// 强制 antigravity 模式:返回 antigravity 支持的模型列表
|
||||
if forcePlatform == service.PlatformAntigravity {
|
||||
if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
|
||||
agModels := antigravity.DefaultGeminiModels()
|
||||
filtered := make([]antigravity.GeminiModel, 0, len(agModels))
|
||||
for _, model := range agModels {
|
||||
if apiKey.Group.ModelAllowlist.Allows(model.Name) {
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, antigravity.GeminiModelsListResponse{Models: filtered})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, antigravity.FallbackGeminiModelsList())
|
||||
return
|
||||
}
|
||||
@@ -58,7 +83,7 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
|
||||
hasAntigravity, _ := h.geminiCompatService.HasAntigravityAccounts(c.Request.Context(), apiKey.GroupID)
|
||||
if hasAntigravity {
|
||||
// antigravity 账户使用静态模型列表
|
||||
c.JSON(http.StatusOK, gemini.FallbackModelsList())
|
||||
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())})
|
||||
return
|
||||
}
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
@@ -72,12 +97,66 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if shouldFallbackGeminiModels(res) {
|
||||
c.JSON(http.StatusOK, gemini.FallbackModelsList())
|
||||
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())})
|
||||
return
|
||||
}
|
||||
if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
|
||||
if filtered, dropped, ok := filterUpstreamGeminiModelsBody(res.Body, apiKey.Group.ModelAllowlist); ok && dropped {
|
||||
// 只在确有条目被过滤时替换响应体;全命中或解析失败时保持原始响应,
|
||||
// 统一经 writeUpstreamResponse 写出(保留全部上游响应头)。
|
||||
res.Body = filtered
|
||||
}
|
||||
}
|
||||
writeUpstreamResponse(c, res)
|
||||
}
|
||||
|
||||
// filterUpstreamGeminiModelsBody 按白名单过滤上游 /v1beta/models 响应中的
|
||||
// models[].name,其余信封字段(如 nextPageToken)原样保留。
|
||||
// 返回值:filtered 为过滤后的响应体;dropped 表示是否有条目被移除(全命中时
|
||||
// 为 false,调用方应保持原始响应以完整透传上游头);ok=false 表示解析失败,
|
||||
// 调用方同样应透传原始响应。
|
||||
func filterUpstreamGeminiModelsBody(body []byte, allowlist service.GroupModelAllowlist) (filtered []byte, dropped bool, ok bool) {
|
||||
var envelope map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
rawModels, hasModels := envelope["models"]
|
||||
if !hasModels {
|
||||
return body, false, true
|
||||
}
|
||||
type geminiModelName struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var models []json.RawMessage
|
||||
if err := json.Unmarshal(rawModels, &models); err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
kept := make([]json.RawMessage, 0, len(models))
|
||||
for _, raw := range models {
|
||||
var model geminiModelName
|
||||
if err := json.Unmarshal(raw, &model); err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
if allowlist.Allows(model.Name) {
|
||||
kept = append(kept, raw)
|
||||
}
|
||||
}
|
||||
if len(kept) == len(models) {
|
||||
// 全部命中时直接透传原始响应体。
|
||||
return body, false, true
|
||||
}
|
||||
mergedModels, err := json.Marshal(kept)
|
||||
if err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
envelope["models"] = mergedModels
|
||||
merged, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
return merged, true, true
|
||||
}
|
||||
|
||||
// GeminiV1BetaGetModel proxies:
|
||||
// GET /v1beta/models/{model}
|
||||
func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
|
||||
|
||||
@@ -3,13 +3,58 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGeminiV1BetaListModels_AllowlistFiltersNativeResponse(t *testing.T) {
|
||||
body := []byte(`{"models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-flash"}],"nextPageToken":"next"}`)
|
||||
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-2.5-pro"}})
|
||||
require.True(t, ok)
|
||||
require.True(t, dropped)
|
||||
require.JSONEq(t, `{"models":[{"name":"models/gemini-2.5-pro"}],"nextPageToken":"next"}`, string(filtered))
|
||||
}
|
||||
|
||||
func TestGeminiV1BetaListModels_ForcedAntigravityAppliesAllowlist(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/antigravity/v1beta/models", nil)
|
||||
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{
|
||||
Platform: service.PlatformGemini,
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gemini-custom"},
|
||||
},
|
||||
},
|
||||
})
|
||||
c.Set(string(middleware.ContextKeyForcePlatform), service.PlatformAntigravity)
|
||||
|
||||
(&GatewayHandler{}).GeminiV1BetaListModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got antigravity.GeminiModelsListResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Empty(t, got.Models)
|
||||
}
|
||||
|
||||
func TestGeminiModelAllowlist_DisabledPreservesNativeResponse(t *testing.T) {
|
||||
body := []byte(`{"models":[{"name":"models/gemini-2.5-pro"}]}`)
|
||||
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, service.GroupModelAllowlist{Enabled: false, Models: []string{"other"}})
|
||||
require.True(t, ok)
|
||||
require.False(t, dropped)
|
||||
require.Equal(t, body, filtered)
|
||||
}
|
||||
|
||||
// TestGeminiV1BetaHandler_PlatformRoutingInvariant 文档化并验证 Handler 层的平台路由逻辑不变量
|
||||
// 该测试确保 gemini 和 antigravity 平台的路由逻辑符合预期
|
||||
func TestGeminiV1BetaHandler_PlatformRoutingInvariant(t *testing.T) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// 广场路由挂 OptionalJWT 中间件:匿名可访问(除非 require_auth 开启),带 token 则
|
||||
// 识别用户。可见性规则(橱窗语义,与「可用渠道」的可绑定语义不同):
|
||||
// - 匿名:仅非专属分组(订阅型照常展示);
|
||||
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性);
|
||||
// - 登录:非专属分组 + user_allowed_groups 授权或持有有效订阅的专属分组;
|
||||
// 若该用户开启了公开分组限制,则公开分组同样需要落在授权集合内。
|
||||
type ModelPlazaHandler struct {
|
||||
plazaService *service.ModelPlazaService
|
||||
@@ -160,7 +160,7 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) {
|
||||
}
|
||||
|
||||
// filterPlazaVisibleGroups 按登录态裁剪分组可见性。
|
||||
// allowedGroups == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。
|
||||
// allowedGroups == nil 表示匿名(仅非专属);非 nil 包含普通授权及有效订阅分组。
|
||||
// restrictPublicGroups 为 true 时,公开分组也必须落在 allowedGroups 内,否则用户会
|
||||
// 在广场看到自己实际绑定不了的分组。
|
||||
func filterPlazaVisibleGroups(
|
||||
|
||||
@@ -244,3 +244,17 @@ func TestToModelPlazaGroupDTO_TimePricing(t *testing.T) {
|
||||
weekdaysTP := weekdaysModel["time_pricing"].(map[string]any)
|
||||
require.Equal(t, true, weekdaysTP["weekdays_only"])
|
||||
}
|
||||
|
||||
func TestFilterPlazaVisibleGroups_SubscribedExclusiveGroup(t *testing.T) {
|
||||
groups := []service.PlazaGroup{
|
||||
{ID: 42, IsExclusive: true, SubscriptionType: "subscription"},
|
||||
{ID: 43, IsExclusive: true, SubscriptionType: "subscription"},
|
||||
{ID: 44, IsExclusive: true, SubscriptionType: "standard"},
|
||||
}
|
||||
require.Empty(t, filterPlazaVisibleGroups(groups, nil, false))
|
||||
for _, restricted := range []bool{false, true} {
|
||||
visible := filterPlazaVisibleGroups(groups, map[int64]struct{}{42: {}}, restricted)
|
||||
require.Len(t, visible, 1)
|
||||
require.Equal(t, int64(42), visible[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,20 @@ func TestNormalizeCodexAutomationBootstrapSupportedLastRunValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexAutomationBootstrapHeartbeat(t *testing.T) {
|
||||
output := `<heartbeat><automation_id>wiki</automation_id></heartbeat>`
|
||||
got, changed := normalizeCodexAutomationBootstrap(codexAutomationBootstrapBody(t, output, ""))
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
|
||||
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
|
||||
require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String())
|
||||
require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists())
|
||||
|
||||
again, changedAgain := normalizeCodexAutomationBootstrap(got)
|
||||
require.False(t, changedAgain)
|
||||
require.Equal(t, got, again)
|
||||
}
|
||||
|
||||
func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) {
|
||||
validOutput := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt)
|
||||
tests := []struct {
|
||||
@@ -75,10 +89,6 @@ func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) {
|
||||
name: "real call context",
|
||||
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"function_call","call_id":"call-1"}]}`),
|
||||
},
|
||||
{
|
||||
name: "heartbeat output",
|
||||
body: codexAutomationBootstrapBody(t, `<heartbeat><automation_id>wiki</automation_id></heartbeat>`, ""),
|
||||
},
|
||||
{
|
||||
name: "mismatched memory id",
|
||||
body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "/wiki/memory.md", "/other/memory.md", 1), ""),
|
||||
@@ -117,6 +127,31 @@ func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexAutomationBootstrapRejectsUnsafeHeartbeatShapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
output string
|
||||
}{
|
||||
{name: "arbitrary tool output", output: `<result><automation_id>wiki</automation_id></result>`},
|
||||
{name: "root attribute", output: `<heartbeat status="ok"><automation_id>wiki</automation_id></heartbeat>`},
|
||||
{name: "namespaced root", output: `<heartbeat xmlns="urn:codex"><automation_id>wiki</automation_id></heartbeat>`},
|
||||
{name: "extra child", output: `<heartbeat><automation_id>wiki</automation_id><status>ok</status></heartbeat>`},
|
||||
{name: "nested id content", output: `<heartbeat><automation_id><value>wiki</value></automation_id></heartbeat>`},
|
||||
{name: "padded id", output: `<heartbeat><automation_id> wiki </automation_id></heartbeat>`},
|
||||
{name: "unsafe id", output: `<heartbeat><automation_id>../wiki</automation_id></heartbeat>`},
|
||||
{name: "comment", output: `<heartbeat><!-- ok --><automation_id>wiki</automation_id></heartbeat>`},
|
||||
{name: "trailing content", output: `<heartbeat><automation_id>wiki</automation_id></heartbeat>extra`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := codexAutomationBootstrapBody(t, tt.output, "")
|
||||
got, changed := normalizeCodexAutomationBootstrap(body)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexAutomationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) {
|
||||
output := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt)
|
||||
body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, output) + `},{"type":"message","role":"user","content":"after"}]}`)
|
||||
|
||||
@@ -116,6 +116,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
|
||||
// 解析渠道级模型映射
|
||||
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
|
||||
forwardModel := openAIChannelForwardModel(channelMapping, reqModel)
|
||||
|
||||
if h.errorPassthroughService != nil {
|
||||
service.BindErrorPassthroughService(c, h.errorPassthroughService)
|
||||
@@ -170,7 +171,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
apiKey.GroupID,
|
||||
"",
|
||||
sessionHash,
|
||||
reqModel,
|
||||
forwardModel,
|
||||
failedAccountIDs,
|
||||
service.OpenAIUpstreamTransportAny,
|
||||
service.OpenAIEndpointCapabilityChatCompletions,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenAIEnsureForwardErrorResponse_SkipsCanceledClient(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil).WithContext(ctx)
|
||||
cancel()
|
||||
|
||||
h := &OpenAIGatewayHandler{}
|
||||
require.False(t, h.ensureForwardErrorResponse(c, true))
|
||||
require.Equal(t, statusClientClosedRequest, c.Writer.Status())
|
||||
require.Empty(t, recorder.Body.String())
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -15,7 +16,8 @@ import (
|
||||
// Codex CLI and the Codex desktop app refresh their model picker from
|
||||
// GET {base_url}/models?client_version=... (custom provider mode) or
|
||||
// GET /backend-api/codex/models (chatgpt_base_url mode). Both routes land
|
||||
// here. Groups with explicit account model mappings are generated locally;
|
||||
// here. Pinned discovery takes precedence over local account model mappings;
|
||||
// when disabled, groups with explicit mappings are generated locally;
|
||||
// otherwise ChatGPT manifests are proxied verbatim and custom API key manifests
|
||||
// receive provider-compatibility normalization plus short-lived caching.
|
||||
func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
@@ -33,21 +35,60 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
}
|
||||
|
||||
ifNoneMatch := c.GetHeader("If-None-Match")
|
||||
configuredManifest, configured, err := h.gatewayService.BuildGroupConfiguredCodexModelsManifest(
|
||||
c.Request.Context(),
|
||||
apiKey.Group,
|
||||
ifNoneMatch,
|
||||
)
|
||||
if err != nil {
|
||||
if c.Request.Context().Err() != nil {
|
||||
// 固定账号分支:开启后只用选定账号拉取 manifest,不经过调度器;
|
||||
// 全部不可用/全部失败时按 FallbackToScheduler 决定回退调度器或返回错误。
|
||||
if apiKey.Group.Platform == service.PlatformOpenAI &&
|
||||
apiKey.Group.CodexModelsManifestConfig.Enabled {
|
||||
pinnedManifest, pinnedAccount, pinnedErr := h.gatewayService.FetchPinnedCodexModelsManifest(
|
||||
c.Request.Context(),
|
||||
apiKey.Group,
|
||||
c.Query("client_version"),
|
||||
)
|
||||
if pinnedErr != nil {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
if !apiKey.Group.CodexModelsManifestConfig.FallbackToScheduler {
|
||||
if errors.Is(pinnedErr, service.ErrNoPinnedCodexModelsAccounts) {
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "upstream_error", "No available pinned OpenAI accounts")
|
||||
return
|
||||
}
|
||||
h.errorResponse(c, infraerrors.Code(pinnedErr), "upstream_error", infraerrors.Message(pinnedErr))
|
||||
return
|
||||
}
|
||||
// 回退开启:跌入下方调度器循环。
|
||||
} else {
|
||||
// 让 ops 错误日志携带实际拉取成功的首个固定账号。
|
||||
setOpsSelectedAccount(c, pinnedAccount.ID, pinnedAccount.Platform)
|
||||
if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, pinnedManifest, ifNoneMatch); err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
}
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
writeOpenAIModelsResponse(c, pinnedManifest)
|
||||
return
|
||||
}
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
}
|
||||
if configured {
|
||||
writeCodexModelsManifestResponse(c, configuredManifest)
|
||||
return
|
||||
|
||||
if !apiKey.Group.CodexModelsManifestConfig.Enabled {
|
||||
configuredManifest, configured, err := h.gatewayService.BuildGroupConfiguredCodexModelsManifest(
|
||||
c.Request.Context(),
|
||||
apiKey.Group,
|
||||
ifNoneMatch,
|
||||
)
|
||||
if err != nil {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
}
|
||||
if configured {
|
||||
writeOpenAIModelsResponse(c, configuredManifest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
maxAccountSwitches := h.maxAccountSwitches
|
||||
@@ -94,6 +135,10 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to complete Codex models manifest")
|
||||
return
|
||||
}
|
||||
if err := service.ApplyPinnedCodexModelsMapping(manifest, account, apiKey.Group); err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to apply model mappings")
|
||||
return
|
||||
}
|
||||
if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, manifest, ifNoneMatch); err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
@@ -102,19 +147,7 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
writeCodexModelsManifestResponse(c, manifest)
|
||||
writeOpenAIModelsResponse(c, manifest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func writeCodexModelsManifestResponse(c *gin.Context, manifest *service.CodexModelsManifest) {
|
||||
if manifest.ETag != "" {
|
||||
c.Header("ETag", manifest.ETag)
|
||||
}
|
||||
if manifest.NotModified {
|
||||
c.Status(http.StatusNotModified)
|
||||
c.Writer.WriteHeaderNow()
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", manifest.Body)
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
@@ -161,7 +163,7 @@ func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) {
|
||||
group := &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"codex-auto-review", "gpt-5.6"},
|
||||
},
|
||||
@@ -179,7 +181,7 @@ func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) {
|
||||
t.Fatal("first response did not include an ETag")
|
||||
}
|
||||
|
||||
group.ModelsListConfig.Enabled = false
|
||||
group.ModelAllowlist.Enabled = false
|
||||
second := performCodexModelsRequestForGroup(t, handler, group, oldETag)
|
||||
if second.Code != http.StatusOK {
|
||||
t.Fatalf("second status: got %d, want %d; body=%s", second.Code, http.StatusOK, second.Body.String())
|
||||
@@ -230,7 +232,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) {
|
||||
groupA := &service.Group{
|
||||
ID: 91,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"model-a"},
|
||||
},
|
||||
@@ -238,7 +240,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) {
|
||||
groupB := &service.Group{
|
||||
ID: 92,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"model-b"},
|
||||
},
|
||||
@@ -290,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{
|
||||
@@ -352,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) {
|
||||
@@ -640,3 +688,316 @@ func equalInt64Slices(got, want []int64) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// --- 固定账号 manifest 模式 ---
|
||||
|
||||
// codexModelsPinnedHTTPUpstream 按账号返回不同 manifest/状态的测试上游。
|
||||
type codexModelsPinnedHTTPUpstream struct {
|
||||
service.HTTPUpstream
|
||||
mu sync.Mutex
|
||||
calls []int64
|
||||
bodies map[int64]string
|
||||
statuses map[int64]int
|
||||
}
|
||||
|
||||
func (u *codexModelsPinnedHTTPUpstream) Do(_ *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
|
||||
u.mu.Lock()
|
||||
u.calls = append(u.calls, accountID)
|
||||
u.mu.Unlock()
|
||||
if status, ok := u.statuses[accountID]; ok {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Status: http.StatusText(status),
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"upstream boom"}}`)),
|
||||
}, nil
|
||||
}
|
||||
body, ok := u.bodies[accountID]
|
||||
if !ok {
|
||||
body = `{"models":[]}`
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *codexModelsPinnedHTTPUpstream) accountIDs() []int64 {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
ids := append([]int64(nil), u.calls...)
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return ids
|
||||
}
|
||||
|
||||
func newPinnedCodexAccount(id int64, status string, schedulable bool, rateLimited bool) service.Account {
|
||||
account := service.Account{
|
||||
ID: id,
|
||||
Name: fmt.Sprintf("pinned-%d", id),
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: status,
|
||||
Schedulable: schedulable,
|
||||
Priority: int(id),
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": fmt.Sprintf("sk-pinned-%d", id),
|
||||
"base_url": fmt.Sprintf("https://pinned-%d.example/v1", id),
|
||||
},
|
||||
}
|
||||
if rateLimited {
|
||||
reset := time.Now().Add(10 * time.Minute)
|
||||
account.RateLimitResetAt = &reset
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
func newPinnedCodexTestHandler(accounts []service.Account, upstream *codexModelsPinnedHTTPUpstream, maxSwitches int) *OpenAIGatewayHandler {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{RunMode: config.RunModeSimple}
|
||||
gatewayService := service.NewOpenAIGatewayService(
|
||||
codexModelsFailoverAccountRepo{accounts: accounts},
|
||||
nil, nil, nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil,
|
||||
upstream,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
return &OpenAIGatewayHandler{gatewayService: gatewayService, maxAccountSwitches: maxSwitches}
|
||||
}
|
||||
|
||||
func performPinnedCodexModelsRequest(t *testing.T, handler *OpenAIGatewayHandler, group *service.Group, etag string) *httptest.ResponseRecorder {
|
||||
return performCodexModelsRequestForGroup(t, handler, group, etag)
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsMergeUnionWithoutScheduler(t *testing.T) {
|
||||
// 账号 1 优先级最高(调度器会先选它);固定配置只用 2、3,
|
||||
// 返回并集且不打账号 1,即可证明没有经过调度器。
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(1, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(3, service.StatusActive, true, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
|
||||
1: `{"models":[{"slug":"scheduler-only"}]}`,
|
||||
2: `{"models":[{"slug":"model-a"}]}`,
|
||||
3: `{"models":[{"slug":"model-a"},{"slug":"model-c"}]}`,
|
||||
}}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 77,
|
||||
Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2, 3},
|
||||
},
|
||||
}
|
||||
|
||||
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []string{"model-a", "model-c"}, codexHandlerManifestSlugs(t, recorder))
|
||||
require.Equal(t, []int64{2, 3}, upstream.accountIDs(), "固定账号模式不得调用调度器或非固定账号")
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsUseRateLimitedAccountAndSkipUnavailable(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, true), // 限流中:仍被使用
|
||||
newPinnedCodexAccount(3, service.StatusActive, false, false), // 调度开关关闭:跳过
|
||||
newPinnedCodexAccount(4, service.StatusDisabled, true, false), // 停用:跳过
|
||||
newPinnedCodexAccount(5, service.StatusActive, true, false), // 正常
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
|
||||
2: `{"models":[{"slug":"from-rate-limited"}]}`,
|
||||
5: `{"models":[{"slug":"model-five"}]}`,
|
||||
}}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 78,
|
||||
Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2, 3, 4, 5, 99}, // 99 不在分组:跳过
|
||||
},
|
||||
}
|
||||
|
||||
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []string{"from-rate-limited", "model-five"}, codexHandlerManifestSlugs(t, recorder))
|
||||
require.Equal(t, []int64{2, 5}, upstream.accountIDs())
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsPartialFailureStillSucceeds(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(3, service.StatusActive, true, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{
|
||||
bodies: map[int64]string{2: `{"models":[{"slug":"model-a"}]}`},
|
||||
statuses: map[int64]int{3: http.StatusServiceUnavailable},
|
||||
}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 79,
|
||||
Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2, 3},
|
||||
},
|
||||
}
|
||||
|
||||
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []string{"model-a"}, codexHandlerManifestSlugs(t, recorder))
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsAllUnavailableReturns503ByDefault(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(2, service.StatusActive, false, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 80,
|
||||
Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2},
|
||||
},
|
||||
}
|
||||
|
||||
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String())
|
||||
require.Empty(t, upstream.accountIDs())
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsAllFailedReturnsUpstreamError(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(3, service.StatusActive, true, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{statuses: map[int64]int{
|
||||
2: http.StatusServiceUnavailable,
|
||||
3: http.StatusGatewayTimeout,
|
||||
}}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 81,
|
||||
Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2, 3},
|
||||
},
|
||||
}
|
||||
|
||||
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusBadGateway, recorder.Code, recorder.Body.String())
|
||||
require.Contains(t, recorder.Body.String(), "upstream error 504", "全部失败时返回最后一个上游错误")
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsFallbackToScheduler(t *testing.T) {
|
||||
// 固定账号 2 不可用且回退开启:跌入调度器,选中优先级最高的账号 1。
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(1, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(2, service.StatusActive, false, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
|
||||
1: `{"models":[{"slug":"from-scheduler"}]}`,
|
||||
}}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 82,
|
||||
Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2},
|
||||
FallbackToScheduler: true,
|
||||
},
|
||||
}
|
||||
|
||||
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []string{"from-scheduler"}, codexHandlerManifestSlugs(t, recorder))
|
||||
require.Equal(t, []int64{1}, upstream.accountIDs())
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsFallbackToSchedulerOnAllFailed(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(1, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{
|
||||
bodies: map[int64]string{1: `{"models":[{"slug":"from-scheduler"}]}`},
|
||||
statuses: map[int64]int{2: http.StatusServiceUnavailable},
|
||||
}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 83,
|
||||
Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2},
|
||||
FallbackToScheduler: true,
|
||||
},
|
||||
}
|
||||
|
||||
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []string{"from-scheduler"}, codexHandlerManifestSlugs(t, recorder))
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsStillApplyCustomModelsListFilter(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(3, service.StatusActive, true, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
|
||||
2: `{"models":[{"slug":"model-a"}]}`,
|
||||
3: `{"models":[{"slug":"model-b"}]}`,
|
||||
}}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 84,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"model-b"},
|
||||
},
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2, 3},
|
||||
},
|
||||
}
|
||||
|
||||
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []string{"model-b"}, codexHandlerManifestSlugs(t, recorder), "分组自定义模型列表过滤仍生效")
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsETagMatchReturns304(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(3, service.StatusActive, true, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
|
||||
2: `{"models":[{"slug":"model-a"}]}`,
|
||||
3: `{"models":[{"slug":"model-c"}]}`,
|
||||
}}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 85,
|
||||
Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2, 3},
|
||||
},
|
||||
}
|
||||
|
||||
first := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
|
||||
etag := first.Header().Get("ETag")
|
||||
require.NotEmpty(t, etag)
|
||||
|
||||
second := performPinnedCodexModelsRequest(t, handler, group, etag)
|
||||
require.Equal(t, http.StatusNotModified, second.Code, second.Body.String())
|
||||
require.Empty(t, second.Body.Bytes())
|
||||
}
|
||||
|
||||
@@ -43,11 +43,7 @@ func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) {
|
||||
}{
|
||||
{name: "ordinary missing call id", item: `{"type":"function_call_output","name":"other","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "valid call id", item: `{"type":"function_call_output","call_id":"call-1","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "previous response", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`, extra: `,"previous_response_id":"resp-1"`},
|
||||
{name: "real call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"}`},
|
||||
{name: "ambiguous call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call"}`},
|
||||
{name: "item reference context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"item_reference","id":"call-1"}`},
|
||||
{name: "built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call","call_id":"call-1"}`},
|
||||
{name: "ambiguous built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call"}`},
|
||||
{name: "mixed missing call id output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call_output","output":"done"}`},
|
||||
{name: "mixed tool search output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"tool_search_output","output":"done"}`},
|
||||
@@ -67,6 +63,18 @@ func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapWithHistoricalContext(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"},{"type":"function_call_output","call_id":"call-1","output":"done"},{"type":"item_reference","id":"item-1"},{"type":"computer_call","call_id":"call-2"}]}`)
|
||||
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
|
||||
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
|
||||
require.Equal(t, "resp-1", gjson.GetBytes(got, "previous_response_id").String())
|
||||
require.Equal(t, "call-1", gjson.GetBytes(got, "input.2.call_id").String())
|
||||
require.Equal(t, "item-1", gjson.GetBytes(got, "input.3.id").String())
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -75,7 +83,7 @@ func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) {
|
||||
}{
|
||||
{name: "absent", changed: true},
|
||||
{name: "blank string", value: `,"previous_response_id":" "`, changed: true},
|
||||
{name: "non-empty string", value: `,"previous_response_id":"resp-1"`},
|
||||
{name: "non-empty string", value: `,"previous_response_id":"resp-1"`, changed: true},
|
||||
{name: "null", value: `,"previous_response_id":null`},
|
||||
{name: "number", value: `,"previous_response_id":42`},
|
||||
{name: "boolean", value: `,"previous_response_id":false`},
|
||||
|
||||
@@ -85,6 +85,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
}
|
||||
|
||||
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
|
||||
forwardModel := openAIChannelForwardModel(channelMapping, reqModel)
|
||||
|
||||
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
||||
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
|
||||
@@ -127,7 +128,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
apiKey.GroupID,
|
||||
"",
|
||||
"",
|
||||
reqModel,
|
||||
forwardModel,
|
||||
failedAccountIDs,
|
||||
service.OpenAIUpstreamTransportHTTPSSE,
|
||||
service.OpenAIEndpointCapabilityEmbeddings,
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) {
|
||||
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, nil), "无 key 保持放行")
|
||||
|
||||
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformGrok} {
|
||||
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformGrok} {
|
||||
apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}}
|
||||
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, apiKey),
|
||||
"%s 分组必须豁免 allow_messages_dispatch 闸门", platform)
|
||||
|
||||
@@ -128,6 +128,44 @@ func TestOpenAICapacityFailoverExhaustionPreservesMessageAsServerError(t *testin
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAIManagedSingleAccountModelNotFoundExhaustionPreservesStructured400(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
body := []byte(`{"error":{"type":"invalid_request_error","code":"model_not_found","param":"model","message":"The requested model is unavailable on this account"}}`)
|
||||
|
||||
(&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
ResponseBody: body,
|
||||
}, false)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String())
|
||||
require.Equal(t, "model_not_found", gjson.Get(recorder.Body.String(), "error.code").String())
|
||||
require.Equal(t, "model", gjson.Get(recorder.Body.String(), "error.param").String())
|
||||
require.Equal(t, "The requested model is unavailable on this account", gjson.Get(recorder.Body.String(), "error.message").String())
|
||||
}
|
||||
|
||||
func TestOpenAIManagedModelNotFoundExhaustionSanitizesMessage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
body := []byte(`{"error":{"code":"model_not_found","message":"Model not found; inspect https://upstream.example/debug?access_token=super-secret-value&model=x"}}`)
|
||||
|
||||
(&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
ResponseBody: body,
|
||||
}, false)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.NotContains(t, recorder.Body.String(), "super-secret-value")
|
||||
require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "access_token=***")
|
||||
recorded, ok := c.Get(service.OpsUpstreamErrorMessageKey)
|
||||
require.True(t, ok)
|
||||
require.NotContains(t, recorded, "super-secret-value")
|
||||
require.Contains(t, recorded, "access_token=***")
|
||||
}
|
||||
|
||||
func TestResponsesFailoverExhaustedAfterForwardedTerminalMarksOpsWithoutDuplicateFrame(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -102,6 +103,44 @@ func TestClearCyberPolicyTurnState(t *testing.T) {
|
||||
require.Equal(t, "turn2", service.GetOpsCyberPolicy(c).Message)
|
||||
}
|
||||
|
||||
func TestAdvanceOpenAIWSCyberBlockStateDefersBlockAcrossFailover(t *testing.T) {
|
||||
failoverErr := &service.UpstreamFailoverError{StatusCode: http.StatusTooManyRequests}
|
||||
|
||||
blocked, pending := advanceOpenAIWSCyberBlockState(false, false, true, failoverErr)
|
||||
require.False(t, blocked, "the replacement account must receive the current turn")
|
||||
require.True(t, pending, "the cyber hit must still block later turns")
|
||||
|
||||
blocked, pending = advanceOpenAIWSCyberBlockState(blocked, pending, false, failoverErr)
|
||||
require.False(t, blocked, "additional failover attempts must remain eligible")
|
||||
require.True(t, pending)
|
||||
|
||||
blocked, pending = advanceOpenAIWSCyberBlockState(blocked, pending, false, nil)
|
||||
require.True(t, blocked, "the next client turn must be blocked after failover finishes")
|
||||
require.False(t, pending)
|
||||
}
|
||||
|
||||
func TestClearCyberPolicyAttemptStatePreservesRecordedGuardDuringFailover(t *testing.T) {
|
||||
c := newTestGinContext()
|
||||
h := &OpenAIGatewayHandler{}
|
||||
|
||||
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "account-a", UpstreamStatus: http.StatusOK})
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
|
||||
require.True(t, c.GetBool(cyberPolicyRecordedKey))
|
||||
|
||||
clearCyberPolicyAttemptState(c, false)
|
||||
require.Nil(t, service.GetOpsCyberPolicy(c))
|
||||
require.True(t, c.GetBool(cyberPolicyRecordedKey), "the same logical turn must not record again after failover")
|
||||
|
||||
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "account-b", UpstreamStatus: http.StatusOK})
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
|
||||
require.Equal(t, "account-b", service.GetOpsCyberPolicy(c).Message)
|
||||
require.True(t, c.GetBool(cyberPolicyRecordedKey))
|
||||
|
||||
clearCyberPolicyAttemptState(c, true)
|
||||
require.Nil(t, service.GetOpsCyberPolicy(c))
|
||||
require.False(t, c.GetBool(cyberPolicyRecordedKey), "a completed logical turn must reset the guard")
|
||||
}
|
||||
|
||||
// TestBuildCyberSessionBlockedOpsEntry verifies the locally-rejected request is
|
||||
// auditable: 403 / phase=request / type=cyber_policy_session_blocked — distinct
|
||||
// from upstream cyber_policy hits, and it must NOT touch moderation/violation.
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/requestmodel"
|
||||
"github.com/Wei-Shaw/sub2api/internal/securityaudit"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -53,6 +54,21 @@ type openAIWSTurnChannelMappingSnapshot struct {
|
||||
mapping service.ChannelMappingResult
|
||||
}
|
||||
|
||||
func advanceOpenAIWSCyberBlockState(blocked, pending, marked bool, turnErr error) (bool, bool) {
|
||||
var failoverErr *service.UpstreamFailoverError
|
||||
isFailover := errors.As(turnErr, &failoverErr)
|
||||
if marked {
|
||||
if isFailover {
|
||||
return false, true
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
if pending && !isFailover {
|
||||
return true, false
|
||||
}
|
||||
return blocked, pending
|
||||
}
|
||||
|
||||
var errOpenAIWSUnsupportedModelSwitch = errors.New("selected account does not support websocket model switch")
|
||||
|
||||
func newOpenAIWSUnsupportedModelSwitchError(model string) error {
|
||||
@@ -140,6 +156,18 @@ func openAIWSTurnBillingModel(result *service.OpenAIForwardResult, mapping servi
|
||||
return billingModel
|
||||
}
|
||||
|
||||
// openAIChannelForwardModel returns the model used for upstream capability
|
||||
// routing. The client-requested model remains separate for logs, billing, and
|
||||
// responses.
|
||||
func openAIChannelForwardModel(mapping service.ChannelMappingResult, requestedModel string) string {
|
||||
if mapping.Mapped {
|
||||
if mappedModel := strings.TrimSpace(mapping.MappedModel); mappedModel != "" {
|
||||
return mappedModel
|
||||
}
|
||||
}
|
||||
return requestedModel
|
||||
}
|
||||
|
||||
type grokMediaEligibilityProber interface {
|
||||
ProbeMediaEligibility(ctx context.Context, accountID int64) (bool, string, error)
|
||||
}
|
||||
@@ -294,7 +322,7 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe
|
||||
func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, model string) bool {
|
||||
return compositeTargetPlatformAllowed(c, apiKey, model,
|
||||
service.PlatformOpenAI, service.PlatformGrok,
|
||||
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek)
|
||||
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax)
|
||||
}
|
||||
|
||||
// isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket
|
||||
@@ -533,10 +561,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
|
||||
forwardBody := openAIModelMappedBody(body, channelMapping.Mapped, channelMapping.MappedModel, h.gatewayService.ReplaceModelInBody)
|
||||
seedOpenAIForwardImageIntentHint(c, channelMapping.Mapped, imageIntent)
|
||||
forwardModel := reqModel
|
||||
if channelMapping.Mapped {
|
||||
forwardModel = channelMapping.MappedModel
|
||||
}
|
||||
forwardModel := openAIChannelForwardModel(channelMapping, reqModel)
|
||||
c.Request = c.Request.WithContext(service.WithOpenAIForwardModel(
|
||||
c.Request.Context(),
|
||||
forwardModel,
|
||||
@@ -629,7 +654,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
apiKey.GroupID,
|
||||
previousResponseID,
|
||||
sessionHash,
|
||||
reqModel,
|
||||
forwardModel,
|
||||
failedAccountIDs,
|
||||
service.OpenAIUpstreamTransportAny,
|
||||
requiredCapability,
|
||||
@@ -810,6 +835,22 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
if result != nil && result.ClientDisconnect {
|
||||
reqLog.Info("openai.client_disconnected",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
submitResponsesUsage(result)
|
||||
return
|
||||
}
|
||||
if failoverClientGone(c) {
|
||||
reqLog.Info("openai.client_disconnected",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
submitResponsesUsage(result)
|
||||
return
|
||||
}
|
||||
if result != nil && result.ImageCount > 0 {
|
||||
reqLog.Warn("openai.forward_partial_error_with_image_result",
|
||||
zap.Int64("account_id", account.ID),
|
||||
@@ -1600,14 +1641,17 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context,
|
||||
}
|
||||
|
||||
func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) {
|
||||
return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate)
|
||||
// 已有任务通过 send_message_to_thread 唤醒时会携带 previous_response_id;
|
||||
// 完整历史回放还会带有已配对的调用项。delegation 仍是客户端注入的用户输入,
|
||||
// 不属于这些历史调用的结果,因此允许它与可明确配对的历史上下文共存。
|
||||
return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate, true)
|
||||
}
|
||||
|
||||
func normalizeCodexAutomationBootstrap(body []byte) ([]byte, bool) {
|
||||
return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate)
|
||||
return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate, false)
|
||||
}
|
||||
|
||||
func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool) ([]byte, bool) {
|
||||
func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool, allowHistoricalContext bool) ([]byte, bool) {
|
||||
if !hasUniqueJSONMembers(body) {
|
||||
return body, false
|
||||
}
|
||||
@@ -1619,7 +1663,7 @@ func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]
|
||||
}
|
||||
if previousResponseID, exists := request["previous_response_id"]; exists {
|
||||
value, ok := previousResponseID.(string)
|
||||
if !ok || strings.TrimSpace(value) != "" {
|
||||
if !ok || (!allowHistoricalContext && strings.TrimSpace(value) != "") {
|
||||
return body, false
|
||||
}
|
||||
}
|
||||
@@ -1628,27 +1672,35 @@ func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]
|
||||
return body, false
|
||||
}
|
||||
|
||||
// Any call/reference anchor makes a call-less output ambiguous. Responses
|
||||
// built-ins follow the *_call / *_call_output naming convention, so classify
|
||||
// by the wire type shape instead of maintaining an incomplete allowlist.
|
||||
// Responses built-ins follow the *_call / *_call_output naming convention,
|
||||
// so classify by the wire type shape instead of maintaining an incomplete
|
||||
// allowlist. Delegation may coexist with historical anchors only when their
|
||||
// IDs make them unambiguous; automation retains the bootstrap-only boundary.
|
||||
for _, raw := range input {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typ := stringField(item, "type")
|
||||
if typ == "item_reference" || strings.HasSuffix(typ, "_call") {
|
||||
return body, false
|
||||
}
|
||||
if isResponsesCallOutputType(typ) {
|
||||
if isCandidate(item) {
|
||||
callIDValue, exists := item["call_id"]
|
||||
callID, isString := callIDValue.(string)
|
||||
if exists && (!isString || strings.TrimSpace(callID) != "") {
|
||||
return body, false
|
||||
}
|
||||
if !isCandidate(item) {
|
||||
return body, false
|
||||
continue
|
||||
}
|
||||
if typ == "item_reference" {
|
||||
if allowHistoricalContext && strings.TrimSpace(stringField(item, "id")) != "" {
|
||||
continue
|
||||
}
|
||||
return body, false
|
||||
}
|
||||
if strings.HasSuffix(typ, "_call") || isResponsesCallOutputType(typ) {
|
||||
if allowHistoricalContext && strings.TrimSpace(stringField(item, "call_id")) != "" {
|
||||
continue
|
||||
}
|
||||
return body, false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1756,7 +1808,7 @@ func isCodexAutomationCandidate(item map[string]any) bool {
|
||||
return false
|
||||
}
|
||||
output, ok := item["output"].(string)
|
||||
return ok && validCodexAutomationBootstrap(output)
|
||||
return ok && (validCodexAutomationBootstrap(output) || validCodexAutomationHeartbeat(output))
|
||||
}
|
||||
|
||||
func stringField(item map[string]any, key string) string {
|
||||
@@ -1834,6 +1886,56 @@ func validCodexAutomationLastRun(value string) bool {
|
||||
return err == nil && runAt.UnixMilli() == epochMillis
|
||||
}
|
||||
|
||||
func validCodexAutomationHeartbeat(value string) bool {
|
||||
decoder := xml.NewDecoder(strings.NewReader(value))
|
||||
var rootSeen, automationIDSeen bool
|
||||
var automationID bytes.Buffer
|
||||
depth := 0
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
id := automationID.String()
|
||||
return rootSeen && automationIDSeen && depth == 0 &&
|
||||
strings.TrimSpace(id) == id && validCodexAutomationID(id)
|
||||
}
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch current := token.(type) {
|
||||
case xml.StartElement:
|
||||
depth++
|
||||
if current.Name.Space != "" || len(current.Attr) != 0 || depth > 2 {
|
||||
return false
|
||||
}
|
||||
if depth == 1 {
|
||||
if rootSeen || current.Name.Local != "heartbeat" {
|
||||
return false
|
||||
}
|
||||
rootSeen = true
|
||||
} else if automationIDSeen || current.Name.Local != "automation_id" {
|
||||
return false
|
||||
}
|
||||
automationIDSeen = depth == 2
|
||||
case xml.EndElement:
|
||||
if current.Name.Space != "" {
|
||||
return false
|
||||
}
|
||||
depth--
|
||||
if depth < 0 {
|
||||
return false
|
||||
}
|
||||
case xml.CharData:
|
||||
if depth == 2 {
|
||||
_, _ = automationID.Write(current)
|
||||
} else if len(bytes.TrimSpace(current)) != 0 {
|
||||
return false
|
||||
}
|
||||
case xml.Comment, xml.ProcInst, xml.Directive:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validCodexDelegationEnvelope(value string) bool {
|
||||
decoder := xml.NewDecoder(strings.NewReader(value))
|
||||
var rootSeen, sourceSeen, inputSeen bool
|
||||
@@ -1940,10 +2042,10 @@ const (
|
||||
// 由 BeforeTurn 在每个 turn 开始时冻结,AfterTurn 的用量提交读取它;turn 在
|
||||
// 连接内串行推进,互斥锁只为跨用量提交 goroutine 的读取安全。
|
||||
//
|
||||
// ws_v2 passthrough ingress 没有 BeforeTurn,因此本值会保持零;AfterTurn 必须
|
||||
// 以 TurnStarted 已记录的所属 turn 开始时刻为回退,而不是用建连或记录时刻。
|
||||
// 这样每个 passthrough turn 都按自己的开始时刻计价,但不改变其仅在建连时执行
|
||||
// 准入门、没有 turn 级利润复核的既有行为。
|
||||
// 零值语义(重要):首轮准入由握手路径完成,不调用 BeforeTurn,因此首轮保持
|
||||
// 零值并回退到 TurnStarted 记录的首轮开始时刻。后续 turn 在 response.create
|
||||
// 写入上游前调用 BeforeTurn,按当时的利润门复核并冻结定价。绝不能用建连时刻
|
||||
// 初始化,否则会把长连接的所有 turn 钉死在建连时的峰谷因子。
|
||||
type openAIWSTurnPricing struct {
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
@@ -2004,7 +2106,7 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot(
|
||||
return h.acquireOpenAIAccountSlot(c, groupID, sessionHash, selection, reqStream, streamStarted, reqLog, nil)
|
||||
}
|
||||
|
||||
type openAISlotErrorWriter func(status int, errType, message string)
|
||||
type openAISlotErrorWriter func(status int, errType, code, message string)
|
||||
|
||||
// acquireOpenAIAccountSlot centralizes scheduler selection admission. The
|
||||
// optional error writer lets non-Responses endpoints retain their wire format
|
||||
@@ -2020,13 +2122,13 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
|
||||
writeError openAISlotErrorWriter,
|
||||
) (func(), openAISlotAcquireResult) {
|
||||
if writeError == nil {
|
||||
writeError = func(status int, errType, message string) {
|
||||
h.handleStreamingAwareError(c, status, errType, message, *streamStarted)
|
||||
writeError = func(status int, errType, code, message string) {
|
||||
h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, *streamStarted, false)
|
||||
}
|
||||
}
|
||||
if selection == nil || selection.Account == nil {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
writeError(http.StatusServiceUnavailable, "api_error", "No available accounts")
|
||||
writeError(http.StatusServiceUnavailable, "api_error", "", "No available accounts")
|
||||
return nil, openAISlotAcquireFailed
|
||||
}
|
||||
|
||||
@@ -2056,7 +2158,7 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
|
||||
}
|
||||
if selection.WaitPlan == nil {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
writeError(http.StatusServiceUnavailable, "api_error", "No available accounts")
|
||||
writeError(http.StatusServiceUnavailable, "api_error", "", "No available accounts")
|
||||
return nil, openAISlotAcquireFailed
|
||||
}
|
||||
|
||||
@@ -2067,8 +2169,8 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
|
||||
)
|
||||
if err != nil {
|
||||
reqLog.Warn("openai.account_slot_quick_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
status, errType, message := concurrencyErrorResponse(err, "account")
|
||||
writeError(status, errType, message)
|
||||
status, errType, code, message := concurrencyErrorResponse(err, "account")
|
||||
writeError(status, errType, code, message)
|
||||
return nil, openAISlotAcquireFailed
|
||||
}
|
||||
if fastAcquired {
|
||||
@@ -2098,7 +2200,7 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int("max_waiting", selection.WaitPlan.MaxWaiting),
|
||||
)
|
||||
writeError(http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later")
|
||||
writeError(http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later")
|
||||
return nil, openAISlotAcquireFailed
|
||||
}
|
||||
|
||||
@@ -2121,8 +2223,8 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
|
||||
)
|
||||
if err != nil {
|
||||
reqLog.Warn("openai.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
status, errType, message := concurrencyErrorResponse(err, "account")
|
||||
writeError(status, errType, message)
|
||||
status, errType, code, message := concurrencyErrorResponse(err, "account")
|
||||
writeError(status, errType, code, message)
|
||||
return nil, openAISlotAcquireFailed
|
||||
}
|
||||
|
||||
@@ -2263,6 +2365,16 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "model is required in first response.create payload")
|
||||
return
|
||||
}
|
||||
// 分组级模型白名单:首帧校验客户端模型,不通过则关闭连接并标记运维原因。
|
||||
// 必须在 ensureCompositeTargetPlatform(合成路由改写)之前执行。
|
||||
// 与 HTTP 准入一致:帧内重复 model 键/大小写变体可能被上游按末值绑定,
|
||||
// 全部候选值逐一校验,任一未命中即拒绝。
|
||||
if blocked := blockedModelAllowlistCandidate(apiKey.Group, requestmodel.FromBodyCandidates("", "application/json", firstMessage)); blocked != "" {
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
|
||||
middleware2.MarkIngressRejected(c, middleware2.IngressRejectModelNotAllowed)
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked))
|
||||
return
|
||||
}
|
||||
ensureCompositeTargetPlatform(c, apiKey, reqModel)
|
||||
ctx = c.Request.Context()
|
||||
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite {
|
||||
@@ -2310,6 +2422,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
cyberBlockedThisConn := false
|
||||
cyberBlockPendingAfterFailover := false
|
||||
var cyberTurnBodiesMu sync.Mutex
|
||||
cyberTurnBodies := map[int][]byte{1: append([]byte(nil), firstMessage...)}
|
||||
setCyberTurnBody := func(turn int, payload []byte) {
|
||||
@@ -2327,10 +2440,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
|
||||
// 解析渠道级模型映射
|
||||
channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel)
|
||||
wsForwardModel := reqModel
|
||||
if channelMappingWS.Mapped && strings.TrimSpace(channelMappingWS.MappedModel) != "" {
|
||||
wsForwardModel = strings.TrimSpace(channelMappingWS.MappedModel)
|
||||
}
|
||||
wsForwardModel := openAIChannelForwardModel(channelMappingWS, reqModel)
|
||||
|
||||
var currentUserRelease func()
|
||||
var currentAccountRelease func()
|
||||
@@ -2493,7 +2603,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
apiKey.GroupID,
|
||||
previousResponseID,
|
||||
sessionHash,
|
||||
reqModel,
|
||||
wsForwardModel,
|
||||
failedAccountIDs,
|
||||
requiredTransport,
|
||||
requiredCapability,
|
||||
@@ -2645,8 +2755,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
// turn-tagged slot preserves the exact mapping used for the in-flight request.
|
||||
var turnChannelMapping atomic.Pointer[openAIWSTurnChannelMappingSnapshot]
|
||||
turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: 1, mapping: channelMappingWS})
|
||||
// turn 级定价:BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号;
|
||||
// passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。
|
||||
// turn 级定价:首轮回退到 TurnStarted 的所属 turn 时刻;后续 turn 由
|
||||
// BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号。
|
||||
var turnPricing openAIWSTurnPricing
|
||||
hooks := &service.OpenAIWSIngressHooks{
|
||||
ClientLifecycleContext: clientLifecycleCtx,
|
||||
@@ -2660,10 +2770,9 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
c.Set(securityAuditWSTurnContextKey, turn)
|
||||
service.BeginOpsStreamTurn(c, turn)
|
||||
setCyberTurnBody(turn, payload)
|
||||
// Passthrough ingress intentionally skips BeforeTurn, so enforce only
|
||||
// the connection-level cyber session gate here as well. Native ingress
|
||||
// visits this hook first and gets the same side-effect-free close error;
|
||||
// its original BeforeTurn guard remains as defense in depth.
|
||||
// 连接级 cyber session gate 也在 BeforeRequest 先执行,使 native 与
|
||||
// passthrough ingress 都能在 BeforeTurn 及上游写入前无副作用地拒绝。
|
||||
// BeforeTurn 中保留同一检查作为防御式兜底。
|
||||
if cyberBlockedThisConn {
|
||||
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil)
|
||||
}
|
||||
@@ -2680,6 +2789,17 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
if model == "" {
|
||||
model = reqModel
|
||||
}
|
||||
// 分组级模型白名单:后续 turn 同样校验客户端模型(省略 model 时
|
||||
// 沿用会话实际生效模型,含 session.update 轮换后的模型),不通过
|
||||
// 则关闭整条连接,与推理强度 deny 一致。实际生效模型始终参与校验;
|
||||
// 帧内重复 model 键/大小写变体/嵌套 session.model 额外逐一校验,
|
||||
// 防止候选集非空时掩盖被轮换掉的禁用模型。
|
||||
candidates := append([]string{model}, requestmodel.FromBodyCandidates("", "application/json", payload)...)
|
||||
if blocked := blockedModelAllowlistCandidate(apiKey.Group, candidates); blocked != "" {
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
|
||||
middleware2.MarkIngressRejected(c, middleware2.IngressRejectModelNotAllowed)
|
||||
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked), nil)
|
||||
}
|
||||
if decision := h.checkSecurityAuditStage(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, model, payload, "subsequent_turn"); decision != nil && !decision.AllowNextStage {
|
||||
writeSecurityAuditWSError(ctx, wsConn, decision)
|
||||
return service.NewOpenAIWSClientCloseError(securityAuditWSCloseStatus(decision), securityAuditWSCloseReason(decision), nil)
|
||||
@@ -2753,10 +2873,12 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
AfterTurn: func(turn int, result *service.OpenAIForwardResult, turnErr error) {
|
||||
turnStart := getTurnStart(turn)
|
||||
cyberBlockBody := takeCyberTurnBody(turn)
|
||||
// F1: cyber 标记按 turn 生命周期清理——defer 保证任意早返回路径都执行;
|
||||
// CyberBlocked 必须在 submit 前同步预捕获(task 闭包由 worker 池异步执行,
|
||||
// 届时 defer 已清除标记)。
|
||||
defer clearCyberPolicyTurnState(c)
|
||||
// 每次 attempt 都清 cyber mark;failover 链结束前保留 recorded guard,
|
||||
// 避免同一逻辑 turn 换号后重复落风控。CyberBlocked 必须在 submit 前
|
||||
// 同步预捕获(task 闭包由 worker 池异步执行,届时 mark 已清除)。
|
||||
defer func() {
|
||||
clearCyberPolicyAttemptState(c, !cyberBlockPendingAfterFailover)
|
||||
}()
|
||||
releaseTurnSlots()
|
||||
turnRequestedModel := reqModel
|
||||
turnUpstreamModel := ""
|
||||
@@ -2778,10 +2900,14 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
turnUpstreamModel = turnRequestedModel
|
||||
}
|
||||
turnUsageFields := turnMapping.ToUsageFields(turnRequestedModel, turnUpstreamModel)
|
||||
cyberMarked := service.GetOpsCyberPolicy(c) != nil
|
||||
h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, turnRequestedModel, turnErr != nil, cyberBlockBody, turnUsageFields, requestPayloadHash)
|
||||
if service.GetOpsCyberPolicy(c) != nil {
|
||||
cyberBlockedThisConn = true
|
||||
}
|
||||
cyberBlockedThisConn, cyberBlockPendingAfterFailover = advanceOpenAIWSCyberBlockState(
|
||||
cyberBlockedThisConn,
|
||||
cyberBlockPendingAfterFailover,
|
||||
cyberMarked,
|
||||
turnErr,
|
||||
)
|
||||
if turnErr != nil {
|
||||
if result == nil || result.ImageCount <= 0 {
|
||||
return
|
||||
@@ -3172,14 +3298,14 @@ func (h *OpenAIGatewayHandler) acquireImageGenerationSlot(c *gin.Context, stream
|
||||
if acquired {
|
||||
return release, true
|
||||
}
|
||||
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Image generation concurrency limit exceeded, please retry later", streamStarted)
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayConcurrencyLimitCode, "Image generation concurrency limit exceeded, please retry later", streamStarted, false)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// handleConcurrencyError handles concurrency-related acquire errors.
|
||||
func (h *OpenAIGatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) {
|
||||
status, errType, message := concurrencyErrorResponse(err, slotType)
|
||||
h.handleStreamingAwareError(c, status, errType, message, streamStarted)
|
||||
status, errType, code, message := concurrencyErrorResponse(err, slotType)
|
||||
h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, streamStarted, false)
|
||||
}
|
||||
|
||||
func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, streamStarted bool) {
|
||||
@@ -3222,6 +3348,12 @@ func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverE
|
||||
}
|
||||
statusCode := failoverErr.StatusCode
|
||||
responseBody := failoverErr.ResponseBody
|
||||
if statusCode == http.StatusBadRequest && service.IsOpenAICompatibleModelNotFound400(responseBody) && !streamStarted {
|
||||
upstreamMsg := service.SanitizeUpstreamErrorMessage(service.ExtractUpstreamErrorMessage(responseBody))
|
||||
service.SetOpsUpstreamError(c, statusCode, upstreamMsg, "")
|
||||
service.WriteOpenAIUpstreamClientError(c, statusCode, responseBody, upstreamMsg)
|
||||
return
|
||||
}
|
||||
if service.IsOpenAISilentRefusalErrorBody(responseBody) {
|
||||
service.SetOpsUpstreamError(c, statusCode, service.OpenAISilentRefusalClientMessage(), "")
|
||||
h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", service.OpenAISilentRefusalClientMessage(), streamStarted)
|
||||
@@ -3359,7 +3491,7 @@ func (h *OpenAIGatewayHandler) handleStreamingAwareErrorWithCode(
|
||||
// 通用 `event: error` 帧不被识别为终止事件,会导致
|
||||
// "stream closed before response.completed"。
|
||||
if inboundIsResponses(c) {
|
||||
if writeResponsesFailedSSE(c, errType, message) {
|
||||
if writeResponsesFailedSSE(c, errType, code, message) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -3412,6 +3544,10 @@ func (h *OpenAIGatewayHandler) ensureForwardErrorResponse(c *gin.Context, stream
|
||||
if c == nil || c.Writer == nil {
|
||||
return false
|
||||
}
|
||||
if c.Request != nil && errors.Is(c.Request.Context().Err(), context.Canceled) {
|
||||
failoverClientGone(c)
|
||||
return false
|
||||
}
|
||||
// 先停 compact 心跳再读 Writer 状态,避免与心跳 goroutine 竞争。
|
||||
compactKeepaliveCommitted := service.StopOpenAICompactSSEKeepaliveCommitted(c)
|
||||
if compactKeepaliveCommitted {
|
||||
@@ -3526,7 +3662,7 @@ func (h *OpenAIGatewayHandler) errorResponse(c *gin.Context, status int, errType
|
||||
// 提交的 SSE 流交错,必须降级为 response.failed 终止事件(#3887)。
|
||||
if service.StopOpenAICompactSSEKeepaliveCommitted(c) {
|
||||
service.MarkOpsStreamError(c, errType, message, status)
|
||||
if writeResponsesFailedSSE(c, errType, message) {
|
||||
if writeResponsesFailedSSE(c, errType, "", message) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -3581,6 +3717,21 @@ func isOpenAIWSUpgradeRequest(r *http.Request) bool {
|
||||
return strings.Contains(strings.ToLower(strings.TrimSpace(r.Header.Get("Connection"))), "upgrade")
|
||||
}
|
||||
|
||||
// blockedModelAllowlistCandidate 对全部候选模型逐一校验分组白名单,返回第一个
|
||||
// 未命中的值(全部命中或白名单未开启返回空串)。WS 帧与 HTTP 请求体共用该
|
||||
// 规则:重复 model 键/大小写变体可能被上游按末值绑定,任一未命中即拒绝。
|
||||
func blockedModelAllowlistCandidate(group *service.Group, candidates []string) string {
|
||||
if group == nil || !group.ModelAllowlistEnabled() {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if !group.ModelAllowlist.Allows(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func closeOpenAIClientWS(conn *coderws.Conn, status coderws.StatusCode, reason string) {
|
||||
if conn == nil {
|
||||
return
|
||||
@@ -3844,7 +3995,7 @@ func (h *OpenAIGatewayHandler) rejectIfCyberSessionBlocked(c *gin.Context, apiKe
|
||||
// 写 JSON(#3887)。
|
||||
if service.StopOpenAICompactSSEKeepaliveCommitted(c) {
|
||||
service.MarkOpsStreamError(c, "permission_error", cyberSessionBlockedClientMsg, http.StatusForbidden)
|
||||
if writeResponsesFailedSSE(c, "permission_error", cyberSessionBlockedClientMsg) {
|
||||
if writeResponsesFailedSSE(c, "permission_error", "", cyberSessionBlockedClientMsg) {
|
||||
h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, model, key)
|
||||
return true
|
||||
}
|
||||
@@ -4088,15 +4239,20 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey
|
||||
}()
|
||||
}
|
||||
|
||||
// clearCyberPolicyTurnState resets the cyber mark and the per-request recorded
|
||||
// guard. WS-only: called at the END of AfterTurn, after recordCyberPolicyIfMarked
|
||||
// and RecordUsage (which reads CyberBlocked) have both consumed the mark.
|
||||
// clearCyberPolicyTurnState resets the cyber mark and recorded guard after a
|
||||
// logical WS turn has finished.
|
||||
func clearCyberPolicyTurnState(c *gin.Context) {
|
||||
clearCyberPolicyAttemptState(c, true)
|
||||
}
|
||||
|
||||
func clearCyberPolicyAttemptState(c *gin.Context, resetRecorded bool) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
service.ClearOpsCyberPolicy(c)
|
||||
c.Set(cyberPolicyRecordedKey, false)
|
||||
if resetRecorded {
|
||||
c.Set(cyberPolicyRecordedKey, false)
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeWSCloseErrorForLog(err error) (string, string) {
|
||||
|
||||
@@ -1530,7 +1530,7 @@ func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) {
|
||||
"each turn must be billed with its own channel-mapped model")
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKeysRemainsValid(t *testing.T) {
|
||||
func TestOpenAIResponsesWebSocket_ChannelMappedTargetSelectsAccountWithoutRequestedAlias(t *testing.T) {
|
||||
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"public-alias","stream":false}`,
|
||||
secondPayload: `{"type":"response.create","stream":false}`,
|
||||
@@ -1538,7 +1538,7 @@ func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKey
|
||||
"public-alias": "gpt-5.6-sol",
|
||||
},
|
||||
accountModelMapping: map[string]any{
|
||||
"public-alias": "gpt-5.6-terra",
|
||||
"gpt-5.6-sol": "gpt-5.6-sol",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1710,6 +1710,40 @@ func TestOpenAIAccountScheduleModelUsesActualOrSharedResolver(t *testing.T) {
|
||||
require.Equal(t, "attempt-actual", openAIAccountScheduleModel(c, account, "public", true, nil))
|
||||
}
|
||||
|
||||
func TestOpenAIChannelForwardModelForScheduler(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mapping service.ChannelMappingResult
|
||||
requested string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "mapped model is used for capability filtering",
|
||||
mapping: service.ChannelMappingResult{Mapped: true, MappedModel: " gpt-forward "},
|
||||
requested: "client-alias",
|
||||
want: "gpt-forward",
|
||||
},
|
||||
{
|
||||
name: "unmapped request preserves client model",
|
||||
mapping: service.ChannelMappingResult{MappedModel: "client-model"},
|
||||
requested: "client-model",
|
||||
want: "client-model",
|
||||
},
|
||||
{
|
||||
name: "empty mapped model safely preserves client model",
|
||||
mapping: service.ChannelMappingResult{Mapped: true, MappedModel: " "},
|
||||
requested: "client-model",
|
||||
want: "client-model",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, openAIChannelForwardModel(tt.mapping, tt.requested))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldReportOpenAIWSProxyAccountFailure(t *testing.T) {
|
||||
t.Run("unsupported client model switch does not penalize account", func(t *testing.T) {
|
||||
err := fmt.Errorf("wrapped ingress turn: %w", newOpenAIWSUnsupportedModelSwitchError("gpt-unsupported"))
|
||||
@@ -1882,7 +1916,10 @@ func newOpenAIWSHandlerTestServer(t *testing.T, h *OpenAIGatewayHandler, subject
|
||||
}
|
||||
|
||||
type openAIResponsesWSUsageLogCase struct {
|
||||
firstPayload string
|
||||
firstPayload string
|
||||
// midPayload 在首个 turn 完成后发送(如 session.update),上游桩会为它
|
||||
// 回一个 response.completed,客户端按普通事件读取。
|
||||
midPayload string
|
||||
secondPayload string
|
||||
userAgent *string
|
||||
ingressMode string
|
||||
@@ -1890,6 +1927,12 @@ type openAIResponsesWSUsageLogCase struct {
|
||||
billingModelSource string
|
||||
accountModelMapping map[string]any
|
||||
afterFirstUpstreamRequest func(channelSvc *service.ChannelService) error
|
||||
// group 覆盖 apiKey.Group(分组级模型白名单测试用);nil 保持原有无分组行为。
|
||||
group *service.Group
|
||||
// firstFrameCloseExpected:首帧即被拒(连接被 1008 关闭),不期待任何响应帧。
|
||||
firstFrameCloseExpected bool
|
||||
// secondTurnCloseExpected:第二个 turn 被拒(连接被 1008 关闭)。
|
||||
secondTurnCloseExpected bool
|
||||
}
|
||||
|
||||
type openAIResponsesWSUsageLogResult struct {
|
||||
@@ -2809,8 +2852,11 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
turnCount := 1
|
||||
if strings.TrimSpace(tc.midPayload) != "" {
|
||||
turnCount++
|
||||
}
|
||||
if strings.TrimSpace(tc.secondPayload) != "" {
|
||||
turnCount = 2
|
||||
turnCount++
|
||||
}
|
||||
upstreamPayloadCh := make(chan []byte, turnCount)
|
||||
upstreamErrCh := make(chan error, 1)
|
||||
@@ -2914,7 +2960,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
||||
BillingModelSource: tc.billingModelSource,
|
||||
}},
|
||||
groupPlatforms: map[int64]string{groupID: service.PlatformOpenAI},
|
||||
}, nil, nil, nil)
|
||||
}, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
|
||||
@@ -2963,6 +3009,9 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
||||
GroupID: &groupID,
|
||||
User: &service.User{ID: 1701, Status: service.StatusActive},
|
||||
}
|
||||
if tc.group != nil {
|
||||
apiKey.Group = tc.group
|
||||
}
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
|
||||
@@ -2994,6 +3043,19 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
|
||||
if tc.firstFrameCloseExpected {
|
||||
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
_, _, readErr := clientConn.Read(readCtx)
|
||||
cancelRead()
|
||||
require.Error(t, readErr, "first frame should have been rejected with a close")
|
||||
var closeErr coderws.CloseError
|
||||
require.ErrorAs(t, readErr, &closeErr)
|
||||
require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
|
||||
require.Contains(t, closeErr.Reason, "not available for this group")
|
||||
_ = clientConn.CloseNow()
|
||||
return openAIResponsesWSUsageLogResult{}
|
||||
}
|
||||
|
||||
clientEvents := make([][]byte, 0, turnCount)
|
||||
readCompleted := func() {
|
||||
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
@@ -3004,11 +3066,30 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
||||
clientEvents = append(clientEvents, append([]byte(nil), event...))
|
||||
}
|
||||
readCompleted()
|
||||
if turnCount == 2 {
|
||||
if tc.midPayload != "" {
|
||||
writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(tc.midPayload))
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
readCompleted()
|
||||
}
|
||||
if strings.TrimSpace(tc.secondPayload) != "" && (turnCount >= 2) {
|
||||
writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(tc.secondPayload))
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
if tc.secondTurnCloseExpected {
|
||||
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
_, _, readErr := clientConn.Read(readCtx)
|
||||
cancelRead()
|
||||
require.Error(t, readErr, "second turn should have been rejected with a close")
|
||||
var closeErr coderws.CloseError
|
||||
require.ErrorAs(t, readErr, &closeErr)
|
||||
require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
|
||||
require.Contains(t, closeErr.Reason, "not available for this group")
|
||||
_ = clientConn.CloseNow()
|
||||
return openAIResponsesWSUsageLogResult{}
|
||||
}
|
||||
readCompleted()
|
||||
}
|
||||
_ = clientConn.Close(coderws.StatusNormalClosure, "done")
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
func wsAllowlistGroup(enabled bool, models ...string) *service.Group {
|
||||
return &service.Group{
|
||||
ID: 4201,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: enabled,
|
||||
Models: models,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 首帧拒绝:模型不在白名单时连接被 1008 关闭(passthrough relay 模式)。
|
||||
func TestOpenAIResponsesWebSocket_FirstFrameModelNotAllowedCloses_Passthrough(t *testing.T) {
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
ingressMode: service.OpenAIWSIngressModePassthrough,
|
||||
firstFrameCloseExpected: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 首帧拒绝:原生 ingress 模式。
|
||||
func TestOpenAIResponsesWebSocket_FirstFrameModelNotAllowedCloses_NativeIngress(t *testing.T) {
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
ingressMode: service.OpenAIWSIngressModeDedicated,
|
||||
firstFrameCloseExpected: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 首帧命中白名单:连接正常建立并收到 response.completed(passthrough)。
|
||||
func TestOpenAIResponsesWebSocket_FirstFrameAllowlistedModelProceeds(t *testing.T) {
|
||||
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
})
|
||||
if len(got.clientEvents) != 1 {
|
||||
t.Fatalf("expected one completed event, got %d", len(got.clientEvents))
|
||||
}
|
||||
}
|
||||
|
||||
// 后续 turn 切换到白名单外的模型:连接被 1008 关闭(passthrough relay 模式)。
|
||||
func TestOpenAIResponsesWebSocket_SubsequentTurnModelNotAllowedCloses_Passthrough(t *testing.T) {
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
secondPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
ingressMode: service.OpenAIWSIngressModePassthrough,
|
||||
secondTurnCloseExpected: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 后续 turn 切换到白名单外的模型:连接被 1008 关闭(原生 ingress 模式)。
|
||||
func TestOpenAIResponsesWebSocket_SubsequentTurnModelNotAllowedCloses_NativeIngress(t *testing.T) {
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
secondPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
ingressMode: service.OpenAIWSIngressModeDedicated,
|
||||
secondTurnCloseExpected: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 后续 turn 省略 model:沿用会话初始模型,白名单校验通过。
|
||||
func TestOpenAIResponsesWebSocket_SubsequentTurnOmittedModelUsesSessionModel(t *testing.T) {
|
||||
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
secondPayload: `{"type":"response.create","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
})
|
||||
if len(got.clientEvents) != 2 {
|
||||
t.Fatalf("expected two completed events, got %d", len(got.clientEvents))
|
||||
}
|
||||
if len(got.upstreamPayloads) != 2 {
|
||||
t.Fatalf("expected two upstream frames, got %d", len(got.upstreamPayloads))
|
||||
}
|
||||
}
|
||||
|
||||
// 白名单关闭:不受任何影响。
|
||||
func TestOpenAIResponsesWebSocket_DisabledAllowlistDoesNotInterfere(t *testing.T) {
|
||||
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
|
||||
secondPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
group: wsAllowlistGroup(false, "gpt-5.4"),
|
||||
})
|
||||
if len(got.clientEvents) != 2 {
|
||||
t.Fatalf("expected two completed events, got %d", len(got.clientEvents))
|
||||
}
|
||||
}
|
||||
|
||||
// 首帧含重复 model 键(首个在白名单、末个不在):passthrough 会原样转发两个字段,
|
||||
// 上游按末值绑定时得到禁用模型,必须在入口拒绝。
|
||||
func TestOpenAIResponsesWebSocket_FirstFrameDuplicateModelKeysRejected(t *testing.T) {
|
||||
for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","model":"gpt-4.1","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
ingressMode: mode,
|
||||
firstFrameCloseExpected: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 首帧混用大小写变体键({"model":"allowed","Model":"blocked"}):绑定系上游
|
||||
// 大小写不敏感且末值生效会绑定 blocked,必须在入口拒绝。仅含变体键的帧则会在
|
||||
// 「model is required」处更早被关闭。
|
||||
func TestOpenAIResponsesWebSocket_FirstFrameCaseVariantModelKeyRejected(t *testing.T) {
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","Model":"gpt-4.1","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
ingressMode: service.OpenAIWSIngressModePassthrough,
|
||||
firstFrameCloseExpected: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 后续 turn 含重复 model 键(首个在白名单、末个不在):拒绝并关闭连接。
|
||||
func TestOpenAIResponsesWebSocket_SubsequentTurnDuplicateModelKeysRejected(t *testing.T) {
|
||||
for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
secondPayload: `{"type":"response.create","model":"gpt-5.4","model":"gpt-4.1","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
ingressMode: mode,
|
||||
secondTurnCloseExpected: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 重复但同值的 model 键不误伤,连接正常完成两个 turn。
|
||||
func TestOpenAIResponsesWebSocket_DuplicateIdenticalModelKeysAllowed(t *testing.T) {
|
||||
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","model":"gpt-5.4","stream":false}`,
|
||||
secondPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
})
|
||||
if len(got.clientEvents) != 2 {
|
||||
t.Fatalf("expected two completed events, got %d", len(got.clientEvents))
|
||||
}
|
||||
}
|
||||
|
||||
// session.update 轮换绕过:首帧用白名单内模型建立会话,session.update 把会话
|
||||
// 模型改为白名单外的模型,随后 response.create 携带嵌套 session.model(白名单
|
||||
// 内)让帧内候选非空。实际生效模型(轮换后的会话模型)必须始终参与校验。
|
||||
func TestOpenAIResponsesWebSocket_SessionUpdateRotationBypassRejected(t *testing.T) {
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
midPayload: `{"type":"session.update","session":{"model":"gpt-4.1"}}`,
|
||||
secondPayload: `{"type":"response.create","session":{"model":"gpt-5.4"},"stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
ingressMode: service.OpenAIWSIngressModePassthrough,
|
||||
secondTurnCloseExpected: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 轮换后的会话模型本身在白名单内时,省略 model 的后续 turn 正常放行。
|
||||
func TestOpenAIResponsesWebSocket_SessionUpdateToAllowedModelStillWorks(t *testing.T) {
|
||||
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
midPayload: `{"type":"session.update","session":{"model":"gpt-5.4"}}`,
|
||||
secondPayload: `{"type":"response.create","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
})
|
||||
if len(got.clientEvents) < 2 {
|
||||
t.Fatalf("expected at least two completed events, got %d", len(got.clientEvents))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *GatewayHandler) pinnedOpenAIModels(c *gin.Context, group *service.Group) {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
if h.openAIGatewayService == nil {
|
||||
writeOpenAIModelsError(c, http.StatusInternalServerError, "api_error", "OpenAI model discovery is not configured")
|
||||
return
|
||||
}
|
||||
response, account, err := h.openAIGatewayService.FetchPinnedOpenAIModelsList(
|
||||
c.Request.Context(), group, h.maxAccountSwitches, c.GetHeader("If-None-Match"),
|
||||
)
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrNoPinnedCodexModelsAccounts) {
|
||||
writeOpenAIModelsError(c, http.StatusServiceUnavailable, "upstream_error", "No available OpenAI model discovery accounts")
|
||||
return
|
||||
}
|
||||
writeOpenAIModelsError(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err))
|
||||
return
|
||||
}
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
writeOpenAIModelsResponse(c, response)
|
||||
}
|
||||
|
||||
func writeOpenAIModelsError(c *gin.Context, status int, errorType, message string) {
|
||||
c.JSON(status, gin.H{"error": gin.H{"type": errorType, "message": message}})
|
||||
}
|
||||
|
||||
func writeOpenAIModelsResponse(c *gin.Context, manifest *service.OpenAIModelsResponse) {
|
||||
if manifest.ETag != "" {
|
||||
c.Header("ETag", manifest.ETag)
|
||||
}
|
||||
if manifest.NotModified {
|
||||
c.Status(http.StatusNotModified)
|
||||
c.Writer.WriteHeaderNow()
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", manifest.Body)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func performOrdinaryPinnedModelsRequest(t *testing.T, codex *OpenAIGatewayHandler, group *service.Group, path, etag string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
h := &GatewayHandler{openAIGatewayService: codex.gatewayService, maxAccountSwitches: 3}
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
c.Request.Header.Set("If-None-Match", etag)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{GroupID: &group.ID, Group: group})
|
||||
h.Models(c)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func ordinaryPinnedModelIDs(t *testing.T, recorder *httptest.ResponseRecorder) []string {
|
||||
t.Helper()
|
||||
var response gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
require.NotNil(t, response.Data, "an empty catalog must be an array, not null")
|
||||
return modelIDsForTest(response.Data)
|
||||
}
|
||||
|
||||
func TestOrdinaryPinnedModelsUsesSelectedAccountsAndFinalETag(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(1, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, true),
|
||||
newPinnedCodexAccount(3, service.StatusActive, true, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
|
||||
1: `{"data":[{"id":"unselected"}]}`,
|
||||
2: `{"data":[{"id":"special-model","owned_by":"first","created":123},{"id":"gpt-image-1"}]}`,
|
||||
3: `{"data":[{"id":"special-model","owned_by":"second"},{"id":"text-embedding-3-large"}]}`,
|
||||
}}
|
||||
h := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{ID: 91, Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2, 3}}}
|
||||
first := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
|
||||
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
|
||||
require.Equal(t, []string{"special-model", "gpt-image-1", "text-embedding-3-large"}, ordinaryPinnedModelIDs(t, first))
|
||||
var response gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(first.Body.Bytes(), &response))
|
||||
require.Equal(t, "model", response.Data[0].Object)
|
||||
require.Equal(t, "first", response.Data[0].OwnedBy)
|
||||
require.EqualValues(t, 123, response.Data[0].Created)
|
||||
require.Equal(t, []int64{2, 3}, upstream.accountIDs())
|
||||
alias := performOrdinaryPinnedModelsRequest(t, h, group, "/models", first.Header().Get("ETag"))
|
||||
require.Equal(t, http.StatusNotModified, alias.Code)
|
||||
require.Empty(t, alias.Body.String())
|
||||
require.Equal(t, []int64{2, 3}, upstream.accountIDs(), "fresh requests must not hit upstream")
|
||||
|
||||
otherGroup := *group
|
||||
otherGroup.ID++
|
||||
otherGroup.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: []string{"text-embedding-3-large", "special-model"}}
|
||||
filtered := performOrdinaryPinnedModelsRequest(t, h, &otherGroup, "/v1/models", first.Header().Get("ETag"))
|
||||
require.Equal(t, http.StatusOK, filtered.Code)
|
||||
require.Equal(t, []string{"text-embedding-3-large", "special-model"}, ordinaryPinnedModelIDs(t, filtered))
|
||||
require.NotEqual(t, first.Header().Get("ETag"), filtered.Header().Get("ETag"))
|
||||
again := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
|
||||
require.Equal(t, first.Body.String(), again.Body.String(), "group filters must not modify account cache")
|
||||
require.Equal(t, []int64{2, 3}, upstream.accountIDs())
|
||||
}
|
||||
|
||||
func TestOrdinaryPinnedModelsFailureAndEmptyPolicies(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
accountIDs []int64
|
||||
bodies map[int64]string
|
||||
statuses map[int64]int
|
||||
fallback bool
|
||||
selected []string
|
||||
wantStatus int
|
||||
wantIDs []string
|
||||
}{
|
||||
{name: "partial failure", accountIDs: []int64{2, 3}, bodies: map[int64]string{2: `{"data":[{"id":"ok"}]}`}, statuses: map[int64]int{3: 503}, wantStatus: 200, wantIDs: []string{"ok"}},
|
||||
{name: "all failed", accountIDs: []int64{2, 3}, statuses: map[int64]int{2: 429, 3: 504}, wantStatus: 502},
|
||||
{name: "missing members", accountIDs: []int64{99}, wantStatus: 503},
|
||||
{name: "empty config", wantStatus: 503},
|
||||
{name: "fallback from missing members", accountIDs: []int64{99}, fallback: true, wantStatus: 200, wantIDs: []string{"from-scheduler"}},
|
||||
{name: "fallback from failure", accountIDs: []int64{2}, statuses: map[int64]int{2: 503}, fallback: true, wantStatus: 200, wantIDs: []string{"from-scheduler"}},
|
||||
{name: "valid empty", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":[]}`}, fallback: true, wantStatus: 200},
|
||||
{name: "filtered empty", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":[{"id":"ok"}]}`}, selected: []string{"absent"}, fallback: true, wantStatus: 200},
|
||||
{name: "invalid envelope", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"error":"denied"}`}, wantStatus: 502},
|
||||
{name: "null data", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":null}`}, wantStatus: 502},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(1, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(3, service.StatusActive, true, false),
|
||||
}
|
||||
bodies := map[int64]string{1: `{"data":[{"id":"from-scheduler"}]}`}
|
||||
for id, body := range tc.bodies {
|
||||
bodies[id] = body
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: bodies, statuses: tc.statuses}
|
||||
h := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{ID: 92, Platform: service.PlatformOpenAI,
|
||||
ModelAllowlist: service.GroupModelAllowlist{Enabled: len(tc.selected) > 0, Models: tc.selected},
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: tc.accountIDs, FallbackToScheduler: tc.fallback}}
|
||||
recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
|
||||
require.Equal(t, tc.wantStatus, recorder.Code, recorder.Body.String())
|
||||
if tc.wantStatus == 200 {
|
||||
require.Equal(t, len(tc.wantIDs), len(ordinaryPinnedModelIDs(t, recorder)))
|
||||
if len(tc.wantIDs) > 0 {
|
||||
require.Equal(t, tc.wantIDs, ordinaryPinnedModelIDs(t, recorder))
|
||||
} else {
|
||||
require.NotContains(t, upstream.accountIDs(), int64(1), "empty success must not trigger fallback")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinaryPinnedModelsSkipsPersistentlyUnavailableAccounts(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(1, service.StatusActive, true, true),
|
||||
newPinnedCodexAccount(2, service.StatusActive, false, false),
|
||||
newPinnedCodexAccount(3, service.StatusDisabled, true, false),
|
||||
newPinnedCodexAccount(4, service.StatusActive, true, false),
|
||||
}
|
||||
expired := time.Now().Add(-time.Hour)
|
||||
accounts[3].ExpiresAt = &expired
|
||||
accounts[3].AutoPauseOnExpired = true
|
||||
overloaded := time.Now().Add(time.Hour)
|
||||
accounts[0].OverloadUntil = &overloaded
|
||||
accounts[0].TempUnschedulableUntil = &overloaded
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{1: `{"data":[{"id":"available"}]}`}}
|
||||
h := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{ID: 93, Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{1, 2, 3, 4, 99}}}
|
||||
recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []int64{1}, upstream.accountIDs())
|
||||
}
|
||||
|
||||
func TestPinnedModelsAllowlistExpandsWildcardsForBothRepresentations(t *testing.T) {
|
||||
for _, codex := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "ordinary", true: "codex"}[codex], func(t *testing.T) {
|
||||
accounts := []service.Account{newPinnedCodexAccount(1, service.StatusActive, true, false)}
|
||||
accounts[0].Credentials["model_mapping"] = map[string]any{
|
||||
"public-a": "gpt-5.5", "public-b": "gpt-5.5", "blocked": "gpt-5.5",
|
||||
}
|
||||
body := `{"data":[{"id":"gpt-5.5","owned_by":"provider"}]}`
|
||||
if codex {
|
||||
body = `{"models":[{"slug":"gpt-5.5","context_window":424242}]}`
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{1: body}}
|
||||
h := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{ID: 95, Platform: service.PlatformOpenAI,
|
||||
ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"public-b", "public-*"}},
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{1}}}
|
||||
if codex {
|
||||
recorder := performPinnedCodexModelsRequest(t, h, group, "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []string{"public-b", "public-a"}, codexHandlerManifestSlugs(t, recorder))
|
||||
require.Contains(t, recorder.Body.String(), `"context_window":424242`)
|
||||
} else {
|
||||
recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []string{"public-b", "public-a"}, ordinaryPinnedModelIDs(t, recorder))
|
||||
require.Contains(t, recorder.Body.String(), `"owned_by":"provider"`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedModelsMappingFollowsUpstreamDiscoveryForBothRepresentations(t *testing.T) {
|
||||
for _, codex := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "ordinary", true: "codex"}[codex], func(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(1, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, false),
|
||||
}
|
||||
accounts[0].Credentials["model_mapping"] = map[string]any{"unselected-alias": "gpt-5.5"}
|
||||
accounts[1].Credentials["model_mapping"] = map[string]any{
|
||||
"public-alias": "gpt-5.5", "missing-alias": "missing-upstream", "custom-*": "gpt-5.5",
|
||||
}
|
||||
body := `{"data":[{"id":"gpt-5.5","owned_by":"provider","created":123},{"id":"unmapped-upstream"}]}`
|
||||
if codex {
|
||||
body = `{"models":[{"slug":"gpt-5.5","context_window":424242},{"slug":"unmapped-upstream"}]}`
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{2: body}}
|
||||
h := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{ID: 94, Platform: service.PlatformOpenAI,
|
||||
ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"custom-concrete", "public-alias", "unselected-alias", "missing-alias"}},
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2}}}
|
||||
var recorder *httptest.ResponseRecorder
|
||||
if codex {
|
||||
recorder = performPinnedCodexModelsRequest(t, h, group, "")
|
||||
} else {
|
||||
recorder = performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
|
||||
}
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []int64{2}, upstream.accountIDs(), "mappings must not bypass pinned discovery")
|
||||
if codex {
|
||||
require.Equal(t, []string{"custom-concrete", "public-alias"}, codexHandlerManifestSlugs(t, recorder))
|
||||
require.Contains(t, recorder.Body.String(), `"context_window":424242`)
|
||||
} else {
|
||||
require.Equal(t, []string{"custom-concrete", "public-alias"}, ordinaryPinnedModelIDs(t, recorder))
|
||||
require.Contains(t, recorder.Body.String(), `"owned_by":"provider"`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// astraProRequestBody is the inbound OpenAI Responses request that must survive
|
||||
// the account-switch loop unchanged: gpt-6-astra with the official top-level
|
||||
// reasoning.mode/effort preserved by the service guard
|
||||
// (normalizeOpenAIResponsesReasoningMode), never downgraded mode -> effort.
|
||||
const astraProRequestBody = `{"model":"gpt-6-astra","stream":false,"input":"hello","reasoning":{"mode":"pro","effort":"max"}}`
|
||||
|
||||
// astraProCodexURL is the OAuth codex Responses exit every forwarded attempt must hit.
|
||||
const astraProCodexURL = "https://chatgpt.com/backend-api/codex/responses"
|
||||
|
||||
// astraProSuccessResponse is the winning account's non-stream success; it echoes
|
||||
// the structured reasoning.mode=pro so the client response must keep mode=pro.
|
||||
const astraProSuccessResponse = `{"id":"resp_astra","model":"gpt-6-astra","status":"completed",` +
|
||||
`"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],` +
|
||||
`"usage":{"input_tokens":1,"output_tokens":2},` +
|
||||
`"reasoning":{"mode":"pro","effort":"max"}}`
|
||||
|
||||
// astraProForbiddenResponse: a plain upstream 403 that stays failover-eligible but
|
||||
// is not reclassified as access-state/credential failure nor a silent refusal.
|
||||
const astraProForbiddenResponse = `{"error":{"type":"forbidden_error","code":"forbidden","message":"Forbidden"}}`
|
||||
|
||||
// astraProCapturedUpstream records URL/accountID/body per forwarded attempt.
|
||||
type astraProCapturedUpstream struct {
|
||||
service.HTTPUpstream
|
||||
mu sync.Mutex
|
||||
urls []string
|
||||
accountIDs []int64
|
||||
bodies [][]byte
|
||||
answers []*http.Response
|
||||
}
|
||||
|
||||
func newAstraProCapturedUpstream(answers ...*http.Response) *astraProCapturedUpstream {
|
||||
return &astraProCapturedUpstream{answers: answers}
|
||||
}
|
||||
|
||||
func (u *astraProCapturedUpstream) Do(req *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
u.urls = append(u.urls, req.URL.String())
|
||||
u.accountIDs = append(u.accountIDs, accountID)
|
||||
u.bodies = append(u.bodies, body)
|
||||
var resp *http.Response
|
||||
if len(u.answers) > 0 {
|
||||
resp = u.answers[0]
|
||||
u.answers = u.answers[1:]
|
||||
}
|
||||
if resp == nil {
|
||||
resp = &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(bytes.NewReader(nil))}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (u *astraProCapturedUpstream) snapshot() ([]string, []int64, [][]byte) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
return append([]string(nil), u.urls...), append([]int64(nil), u.accountIDs...), append([][]byte(nil), u.bodies...)
|
||||
}
|
||||
|
||||
// newAstraProFailoverContext builds the gin context that drives handler.Responses
|
||||
// through the real failover loop for the astra pro request.
|
||||
func newAstraProFailoverContext(t *testing.T, body string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
groupID := int64(3132)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
ID: 99,
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
},
|
||||
User: &service.User{ID: 100},
|
||||
})
|
||||
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 100, Concurrency: 0})
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func astra403() *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(astraProForbiddenResponse)),
|
||||
}
|
||||
}
|
||||
|
||||
func astra200() *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(astraProSuccessResponse)),
|
||||
}
|
||||
}
|
||||
|
||||
// assertAstraProWire: every forwarded attempt must hit the codex responses URL and
|
||||
// keep model=gpt-6-astra + reasoning.mode=pro + effort=max (no silent downgrade).
|
||||
func assertAstraProWire(t *testing.T, urls []string, bodies [][]byte) {
|
||||
t.Helper()
|
||||
require.NotEmpty(t, urls, "failover loop must reach the upstream transport")
|
||||
for i, u := range urls {
|
||||
require.Equal(t, astraProCodexURL, u, "attempt %d must target the codex responses URL", i)
|
||||
}
|
||||
for i, body := range bodies {
|
||||
require.Equal(t, "gpt-6-astra", gjson.GetBytes(body, "model").String(), "attempt %d must keep model", i)
|
||||
require.Equal(t, "pro", gjson.GetBytes(body, "reasoning.mode").String(), "attempt %d must keep mode=pro", i)
|
||||
require.Equal(t, "max", gjson.GetBytes(body, "reasoning.effort").String(), "attempt %d must keep effort=max", i)
|
||||
}
|
||||
}
|
||||
|
||||
// assertAstraProAccountSwitch: the failover loop must have actually touched both
|
||||
// distinct OAuth fixture accounts (IDs 1 and 2), not replayed the same one.
|
||||
func assertAstraProAccountSwitch(t *testing.T, accountIDs []int64) {
|
||||
t.Helper()
|
||||
require.Len(t, accountIDs, 2, "failover must attempt two accounts")
|
||||
require.ElementsMatch(t, []int64{1, 2}, accountIDs, "both OAuth accounts must be reached")
|
||||
}
|
||||
|
||||
// 403 then a second OAuth account succeeding: wire bodies keep pro+max/model and the
|
||||
// winning account's structured reasoning.mode=pro reaches the client unchanged.
|
||||
func TestOpenAIGatewayHandlerResponses_AstraProFirst403SecondSucceeds(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
upstream := newAstraProCapturedUpstream(astra403(), astra200())
|
||||
handler := newOpenAIResponsesFailoverTestHandler(t, upstream)
|
||||
c, rec := newAstraProFailoverContext(t, astraProRequestBody)
|
||||
|
||||
handler.Responses(c)
|
||||
|
||||
urls, accountIDs, bodies := upstream.snapshot()
|
||||
assertAstraProWire(t, urls, bodies)
|
||||
assertAstraProAccountSwitch(t, accountIDs)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "winning account must yield a 200 to the client")
|
||||
require.Equal(t, "completed", gjson.GetBytes(rec.Body.Bytes(), "status").String())
|
||||
require.Equal(t, "pro", gjson.GetBytes(rec.Body.Bytes(), "reasoning.mode").String(), "client must keep the winning response's mode=pro")
|
||||
require.Equal(t, "max", gjson.GetBytes(rec.Body.Bytes(), "reasoning.effort").String())
|
||||
}
|
||||
|
||||
// both OAuth accounts 403: exhausted keeps every wire body pro+max/model, is not 200,
|
||||
// and surfaces the existing-policy 502 upstream_error (unchanged 403-masking policy).
|
||||
func TestOpenAIGatewayHandlerResponses_AstraProBoth403NoDowngrade(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
upstream := newAstraProCapturedUpstream(astra403(), astra403())
|
||||
handler := newOpenAIResponsesFailoverTestHandler(t, upstream)
|
||||
c, rec := newAstraProFailoverContext(t, astraProRequestBody)
|
||||
|
||||
handler.Responses(c)
|
||||
|
||||
urls, accountIDs, bodies := upstream.snapshot()
|
||||
assertAstraProWire(t, urls, bodies)
|
||||
assertAstraProAccountSwitch(t, accountIDs)
|
||||
require.NotEqual(t, http.StatusOK, rec.Code, "all-403 must never surface a 200 to the client")
|
||||
require.Equal(t, http.StatusBadGateway, rec.Code)
|
||||
require.Equal(t, "upstream_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
|
||||
}
|
||||
|
||||
// passthrough -> non-passthrough drops only the encrypted *input* reasoning item;
|
||||
// the official top-level reasoning.mode/effort survive verbatim.
|
||||
func TestDeriveOpenAIForwardAttemptBody_AstraCrossModeKeepsTopLevelReasoningMode(t *testing.T) {
|
||||
h := &OpenAIGatewayHandler{}
|
||||
canonical := []byte(`{"model":"gpt-6-astra","stream":false,"reasoning":{"mode":"pro","effort":"max"},"input":[` +
|
||||
`{"type":"message","role":"user","content":"hello"},` +
|
||||
`{"type":"reasoning","id":"rs_kiro_abc","encrypted_content":"ENC_BLOB","summary":[{"type":"summary_text","text":"thinking"}]}` +
|
||||
`]}`)
|
||||
canonicalSnapshot := append([]byte(nil), canonical...)
|
||||
state := &openAIPassthroughFailoverState{}
|
||||
|
||||
kiro := newOpenAIPassthroughAccount(1, true) // passthrough first
|
||||
bedrock := newOpenAIPassthroughAccount(2, false) // non-passthrough after
|
||||
|
||||
firstBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state)
|
||||
require.Equal(t, 1, reasoningItemCount(t, firstBody), "first passthrough attempt keeps the encrypted input reasoning item")
|
||||
require.Equal(t, "pro", gjson.GetBytes(firstBody, "reasoning.mode").String())
|
||||
require.Equal(t, "max", gjson.GetBytes(firstBody, "reasoning.effort").String())
|
||||
|
||||
secondBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrock, state)
|
||||
require.Equal(t, 0, reasoningItemCount(t, secondBody), "cross-mode attempt drops the encrypted input reasoning item")
|
||||
require.Equal(t, "pro", gjson.GetBytes(secondBody, "reasoning.mode").String(), "mode=pro must survive cross-mode sanitization")
|
||||
require.Equal(t, "max", gjson.GetBytes(secondBody, "reasoning.effort").String(), "effort=max must survive cross-mode sanitization")
|
||||
require.Equal(t, "gpt-6-astra", gjson.GetBytes(secondBody, "model").String())
|
||||
require.JSONEq(t, string(canonicalSnapshot), string(canonical), "canonical forwardBody must never be mutated")
|
||||
require.True(t, gjson.GetBytes(canonical, "input.1.encrypted_content").Exists())
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// 上以 OpenAI 兼容错误结构返回 HTTP 400。这些用例在 handler 的 service_tier
|
||||
// 校验处短路,不会进入账号选择/重试。
|
||||
//
|
||||
// 合法值(fast/priority/flex/auto/default/scale)与省略/null 的接受语义由
|
||||
// 合法值(fast/priority/flex/auto/default/scale/ultrafast)与省略/null 的接受语义由
|
||||
// service 层纯校验函数 TestValidateOpenAIServiceTierField 覆盖,避免 handler
|
||||
// 测试走入真实账号选择/重试路径。
|
||||
|
||||
|
||||
@@ -2189,9 +2189,17 @@ func classifyOpsErrorLog(c *gin.Context, errType, message, code string, status i
|
||||
routingCapacityLimited := isOpsRoutingCapacityLimited(c)
|
||||
clientBusinessLimited := service.HasOpsClientBusinessLimited(c)
|
||||
localModelConfiguration := clientBusinessLimited && service.OpsClientBusinessLimitedReason(c) == service.OpsClientBusinessLimitedReasonLocalModelConfiguration
|
||||
// 分组模型白名单的入口拒绝发生在调度之前(本地面请求策略,业务限流原因与
|
||||
// 账号模型映射复用 local_model_configuration):保持 classifyOpsPhase 的
|
||||
// 自然分类(not_found/invalid_request → request,owner=client),不落到
|
||||
// routing;上游归因仍由 suppressOpsUpstreamAttributionForLocalModelConfiguration 清空。
|
||||
ingressModelNotAllowed := false
|
||||
if reason, rejected := middleware2.GetIngressRejectReason(c); rejected && reason == middleware2.IngressRejectModelNotAllowed {
|
||||
ingressModelNotAllowed = true
|
||||
}
|
||||
upstreamError := hasOpsUpstreamErrorContext(c)
|
||||
accountAuthFailure := hasOpsAccountAuthFailure(c)
|
||||
if localModelConfiguration {
|
||||
if localModelConfiguration && !ingressModelNotAllowed {
|
||||
phase = "routing"
|
||||
} else if accountAuthFailure && !routingCapacityLimited {
|
||||
phase = "account_auth"
|
||||
|
||||
@@ -1954,3 +1954,45 @@ func TestGetOpsAPIKeyPrefersPrimaryContextKey(t *testing.T) {
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, int64(1), got.ID, "已鉴权请求应优先使用正式 api key")
|
||||
}
|
||||
|
||||
// 分组模型白名单入口拒绝:业务限流原因复用 local_model_configuration,但
|
||||
// 携带 ingress 拒绝原因 model_not_allowed,阶段应保持自然分类(request /
|
||||
// client),不落到 routing。
|
||||
func TestClassifyOpsIngressModelNotAllowedKeepsRequestPhase(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
|
||||
middleware2.MarkIngressRejected(c, middleware2.IngressRejectModelNotAllowed)
|
||||
|
||||
phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(
|
||||
c,
|
||||
"not_found_error",
|
||||
`Model "gpt-4.1" is not available for this group`,
|
||||
"model_not_found",
|
||||
http.StatusNotFound,
|
||||
)
|
||||
|
||||
require.Equal(t, "request", phase)
|
||||
require.True(t, isBusinessLimited)
|
||||
require.Equal(t, "client", errorOwner)
|
||||
require.Equal(t, "client_request", errorSource)
|
||||
}
|
||||
|
||||
// 调度阶段的账号模型映射拒绝(无 ingress 标记)仍归类为 routing。
|
||||
func TestClassifyOpsLocalModelConfigurationWithoutIngressMarkStaysRouting(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
|
||||
|
||||
phase, isBusinessLimited, errorOwner, _ := classifyOpsErrorLog(
|
||||
c,
|
||||
"model_not_found",
|
||||
"Model \"gpt-missing\" is not supported by any configured account in this group",
|
||||
"",
|
||||
http.StatusNotFound,
|
||||
)
|
||||
|
||||
require.Equal(t, "routing", phase)
|
||||
require.True(t, isBusinessLimited)
|
||||
require.Equal(t, "platform", errorOwner)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
|
||||
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
|
||||
ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ type responsesFailedEvent struct {
|
||||
// 返回 false 表示 writer 不支持 Flusher,无法以 SSE 形式回报错误;
|
||||
// 此时 caller 也无法回退到 JSON(HTTP 200 已固化),通常意味着连接已经损坏,
|
||||
// 应当让请求处理函数 return,由上层关闭连接。
|
||||
func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool {
|
||||
func writeResponsesFailedSSE(c *gin.Context, errType, code, message string) bool {
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
return false
|
||||
@@ -72,7 +72,7 @@ func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool {
|
||||
Status: "failed",
|
||||
Output: []any{},
|
||||
Error: responsesFailedError{
|
||||
Code: mapResponsesErrorCode(errType),
|
||||
Code: mapResponsesErrorCode(errType, code),
|
||||
Message: message,
|
||||
},
|
||||
},
|
||||
@@ -155,7 +155,10 @@ func requestModel(c *gin.Context) string {
|
||||
|
||||
// mapResponsesErrorCode 把内部 errType 映射为 Responses 协议常见的 error.code。
|
||||
// 无明确映射时原样返回,保证至少可读。
|
||||
func mapResponsesErrorCode(errType string) string {
|
||||
func mapResponsesErrorCode(errType, code string) string {
|
||||
if code != "" {
|
||||
return code
|
||||
}
|
||||
switch errType {
|
||||
case "rate_limit_error":
|
||||
return "rate_limit_exceeded"
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// Regression for the production incident on 2026-05-24 around 9:13 CST:
|
||||
@@ -77,6 +78,60 @@ func TestOpenAIHandleStreamingAwareError_ResponsesStreamingEmitsResponseFailed(t
|
||||
assert.Equal(t, "Concurrency limit exceeded for user, please retry later", errObj["message"])
|
||||
}
|
||||
|
||||
func TestOpenAIAdmissionError_SynchronousNonResponsesIncludesGatewayCode(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointChatCompletions)
|
||||
h := &OpenAIGatewayHandler{}
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
|
||||
gatewayQueueFullCode, "Too many pending requests, please retry later", false, false)
|
||||
|
||||
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
||||
assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
|
||||
assert.Equal(t, gatewayQueueFullCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String())
|
||||
}
|
||||
|
||||
func TestOpenAIAdmissionError_SynchronousResponsesIncludesGatewayCode(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointResponses)
|
||||
h := &OpenAIGatewayHandler{}
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
|
||||
gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", false, false)
|
||||
|
||||
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
||||
assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
|
||||
assert.Equal(t, gatewayConcurrencyLimitCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String())
|
||||
}
|
||||
|
||||
func TestOpenAIAdmissionError_ResponsesFailedIncludesGatewayCode(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointResponses)
|
||||
h := &OpenAIGatewayHandler{}
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
|
||||
gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", true, false)
|
||||
|
||||
_, errObj := parseResponsesFailedSSE(t, w.Body.String())
|
||||
assert.Equal(t, gatewayConcurrencyLimitCode, errObj["code"])
|
||||
}
|
||||
|
||||
func TestOpenAIAdmissionError_StreamingNonResponsesIncludesGatewayCode(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointChatCompletions)
|
||||
h := &OpenAIGatewayHandler{}
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
|
||||
gatewayQueueFullCode, "Too many pending requests, please retry later", true, false)
|
||||
|
||||
body := w.Body.String()
|
||||
assert.True(t, strings.HasPrefix(body, "event: error\n"))
|
||||
payload := body[strings.Index(body, "{"):]
|
||||
assert.Equal(t, "rate_limit_error", gjson.Get(payload, "error.type").String())
|
||||
assert.Equal(t, gatewayQueueFullCode, gjson.Get(payload, "error.code").String())
|
||||
}
|
||||
|
||||
func TestOpenAIUpstreamRateLimitWithoutLocalCodeRemainsUnchanged(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointChatCompletions)
|
||||
h := &OpenAIGatewayHandler{}
|
||||
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "upstream limited", false)
|
||||
|
||||
assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
|
||||
assert.False(t, gjson.GetBytes(w.Body.Bytes(), "error.code").Exists())
|
||||
}
|
||||
|
||||
// 当 setOpsRequestContext 写过 model,合成事件应回填该字段(与 codebase 已有 makeResponsesCompletedEvent 对齐)。
|
||||
func TestOpenAIHandleStreamingAwareError_ResponsesStreamingIncludesModel(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointResponses)
|
||||
@@ -164,6 +219,41 @@ func TestGatewayHandleStreamingAwareError_ResponsesStreamingEmitsResponseFailed(
|
||||
assert.Equal(t, "upstream gone", errObj["message"])
|
||||
}
|
||||
|
||||
func TestGatewayAdmissionError_SynchronousIncludesGatewayCode(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointMessages)
|
||||
h := &GatewayHandler{}
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
|
||||
gatewayQueueFullCode, "Too many pending requests, please retry later", false)
|
||||
|
||||
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
||||
assert.Equal(t, "error", gjson.GetBytes(w.Body.Bytes(), "type").String())
|
||||
assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
|
||||
assert.Equal(t, gatewayQueueFullCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String())
|
||||
}
|
||||
|
||||
func TestGatewayAdmissionError_MessagesStreamingIncludesGatewayCode(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointMessages)
|
||||
h := &GatewayHandler{}
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
|
||||
gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", true)
|
||||
|
||||
body := w.Body.String()
|
||||
assert.True(t, strings.HasPrefix(body, `data: {"type":"error"`))
|
||||
payload := body[strings.Index(body, "{"):]
|
||||
assert.Equal(t, "rate_limit_error", gjson.Get(payload, "error.type").String())
|
||||
assert.Equal(t, gatewayConcurrencyLimitCode, gjson.Get(payload, "error.code").String())
|
||||
}
|
||||
|
||||
func TestGatewayAdmissionError_BareResponsesFailedIncludesGatewayCode(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, "/responses")
|
||||
h := &GatewayHandler{}
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
|
||||
gatewayQueueFullCode, "Too many pending requests, please retry later", true)
|
||||
|
||||
_, errObj := parseResponsesFailedSSE(t, w.Body.String())
|
||||
assert.Equal(t, gatewayQueueFullCode, errObj["code"])
|
||||
}
|
||||
|
||||
// Gateway handler: /v1/messages preserves the legacy data:{type:error,...} format
|
||||
// (Anthropic spec accepts a type:"error" stream event).
|
||||
func TestGatewayHandleStreamingAwareError_MessagesStreamingKeepsLegacy(t *testing.T) {
|
||||
@@ -264,6 +354,7 @@ func TestMapResponsesErrorCode(t *testing.T) {
|
||||
{"custom_thing", "custom_thing"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
assert.Equal(t, tc.out, mapResponsesErrorCode(tc.in), "in=%q", tc.in)
|
||||
assert.Equal(t, tc.out, mapResponsesErrorCode(tc.in, ""), "in=%q", tc.in)
|
||||
}
|
||||
assert.Equal(t, gatewayQueueFullCode, mapResponsesErrorCode("rate_limit_error", gatewayQueueFullCode))
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ var ProviderSet = wire.NewSet(
|
||||
// Admin handlers
|
||||
admin.NewDashboardHandler,
|
||||
admin.NewUserHandler,
|
||||
admin.NewGroupHandler,
|
||||
admin.NewGroupHandlerWithConfig,
|
||||
admin.ProvideAccountHandler,
|
||||
admin.NewAnnouncementHandler,
|
||||
admin.NewDataManagementHandler,
|
||||
|
||||
@@ -44,6 +44,7 @@ const (
|
||||
PlatformKimi = domain.PlatformKimi
|
||||
PlatformZhipu = domain.PlatformZhipu
|
||||
PlatformDeepseek = domain.PlatformDeepseek
|
||||
PlatformMiniMax = domain.PlatformMiniMax
|
||||
)
|
||||
|
||||
// AllPlatforms 返回所有支持的平台列表
|
||||
@@ -57,6 +58,7 @@ func AllPlatforms() []string {
|
||||
PlatformKimi,
|
||||
PlatformZhipu,
|
||||
PlatformDeepseek,
|
||||
PlatformMiniMax,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user