feat(group): 支持自定义 /v1/models 模型列表
This commit is contained in:
parent
a391635191
commit
f597c1581b
@ -85,6 +85,8 @@ type Group struct {
|
|||||||
DefaultMappedModel string `json:"default_mapped_model,omitempty"`
|
DefaultMappedModel string `json:"default_mapped_model,omitempty"`
|
||||||
// OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型
|
// OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型
|
||||||
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"`
|
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"`
|
||||||
|
// 自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度
|
||||||
|
ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config,omitempty"`
|
||||||
// 分组 RPM 上限,0 表示不限制;设置后接管该分组用户的限流
|
// 分组 RPM 上限,0 表示不限制;设置后接管该分组用户的限流
|
||||||
RpmLimit int `json:"rpm_limit,omitempty"`
|
RpmLimit int `json:"rpm_limit,omitempty"`
|
||||||
// Edges holds the relations/edges for other nodes in the graph.
|
// Edges holds the relations/edges for other nodes in the graph.
|
||||||
@ -193,7 +195,7 @@ func (*Group) scanValues(columns []string) ([]any, error) {
|
|||||||
values := make([]any, len(columns))
|
values := make([]any, len(columns))
|
||||||
for i := range columns {
|
for i := range columns {
|
||||||
switch columns[i] {
|
switch columns[i] {
|
||||||
case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig:
|
case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig:
|
||||||
values[i] = new([]byte)
|
values[i] = new([]byte)
|
||||||
case group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldImageRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet:
|
case group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldImageRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet:
|
||||||
values[i] = new(sql.NullBool)
|
values[i] = new(sql.NullBool)
|
||||||
@ -440,6 +442,14 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
|||||||
return fmt.Errorf("unmarshal field messages_dispatch_model_config: %w", err)
|
return fmt.Errorf("unmarshal field messages_dispatch_model_config: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case group.FieldModelsListConfig:
|
||||||
|
if value, ok := values[i].(*[]byte); !ok {
|
||||||
|
return fmt.Errorf("unexpected type %T for field models_list_config", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
case group.FieldRpmLimit:
|
case group.FieldRpmLimit:
|
||||||
if value, ok := values[i].(*sql.NullInt64); !ok {
|
if value, ok := values[i].(*sql.NullInt64); !ok {
|
||||||
return fmt.Errorf("unexpected type %T for field rpm_limit", values[i])
|
return fmt.Errorf("unexpected type %T for field rpm_limit", values[i])
|
||||||
@ -641,6 +651,9 @@ func (_m *Group) String() string {
|
|||||||
builder.WriteString("messages_dispatch_model_config=")
|
builder.WriteString("messages_dispatch_model_config=")
|
||||||
builder.WriteString(fmt.Sprintf("%v", _m.MessagesDispatchModelConfig))
|
builder.WriteString(fmt.Sprintf("%v", _m.MessagesDispatchModelConfig))
|
||||||
builder.WriteString(", ")
|
builder.WriteString(", ")
|
||||||
|
builder.WriteString("models_list_config=")
|
||||||
|
builder.WriteString(fmt.Sprintf("%v", _m.ModelsListConfig))
|
||||||
|
builder.WriteString(", ")
|
||||||
builder.WriteString("rpm_limit=")
|
builder.WriteString("rpm_limit=")
|
||||||
builder.WriteString(fmt.Sprintf("%v", _m.RpmLimit))
|
builder.WriteString(fmt.Sprintf("%v", _m.RpmLimit))
|
||||||
builder.WriteByte(')')
|
builder.WriteByte(')')
|
||||||
|
|||||||
@ -82,6 +82,8 @@ const (
|
|||||||
FieldDefaultMappedModel = "default_mapped_model"
|
FieldDefaultMappedModel = "default_mapped_model"
|
||||||
// FieldMessagesDispatchModelConfig holds the string denoting the messages_dispatch_model_config field in the database.
|
// FieldMessagesDispatchModelConfig holds the string denoting the messages_dispatch_model_config field in the database.
|
||||||
FieldMessagesDispatchModelConfig = "messages_dispatch_model_config"
|
FieldMessagesDispatchModelConfig = "messages_dispatch_model_config"
|
||||||
|
// FieldModelsListConfig holds the string denoting the models_list_config field in the database.
|
||||||
|
FieldModelsListConfig = "models_list_config"
|
||||||
// FieldRpmLimit holds the string denoting the rpm_limit field in the database.
|
// FieldRpmLimit holds the string denoting the rpm_limit field in the database.
|
||||||
FieldRpmLimit = "rpm_limit"
|
FieldRpmLimit = "rpm_limit"
|
||||||
// EdgeAPIKeys holds the string denoting the api_keys edge name in mutations.
|
// EdgeAPIKeys holds the string denoting the api_keys edge name in mutations.
|
||||||
@ -192,6 +194,7 @@ var Columns = []string{
|
|||||||
FieldRequirePrivacySet,
|
FieldRequirePrivacySet,
|
||||||
FieldDefaultMappedModel,
|
FieldDefaultMappedModel,
|
||||||
FieldMessagesDispatchModelConfig,
|
FieldMessagesDispatchModelConfig,
|
||||||
|
FieldModelsListConfig,
|
||||||
FieldRpmLimit,
|
FieldRpmLimit,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,6 +279,8 @@ var (
|
|||||||
DefaultMappedModelValidator func(string) error
|
DefaultMappedModelValidator func(string) error
|
||||||
// DefaultMessagesDispatchModelConfig holds the default value on creation for the "messages_dispatch_model_config" field.
|
// DefaultMessagesDispatchModelConfig holds the default value on creation for the "messages_dispatch_model_config" field.
|
||||||
DefaultMessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig
|
DefaultMessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig
|
||||||
|
// DefaultModelsListConfig holds the default value on creation for the "models_list_config" field.
|
||||||
|
DefaultModelsListConfig domain.GroupModelsListConfig
|
||||||
// DefaultRpmLimit holds the default value on creation for the "rpm_limit" field.
|
// DefaultRpmLimit holds the default value on creation for the "rpm_limit" field.
|
||||||
DefaultRpmLimit int
|
DefaultRpmLimit int
|
||||||
)
|
)
|
||||||
|
|||||||
@ -467,6 +467,20 @@ func (_c *GroupCreate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMe
|
|||||||
return _c
|
return _c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetModelsListConfig sets the "models_list_config" field.
|
||||||
|
func (_c *GroupCreate) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupCreate {
|
||||||
|
_c.mutation.SetModelsListConfig(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 {
|
||||||
|
if v != nil {
|
||||||
|
_c.SetModelsListConfig(*v)
|
||||||
|
}
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
// SetRpmLimit sets the "rpm_limit" field.
|
// SetRpmLimit sets the "rpm_limit" field.
|
||||||
func (_c *GroupCreate) SetRpmLimit(v int) *GroupCreate {
|
func (_c *GroupCreate) SetRpmLimit(v int) *GroupCreate {
|
||||||
_c.mutation.SetRpmLimit(v)
|
_c.mutation.SetRpmLimit(v)
|
||||||
@ -698,6 +712,10 @@ func (_c *GroupCreate) defaults() error {
|
|||||||
v := group.DefaultMessagesDispatchModelConfig
|
v := group.DefaultMessagesDispatchModelConfig
|
||||||
_c.mutation.SetMessagesDispatchModelConfig(v)
|
_c.mutation.SetMessagesDispatchModelConfig(v)
|
||||||
}
|
}
|
||||||
|
if _, ok := _c.mutation.ModelsListConfig(); !ok {
|
||||||
|
v := group.DefaultModelsListConfig
|
||||||
|
_c.mutation.SetModelsListConfig(v)
|
||||||
|
}
|
||||||
if _, ok := _c.mutation.RpmLimit(); !ok {
|
if _, ok := _c.mutation.RpmLimit(); !ok {
|
||||||
v := group.DefaultRpmLimit
|
v := group.DefaultRpmLimit
|
||||||
_c.mutation.SetRpmLimit(v)
|
_c.mutation.SetRpmLimit(v)
|
||||||
@ -798,6 +816,9 @@ func (_c *GroupCreate) check() error {
|
|||||||
if _, ok := _c.mutation.MessagesDispatchModelConfig(); !ok {
|
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"`)}
|
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.RpmLimit(); !ok {
|
if _, ok := _c.mutation.RpmLimit(); !ok {
|
||||||
return &ValidationError{Name: "rpm_limit", err: errors.New(`ent: missing required field "Group.rpm_limit"`)}
|
return &ValidationError{Name: "rpm_limit", err: errors.New(`ent: missing required field "Group.rpm_limit"`)}
|
||||||
}
|
}
|
||||||
@ -960,6 +981,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
|||||||
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
|
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
|
||||||
_node.MessagesDispatchModelConfig = 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.RpmLimit(); ok {
|
if value, ok := _c.mutation.RpmLimit(); ok {
|
||||||
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
|
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
|
||||||
_node.RpmLimit = value
|
_node.RpmLimit = value
|
||||||
@ -1642,6 +1667,18 @@ func (u *GroupUpsert) UpdateMessagesDispatchModelConfig() *GroupUpsert {
|
|||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetModelsListConfig sets the "models_list_config" field.
|
||||||
|
func (u *GroupUpsert) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsert {
|
||||||
|
u.Set(group.FieldModelsListConfig, 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)
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
// SetRpmLimit sets the "rpm_limit" field.
|
// SetRpmLimit sets the "rpm_limit" field.
|
||||||
func (u *GroupUpsert) SetRpmLimit(v int) *GroupUpsert {
|
func (u *GroupUpsert) SetRpmLimit(v int) *GroupUpsert {
|
||||||
u.Set(group.FieldRpmLimit, v)
|
u.Set(group.FieldRpmLimit, v)
|
||||||
@ -2314,6 +2351,20 @@ func (u *GroupUpsertOne) UpdateMessagesDispatchModelConfig() *GroupUpsertOne {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetModelsListConfig sets the "models_list_config" field.
|
||||||
|
func (u *GroupUpsertOne) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsertOne {
|
||||||
|
return u.Update(func(s *GroupUpsert) {
|
||||||
|
s.SetModelsListConfig(v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
|
||||||
|
func (u *GroupUpsertOne) UpdateModelsListConfig() *GroupUpsertOne {
|
||||||
|
return u.Update(func(s *GroupUpsert) {
|
||||||
|
s.UpdateModelsListConfig()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// SetRpmLimit sets the "rpm_limit" field.
|
// SetRpmLimit sets the "rpm_limit" field.
|
||||||
func (u *GroupUpsertOne) SetRpmLimit(v int) *GroupUpsertOne {
|
func (u *GroupUpsertOne) SetRpmLimit(v int) *GroupUpsertOne {
|
||||||
return u.Update(func(s *GroupUpsert) {
|
return u.Update(func(s *GroupUpsert) {
|
||||||
@ -3155,6 +3206,20 @@ func (u *GroupUpsertBulk) UpdateMessagesDispatchModelConfig() *GroupUpsertBulk {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetModelsListConfig sets the "models_list_config" field.
|
||||||
|
func (u *GroupUpsertBulk) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsertBulk {
|
||||||
|
return u.Update(func(s *GroupUpsert) {
|
||||||
|
s.SetModelsListConfig(v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
|
||||||
|
func (u *GroupUpsertBulk) UpdateModelsListConfig() *GroupUpsertBulk {
|
||||||
|
return u.Update(func(s *GroupUpsert) {
|
||||||
|
s.UpdateModelsListConfig()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// SetRpmLimit sets the "rpm_limit" field.
|
// SetRpmLimit sets the "rpm_limit" field.
|
||||||
func (u *GroupUpsertBulk) SetRpmLimit(v int) *GroupUpsertBulk {
|
func (u *GroupUpsertBulk) SetRpmLimit(v int) *GroupUpsertBulk {
|
||||||
return u.Update(func(s *GroupUpsert) {
|
return u.Update(func(s *GroupUpsert) {
|
||||||
|
|||||||
@ -616,6 +616,20 @@ func (_u *GroupUpdate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMe
|
|||||||
return _u
|
return _u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetModelsListConfig sets the "models_list_config" field.
|
||||||
|
func (_u *GroupUpdate) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpdate {
|
||||||
|
_u.mutation.SetModelsListConfig(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 {
|
||||||
|
if v != nil {
|
||||||
|
_u.SetModelsListConfig(*v)
|
||||||
|
}
|
||||||
|
return _u
|
||||||
|
}
|
||||||
|
|
||||||
// SetRpmLimit sets the "rpm_limit" field.
|
// SetRpmLimit sets the "rpm_limit" field.
|
||||||
func (_u *GroupUpdate) SetRpmLimit(v int) *GroupUpdate {
|
func (_u *GroupUpdate) SetRpmLimit(v int) *GroupUpdate {
|
||||||
_u.mutation.ResetRpmLimit()
|
_u.mutation.ResetRpmLimit()
|
||||||
@ -1112,6 +1126,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
|||||||
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
|
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
|
||||||
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
|
_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.RpmLimit(); ok {
|
if value, ok := _u.mutation.RpmLimit(); ok {
|
||||||
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
|
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
|
||||||
}
|
}
|
||||||
@ -2012,6 +2029,20 @@ func (_u *GroupUpdateOne) SetNillableMessagesDispatchModelConfig(v *domain.OpenA
|
|||||||
return _u
|
return _u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetModelsListConfig sets the "models_list_config" field.
|
||||||
|
func (_u *GroupUpdateOne) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpdateOne {
|
||||||
|
_u.mutation.SetModelsListConfig(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 {
|
||||||
|
if v != nil {
|
||||||
|
_u.SetModelsListConfig(*v)
|
||||||
|
}
|
||||||
|
return _u
|
||||||
|
}
|
||||||
|
|
||||||
// SetRpmLimit sets the "rpm_limit" field.
|
// SetRpmLimit sets the "rpm_limit" field.
|
||||||
func (_u *GroupUpdateOne) SetRpmLimit(v int) *GroupUpdateOne {
|
func (_u *GroupUpdateOne) SetRpmLimit(v int) *GroupUpdateOne {
|
||||||
_u.mutation.ResetRpmLimit()
|
_u.mutation.ResetRpmLimit()
|
||||||
@ -2538,6 +2569,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
|||||||
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
|
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
|
||||||
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
|
_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.RpmLimit(); ok {
|
if value, ok := _u.mutation.RpmLimit(); ok {
|
||||||
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
|
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -669,6 +669,7 @@ var (
|
|||||||
{Name: "require_privacy_set", Type: field.TypeBool, Default: false},
|
{Name: "require_privacy_set", Type: field.TypeBool, Default: false},
|
||||||
{Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""},
|
{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: "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: "rpm_limit", Type: field.TypeInt, Default: 0},
|
{Name: "rpm_limit", Type: field.TypeInt, Default: 0},
|
||||||
}
|
}
|
||||||
// GroupsTable holds the schema information for the "groups" table.
|
// GroupsTable holds the schema information for the "groups" table.
|
||||||
|
|||||||
@ -14901,6 +14901,7 @@ type GroupMutation struct {
|
|||||||
require_privacy_set *bool
|
require_privacy_set *bool
|
||||||
default_mapped_model *string
|
default_mapped_model *string
|
||||||
messages_dispatch_model_config *domain.OpenAIMessagesDispatchModelConfig
|
messages_dispatch_model_config *domain.OpenAIMessagesDispatchModelConfig
|
||||||
|
models_list_config *domain.GroupModelsListConfig
|
||||||
rpm_limit *int
|
rpm_limit *int
|
||||||
addrpm_limit *int
|
addrpm_limit *int
|
||||||
clearedFields map[string]struct{}
|
clearedFields map[string]struct{}
|
||||||
@ -16619,6 +16620,42 @@ func (m *GroupMutation) ResetMessagesDispatchModelConfig() {
|
|||||||
m.messages_dispatch_model_config = nil
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
if v == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return *v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// OldModelsListConfig returns the old "models_list_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) OldModelsListConfig(ctx context.Context) (v domain.GroupModelsListConfig, err error) {
|
||||||
|
if !m.op.Is(OpUpdateOne) {
|
||||||
|
return v, errors.New("OldModelsListConfig 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")
|
||||||
|
}
|
||||||
|
oldValue, err := m.oldValue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return v, fmt.Errorf("querying old value for OldModelsListConfig: %w", err)
|
||||||
|
}
|
||||||
|
return oldValue.ModelsListConfig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetModelsListConfig resets all changes to the "models_list_config" field.
|
||||||
|
func (m *GroupMutation) ResetModelsListConfig() {
|
||||||
|
m.models_list_config = nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetRpmLimit sets the "rpm_limit" field.
|
// SetRpmLimit sets the "rpm_limit" field.
|
||||||
func (m *GroupMutation) SetRpmLimit(i int) {
|
func (m *GroupMutation) SetRpmLimit(i int) {
|
||||||
m.rpm_limit = &i
|
m.rpm_limit = &i
|
||||||
@ -17033,7 +17070,7 @@ func (m *GroupMutation) Type() string {
|
|||||||
// order to get all numeric fields that were incremented/decremented, call
|
// order to get all numeric fields that were incremented/decremented, call
|
||||||
// AddedFields().
|
// AddedFields().
|
||||||
func (m *GroupMutation) Fields() []string {
|
func (m *GroupMutation) Fields() []string {
|
||||||
fields := make([]string, 0, 34)
|
fields := make([]string, 0, 35)
|
||||||
if m.created_at != nil {
|
if m.created_at != nil {
|
||||||
fields = append(fields, group.FieldCreatedAt)
|
fields = append(fields, group.FieldCreatedAt)
|
||||||
}
|
}
|
||||||
@ -17133,6 +17170,9 @@ func (m *GroupMutation) Fields() []string {
|
|||||||
if m.messages_dispatch_model_config != nil {
|
if m.messages_dispatch_model_config != nil {
|
||||||
fields = append(fields, group.FieldMessagesDispatchModelConfig)
|
fields = append(fields, group.FieldMessagesDispatchModelConfig)
|
||||||
}
|
}
|
||||||
|
if m.models_list_config != nil {
|
||||||
|
fields = append(fields, group.FieldModelsListConfig)
|
||||||
|
}
|
||||||
if m.rpm_limit != nil {
|
if m.rpm_limit != nil {
|
||||||
fields = append(fields, group.FieldRpmLimit)
|
fields = append(fields, group.FieldRpmLimit)
|
||||||
}
|
}
|
||||||
@ -17210,6 +17250,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
|||||||
return m.DefaultMappedModel()
|
return m.DefaultMappedModel()
|
||||||
case group.FieldMessagesDispatchModelConfig:
|
case group.FieldMessagesDispatchModelConfig:
|
||||||
return m.MessagesDispatchModelConfig()
|
return m.MessagesDispatchModelConfig()
|
||||||
|
case group.FieldModelsListConfig:
|
||||||
|
return m.ModelsListConfig()
|
||||||
case group.FieldRpmLimit:
|
case group.FieldRpmLimit:
|
||||||
return m.RpmLimit()
|
return m.RpmLimit()
|
||||||
}
|
}
|
||||||
@ -17287,6 +17329,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
|||||||
return m.OldDefaultMappedModel(ctx)
|
return m.OldDefaultMappedModel(ctx)
|
||||||
case group.FieldMessagesDispatchModelConfig:
|
case group.FieldMessagesDispatchModelConfig:
|
||||||
return m.OldMessagesDispatchModelConfig(ctx)
|
return m.OldMessagesDispatchModelConfig(ctx)
|
||||||
|
case group.FieldModelsListConfig:
|
||||||
|
return m.OldModelsListConfig(ctx)
|
||||||
case group.FieldRpmLimit:
|
case group.FieldRpmLimit:
|
||||||
return m.OldRpmLimit(ctx)
|
return m.OldRpmLimit(ctx)
|
||||||
}
|
}
|
||||||
@ -17529,6 +17573,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
|||||||
}
|
}
|
||||||
m.SetMessagesDispatchModelConfig(v)
|
m.SetMessagesDispatchModelConfig(v)
|
||||||
return nil
|
return nil
|
||||||
|
case group.FieldModelsListConfig:
|
||||||
|
v, ok := value.(domain.GroupModelsListConfig)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||||
|
}
|
||||||
|
m.SetModelsListConfig(v)
|
||||||
|
return nil
|
||||||
case group.FieldRpmLimit:
|
case group.FieldRpmLimit:
|
||||||
v, ok := value.(int)
|
v, ok := value.(int)
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -17912,6 +17963,9 @@ func (m *GroupMutation) ResetField(name string) error {
|
|||||||
case group.FieldMessagesDispatchModelConfig:
|
case group.FieldMessagesDispatchModelConfig:
|
||||||
m.ResetMessagesDispatchModelConfig()
|
m.ResetMessagesDispatchModelConfig()
|
||||||
return nil
|
return nil
|
||||||
|
case group.FieldModelsListConfig:
|
||||||
|
m.ResetModelsListConfig()
|
||||||
|
return nil
|
||||||
case group.FieldRpmLimit:
|
case group.FieldRpmLimit:
|
||||||
m.ResetRpmLimit()
|
m.ResetRpmLimit()
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -870,8 +870,12 @@ func init() {
|
|||||||
groupDescMessagesDispatchModelConfig := groupFields[29].Descriptor()
|
groupDescMessagesDispatchModelConfig := groupFields[29].Descriptor()
|
||||||
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
|
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
|
||||||
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
|
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
|
||||||
|
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
|
||||||
|
groupDescModelsListConfig := groupFields[30].Descriptor()
|
||||||
|
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
|
||||||
|
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
|
||||||
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
|
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
|
||||||
groupDescRpmLimit := groupFields[30].Descriptor()
|
groupDescRpmLimit := groupFields[31].Descriptor()
|
||||||
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
|
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
|
||||||
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
|
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
|
||||||
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
|
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
|
||||||
|
|||||||
@ -155,6 +155,10 @@ func (Group) Fields() []ent.Field {
|
|||||||
Default(domain.OpenAIMessagesDispatchModelConfig{}).
|
Default(domain.OpenAIMessagesDispatchModelConfig{}).
|
||||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||||
Comment("OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型"),
|
Comment("OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型"),
|
||||||
|
field.JSON("models_list_config", domain.GroupModelsListConfig{}).
|
||||||
|
Default(domain.GroupModelsListConfig{}).
|
||||||
|
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||||
|
Comment("自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度"),
|
||||||
|
|
||||||
// 分组级每分钟请求数上限(0 = 不限制)。设置后优先于用户级兜底生效。
|
// 分组级每分钟请求数上限(0 = 不限制)。设置后优先于用户级兜底生效。
|
||||||
field.Int("rpm_limit").
|
field.Int("rpm_limit").
|
||||||
|
|||||||
7
backend/internal/domain/models_list_config.go
Normal file
7
backend/internal/domain/models_list_config.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
// GroupModelsListConfig controls the optional custom /v1/models response list.
|
||||||
|
type GroupModelsListConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Models []string `json:"models,omitempty"`
|
||||||
|
}
|
||||||
@ -33,6 +33,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
|
|||||||
|
|
||||||
router.GET("/api/v1/admin/groups", groupHandler.List)
|
router.GET("/api/v1/admin/groups", groupHandler.List)
|
||||||
router.GET("/api/v1/admin/groups/all", groupHandler.GetAll)
|
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", groupHandler.GetByID)
|
router.GET("/api/v1/admin/groups/:id", groupHandler.GetByID)
|
||||||
router.POST("/api/v1/admin/groups", groupHandler.Create)
|
router.POST("/api/v1/admin/groups", groupHandler.Create)
|
||||||
router.PUT("/api/v1/admin/groups/:id", groupHandler.Update)
|
router.PUT("/api/v1/admin/groups/:id", groupHandler.Update)
|
||||||
@ -177,6 +178,12 @@ func TestGroupHandlerEndpoints(t *testing.T) {
|
|||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
require.Equal(t, http.StatusOK, rec.Code)
|
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)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
require.Contains(t, rec.Body.String(), "gpt-5.5")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{"name": "new", "platform": "anthropic", "subscription_type": "standard"})
|
body, _ := json.Marshal(map[string]any{"name": "new", "platform": "anthropic", "subscription_type": "standard"})
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups", bytes.NewReader(body))
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups", bytes.NewReader(body))
|
||||||
|
|||||||
@ -265,6 +265,13 @@ func (s *stubAdminService) GetGroup(ctx context.Context, id int64) (*service.Gro
|
|||||||
return &group, nil
|
return &group, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubAdminService) GetGroupModelsListCandidates(ctx context.Context, id int64, platform string) ([]string, error) {
|
||||||
|
if platform == service.PlatformOpenAI {
|
||||||
|
return []string{"gpt-5.5", "gpt-5.4"}, nil
|
||||||
|
}
|
||||||
|
return []string{"claude-sonnet-4-6"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.CreateGroupInput) (*service.Group, error) {
|
func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.CreateGroupInput) (*service.Group, error) {
|
||||||
group := service.Group{ID: 200, Name: input.Name, Status: service.StatusActive}
|
group := service.Group{ID: 200, Name: input.Name, Status: service.StatusActive}
|
||||||
return &group, nil
|
return &group, nil
|
||||||
|
|||||||
@ -113,6 +113,7 @@ type CreateGroupRequest struct {
|
|||||||
RequirePrivacySet bool `json:"require_privacy_set"`
|
RequirePrivacySet bool `json:"require_privacy_set"`
|
||||||
DefaultMappedModel string `json:"default_mapped_model"`
|
DefaultMappedModel string `json:"default_mapped_model"`
|
||||||
MessagesDispatchModelConfig service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
MessagesDispatchModelConfig service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
||||||
|
ModelsListConfig service.GroupModelsListConfig `json:"models_list_config"`
|
||||||
// 分组 RPM 上限(0 = 不限制)
|
// 分组 RPM 上限(0 = 不限制)
|
||||||
RPMLimit int `json:"rpm_limit"`
|
RPMLimit int `json:"rpm_limit"`
|
||||||
// 从指定分组复制账号(创建后自动绑定)
|
// 从指定分组复制账号(创建后自动绑定)
|
||||||
@ -153,6 +154,7 @@ type UpdateGroupRequest struct {
|
|||||||
RequirePrivacySet *bool `json:"require_privacy_set"`
|
RequirePrivacySet *bool `json:"require_privacy_set"`
|
||||||
DefaultMappedModel *string `json:"default_mapped_model"`
|
DefaultMappedModel *string `json:"default_mapped_model"`
|
||||||
MessagesDispatchModelConfig *service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
MessagesDispatchModelConfig *service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
||||||
|
ModelsListConfig *service.GroupModelsListConfig `json:"models_list_config"`
|
||||||
// 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动
|
// 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动
|
||||||
RPMLimit *int `json:"rpm_limit"`
|
RPMLimit *int `json:"rpm_limit"`
|
||||||
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
|
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
|
||||||
@ -238,6 +240,28 @@ func (h *GroupHandler) GetByID(c *gin.Context) {
|
|||||||
response.Success(c, dto.GroupFromServiceAdmin(group))
|
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) {
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || groupID < 0 {
|
||||||
|
response.BadRequest(c, "Invalid group ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
models, err := h.adminService.GetGroupModelsListCandidates(
|
||||||
|
c.Request.Context(),
|
||||||
|
groupID,
|
||||||
|
c.Query("platform"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, gin.H{"models": models})
|
||||||
|
}
|
||||||
|
|
||||||
// Create handles creating a new group
|
// Create handles creating a new group
|
||||||
// POST /api/v1/admin/groups
|
// POST /api/v1/admin/groups
|
||||||
func (h *GroupHandler) Create(c *gin.Context) {
|
func (h *GroupHandler) Create(c *gin.Context) {
|
||||||
@ -275,6 +299,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
|||||||
RequirePrivacySet: req.RequirePrivacySet,
|
RequirePrivacySet: req.RequirePrivacySet,
|
||||||
DefaultMappedModel: req.DefaultMappedModel,
|
DefaultMappedModel: req.DefaultMappedModel,
|
||||||
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
|
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
|
||||||
|
ModelsListConfig: req.ModelsListConfig,
|
||||||
RPMLimit: req.RPMLimit,
|
RPMLimit: req.RPMLimit,
|
||||||
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
||||||
})
|
})
|
||||||
@ -330,6 +355,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
|||||||
RequirePrivacySet: req.RequirePrivacySet,
|
RequirePrivacySet: req.RequirePrivacySet,
|
||||||
DefaultMappedModel: req.DefaultMappedModel,
|
DefaultMappedModel: req.DefaultMappedModel,
|
||||||
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
|
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
|
||||||
|
ModelsListConfig: req.ModelsListConfig,
|
||||||
RPMLimit: req.RPMLimit,
|
RPMLimit: req.RPMLimit,
|
||||||
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -147,6 +147,7 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
|
|||||||
MCPXMLInject: g.MCPXMLInject,
|
MCPXMLInject: g.MCPXMLInject,
|
||||||
DefaultMappedModel: g.DefaultMappedModel,
|
DefaultMappedModel: g.DefaultMappedModel,
|
||||||
MessagesDispatchModelConfig: g.MessagesDispatchModelConfig,
|
MessagesDispatchModelConfig: g.MessagesDispatchModelConfig,
|
||||||
|
ModelsListConfig: g.ModelsListConfig,
|
||||||
SupportedModelScopes: g.SupportedModelScopes,
|
SupportedModelScopes: g.SupportedModelScopes,
|
||||||
AccountCount: g.AccountCount,
|
AccountCount: g.AccountCount,
|
||||||
ActiveAccountCount: g.ActiveAccountCount,
|
ActiveAccountCount: g.ActiveAccountCount,
|
||||||
|
|||||||
@ -138,6 +138,7 @@ type AdminGroup struct {
|
|||||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||||
DefaultMappedModel string `json:"default_mapped_model"`
|
DefaultMappedModel string `json:"default_mapped_model"`
|
||||||
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
||||||
|
ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config"`
|
||||||
|
|
||||||
// 支持的模型系列(仅 antigravity 平台使用)
|
// 支持的模型系列(仅 antigravity 平台使用)
|
||||||
SupportedModelScopes []string `json:"supported_model_scopes"`
|
SupportedModelScopes []string `json:"supported_model_scopes"`
|
||||||
|
|||||||
@ -961,22 +961,14 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
|||||||
|
|
||||||
// Get available models from account configurations for the selected group platform.
|
// Get available models from account configurations for the selected group platform.
|
||||||
availableModels := h.gatewayService.GetAvailableModels(c.Request.Context(), groupID, platform)
|
availableModels := h.gatewayService.GetAvailableModels(c.Request.Context(), groupID, platform)
|
||||||
|
if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() {
|
||||||
|
availableModels = filterModelsByCustomList(availableModels, defaultModelIDsForPlatform(platform), apiKey.Group.ModelsListConfig.Models)
|
||||||
|
writeCustomModelsList(c, platform, availableModels)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if len(availableModels) > 0 {
|
if len(availableModels) > 0 {
|
||||||
// Build model list from whitelist
|
writeModelsList(c, availableModels)
|
||||||
models := make([]claude.Model, 0, len(availableModels))
|
|
||||||
for _, modelID := range availableModels {
|
|
||||||
models = append(models, claude.Model{
|
|
||||||
ID: modelID,
|
|
||||||
Type: "model",
|
|
||||||
DisplayName: modelID,
|
|
||||||
CreatedAt: "2024-01-01T00:00:00Z",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"object": "list",
|
|
||||||
"data": models,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1003,6 +995,134 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeModelsList(c *gin.Context, modelIDs []string) {
|
||||||
|
models := make([]claude.Model, 0, len(modelIDs))
|
||||||
|
for _, modelID := range modelIDs {
|
||||||
|
models = append(models, claude.Model{
|
||||||
|
ID: modelID,
|
||||||
|
Type: "model",
|
||||||
|
DisplayName: modelID,
|
||||||
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"object": "list",
|
||||||
|
"data": models,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCustomModelsList(c *gin.Context, platform string, modelIDs []string) {
|
||||||
|
if platform == service.PlatformOpenAI {
|
||||||
|
writeOpenAIModelsList(c, modelIDs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeModelsList(c, modelIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOpenAIModelsList(c *gin.Context, modelIDs []string) {
|
||||||
|
defaultsByID := make(map[string]openai.Model, len(openai.DefaultModels))
|
||||||
|
for _, model := range openai.DefaultModels {
|
||||||
|
defaultsByID[model.ID] = model
|
||||||
|
}
|
||||||
|
|
||||||
|
models := make([]openai.Model, 0, len(modelIDs))
|
||||||
|
for _, modelID := range modelIDs {
|
||||||
|
if model, ok := defaultsByID[modelID]; ok {
|
||||||
|
models = append(models, model)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
models = append(models, openai.Model{
|
||||||
|
ID: modelID,
|
||||||
|
Object: "model",
|
||||||
|
Created: 1704067200,
|
||||||
|
OwnedBy: "openai",
|
||||||
|
Type: "model",
|
||||||
|
DisplayName: modelID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"object": "list",
|
||||||
|
"data": models,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultModelIDsForPlatform(platform string) []string {
|
||||||
|
switch platform {
|
||||||
|
case service.PlatformOpenAI:
|
||||||
|
return openai.DefaultModelIDs()
|
||||||
|
case service.PlatformGemini:
|
||||||
|
ids := make([]string, 0, len(geminicli.DefaultModels))
|
||||||
|
for _, model := range geminicli.DefaultModels {
|
||||||
|
ids = append(ids, model.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
case service.PlatformAntigravity:
|
||||||
|
models := antigravity.DefaultModels()
|
||||||
|
ids := make([]string, 0, len(models))
|
||||||
|
for _, model := range models {
|
||||||
|
ids = append(ids, model.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
default:
|
||||||
|
ids := make([]string, 0, len(claude.DefaultModels))
|
||||||
|
for _, model := range claude.DefaultModels {
|
||||||
|
ids = append(ids, model.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AntigravityModels 返回 Antigravity 支持的全部模型
|
// AntigravityModels 返回 Antigravity 支持的全部模型
|
||||||
// GET /antigravity/models
|
// GET /antigravity/models
|
||||||
func (h *GatewayHandler) AntigravityModels(c *gin.Context) {
|
func (h *GatewayHandler) AntigravityModels(c *gin.Context) {
|
||||||
|
|||||||
@ -25,7 +25,11 @@ type gatewayModelsResponseForTest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type gatewayModelItemForTest struct {
|
type gatewayModelItemForTest struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
OwnedBy string `json:"owned_by"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *gatewayModelsAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]service.Account, error) {
|
func (s *gatewayModelsAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]service.Account, error) {
|
||||||
@ -127,6 +131,267 @@ func TestGatewayModels_GeminiGroupFiltersMappedModelsByPlatform(t *testing.T) {
|
|||||||
require.Equal(t, []string{"gemini-2.5-flash"}, modelIDsForTest(got.Data))
|
require.Equal(t, []string{"gemini-2.5-flash"}, modelIDsForTest(got.Data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGatewayModels_CustomModelsListDisabledKeepsOriginalModels(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
groupID := int64(22)
|
||||||
|
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.5": "gpt-5.5",
|
||||||
|
"gpt-5.4": "gpt-5.4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||||
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||||
|
Group: &service.Group{
|
||||||
|
ID: groupID,
|
||||||
|
Platform: service.PlatformOpenAI,
|
||||||
|
ModelsListConfig: service.GroupModelsListConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Models: []string{"gpt-5.5"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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.4", "gpt-5.5"}, modelIDsForTest(got.Data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayModels_CustomModelsListFiltersAndOrdersMappedModels(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
groupID := int64(23)
|
||||||
|
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": "gpt-5.5",
|
||||||
|
"legacy-gpt-2024": "legacy-gpt-2024",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||||
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||||
|
Group: &service.Group{
|
||||||
|
ID: groupID,
|
||||||
|
Platform: service.PlatformOpenAI,
|
||||||
|
ModelsListConfig: service.GroupModelsListConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Models: []string{"gpt-5.5", "missing-model", "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", "gpt-5.4"}, modelIDsForTest(got.Data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMapping(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
groupID := int64(26)
|
||||||
|
h := newGatewayModelsHandlerForTest(
|
||||||
|
&gatewayModelsAccountRepoStub{
|
||||||
|
byGroup: map[int64][]service.Account{
|
||||||
|
groupID: {
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Platform: service.PlatformAnthropic,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"model_mapping": map[string]any{
|
||||||
|
"claude-*": "claude-sonnet-4-6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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.PlatformAnthropic,
|
||||||
|
ModelsListConfig: service.GroupModelsListConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Models: []string{"claude-sonnet-4-6"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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{"claude-sonnet-4-6"}, modelIDsForTest(got.Data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayModels_CustomModelsListCanReturnEmptyWhenSelectionsUnavailable(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
groupID := int64(24)
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||||
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||||
|
Group: &service.Group{
|
||||||
|
ID: groupID,
|
||||||
|
Platform: service.PlatformOpenAI,
|
||||||
|
ModelsListConfig: service.GroupModelsListConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Models: []string{"gpt-5.5"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
h.Models(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
|
var got gatewayModelsResponseForTest
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||||
|
require.Empty(t, modelIDsForTest(got.Data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayModels_CustomModelsListFiltersDefaultFallbackModels(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
groupID := int64(25)
|
||||||
|
h := newGatewayModelsHandlerForTest(
|
||||||
|
&gatewayModelsAccountRepoStub{
|
||||||
|
byGroup: map[int64][]service.Account{
|
||||||
|
groupID: {
|
||||||
|
{ID: 1, Platform: service.PlatformOpenAI},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||||
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||||
|
Group: &service.Group{
|
||||||
|
ID: groupID,
|
||||||
|
Platform: service.PlatformOpenAI,
|
||||||
|
ModelsListConfig: service.GroupModelsListConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Models: []string{"gpt-5.5", "legacy-gpt-2024", "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", "gpt-5.4"}, modelIDsForTest(got.Data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayModels_OpenAICustomModelsListKeepsOpenAIResponseShapeForDefaultFallback(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
groupID := int64(27)
|
||||||
|
h := newGatewayModelsHandlerForTest(
|
||||||
|
&gatewayModelsAccountRepoStub{
|
||||||
|
byGroup: map[int64][]service.Account{
|
||||||
|
groupID: {
|
||||||
|
{ID: 1, Platform: service.PlatformOpenAI},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||||
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||||
|
Group: &service.Group{
|
||||||
|
ID: groupID,
|
||||||
|
Platform: service.PlatformOpenAI,
|
||||||
|
ModelsListConfig: service.GroupModelsListConfig{
|
||||||
|
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", "gpt-5.4"}, modelIDsForTest(got.Data))
|
||||||
|
require.Equal(t, "model", got.Data[0].Object)
|
||||||
|
require.NotZero(t, got.Data[0].Created)
|
||||||
|
require.Equal(t, "openai", got.Data[0].OwnedBy)
|
||||||
|
require.Empty(t, got.Data[0].CreatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
func modelIDsForTest(models []gatewayModelItemForTest) []string {
|
func modelIDsForTest(models []gatewayModelItemForTest) []string {
|
||||||
ids := make([]string, 0, len(models))
|
ids := make([]string, 0, len(models))
|
||||||
for _, model := range models {
|
for _, model := range models {
|
||||||
|
|||||||
@ -183,6 +183,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
|
|||||||
group.FieldAllowMessagesDispatch,
|
group.FieldAllowMessagesDispatch,
|
||||||
group.FieldDefaultMappedModel,
|
group.FieldDefaultMappedModel,
|
||||||
group.FieldMessagesDispatchModelConfig,
|
group.FieldMessagesDispatchModelConfig,
|
||||||
|
group.FieldModelsListConfig,
|
||||||
group.FieldRpmLimit,
|
group.FieldRpmLimit,
|
||||||
)
|
)
|
||||||
}).
|
}).
|
||||||
@ -723,6 +724,7 @@ func groupEntityToService(g *dbent.Group) *service.Group {
|
|||||||
RequirePrivacySet: g.RequirePrivacySet,
|
RequirePrivacySet: g.RequirePrivacySet,
|
||||||
DefaultMappedModel: g.DefaultMappedModel,
|
DefaultMappedModel: g.DefaultMappedModel,
|
||||||
MessagesDispatchModelConfig: g.MessagesDispatchModelConfig,
|
MessagesDispatchModelConfig: g.MessagesDispatchModelConfig,
|
||||||
|
ModelsListConfig: g.ModelsListConfig,
|
||||||
RPMLimit: g.RpmLimit,
|
RPMLimit: g.RpmLimit,
|
||||||
CreatedAt: g.CreatedAt,
|
CreatedAt: g.CreatedAt,
|
||||||
UpdatedAt: g.UpdatedAt,
|
UpdatedAt: g.UpdatedAt,
|
||||||
|
|||||||
@ -66,6 +66,7 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
|
|||||||
SetRequirePrivacySet(groupIn.RequirePrivacySet).
|
SetRequirePrivacySet(groupIn.RequirePrivacySet).
|
||||||
SetDefaultMappedModel(groupIn.DefaultMappedModel).
|
SetDefaultMappedModel(groupIn.DefaultMappedModel).
|
||||||
SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig).
|
SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig).
|
||||||
|
SetModelsListConfig(groupIn.ModelsListConfig).
|
||||||
SetRpmLimit(groupIn.RPMLimit)
|
SetRpmLimit(groupIn.RPMLimit)
|
||||||
|
|
||||||
// 设置模型路由配置
|
// 设置模型路由配置
|
||||||
@ -141,6 +142,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
|
|||||||
SetRequirePrivacySet(groupIn.RequirePrivacySet).
|
SetRequirePrivacySet(groupIn.RequirePrivacySet).
|
||||||
SetDefaultMappedModel(groupIn.DefaultMappedModel).
|
SetDefaultMappedModel(groupIn.DefaultMappedModel).
|
||||||
SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig).
|
SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig).
|
||||||
|
SetModelsListConfig(groupIn.ModelsListConfig).
|
||||||
SetRpmLimit(groupIn.RPMLimit)
|
SetRpmLimit(groupIn.RPMLimit)
|
||||||
|
|
||||||
// 显式处理可空字段:nil 需要 clear,非 nil 需要 set。
|
// 显式处理可空字段:nil 需要 clear,非 nil 需要 set。
|
||||||
|
|||||||
@ -259,6 +259,7 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
groups.GET("/usage-summary", h.Admin.Group.GetUsageSummary)
|
groups.GET("/usage-summary", h.Admin.Group.GetUsageSummary)
|
||||||
groups.GET("/capacity-summary", h.Admin.Group.GetCapacitySummary)
|
groups.GET("/capacity-summary", h.Admin.Group.GetCapacitySummary)
|
||||||
groups.PUT("/sort-order", h.Admin.Group.UpdateSortOrder)
|
groups.PUT("/sort-order", h.Admin.Group.UpdateSortOrder)
|
||||||
|
groups.GET("/:id/models-list-candidates", h.Admin.Group.GetModelsListCandidates)
|
||||||
groups.GET("/:id", h.Admin.Group.GetByID)
|
groups.GET("/:id", h.Admin.Group.GetByID)
|
||||||
groups.POST("", h.Admin.Group.Create)
|
groups.POST("", h.Admin.Group.Create)
|
||||||
groups.PUT("/:id", h.Admin.Group.Update)
|
groups.PUT("/:id", h.Admin.Group.Update)
|
||||||
|
|||||||
@ -17,9 +17,13 @@ import (
|
|||||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
|
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/util/httputil"
|
"github.com/Wei-Shaw/sub2api/internal/util/httputil"
|
||||||
)
|
)
|
||||||
@ -48,6 +52,7 @@ type AdminService interface {
|
|||||||
GetAllGroups(ctx context.Context) ([]Group, error)
|
GetAllGroups(ctx context.Context) ([]Group, error)
|
||||||
GetAllGroupsByPlatform(ctx context.Context, platform string) ([]Group, error)
|
GetAllGroupsByPlatform(ctx context.Context, platform string) ([]Group, error)
|
||||||
GetGroup(ctx context.Context, id int64) (*Group, error)
|
GetGroup(ctx context.Context, id int64) (*Group, error)
|
||||||
|
GetGroupModelsListCandidates(ctx context.Context, id int64, platform string) ([]string, error)
|
||||||
CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error)
|
CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error)
|
||||||
UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error)
|
UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error)
|
||||||
DeleteGroup(ctx context.Context, id int64) error
|
DeleteGroup(ctx context.Context, id int64) error
|
||||||
@ -215,6 +220,7 @@ type CreateGroupInput struct {
|
|||||||
RequireOAuthOnly bool
|
RequireOAuthOnly bool
|
||||||
RequirePrivacySet bool
|
RequirePrivacySet bool
|
||||||
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig
|
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig
|
||||||
|
ModelsListConfig GroupModelsListConfig
|
||||||
// RPMLimit 分组 RPM 上限(0 = 不限制)
|
// RPMLimit 分组 RPM 上限(0 = 不限制)
|
||||||
RPMLimit int
|
RPMLimit int
|
||||||
// 从指定分组复制账号(创建分组后在同一事务内绑定)
|
// 从指定分组复制账号(创建分组后在同一事务内绑定)
|
||||||
@ -255,6 +261,7 @@ type UpdateGroupInput struct {
|
|||||||
RequireOAuthOnly *bool
|
RequireOAuthOnly *bool
|
||||||
RequirePrivacySet *bool
|
RequirePrivacySet *bool
|
||||||
MessagesDispatchModelConfig *OpenAIMessagesDispatchModelConfig
|
MessagesDispatchModelConfig *OpenAIMessagesDispatchModelConfig
|
||||||
|
ModelsListConfig *GroupModelsListConfig
|
||||||
// RPMLimit 分组 RPM 上限(0 = 不限制),nil 表示未提供不改动。
|
// RPMLimit 分组 RPM 上限(0 = 不限制),nil 表示未提供不改动。
|
||||||
RPMLimit *int
|
RPMLimit *int
|
||||||
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
|
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
|
||||||
@ -1582,6 +1589,80 @@ func (s *adminServiceImpl) GetGroup(ctx context.Context, id int64) (*Group, erro
|
|||||||
return s.groupRepo.GetByID(ctx, id)
|
return s.groupRepo.GetByID(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *adminServiceImpl) GetGroupModelsListCandidates(ctx context.Context, id int64, platform string) ([]string, error) {
|
||||||
|
platform = strings.TrimSpace(platform)
|
||||||
|
if id > 0 {
|
||||||
|
group, err := s.groupRepo.GetByIDLite(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if platform == "" {
|
||||||
|
platform = group.Platform
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if platform == "" {
|
||||||
|
platform = PlatformAnthropic
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := defaultModelsListCandidateIDs(platform)
|
||||||
|
if id <= 0 || s.accountRepo == nil {
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(candidates))
|
||||||
|
for _, model := range candidates {
|
||||||
|
seen[model] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, acc := range accounts {
|
||||||
|
if acc.Platform != platform {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for model := range acc.GetModelMapping() {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if model == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[model]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[model] = struct{}{}
|
||||||
|
candidates = append(candidates, model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultModelsListCandidateIDs(platform string) []string {
|
||||||
|
switch platform {
|
||||||
|
case PlatformOpenAI:
|
||||||
|
return openai.DefaultModelIDs()
|
||||||
|
case PlatformGemini:
|
||||||
|
ids := make([]string, 0, len(geminicli.DefaultModels))
|
||||||
|
for _, model := range geminicli.DefaultModels {
|
||||||
|
ids = append(ids, model.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
case PlatformAntigravity:
|
||||||
|
models := antigravity.DefaultModels()
|
||||||
|
ids := make([]string, 0, len(models))
|
||||||
|
for _, model := range models {
|
||||||
|
ids = append(ids, model.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
default:
|
||||||
|
ids := make([]string, 0, len(claude.DefaultModels))
|
||||||
|
for _, model := range claude.DefaultModels {
|
||||||
|
ids = append(ids, model.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) {
|
func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) {
|
||||||
if input.RateMultiplier <= 0 {
|
if input.RateMultiplier <= 0 {
|
||||||
return nil, errors.New("rate_multiplier must be > 0")
|
return nil, errors.New("rate_multiplier must be > 0")
|
||||||
@ -1697,6 +1778,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
|||||||
RequirePrivacySet: input.RequirePrivacySet,
|
RequirePrivacySet: input.RequirePrivacySet,
|
||||||
DefaultMappedModel: input.DefaultMappedModel,
|
DefaultMappedModel: input.DefaultMappedModel,
|
||||||
MessagesDispatchModelConfig: normalizeOpenAIMessagesDispatchModelConfig(input.MessagesDispatchModelConfig),
|
MessagesDispatchModelConfig: normalizeOpenAIMessagesDispatchModelConfig(input.MessagesDispatchModelConfig),
|
||||||
|
ModelsListConfig: normalizeGroupModelsListConfig(input.ModelsListConfig),
|
||||||
RPMLimit: input.RPMLimit,
|
RPMLimit: input.RPMLimit,
|
||||||
}
|
}
|
||||||
sanitizeGroupMessagesDispatchFields(group)
|
sanitizeGroupMessagesDispatchFields(group)
|
||||||
@ -1944,6 +2026,9 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
|
|||||||
if input.MessagesDispatchModelConfig != nil {
|
if input.MessagesDispatchModelConfig != nil {
|
||||||
group.MessagesDispatchModelConfig = normalizeOpenAIMessagesDispatchModelConfig(*input.MessagesDispatchModelConfig)
|
group.MessagesDispatchModelConfig = normalizeOpenAIMessagesDispatchModelConfig(*input.MessagesDispatchModelConfig)
|
||||||
}
|
}
|
||||||
|
if input.ModelsListConfig != nil {
|
||||||
|
group.ModelsListConfig = normalizeGroupModelsListConfig(*input.ModelsListConfig)
|
||||||
|
}
|
||||||
if input.RPMLimit != nil {
|
if input.RPMLimit != nil {
|
||||||
group.RPMLimit = *input.RPMLimit
|
group.RPMLimit = *input.RPMLimit
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,6 +87,7 @@ type APIKeyAuthGroupSnapshot struct {
|
|||||||
AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
|
AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
|
||||||
DefaultMappedModel string `json:"default_mapped_model,omitempty"`
|
DefaultMappedModel string `json:"default_mapped_model,omitempty"`
|
||||||
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"`
|
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"`
|
||||||
|
ModelsListConfig GroupModelsListConfig `json:"models_list_config,omitempty"`
|
||||||
|
|
||||||
// RPMLimit 分组级每分钟请求数上限(0 = 不限制);用于 billing_cache_service.checkRPM 级联判断。
|
// RPMLimit 分组级每分钟请求数上限(0 = 不限制);用于 billing_cache_service.checkRPM 级联判断。
|
||||||
RPMLimit int `json:"rpm_limit"`
|
RPMLimit int `json:"rpm_limit"`
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import (
|
|||||||
"github.com/dgraph-io/ristretto"
|
"github.com/dgraph-io/ristretto"
|
||||||
)
|
)
|
||||||
|
|
||||||
const apiKeyAuthSnapshotVersion = 10 // v10: reload snapshots for group availability checks
|
const apiKeyAuthSnapshotVersion = 11 // v11: reload snapshots for custom models_list_config
|
||||||
|
|
||||||
type apiKeyAuthCacheConfig struct {
|
type apiKeyAuthCacheConfig struct {
|
||||||
l1Size int
|
l1Size int
|
||||||
@ -272,6 +272,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey)
|
|||||||
AllowMessagesDispatch: apiKey.Group.AllowMessagesDispatch,
|
AllowMessagesDispatch: apiKey.Group.AllowMessagesDispatch,
|
||||||
DefaultMappedModel: apiKey.Group.DefaultMappedModel,
|
DefaultMappedModel: apiKey.Group.DefaultMappedModel,
|
||||||
MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig,
|
MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig,
|
||||||
|
ModelsListConfig: apiKey.Group.ModelsListConfig,
|
||||||
RPMLimit: apiKey.Group.RPMLimit,
|
RPMLimit: apiKey.Group.RPMLimit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -342,6 +343,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
|
|||||||
AllowMessagesDispatch: snapshot.Group.AllowMessagesDispatch,
|
AllowMessagesDispatch: snapshot.Group.AllowMessagesDispatch,
|
||||||
DefaultMappedModel: snapshot.Group.DefaultMappedModel,
|
DefaultMappedModel: snapshot.Group.DefaultMappedModel,
|
||||||
MessagesDispatchModelConfig: snapshot.Group.MessagesDispatchModelConfig,
|
MessagesDispatchModelConfig: snapshot.Group.MessagesDispatchModelConfig,
|
||||||
|
ModelsListConfig: snapshot.Group.ModelsListConfig,
|
||||||
RPMLimit: snapshot.Group.RPMLimit,
|
RPMLimit: snapshot.Group.RPMLimit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
backend/internal/service/api_key_auth_cache_version_test.go
Normal file
43
backend/internal/service/api_key_auth_cache_version_test.go
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAPIKeyService_RejectsV10AuthSnapshotWithoutModelsListConfig(t *testing.T) {
|
||||||
|
groupID := int64(9)
|
||||||
|
svc := &APIKeyService{}
|
||||||
|
|
||||||
|
apiKey, ok, err := svc.applyAuthCacheEntry("k-legacy-models-list", &APIKeyAuthCacheEntry{
|
||||||
|
Snapshot: &APIKeyAuthSnapshot{
|
||||||
|
Version: 10,
|
||||||
|
APIKeyID: 1,
|
||||||
|
UserID: 2,
|
||||||
|
GroupID: &groupID,
|
||||||
|
Status: StatusActive,
|
||||||
|
User: APIKeyAuthUserSnapshot{
|
||||||
|
ID: 2,
|
||||||
|
Status: StatusActive,
|
||||||
|
Role: RoleUser,
|
||||||
|
Balance: 10,
|
||||||
|
Concurrency: 3,
|
||||||
|
},
|
||||||
|
Group: &APIKeyAuthGroupSnapshot{
|
||||||
|
ID: groupID,
|
||||||
|
Name: "openai",
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Status: StatusActive,
|
||||||
|
SubscriptionType: SubscriptionTypeStandard,
|
||||||
|
RateMultiplier: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected stale snapshot to be ignored without error, got %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected v10 auth snapshot to be rejected after models_list_config was added")
|
||||||
|
}
|
||||||
|
if apiKey != nil {
|
||||||
|
t.Fatalf("expected no API key from stale snapshot, got %#v", apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type OpenAIMessagesDispatchModelConfig = domain.OpenAIMessagesDispatchModelConfig
|
type OpenAIMessagesDispatchModelConfig = domain.OpenAIMessagesDispatchModelConfig
|
||||||
|
type GroupModelsListConfig = domain.GroupModelsListConfig
|
||||||
|
|
||||||
type Group struct {
|
type Group struct {
|
||||||
ID int64
|
ID int64
|
||||||
@ -61,6 +62,7 @@ type Group struct {
|
|||||||
RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini)
|
RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini)
|
||||||
DefaultMappedModel string
|
DefaultMappedModel string
|
||||||
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig
|
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig
|
||||||
|
ModelsListConfig GroupModelsListConfig
|
||||||
|
|
||||||
// RPMLimit 分组级每分钟请求数上限(0 = 不限制)。
|
// RPMLimit 分组级每分钟请求数上限(0 = 不限制)。
|
||||||
// 一旦设置即接管该分组用户的限流(覆盖用户级 rpm_limit),可被 user-group rpm_override 进一步覆盖。
|
// 一旦设置即接管该分组用户的限流(覆盖用户级 rpm_limit),可被 user-group rpm_override 进一步覆盖。
|
||||||
|
|||||||
32
backend/internal/service/group_models_list.go
Normal file
32
backend/internal/service/group_models_list.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func normalizeGroupModelsListConfig(cfg GroupModelsListConfig) GroupModelsListConfig {
|
||||||
|
out := GroupModelsListConfig{Enabled: cfg.Enabled}
|
||||||
|
if len(cfg.Models) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(cfg.Models))
|
||||||
|
out.Models = make([]string, 0, len(cfg.Models))
|
||||||
|
for _, model := range cfg.Models {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if model == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[model]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[model] = struct{}{}
|
||||||
|
out.Models = append(out.Models, model)
|
||||||
|
}
|
||||||
|
if len(out.Models) == 0 {
|
||||||
|
out.Models = nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Group) CustomModelsListEnabled() bool {
|
||||||
|
return g != nil && g.ModelsListConfig.Enabled && len(g.ModelsListConfig.Models) > 0
|
||||||
|
}
|
||||||
5
backend/migrations/143_group_models_list_config.sql
Normal file
5
backend/migrations/143_group_models_list_config.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- 分组级自定义 /v1/models 展示列表配置。
|
||||||
|
-- 仅用于控制 GET /v1/models 的展示结果,不参与账号白名单、模型映射或网关调度。
|
||||||
|
|
||||||
|
ALTER TABLE groups
|
||||||
|
ADD COLUMN IF NOT EXISTS models_list_config JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||||
@ -76,6 +76,23 @@ export async function getById(id: number): Promise<AdminGroup> {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get candidate models for custom /v1/models list.
|
||||||
|
* id=0 returns platform default models for create flow.
|
||||||
|
*/
|
||||||
|
export async function getModelsListCandidates(
|
||||||
|
id: number,
|
||||||
|
platform?: GroupPlatform
|
||||||
|
): Promise<string[]> {
|
||||||
|
const { data } = await apiClient.get<{ models: string[] }>(
|
||||||
|
`/admin/groups/${id}/models-list-candidates`,
|
||||||
|
{
|
||||||
|
params: platform ? { platform } : undefined
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data.models || []
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create new group
|
* Create new group
|
||||||
* @param groupData - Group data
|
* @param groupData - Group data
|
||||||
@ -306,6 +323,7 @@ export const groupsAPI = {
|
|||||||
getAll,
|
getAll,
|
||||||
getByPlatform,
|
getByPlatform,
|
||||||
getById,
|
getById,
|
||||||
|
getModelsListCandidates,
|
||||||
create,
|
create,
|
||||||
update,
|
update,
|
||||||
delete: deleteGroup,
|
delete: deleteGroup,
|
||||||
|
|||||||
@ -2179,6 +2179,12 @@ export default {
|
|||||||
finalPricePreview: 'Final per-image price preview',
|
finalPricePreview: 'Final per-image price preview',
|
||||||
notConfigured: 'Not configured'
|
notConfigured: 'Not configured'
|
||||||
},
|
},
|
||||||
|
modelsList: {
|
||||||
|
title: 'Custom /v1/models Model List',
|
||||||
|
hint: 'Only changes the /v1/models response. Whitelist model calls and account routing are unchanged.',
|
||||||
|
loading: 'Loading model list...',
|
||||||
|
empty: 'No displayable models'
|
||||||
|
},
|
||||||
claudeCode: {
|
claudeCode: {
|
||||||
title: 'Claude Code Client Restriction',
|
title: 'Claude Code Client Restriction',
|
||||||
tooltip: 'When enabled, this group only allows official Claude Code clients. Non-Claude Code requests will be rejected or fallback to the specified group.',
|
tooltip: 'When enabled, this group only allows official Claude Code clients. Non-Claude Code requests will be rejected or fallback to the specified group.',
|
||||||
|
|||||||
@ -2262,6 +2262,12 @@ export default {
|
|||||||
finalPricePreview: '最终单张价格预览',
|
finalPricePreview: '最终单张价格预览',
|
||||||
notConfigured: '未配置'
|
notConfigured: '未配置'
|
||||||
},
|
},
|
||||||
|
modelsList: {
|
||||||
|
title: '自定义 /v1/models 模型列表',
|
||||||
|
hint: '仅影响 /v1/models 展示结果,不影响白名单模型调用和账号调度。',
|
||||||
|
loading: '正在加载模型列表...',
|
||||||
|
empty: '暂无可展示模型'
|
||||||
|
},
|
||||||
claudeCode: {
|
claudeCode: {
|
||||||
title: 'Claude Code 客户端限制',
|
title: 'Claude Code 客户端限制',
|
||||||
tooltip:
|
tooltip:
|
||||||
|
|||||||
@ -548,11 +548,17 @@ export interface AdminGroup extends Group {
|
|||||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||||
default_mapped_model?: string
|
default_mapped_model?: string
|
||||||
messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig
|
messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig
|
||||||
|
models_list_config?: ModelsListConfig
|
||||||
|
|
||||||
// 分组排序
|
// 分组排序
|
||||||
sort_order: number
|
sort_order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModelsListConfig {
|
||||||
|
enabled: boolean
|
||||||
|
models: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiKey {
|
export interface ApiKey {
|
||||||
id: number
|
id: number
|
||||||
user_id: number
|
user_id: number
|
||||||
@ -632,6 +638,13 @@ export interface CreateGroupRequest {
|
|||||||
fallback_group_id_on_invalid_request?: number | null
|
fallback_group_id_on_invalid_request?: number | null
|
||||||
mcp_xml_inject?: boolean
|
mcp_xml_inject?: boolean
|
||||||
supported_model_scopes?: string[]
|
supported_model_scopes?: string[]
|
||||||
|
models_list_config?: ModelsListConfig
|
||||||
|
allow_messages_dispatch?: boolean
|
||||||
|
default_mapped_model?: string
|
||||||
|
messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig
|
||||||
|
model_routing?: Record<string, number[]> | null
|
||||||
|
model_routing_enabled?: boolean
|
||||||
|
rpm_limit?: number
|
||||||
require_oauth_only?: boolean
|
require_oauth_only?: boolean
|
||||||
require_privacy_set?: boolean
|
require_privacy_set?: boolean
|
||||||
// 从指定分组复制账号
|
// 从指定分组复制账号
|
||||||
@ -660,6 +673,13 @@ export interface UpdateGroupRequest {
|
|||||||
fallback_group_id_on_invalid_request?: number | null
|
fallback_group_id_on_invalid_request?: number | null
|
||||||
mcp_xml_inject?: boolean
|
mcp_xml_inject?: boolean
|
||||||
supported_model_scopes?: string[]
|
supported_model_scopes?: string[]
|
||||||
|
models_list_config?: ModelsListConfig
|
||||||
|
allow_messages_dispatch?: boolean
|
||||||
|
default_mapped_model?: string
|
||||||
|
messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig
|
||||||
|
model_routing?: Record<string, number[]> | null
|
||||||
|
model_routing_enabled?: boolean
|
||||||
|
rpm_limit?: number
|
||||||
require_oauth_only?: boolean
|
require_oauth_only?: boolean
|
||||||
require_privacy_set?: boolean
|
require_privacy_set?: boolean
|
||||||
copy_accounts_from_group_ids?: number[]
|
copy_accounts_from_group_ids?: number[]
|
||||||
|
|||||||
@ -69,7 +69,7 @@
|
|||||||
{{ t("admin.groups.sortOrder") }}
|
{{ t("admin.groups.sortOrder") }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="showCreateModal = true"
|
@click="openCreateModal"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
data-tour="groups-create-btn"
|
data-tour="groups-create-btn"
|
||||||
>
|
>
|
||||||
@ -329,7 +329,7 @@
|
|||||||
:title="t('admin.groups.noGroupsYet')"
|
:title="t('admin.groups.noGroupsYet')"
|
||||||
:description="t('admin.groups.createFirstGroup')"
|
:description="t('admin.groups.createFirstGroup')"
|
||||||
:action-text="t('admin.groups.createGroup')"
|
:action-text="t('admin.groups.createGroup')"
|
||||||
@action="showCreateModal = true"
|
@action="openCreateModal"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
@ -646,6 +646,109 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t pt-4">
|
||||||
|
<div class="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t("admin.groups.modelsList.title") }}
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t("admin.groups.modelsList.hint") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="createModelsListState.enabled = !createModelsListState.enabled"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors',
|
||||||
|
createModelsListState.enabled
|
||||||
|
? 'bg-primary-500'
|
||||||
|
: 'bg-gray-300 dark:bg-dark-600',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||||
|
createModelsListState.enabled ? 'translate-x-6' : 'translate-x-1',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="createModelsListState.enabled"
|
||||||
|
class="overflow-hidden rounded-lg border border-gray-200 bg-gray-50/50 dark:border-dark-600 dark:bg-dark-800/40"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="!createModelsListLoading && createModelsListState.items.length > 0"
|
||||||
|
class="flex items-center justify-between gap-2 border-b border-gray-200 bg-gray-50 px-3 py-2 text-xs dark:border-dark-600 dark:bg-dark-800"
|
||||||
|
>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
已选 {{ createModelsListSelectedCount }} /
|
||||||
|
{{ createModelsListState.items.length }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-2 py-1 font-medium text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-primary-900/20"
|
||||||
|
@click="selectAllModelsListItems(createModelsListState)"
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-2 py-1 font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||||
|
@click="invertModelsListSelection(createModelsListState)"
|
||||||
|
>
|
||||||
|
反选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="max-h-64 space-y-2 overflow-y-auto p-2"
|
||||||
|
>
|
||||||
|
<p v-if="createModelsListLoading" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t("admin.groups.modelsList.loading") }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="createModelsListState.items.length === 0"
|
||||||
|
class="text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ t("admin.groups.modelsList.empty") }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in createModelsListState.items"
|
||||||
|
:key="item.id"
|
||||||
|
class="flex items-center gap-2 rounded border border-gray-200 bg-white px-3 py-2 dark:border-dark-600 dark:bg-dark-800"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="item.selected"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 flex-1 break-all text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ item.id }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="index === 0"
|
||||||
|
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dark-600 dark:hover:text-gray-200"
|
||||||
|
@click="moveCreateModelsListItem(index, index - 1)"
|
||||||
|
>
|
||||||
|
<Icon name="arrowUp" size="sm" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="index === createModelsListState.items.length - 1"
|
||||||
|
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dark-600 dark:hover:text-gray-200"
|
||||||
|
@click="moveCreateModelsListItem(index, index + 1)"
|
||||||
|
>
|
||||||
|
<Icon name="arrowDown" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 图片生成计费配置 -->
|
<!-- 图片生成计费配置 -->
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
@ -1831,6 +1934,109 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t pt-4">
|
||||||
|
<div class="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t("admin.groups.modelsList.title") }}
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t("admin.groups.modelsList.hint") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="editModelsListState.enabled = !editModelsListState.enabled"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors',
|
||||||
|
editModelsListState.enabled
|
||||||
|
? 'bg-primary-500'
|
||||||
|
: 'bg-gray-300 dark:bg-dark-600',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||||
|
editModelsListState.enabled ? 'translate-x-6' : 'translate-x-1',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="editModelsListState.enabled"
|
||||||
|
class="overflow-hidden rounded-lg border border-gray-200 bg-gray-50/50 dark:border-dark-600 dark:bg-dark-800/40"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="!editModelsListLoading && editModelsListState.items.length > 0"
|
||||||
|
class="flex items-center justify-between gap-2 border-b border-gray-200 bg-gray-50 px-3 py-2 text-xs dark:border-dark-600 dark:bg-dark-800"
|
||||||
|
>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
已选 {{ editModelsListSelectedCount }} /
|
||||||
|
{{ editModelsListState.items.length }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-2 py-1 font-medium text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-primary-900/20"
|
||||||
|
@click="selectAllModelsListItems(editModelsListState)"
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-2 py-1 font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||||
|
@click="invertModelsListSelection(editModelsListState)"
|
||||||
|
>
|
||||||
|
反选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="max-h-64 space-y-2 overflow-y-auto p-2"
|
||||||
|
>
|
||||||
|
<p v-if="editModelsListLoading" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t("admin.groups.modelsList.loading") }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="editModelsListState.items.length === 0"
|
||||||
|
class="text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ t("admin.groups.modelsList.empty") }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in editModelsListState.items"
|
||||||
|
:key="item.id"
|
||||||
|
class="flex items-center gap-2 rounded border border-gray-200 bg-white px-3 py-2 dark:border-dark-600 dark:bg-dark-800"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="item.selected"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 flex-1 break-all text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ item.id }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="index === 0"
|
||||||
|
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dark-600 dark:hover:text-gray-200"
|
||||||
|
@click="moveEditModelsListItem(index, index - 1)"
|
||||||
|
>
|
||||||
|
<Icon name="arrowUp" size="sm" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="index === editModelsListState.items.length - 1"
|
||||||
|
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dark-600 dark:hover:text-gray-200"
|
||||||
|
@click="moveEditModelsListItem(index, index + 1)"
|
||||||
|
>
|
||||||
|
<Icon name="arrowDown" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 图片生成计费配置 -->
|
<!-- 图片生成计费配置 -->
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
@ -2862,6 +3068,15 @@ import {
|
|||||||
resetMessagesDispatchFormState,
|
resetMessagesDispatchFormState,
|
||||||
type MessagesDispatchMappingRow,
|
type MessagesDispatchMappingRow,
|
||||||
} from "./groupsMessagesDispatch";
|
} from "./groupsMessagesDispatch";
|
||||||
|
import {
|
||||||
|
buildModelsListConfig,
|
||||||
|
createModelsListState as createInitialModelsListState,
|
||||||
|
invertModelsListSelection,
|
||||||
|
moveModelsListItem,
|
||||||
|
selectAllModelsListItems,
|
||||||
|
setModelsListCandidates,
|
||||||
|
} from "./groupsModelsList";
|
||||||
|
import { createModelsListCandidatesTracker } from "./groupsModelsListCandidates";
|
||||||
import { normalizeSupportedModelScopesForPlatform } from "./groupsSupportedModelScopes";
|
import { normalizeSupportedModelScopesForPlatform } from "./groupsSupportedModelScopes";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@ -3096,6 +3311,17 @@ const rpmOverridesGroup = ref<AdminGroup | null>(null);
|
|||||||
const sortableGroups = ref<AdminGroup[]>([]);
|
const sortableGroups = ref<AdminGroup[]>([]);
|
||||||
const createMessagesDispatchDefaults = createDefaultMessagesDispatchFormState();
|
const createMessagesDispatchDefaults = createDefaultMessagesDispatchFormState();
|
||||||
const editMessagesDispatchDefaults = createDefaultMessagesDispatchFormState();
|
const editMessagesDispatchDefaults = createDefaultMessagesDispatchFormState();
|
||||||
|
const createModelsListState = reactive(createInitialModelsListState());
|
||||||
|
const editModelsListState = reactive(createInitialModelsListState());
|
||||||
|
const createModelsListLoading = ref(false);
|
||||||
|
const editModelsListLoading = ref(false);
|
||||||
|
const modelsListCandidatesTracker = createModelsListCandidatesTracker();
|
||||||
|
const createModelsListSelectedCount = computed(
|
||||||
|
() => createModelsListState.items.filter((item) => item.selected).length,
|
||||||
|
);
|
||||||
|
const editModelsListSelectedCount = computed(
|
||||||
|
() => editModelsListState.items.filter((item) => item.selected).length,
|
||||||
|
);
|
||||||
|
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
@ -3335,6 +3561,52 @@ const removeEditRoutingRule = (rule: ModelRoutingRule) => {
|
|||||||
editModelRoutingRules.value.splice(index, 1);
|
editModelRoutingRules.value.splice(index, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetModelsListState = (
|
||||||
|
state: typeof createModelsListState,
|
||||||
|
config?: Parameters<typeof createInitialModelsListState>[0],
|
||||||
|
) => {
|
||||||
|
const fresh = createInitialModelsListState(config);
|
||||||
|
state.enabled = fresh.enabled;
|
||||||
|
state.savedModels = fresh.savedModels;
|
||||||
|
state.items = fresh.items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadModelsListCandidates = async (
|
||||||
|
mode: "create" | "edit",
|
||||||
|
groupID: number,
|
||||||
|
platform: GroupPlatform,
|
||||||
|
) => {
|
||||||
|
const request = { mode, groupID, platform };
|
||||||
|
const requestID = modelsListCandidatesTracker.next(request);
|
||||||
|
const state = mode === "create" ? createModelsListState : editModelsListState;
|
||||||
|
const loadingRef = mode === "create" ? createModelsListLoading : editModelsListLoading;
|
||||||
|
loadingRef.value = true;
|
||||||
|
try {
|
||||||
|
const models = await adminAPI.groups.getModelsListCandidates(groupID, platform);
|
||||||
|
if (!modelsListCandidatesTracker.isCurrent(requestID, request)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setModelsListCandidates(state, models);
|
||||||
|
} catch (error) {
|
||||||
|
if (!modelsListCandidatesTracker.isCurrent(requestID, request)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("Error loading group models list candidates:", error);
|
||||||
|
} finally {
|
||||||
|
if (modelsListCandidatesTracker.isCurrent(requestID, request)) {
|
||||||
|
loadingRef.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveCreateModelsListItem = (fromIndex: number, toIndex: number) => {
|
||||||
|
moveModelsListItem(createModelsListState, fromIndex, toIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveEditModelsListItem = (fromIndex: number, toIndex: number) => {
|
||||||
|
moveModelsListItem(editModelsListState, fromIndex, toIndex);
|
||||||
|
};
|
||||||
|
|
||||||
// 将 UI 格式的路由规则转换为 API 格式
|
// 将 UI 格式的路由规则转换为 API 格式
|
||||||
const convertRoutingRulesToApiFormat = (
|
const convertRoutingRulesToApiFormat = (
|
||||||
rules: ModelRoutingRule[],
|
rules: ModelRoutingRule[],
|
||||||
@ -3624,6 +3896,11 @@ const handleSort = (key: string, order: 'asc' | 'desc') => {
|
|||||||
loadGroups();
|
loadGroups();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
showCreateModal.value = true;
|
||||||
|
loadModelsListCandidates("create", 0, createForm.platform);
|
||||||
|
};
|
||||||
|
|
||||||
const closeCreateModal = () => {
|
const closeCreateModal = () => {
|
||||||
showCreateModal.value = false;
|
showCreateModal.value = false;
|
||||||
createModelRoutingRules.value.forEach((rule) => {
|
createModelRoutingRules.value.forEach((rule) => {
|
||||||
@ -3654,6 +3931,8 @@ const closeCreateModal = () => {
|
|||||||
createForm.supported_model_scopes = ["claude", "gemini_text", "gemini_image"];
|
createForm.supported_model_scopes = ["claude", "gemini_text", "gemini_image"];
|
||||||
createForm.mcp_xml_inject = true;
|
createForm.mcp_xml_inject = true;
|
||||||
createForm.copy_accounts_from_group_ids = [];
|
createForm.copy_accounts_from_group_ids = [];
|
||||||
|
createForm.rpm_limit = 0;
|
||||||
|
resetModelsListState(createModelsListState);
|
||||||
createModelRoutingRules.value = [];
|
createModelRoutingRules.value = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -3708,6 +3987,7 @@ const handleCreateGroup = async () => {
|
|||||||
model_routing: convertRoutingRulesToApiFormat(
|
model_routing: convertRoutingRulesToApiFormat(
|
||||||
createModelRoutingRules.value,
|
createModelRoutingRules.value,
|
||||||
),
|
),
|
||||||
|
models_list_config: buildModelsListConfig(createModelsListState),
|
||||||
supported_model_scopes: normalizeSupportedModelScopesForPlatform(
|
supported_model_scopes: normalizeSupportedModelScopesForPlatform(
|
||||||
createForm.platform,
|
createForm.platform,
|
||||||
createForm.supported_model_scopes,
|
createForm.supported_model_scopes,
|
||||||
@ -3794,10 +4074,12 @@ const handleEdit = async (group: AdminGroup) => {
|
|||||||
editForm.mcp_xml_inject = group.mcp_xml_inject ?? true;
|
editForm.mcp_xml_inject = group.mcp_xml_inject ?? true;
|
||||||
editForm.copy_accounts_from_group_ids = []; // 复制账号字段每次编辑时重置为空
|
editForm.copy_accounts_from_group_ids = []; // 复制账号字段每次编辑时重置为空
|
||||||
editForm.rpm_limit = group.rpm_limit ?? 0;
|
editForm.rpm_limit = group.rpm_limit ?? 0;
|
||||||
|
resetModelsListState(editModelsListState, group.models_list_config);
|
||||||
// 加载模型路由规则(异步加载账号名称)
|
// 加载模型路由规则(异步加载账号名称)
|
||||||
editModelRoutingRules.value = await convertApiFormatToRoutingRules(
|
editModelRoutingRules.value = await convertApiFormatToRoutingRules(
|
||||||
group.model_routing,
|
group.model_routing,
|
||||||
);
|
);
|
||||||
|
loadModelsListCandidates("edit", group.id, group.platform);
|
||||||
showEditModal.value = true;
|
showEditModal.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -3811,6 +4093,7 @@ const closeEditModal = () => {
|
|||||||
editModelRoutingRules.value = [];
|
editModelRoutingRules.value = [];
|
||||||
editForm.copy_accounts_from_group_ids = [];
|
editForm.copy_accounts_from_group_ids = [];
|
||||||
resetMessagesDispatchFormState(editForm);
|
resetMessagesDispatchFormState(editForm);
|
||||||
|
resetModelsListState(editModelsListState);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateGroup = async () => {
|
const handleUpdateGroup = async () => {
|
||||||
@ -3843,6 +4126,7 @@ const handleUpdateGroup = async () => {
|
|||||||
model_routing: convertRoutingRulesToApiFormat(
|
model_routing: convertRoutingRulesToApiFormat(
|
||||||
editModelRoutingRules.value,
|
editModelRoutingRules.value,
|
||||||
),
|
),
|
||||||
|
models_list_config: buildModelsListConfig(editModelsListState),
|
||||||
supported_model_scopes: normalizeSupportedModelScopesForPlatform(
|
supported_model_scopes: normalizeSupportedModelScopesForPlatform(
|
||||||
editForm.platform,
|
editForm.platform,
|
||||||
editForm.supported_model_scopes,
|
editForm.supported_model_scopes,
|
||||||
@ -3960,6 +4244,8 @@ watch(
|
|||||||
createForm.require_oauth_only = false;
|
createForm.require_oauth_only = false;
|
||||||
createForm.require_privacy_set = false;
|
createForm.require_privacy_set = false;
|
||||||
}
|
}
|
||||||
|
resetModelsListState(createModelsListState);
|
||||||
|
loadModelsListCandidates("create", 0, newVal);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -3976,6 +4262,10 @@ watch(
|
|||||||
editForm.require_oauth_only = false;
|
editForm.require_oauth_only = false;
|
||||||
editForm.require_privacy_set = false;
|
editForm.require_privacy_set = false;
|
||||||
}
|
}
|
||||||
|
if (editingGroup.value) {
|
||||||
|
resetModelsListState(editModelsListState, editForm.platform === editingGroup.value.platform ? editingGroup.value.models_list_config : undefined);
|
||||||
|
loadModelsListCandidates("edit", editingGroup.value.id, newVal);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -4049,6 +4339,7 @@ const saveSortOrder = async () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadGroups();
|
loadGroups();
|
||||||
|
loadModelsListCandidates("create", 0, createForm.platform);
|
||||||
document.addEventListener("click", handleClickOutside);
|
document.addEventListener("click", handleClickOutside);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
125
frontend/src/views/admin/__tests__/groupsModelsList.spec.ts
Normal file
125
frontend/src/views/admin/__tests__/groupsModelsList.spec.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildModelsListConfig,
|
||||||
|
createModelsListState,
|
||||||
|
hydrateModelsListState,
|
||||||
|
invertModelsListSelection,
|
||||||
|
moveModelsListItem,
|
||||||
|
selectAllModelsListItems,
|
||||||
|
setModelsListCandidates,
|
||||||
|
toggleModelsListItem,
|
||||||
|
} from "../groupsModelsList";
|
||||||
|
|
||||||
|
describe("groupsModelsList", () => {
|
||||||
|
it("selects all default candidates for a new disabled config", () => {
|
||||||
|
const state = createModelsListState();
|
||||||
|
|
||||||
|
setModelsListCandidates(state, ["gpt-5.5", "gpt-5.4"]);
|
||||||
|
|
||||||
|
expect(state.enabled).toBe(false);
|
||||||
|
expect(state.items).toEqual([
|
||||||
|
{ id: "gpt-5.5", selected: true },
|
||||||
|
{ id: "gpt-5.4", selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps saved selections and marks new candidates as unselected when editing", () => {
|
||||||
|
const state = createModelsListState({
|
||||||
|
enabled: true,
|
||||||
|
models: ["gpt-5.5", "gpt-5.4"],
|
||||||
|
});
|
||||||
|
|
||||||
|
setModelsListCandidates(state, ["gpt-5.4", "legacy-gpt", "gpt-5.5"]);
|
||||||
|
|
||||||
|
expect(state.enabled).toBe(true);
|
||||||
|
expect(state.items).toEqual([
|
||||||
|
{ id: "gpt-5.5", selected: true },
|
||||||
|
{ id: "gpt-5.4", selected: true },
|
||||||
|
{ id: "legacy-gpt", selected: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves explicitly unselected saved candidates when candidates refresh", () => {
|
||||||
|
const state = createModelsListState({
|
||||||
|
enabled: true,
|
||||||
|
models: ["gpt-5.5"],
|
||||||
|
});
|
||||||
|
|
||||||
|
setModelsListCandidates(state, ["gpt-5.5", "gpt-5.4"]);
|
||||||
|
|
||||||
|
expect(state.items).toEqual([
|
||||||
|
{ id: "gpt-5.5", selected: true },
|
||||||
|
{ id: "gpt-5.4", selected: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds config with selected models in current display order", () => {
|
||||||
|
const state = hydrateModelsListState({
|
||||||
|
enabled: true,
|
||||||
|
models: ["gpt-5.5", "gpt-5.4", "legacy-gpt"],
|
||||||
|
}, ["gpt-5.5", "gpt-5.4", "legacy-gpt"]);
|
||||||
|
|
||||||
|
toggleModelsListItem(state, "legacy-gpt");
|
||||||
|
moveModelsListItem(state, 1, 0);
|
||||||
|
|
||||||
|
expect(buildModelsListConfig(state)).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
models: ["gpt-5.4", "gpt-5.5"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps selected models in payload even when disabled so reopening can restore choices", () => {
|
||||||
|
const state = hydrateModelsListState({
|
||||||
|
enabled: false,
|
||||||
|
models: ["gpt-5.5"],
|
||||||
|
}, ["gpt-5.5", "gpt-5.4"]);
|
||||||
|
|
||||||
|
expect(buildModelsListConfig(state)).toEqual({
|
||||||
|
enabled: false,
|
||||||
|
models: ["gpt-5.5"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves saved models when candidates have not loaded yet", () => {
|
||||||
|
const state = createModelsListState({
|
||||||
|
enabled: true,
|
||||||
|
models: ["gpt-5.5", "gpt-5.4"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(buildModelsListConfig(state)).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
models: ["gpt-5.5", "gpt-5.4"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects all candidate models from the toolbar action", () => {
|
||||||
|
const state = hydrateModelsListState({
|
||||||
|
enabled: true,
|
||||||
|
models: ["gpt-5.5"],
|
||||||
|
}, ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]);
|
||||||
|
|
||||||
|
selectAllModelsListItems(state);
|
||||||
|
|
||||||
|
expect(state.items).toEqual([
|
||||||
|
{ id: "gpt-5.5", selected: true },
|
||||||
|
{ id: "gpt-5.4", selected: true },
|
||||||
|
{ id: "gpt-5.4-mini", selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inverts selected models from the toolbar action", () => {
|
||||||
|
const state = hydrateModelsListState({
|
||||||
|
enabled: true,
|
||||||
|
models: ["gpt-5.5"],
|
||||||
|
}, ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]);
|
||||||
|
|
||||||
|
invertModelsListSelection(state);
|
||||||
|
|
||||||
|
expect(state.items).toEqual([
|
||||||
|
{ id: "gpt-5.5", selected: false },
|
||||||
|
{ id: "gpt-5.4", selected: true },
|
||||||
|
{ id: "gpt-5.4-mini", selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createModelsListCandidatesTracker,
|
||||||
|
} from "../groupsModelsListCandidates";
|
||||||
|
|
||||||
|
describe("groupsModelsListCandidates", () => {
|
||||||
|
it("rejects stale candidate responses after a newer platform request starts", () => {
|
||||||
|
const tracker = createModelsListCandidatesTracker();
|
||||||
|
const first = {
|
||||||
|
mode: "create" as const,
|
||||||
|
groupID: 0,
|
||||||
|
platform: "openai" as const,
|
||||||
|
};
|
||||||
|
const second = {
|
||||||
|
mode: "create" as const,
|
||||||
|
groupID: 0,
|
||||||
|
platform: "anthropic" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstID = tracker.next(first);
|
||||||
|
const secondID = tracker.next(second);
|
||||||
|
|
||||||
|
expect(tracker.isCurrent(firstID, first)).toBe(false);
|
||||||
|
expect(tracker.isCurrent(secondID, second)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects responses for a previous edit group even with the same platform", () => {
|
||||||
|
const tracker = createModelsListCandidatesTracker();
|
||||||
|
const first = {
|
||||||
|
mode: "edit" as const,
|
||||||
|
groupID: 10,
|
||||||
|
platform: "openai" as const,
|
||||||
|
};
|
||||||
|
const second = {
|
||||||
|
mode: "edit" as const,
|
||||||
|
groupID: 11,
|
||||||
|
platform: "openai" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstID = tracker.next(first);
|
||||||
|
tracker.next(second);
|
||||||
|
|
||||||
|
expect(tracker.isCurrent(firstID, first)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks create and edit requests independently", () => {
|
||||||
|
const tracker = createModelsListCandidatesTracker();
|
||||||
|
const editRequest = {
|
||||||
|
mode: "edit" as const,
|
||||||
|
groupID: 10,
|
||||||
|
platform: "openai" as const,
|
||||||
|
};
|
||||||
|
const createRequest = {
|
||||||
|
mode: "create" as const,
|
||||||
|
groupID: 0,
|
||||||
|
platform: "anthropic" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const editID = tracker.next(editRequest);
|
||||||
|
tracker.next(createRequest);
|
||||||
|
|
||||||
|
expect(tracker.isCurrent(editID, editRequest)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const groupsViewSource = readFileSync(
|
||||||
|
resolve(currentDir, "../GroupsView.vue"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("groups models list layout", () => {
|
||||||
|
it("keeps the toolbar outside of the scrolling list content", () => {
|
||||||
|
expect(groupsViewSource).toContain("overflow-hidden rounded-lg border");
|
||||||
|
expect(groupsViewSource).toContain("max-h-64 space-y-2 overflow-y-auto p-2");
|
||||||
|
expect(groupsViewSource).not.toContain("sticky top-0");
|
||||||
|
});
|
||||||
|
});
|
||||||
121
frontend/src/views/admin/groupsModelsList.ts
Normal file
121
frontend/src/views/admin/groupsModelsList.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
export interface ModelsListConfig {
|
||||||
|
enabled: boolean
|
||||||
|
models: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelsListItem {
|
||||||
|
id: string
|
||||||
|
selected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelsListState {
|
||||||
|
enabled: boolean
|
||||||
|
savedModels: string[]
|
||||||
|
items: ModelsListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createModelsListState = (
|
||||||
|
config?: Partial<ModelsListConfig> | null,
|
||||||
|
): ModelsListState => ({
|
||||||
|
enabled: config?.enabled ?? false,
|
||||||
|
savedModels: normalizeModels(config?.models ?? []),
|
||||||
|
items: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const hydrateModelsListState = (
|
||||||
|
config: Partial<ModelsListConfig> | null | undefined,
|
||||||
|
candidates: string[],
|
||||||
|
): ModelsListState => {
|
||||||
|
const state = createModelsListState(config)
|
||||||
|
setModelsListCandidates(state, candidates)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setModelsListCandidates = (
|
||||||
|
state: ModelsListState,
|
||||||
|
candidates: string[],
|
||||||
|
) => {
|
||||||
|
const normalizedCandidates = normalizeModels(candidates)
|
||||||
|
const currentSelected = new Set(
|
||||||
|
state.items.filter(item => item.selected).map(item => item.id),
|
||||||
|
)
|
||||||
|
const currentKnown = new Set(state.items.map(item => item.id))
|
||||||
|
const savedSelected = new Set(state.savedModels)
|
||||||
|
const hasExistingItems = state.items.length > 0
|
||||||
|
const selectionOrder = normalizeModels([
|
||||||
|
...state.items.map(item => item.id),
|
||||||
|
...state.savedModels,
|
||||||
|
...normalizedCandidates,
|
||||||
|
])
|
||||||
|
|
||||||
|
state.items = selectionOrder.map(id => {
|
||||||
|
const selected = hasExistingItems
|
||||||
|
? currentSelected.has(id)
|
||||||
|
: state.savedModels.length > 0
|
||||||
|
? savedSelected.has(id)
|
||||||
|
: normalizedCandidates.includes(id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
selected: selected && (currentKnown.has(id) || savedSelected.has(id) || state.savedModels.length === 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toggleModelsListItem = (state: ModelsListState, modelID: string) => {
|
||||||
|
const item = state.items.find(item => item.id === modelID)
|
||||||
|
if (item) {
|
||||||
|
item.selected = !item.selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selectAllModelsListItems = (state: ModelsListState) => {
|
||||||
|
state.items.forEach(item => {
|
||||||
|
item.selected = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const invertModelsListSelection = (state: ModelsListState) => {
|
||||||
|
state.items.forEach(item => {
|
||||||
|
item.selected = !item.selected
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moveModelsListItem = (
|
||||||
|
state: ModelsListState,
|
||||||
|
fromIndex: number,
|
||||||
|
toIndex: number,
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
fromIndex === toIndex ||
|
||||||
|
fromIndex < 0 ||
|
||||||
|
toIndex < 0 ||
|
||||||
|
fromIndex >= state.items.length ||
|
||||||
|
toIndex >= state.items.length
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [item] = state.items.splice(fromIndex, 1)
|
||||||
|
state.items.splice(toIndex, 0, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildModelsListConfig = (state: ModelsListState): ModelsListConfig => ({
|
||||||
|
enabled: state.enabled,
|
||||||
|
models: state.items.length > 0
|
||||||
|
? state.items.filter(item => item.selected).map(item => item.id)
|
||||||
|
: [...state.savedModels],
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeModels = (models: string[]): string[] => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: string[] = []
|
||||||
|
for (const raw of models) {
|
||||||
|
const model = raw.trim()
|
||||||
|
if (!model || seen.has(model)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(model)
|
||||||
|
out.push(model)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
41
frontend/src/views/admin/groupsModelsListCandidates.ts
Normal file
41
frontend/src/views/admin/groupsModelsListCandidates.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import type { GroupPlatform } from "@/types";
|
||||||
|
|
||||||
|
export type ModelsListCandidatesMode = "create" | "edit";
|
||||||
|
|
||||||
|
export interface ModelsListCandidatesRequest {
|
||||||
|
mode: ModelsListCandidatesMode;
|
||||||
|
groupID: number;
|
||||||
|
platform: GroupPlatform;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelsListCandidatesTracker {
|
||||||
|
next(request: ModelsListCandidatesRequest): number;
|
||||||
|
isCurrent(requestID: number, request: ModelsListCandidatesRequest): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createModelsListCandidatesTracker = (): ModelsListCandidatesTracker => {
|
||||||
|
let currentRequestID = 0;
|
||||||
|
const currentByMode: Partial<Record<ModelsListCandidatesMode, {
|
||||||
|
id: number;
|
||||||
|
request: ModelsListCandidatesRequest;
|
||||||
|
}>> = {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
next(request) {
|
||||||
|
currentRequestID += 1;
|
||||||
|
currentByMode[request.mode] = {
|
||||||
|
id: currentRequestID,
|
||||||
|
request: { ...request },
|
||||||
|
};
|
||||||
|
return currentRequestID;
|
||||||
|
},
|
||||||
|
isCurrent(requestID, request) {
|
||||||
|
const current = currentByMode[request.mode];
|
||||||
|
return (
|
||||||
|
current?.id === requestID &&
|
||||||
|
current.request.groupID === request.groupID &&
|
||||||
|
current.request.platform === request.platform
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user