From 1e28ef59423f121761fd0b7bb95e2fac03ebe823 Mon Sep 17 00:00:00 2001 From: Zero Clover Date: Fri, 4 Sep 2026 23:40:41 +0800 Subject: [PATCH] feat(codex): pinned-accounts Codex model manifest for OpenAI groups --- backend/ent/group.go | 17 +- backend/ent/group/group.go | 5 + backend/ent/group_create.go | 65 ++++ backend/ent/group_update.go | 34 ++ backend/ent/migrate/schema.go | 1 + backend/ent/mutation.go | 56 +++- backend/ent/runtime/runtime.go | 18 +- backend/ent/schema/group.go | 4 + .../domain/codex_models_manifest_config.go | 15 + .../internal/handler/admin/group_handler.go | 6 + .../dto/group_mapper_codex_manifest_test.go | 41 +++ backend/internal/handler/dto/mappers.go | 1 + backend/internal/handler/dto/types.go | 2 + .../handler/openai_codex_models_handler.go | 39 +++ .../openai_codex_models_handler_test.go | 315 +++++++++++++++++ backend/internal/repository/api_key_repo.go | 2 + ...ex_manifest_projection_integration_test.go | 52 +++ .../repository/fixtures_integration_test.go | 1 + backend/internal/repository/group_repo.go | 2 + backend/internal/service/admin_group.go | 28 +- .../internal/service/admin_group_duplicate.go | 2 + backend/internal/service/admin_service.go | 4 + .../service/admin_service_group_test.go | 205 ++++++++++++ .../internal/service/api_key_auth_cache.go | 3 + .../api_key_auth_cache_codex_manifest_test.go | 43 +++ .../service/api_key_auth_cache_impl.go | 4 +- .../api_key_auth_cache_openai_fast_test.go | 2 +- .../service/api_key_auth_cache_profit_test.go | 2 +- backend/internal/service/group.go | 4 + .../service/group_codex_models_manifest.go | 86 +++++ .../service/openai_codex_models_pinned.go | 214 ++++++++++++ .../openai_codex_models_pinned_test.go | 82 +++++ .../service/openai_codex_models_service.go | 91 +++-- .../openai_codex_models_service_test.go | 316 +++++++++++++++++- ...234_group_codex_models_manifest_config.sql | 5 + .../group/CodexManifestAccountsField.vue | 290 ++++++++++++++++ .../CodexManifestAccountsField.spec.ts | 212 ++++++++++++ .../src/i18n/locales/en/admin/overview.ts | 13 + .../src/i18n/locales/zh/admin/overview.ts | 13 + frontend/src/types/index.ts | 10 + frontend/src/views/admin/GroupsView.vue | 71 ++++ 41 files changed, 2308 insertions(+), 68 deletions(-) create mode 100644 backend/internal/domain/codex_models_manifest_config.go create mode 100644 backend/internal/handler/dto/group_mapper_codex_manifest_test.go create mode 100644 backend/internal/repository/api_key_repo_codex_manifest_projection_integration_test.go create mode 100644 backend/internal/service/api_key_auth_cache_codex_manifest_test.go create mode 100644 backend/internal/service/group_codex_models_manifest.go create mode 100644 backend/internal/service/openai_codex_models_pinned.go create mode 100644 backend/internal/service/openai_codex_models_pinned_test.go create mode 100644 backend/migrations/234_group_codex_models_manifest_config.sql create mode 100644 frontend/src/components/admin/group/CodexManifestAccountsField.vue create mode 100644 frontend/src/components/admin/group/__tests__/CodexManifestAccountsField.spec.ts diff --git a/backend/ent/group.go b/backend/ent/group.go index bb49f43820..91f0761670 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -136,13 +136,15 @@ type Group struct { MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"` // 自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度 ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config,omitempty"` + // 固定账号获取 Codex Model Manifest 配置;开启后 /models 请求只用选定账号拉取(仅 openai 平台) + CodexModelsManifestConfig domain.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config,omitempty"` // 分组 RPM 上限,0 表示不限制;设置后接管该分组用户的限流 RpmLimit int `json:"rpm_limit,omitempty"` // OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"` // 超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问 MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"` - // OpenAI reasoning effort 自定义精确映射;先映射再应用上限 + // OpenAI reasoning effort 自定义映射;可按模型精确名、前缀或后缀限定,先映射再应用上限 ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings,omitempty"` // 是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池 ProfitControlEnabled bool `json:"profit_control_enabled,omitempty"` @@ -256,7 +258,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings: + case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings: values[i] = new([]byte) case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled: values[i] = new(sql.NullBool) @@ -668,6 +670,14 @@ func (_m *Group) assignValues(columns []string, values []any) error { return fmt.Errorf("unmarshal field models_list_config: %w", err) } } + case group.FieldCodexModelsManifestConfig: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field codex_models_manifest_config", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.CodexModelsManifestConfig); err != nil { + return fmt.Errorf("unmarshal field codex_models_manifest_config: %w", err) + } + } case group.FieldRpmLimit: if value, ok := values[i].(*sql.NullInt64); !ok { return fmt.Errorf("unexpected type %T for field rpm_limit", values[i]) @@ -1000,6 +1010,9 @@ func (_m *Group) String() string { builder.WriteString("models_list_config=") builder.WriteString(fmt.Sprintf("%v", _m.ModelsListConfig)) builder.WriteString(", ") + builder.WriteString("codex_models_manifest_config=") + builder.WriteString(fmt.Sprintf("%v", _m.CodexModelsManifestConfig)) + builder.WriteString(", ") builder.WriteString("rpm_limit=") builder.WriteString(fmt.Sprintf("%v", _m.RpmLimit)) builder.WriteString(", ") diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 2b634acee5..76719581d0 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -132,6 +132,8 @@ const ( FieldMessagesDispatchModelConfig = "messages_dispatch_model_config" // FieldModelsListConfig holds the string denoting the models_list_config field in the database. FieldModelsListConfig = "models_list_config" + // FieldCodexModelsManifestConfig holds the string denoting the codex_models_manifest_config field in the database. + FieldCodexModelsManifestConfig = "codex_models_manifest_config" // FieldRpmLimit holds the string denoting the rpm_limit field in the database. FieldRpmLimit = "rpm_limit" // FieldMaxReasoningEffort holds the string denoting the max_reasoning_effort field in the database. @@ -279,6 +281,7 @@ var Columns = []string{ FieldDefaultMappedModel, FieldMessagesDispatchModelConfig, FieldModelsListConfig, + FieldCodexModelsManifestConfig, FieldRpmLimit, FieldMaxReasoningEffort, FieldMaxReasoningEffortOverLimit, @@ -411,6 +414,8 @@ var ( DefaultMessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig // DefaultModelsListConfig holds the default value on creation for the "models_list_config" field. DefaultModelsListConfig domain.GroupModelsListConfig + // DefaultCodexModelsManifestConfig holds the default value on creation for the "codex_models_manifest_config" field. + DefaultCodexModelsManifestConfig domain.GroupCodexModelsManifestConfig // DefaultRpmLimit holds the default value on creation for the "rpm_limit" field. DefaultRpmLimit int // DefaultMaxReasoningEffort holds the default value on creation for the "max_reasoning_effort" field. diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 75b6ed1151..0e75ad78c1 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -802,6 +802,20 @@ func (_c *GroupCreate) SetNillableModelsListConfig(v *domain.GroupModelsListConf return _c } +// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field. +func (_c *GroupCreate) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupCreate { + _c.mutation.SetCodexModelsManifestConfig(v) + return _c +} + +// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil. +func (_c *GroupCreate) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupCreate { + if v != nil { + _c.SetCodexModelsManifestConfig(*v) + } + return _c +} + // SetRpmLimit sets the "rpm_limit" field. func (_c *GroupCreate) SetRpmLimit(v int) *GroupCreate { _c.mutation.SetRpmLimit(v) @@ -1165,6 +1179,10 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultModelsListConfig _c.mutation.SetModelsListConfig(v) } + if _, ok := _c.mutation.CodexModelsManifestConfig(); !ok { + v := group.DefaultCodexModelsManifestConfig + _c.mutation.SetCodexModelsManifestConfig(v) + } if _, ok := _c.mutation.RpmLimit(); !ok { v := group.DefaultRpmLimit _c.mutation.SetRpmLimit(v) @@ -1366,6 +1384,9 @@ func (_c *GroupCreate) check() error { 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.CodexModelsManifestConfig(); !ok { + return &ValidationError{Name: "codex_models_manifest_config", err: errors.New(`ent: missing required field "Group.codex_models_manifest_config"`)} + } if _, ok := _c.mutation.RpmLimit(); !ok { return &ValidationError{Name: "rpm_limit", err: errors.New(`ent: missing required field "Group.rpm_limit"`)} } @@ -1656,6 +1677,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value) _node.ModelsListConfig = value } + if value, ok := _c.mutation.CodexModelsManifestConfig(); ok { + _spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value) + _node.CodexModelsManifestConfig = value + } if value, ok := _c.mutation.RpmLimit(); ok { _spec.SetField(group.FieldRpmLimit, field.TypeInt, value) _node.RpmLimit = value @@ -2782,6 +2807,18 @@ func (u *GroupUpsert) UpdateModelsListConfig() *GroupUpsert { return u } +// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field. +func (u *GroupUpsert) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsert { + u.Set(group.FieldCodexModelsManifestConfig, v) + return u +} + +// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create. +func (u *GroupUpsert) UpdateCodexModelsManifestConfig() *GroupUpsert { + u.SetExcluded(group.FieldCodexModelsManifestConfig) + return u +} + // SetRpmLimit sets the "rpm_limit" field. func (u *GroupUpsert) SetRpmLimit(v int) *GroupUpsert { u.Set(group.FieldRpmLimit, v) @@ -4031,6 +4068,20 @@ func (u *GroupUpsertOne) UpdateModelsListConfig() *GroupUpsertOne { }) } +// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field. +func (u *GroupUpsertOne) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetCodexModelsManifestConfig(v) + }) +} + +// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateCodexModelsManifestConfig() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateCodexModelsManifestConfig() + }) +} + // SetRpmLimit sets the "rpm_limit" field. func (u *GroupUpsertOne) SetRpmLimit(v int) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -5463,6 +5514,20 @@ func (u *GroupUpsertBulk) UpdateModelsListConfig() *GroupUpsertBulk { }) } +// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field. +func (u *GroupUpsertBulk) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetCodexModelsManifestConfig(v) + }) +} + +// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateCodexModelsManifestConfig() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateCodexModelsManifestConfig() + }) +} + // SetRpmLimit sets the "rpm_limit" field. func (u *GroupUpsertBulk) SetRpmLimit(v int) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index fac039fa95..19177d4bd4 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -1087,6 +1087,20 @@ func (_u *GroupUpdate) SetNillableModelsListConfig(v *domain.GroupModelsListConf return _u } +// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field. +func (_u *GroupUpdate) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpdate { + _u.mutation.SetCodexModelsManifestConfig(v) + return _u +} + +// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupUpdate { + if v != nil { + _u.SetCodexModelsManifestConfig(*v) + } + return _u +} + // SetRpmLimit sets the "rpm_limit" field. func (_u *GroupUpdate) SetRpmLimit(v int) *GroupUpdate { _u.mutation.ResetRpmLimit() @@ -1865,6 +1879,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.ModelsListConfig(); ok { _spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value) } + if value, ok := _u.mutation.CodexModelsManifestConfig(); ok { + _spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value) + } if value, ok := _u.mutation.RpmLimit(); ok { _spec.SetField(group.FieldRpmLimit, field.TypeInt, value) } @@ -3264,6 +3281,20 @@ func (_u *GroupUpdateOne) SetNillableModelsListConfig(v *domain.GroupModelsListC return _u } +// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field. +func (_u *GroupUpdateOne) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpdateOne { + _u.mutation.SetCodexModelsManifestConfig(v) + return _u +} + +// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupUpdateOne { + if v != nil { + _u.SetCodexModelsManifestConfig(*v) + } + return _u +} + // SetRpmLimit sets the "rpm_limit" field. func (_u *GroupUpdateOne) SetRpmLimit(v int) *GroupUpdateOne { _u.mutation.ResetRpmLimit() @@ -4072,6 +4103,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.ModelsListConfig(); ok { _spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value) } + if value, ok := _u.mutation.CodexModelsManifestConfig(); ok { + _spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value) + } if value, ok := _u.mutation.RpmLimit(); ok { _spec.SetField(group.FieldRpmLimit, field.TypeInt, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 59aba5be05..0c08716e1a 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -961,6 +961,7 @@ var ( {Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""}, {Name: "messages_dispatch_model_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, {Name: "models_list_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, + {Name: "codex_models_manifest_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, {Name: "rpm_limit", Type: field.TypeInt, Default: 0}, {Name: "max_reasoning_effort", Type: field.TypeString, Size: 20, Default: ""}, {Name: "max_reasoning_effort_over_limit", Type: field.TypeString, Size: 20, Default: "downgrade"}, diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 2e166a6a84..522bbd3a21 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -22165,6 +22165,7 @@ type GroupMutation struct { default_mapped_model *string messages_dispatch_model_config *domain.OpenAIMessagesDispatchModelConfig models_list_config *domain.GroupModelsListConfig + codex_models_manifest_config *domain.GroupCodexModelsManifestConfig rpm_limit *int addrpm_limit *int max_reasoning_effort *string @@ -25199,6 +25200,42 @@ func (m *GroupMutation) ResetModelsListConfig() { m.models_list_config = nil } +// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field. +func (m *GroupMutation) SetCodexModelsManifestConfig(dcmmc domain.GroupCodexModelsManifestConfig) { + m.codex_models_manifest_config = &dcmmc +} + +// CodexModelsManifestConfig returns the value of the "codex_models_manifest_config" field in the mutation. +func (m *GroupMutation) CodexModelsManifestConfig() (r domain.GroupCodexModelsManifestConfig, exists bool) { + v := m.codex_models_manifest_config + if v == nil { + return + } + return *v, true +} + +// OldCodexModelsManifestConfig returns the old "codex_models_manifest_config" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldCodexModelsManifestConfig(ctx context.Context) (v domain.GroupCodexModelsManifestConfig, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCodexModelsManifestConfig is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCodexModelsManifestConfig requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCodexModelsManifestConfig: %w", err) + } + return oldValue.CodexModelsManifestConfig, nil +} + +// ResetCodexModelsManifestConfig resets all changes to the "codex_models_manifest_config" field. +func (m *GroupMutation) ResetCodexModelsManifestConfig() { + m.codex_models_manifest_config = nil +} + // SetRpmLimit sets the "rpm_limit" field. func (m *GroupMutation) SetRpmLimit(i int) { m.rpm_limit = &i @@ -25884,7 +25921,7 @@ func (m *GroupMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GroupMutation) Fields() []string { - fields := make([]string, 0, 65) + fields := make([]string, 0, 66) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -26059,6 +26096,9 @@ func (m *GroupMutation) Fields() []string { if m.models_list_config != nil { fields = append(fields, group.FieldModelsListConfig) } + if m.codex_models_manifest_config != nil { + fields = append(fields, group.FieldCodexModelsManifestConfig) + } if m.rpm_limit != nil { fields = append(fields, group.FieldRpmLimit) } @@ -26204,6 +26244,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.MessagesDispatchModelConfig() case group.FieldModelsListConfig: return m.ModelsListConfig() + case group.FieldCodexModelsManifestConfig: + return m.CodexModelsManifestConfig() case group.FieldRpmLimit: return m.RpmLimit() case group.FieldMaxReasoningEffort: @@ -26343,6 +26385,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldMessagesDispatchModelConfig(ctx) case group.FieldModelsListConfig: return m.OldModelsListConfig(ctx) + case group.FieldCodexModelsManifestConfig: + return m.OldCodexModelsManifestConfig(ctx) case group.FieldRpmLimit: return m.OldRpmLimit(ctx) case group.FieldMaxReasoningEffort: @@ -26772,6 +26816,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetModelsListConfig(v) return nil + case group.FieldCodexModelsManifestConfig: + v, ok := value.(domain.GroupCodexModelsManifestConfig) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCodexModelsManifestConfig(v) + return nil case group.FieldRpmLimit: v, ok := value.(int) if !ok { @@ -27506,6 +27557,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldModelsListConfig: m.ResetModelsListConfig() return nil + case group.FieldCodexModelsManifestConfig: + m.ResetCodexModelsManifestConfig() + return nil case group.FieldRpmLimit: m.ResetRpmLimit() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 1f84b42da9..456f5248de 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1187,36 +1187,40 @@ func init() { groupDescModelsListConfig := groupFields[54].Descriptor() // group.DefaultModelsListConfig holds the default value on creation for the models_list_config field. group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig) + // groupDescCodexModelsManifestConfig is the schema descriptor for codex_models_manifest_config field. + groupDescCodexModelsManifestConfig := groupFields[55].Descriptor() + // group.DefaultCodexModelsManifestConfig holds the default value on creation for the codex_models_manifest_config field. + group.DefaultCodexModelsManifestConfig = groupDescCodexModelsManifestConfig.Default.(domain.GroupCodexModelsManifestConfig) // groupDescRpmLimit is the schema descriptor for rpm_limit field. - groupDescRpmLimit := groupFields[55].Descriptor() + groupDescRpmLimit := groupFields[56].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) // groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field. - groupDescMaxReasoningEffort := groupFields[56].Descriptor() + groupDescMaxReasoningEffort := groupFields[57].Descriptor() // group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field. group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string) // group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save. group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error) // groupDescMaxReasoningEffortOverLimit is the schema descriptor for max_reasoning_effort_over_limit field. - groupDescMaxReasoningEffortOverLimit := groupFields[57].Descriptor() + groupDescMaxReasoningEffortOverLimit := groupFields[58].Descriptor() // group.DefaultMaxReasoningEffortOverLimit holds the default value on creation for the max_reasoning_effort_over_limit field. group.DefaultMaxReasoningEffortOverLimit = groupDescMaxReasoningEffortOverLimit.Default.(string) // group.MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save. group.MaxReasoningEffortOverLimitValidator = groupDescMaxReasoningEffortOverLimit.Validators[0].(func(string) error) // groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field. - groupDescReasoningEffortMappings := groupFields[58].Descriptor() + groupDescReasoningEffortMappings := groupFields[59].Descriptor() // group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field. group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping) // groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field. - groupDescProfitControlEnabled := groupFields[59].Descriptor() + groupDescProfitControlEnabled := groupFields[60].Descriptor() // group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field. group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool) // groupDescProfitMinMargin is the schema descriptor for profit_min_margin field. - groupDescProfitMinMargin := groupFields[60].Descriptor() + groupDescProfitMinMargin := groupFields[61].Descriptor() // group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field. group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64) // groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field. - groupDescProfitSafetyBuffer := groupFields[61].Descriptor() + groupDescProfitSafetyBuffer := groupFields[62].Descriptor() // group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field. group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 38f48b4fdf..46c2214491 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -266,6 +266,10 @@ func (Group) Fields() []ent.Field { Default(domain.GroupModelsListConfig{}). SchemaType(map[string]string{dialect.Postgres: "jsonb"}). Comment("自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度"), + field.JSON("codex_models_manifest_config", domain.GroupCodexModelsManifestConfig{}). + Default(domain.GroupCodexModelsManifestConfig{}). + SchemaType(map[string]string{dialect.Postgres: "jsonb"}). + Comment("固定账号获取 Codex Model Manifest 配置;开启后 /models 请求只用选定账号拉取(仅 openai 平台)"), // 分组级每分钟请求数上限(0 = 不限制)。设置后优先于用户级兜底生效。 field.Int("rpm_limit"). diff --git a/backend/internal/domain/codex_models_manifest_config.go b/backend/internal/domain/codex_models_manifest_config.go new file mode 100644 index 0000000000..22b36d8b46 --- /dev/null +++ b/backend/internal/domain/codex_models_manifest_config.go @@ -0,0 +1,15 @@ +package domain + +// GroupCodexModelsManifestConfig controls which accounts fetch the Codex +// models manifest for an OpenAI group. When enabled, the group's Codex +// /models requests bypass the scheduler and only use the pinned accounts. +type GroupCodexModelsManifestConfig struct { + Enabled bool `json:"enabled"` + // AccountIDs is the ordered list of pinned account IDs. Order decides + // merge precedence for duplicate model slugs. + AccountIDs []int64 `json:"account_ids,omitempty"` + // FallbackToScheduler controls the behavior when no pinned account is + // usable or all upstream fetches fail: fall back to the scheduler loop + // (true) or return the upstream error / 503 (false, default). + FallbackToScheduler bool `json:"fallback_to_scheduler,omitempty"` +} diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index fa6991ee66..b6620015c1 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -154,6 +154,8 @@ type CreateGroupRequest struct { DefaultMappedModel string `json:"default_mapped_model"` MessagesDispatchModelConfig service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"` ModelsListConfig service.GroupModelsListConfig `json:"models_list_config"` + // 固定账号 manifest 配置;创建路径禁止开启,仅编辑可配置。 + CodexModelsManifestConfig service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"` // 分组 RPM 上限(0 = 不限制) RPMLimit int `json:"rpm_limit"` // OpenAI/Codex 请求推理强度上限,空字符串表示不限制。 @@ -227,6 +229,8 @@ type UpdateGroupRequest struct { DefaultMappedModel *string `json:"default_mapped_model"` MessagesDispatchModelConfig *service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"` ModelsListConfig *service.GroupModelsListConfig `json:"models_list_config"` + // 固定账号 manifest 配置;nil 表示不修改。 + CodexModelsManifestConfig *service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"` // 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动 RPMLimit *int `json:"rpm_limit"` // OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。 @@ -565,6 +569,7 @@ func (h *GroupHandler) Create(c *gin.Context) { DefaultMappedModel: req.DefaultMappedModel, MessagesDispatchModelConfig: req.MessagesDispatchModelConfig, ModelsListConfig: req.ModelsListConfig, + CodexModelsManifestConfig: req.CodexModelsManifestConfig, RPMLimit: req.RPMLimit, MaxReasoningEffort: req.MaxReasoningEffort, MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit, @@ -697,6 +702,7 @@ func (h *GroupHandler) Update(c *gin.Context) { DefaultMappedModel: req.DefaultMappedModel, MessagesDispatchModelConfig: req.MessagesDispatchModelConfig, ModelsListConfig: req.ModelsListConfig, + CodexModelsManifestConfig: req.CodexModelsManifestConfig, RPMLimit: req.RPMLimit, MaxReasoningEffort: req.MaxReasoningEffort, MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit, diff --git a/backend/internal/handler/dto/group_mapper_codex_manifest_test.go b/backend/internal/handler/dto/group_mapper_codex_manifest_test.go new file mode 100644 index 0000000000..d5f550d6e4 --- /dev/null +++ b/backend/internal/handler/dto/group_mapper_codex_manifest_test.go @@ -0,0 +1,41 @@ +package dto + +import ( + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +// 固定账号 manifest 配置必须在管理端 DTO 完整序列化:漏映射会让编辑对话框回显 +// 关闭状态,管理员保存无关字段时会把已开启的配置静默关掉。用户侧 DTO 不携带该字段。 +func TestGroupMapperRoundTripsCodexModelsManifestConfig(t *testing.T) { + group := &service.Group{ + ID: 31, Name: "codex-manifest-dto", Platform: service.PlatformOpenAI, Status: service.StatusActive, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{101, 202}, + FallbackToScheduler: true, + }, + } + + adminJSON, err := json.Marshal(GroupFromServiceAdmin(group)) + require.NoError(t, err) + var adminEnvelope struct { + CodexModelsManifestConfig *struct { + Enabled bool `json:"enabled"` + AccountIDs []int64 `json:"account_ids"` + FallbackToScheduler bool `json:"fallback_to_scheduler"` + } `json:"codex_models_manifest_config"` + } + require.NoError(t, json.Unmarshal(adminJSON, &adminEnvelope)) + require.NotNil(t, adminEnvelope.CodexModelsManifestConfig, "GroupFromServiceAdmin 漏映射 codex_models_manifest_config") + require.True(t, adminEnvelope.CodexModelsManifestConfig.Enabled) + require.Equal(t, []int64{101, 202}, adminEnvelope.CodexModelsManifestConfig.AccountIDs) + require.True(t, adminEnvelope.CodexModelsManifestConfig.FallbackToScheduler) + + userJSON, err := json.Marshal(GroupFromService(group)) + require.NoError(t, err) + require.NotContains(t, string(userJSON), "codex_models_manifest_config", "用户侧分组 DTO 不得暴露管理端 manifest 配置") +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index e6759a110c..c9978d22aa 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -160,6 +160,7 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { DefaultMappedModel: g.DefaultMappedModel, MessagesDispatchModelConfig: g.MessagesDispatchModelConfig, ModelsListConfig: g.ModelsListConfig, + CodexModelsManifestConfig: g.CodexModelsManifestConfig, SupportedModelScopes: g.SupportedModelScopes, AccountCount: g.AccountCount, ActiveAccountCount: g.ActiveAccountCount, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 7fb8373810..bfd3a42aa2 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -190,6 +190,8 @@ type AdminGroup struct { DefaultMappedModel string `json:"default_mapped_model"` MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"` ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config"` + // 固定账号获取 Codex Model Manifest 配置(仅 openai 平台使用)。 + CodexModelsManifestConfig domain.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"` // 支持的模型系列(仅 antigravity 平台使用) SupportedModelScopes []string `json:"supported_model_scopes"` diff --git a/backend/internal/handler/openai_codex_models_handler.go b/backend/internal/handler/openai_codex_models_handler.go index 3a38ce5154..73d8951ca9 100644 --- a/backend/internal/handler/openai_codex_models_handler.go +++ b/backend/internal/handler/openai_codex_models_handler.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "net/http" "github.com/gin-gonic/gin" @@ -50,6 +51,44 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { return } + // 固定账号分支:开启后只用选定账号拉取 manifest,不经过调度器; + // 全部不可用/全部失败时按 FallbackToScheduler 决定回退调度器或返回错误。 + if apiKey.Group.Platform == service.PlatformOpenAI && + apiKey.Group.CodexModelsManifestConfig.Enabled && + len(apiKey.Group.CodexModelsManifestConfig.AccountIDs) > 0 { + pinnedManifest, pinnedAccount, pinnedErr := h.gatewayService.FetchPinnedCodexModelsManifest( + c.Request.Context(), + apiKey.Group, + c.Query("client_version"), + ) + if pinnedErr != nil { + if c.Request.Context().Err() != nil { + return + } + if !apiKey.Group.CodexModelsManifestConfig.FallbackToScheduler { + if errors.Is(pinnedErr, service.ErrNoPinnedCodexModelsAccounts) { + h.errorResponse(c, http.StatusServiceUnavailable, "upstream_error", "No available pinned OpenAI accounts") + return + } + h.errorResponse(c, infraerrors.Code(pinnedErr), "upstream_error", infraerrors.Message(pinnedErr)) + return + } + // 回退开启:跌入下方调度器循环。 + } else { + // 让 ops 错误日志携带实际拉取成功的首个固定账号。 + setOpsSelectedAccount(c, pinnedAccount.ID, pinnedAccount.Platform) + if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, pinnedManifest, ifNoneMatch); err != nil { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest") + return + } + if c.Request.Context().Err() != nil { + return + } + writeCodexModelsManifestResponse(c, pinnedManifest) + return + } + } + maxAccountSwitches := h.maxAccountSwitches if maxAccountSwitches <= 0 { maxAccountSwitches = 3 diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index aed2ba34b6..89d3763602 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -9,9 +9,11 @@ import ( "net" "net/http" "net/http/httptest" + "sort" "strings" "sync" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/config" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" @@ -640,3 +642,316 @@ func equalInt64Slices(got, want []int64) bool { } return true } + +// --- 固定账号 manifest 模式 --- + +// codexModelsPinnedHTTPUpstream 按账号返回不同 manifest/状态的测试上游。 +type codexModelsPinnedHTTPUpstream struct { + service.HTTPUpstream + mu sync.Mutex + calls []int64 + bodies map[int64]string + statuses map[int64]int +} + +func (u *codexModelsPinnedHTTPUpstream) Do(_ *http.Request, _ string, accountID int64, _ int) (*http.Response, error) { + u.mu.Lock() + u.calls = append(u.calls, accountID) + u.mu.Unlock() + if status, ok := u.statuses[accountID]; ok { + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"upstream boom"}}`)), + }, nil + } + body, ok := u.bodies[accountID] + if !ok { + body = `{"models":[]}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + }, nil +} + +func (u *codexModelsPinnedHTTPUpstream) accountIDs() []int64 { + u.mu.Lock() + defer u.mu.Unlock() + ids := append([]int64(nil), u.calls...) + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids +} + +func newPinnedCodexAccount(id int64, status string, schedulable bool, rateLimited bool) service.Account { + account := service.Account{ + ID: id, + Name: fmt.Sprintf("pinned-%d", id), + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: status, + Schedulable: schedulable, + Priority: int(id), + Concurrency: 1, + Credentials: map[string]any{ + "api_key": fmt.Sprintf("sk-pinned-%d", id), + "base_url": fmt.Sprintf("https://pinned-%d.example/v1", id), + }, + } + if rateLimited { + reset := time.Now().Add(10 * time.Minute) + account.RateLimitResetAt = &reset + } + return account +} + +func newPinnedCodexTestHandler(accounts []service.Account, upstream *codexModelsPinnedHTTPUpstream, maxSwitches int) *OpenAIGatewayHandler { + gin.SetMode(gin.TestMode) + cfg := &config.Config{RunMode: config.RunModeSimple} + gatewayService := service.NewOpenAIGatewayService( + codexModelsFailoverAccountRepo{accounts: accounts}, + nil, nil, nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil, + upstream, + nil, nil, nil, nil, nil, nil, nil, nil, + ) + return &OpenAIGatewayHandler{gatewayService: gatewayService, maxAccountSwitches: maxSwitches} +} + +func performPinnedCodexModelsRequest(t *testing.T, handler *OpenAIGatewayHandler, group *service.Group, etag string) *httptest.ResponseRecorder { + return performCodexModelsRequestForGroup(t, handler, group, etag) +} + +func TestCodexModelsPinnedAccountsMergeUnionWithoutScheduler(t *testing.T) { + // 账号 1 优先级最高(调度器会先选它);固定配置只用 2、3, + // 返回并集且不打账号 1,即可证明没有经过调度器。 + accounts := []service.Account{ + newPinnedCodexAccount(1, service.StatusActive, true, false), + newPinnedCodexAccount(2, service.StatusActive, true, false), + newPinnedCodexAccount(3, service.StatusActive, true, false), + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{ + 1: `{"models":[{"slug":"scheduler-only"}]}`, + 2: `{"models":[{"slug":"model-a"}]}`, + 3: `{"models":[{"slug":"model-a"},{"slug":"model-c"}]}`, + }} + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 77, + Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2, 3}, + }, + } + + recorder := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []string{"model-a", "model-c"}, codexHandlerManifestSlugs(t, recorder)) + require.Equal(t, []int64{2, 3}, upstream.accountIDs(), "固定账号模式不得调用调度器或非固定账号") +} + +func TestCodexModelsPinnedAccountsUseRateLimitedAccountAndSkipUnavailable(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(2, service.StatusActive, true, true), // 限流中:仍被使用 + newPinnedCodexAccount(3, service.StatusActive, false, false), // 调度开关关闭:跳过 + newPinnedCodexAccount(4, service.StatusDisabled, true, false), // 停用:跳过 + newPinnedCodexAccount(5, service.StatusActive, true, false), // 正常 + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{ + 2: `{"models":[{"slug":"from-rate-limited"}]}`, + 5: `{"models":[{"slug":"model-five"}]}`, + }} + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 78, + Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2, 3, 4, 5, 99}, // 99 不在分组:跳过 + }, + } + + recorder := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []string{"from-rate-limited", "model-five"}, codexHandlerManifestSlugs(t, recorder)) + require.Equal(t, []int64{2, 5}, upstream.accountIDs()) +} + +func TestCodexModelsPinnedAccountsPartialFailureStillSucceeds(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(2, service.StatusActive, true, false), + newPinnedCodexAccount(3, service.StatusActive, true, false), + } + upstream := &codexModelsPinnedHTTPUpstream{ + bodies: map[int64]string{2: `{"models":[{"slug":"model-a"}]}`}, + statuses: map[int64]int{3: http.StatusServiceUnavailable}, + } + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 79, + Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2, 3}, + }, + } + + recorder := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []string{"model-a"}, codexHandlerManifestSlugs(t, recorder)) +} + +func TestCodexModelsPinnedAccountsAllUnavailableReturns503ByDefault(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(2, service.StatusActive, false, false), + } + upstream := &codexModelsPinnedHTTPUpstream{} + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 80, + Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2}, + }, + } + + recorder := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String()) + require.Empty(t, upstream.accountIDs()) +} + +func TestCodexModelsPinnedAccountsAllFailedReturnsUpstreamError(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(2, service.StatusActive, true, false), + newPinnedCodexAccount(3, service.StatusActive, true, false), + } + upstream := &codexModelsPinnedHTTPUpstream{statuses: map[int64]int{ + 2: http.StatusServiceUnavailable, + 3: http.StatusGatewayTimeout, + }} + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 81, + Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2, 3}, + }, + } + + recorder := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusBadGateway, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), "upstream error 504", "全部失败时返回最后一个上游错误") +} + +func TestCodexModelsPinnedAccountsFallbackToScheduler(t *testing.T) { + // 固定账号 2 不可用且回退开启:跌入调度器,选中优先级最高的账号 1。 + accounts := []service.Account{ + newPinnedCodexAccount(1, service.StatusActive, true, false), + newPinnedCodexAccount(2, service.StatusActive, false, false), + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{ + 1: `{"models":[{"slug":"from-scheduler"}]}`, + }} + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 82, + Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2}, + FallbackToScheduler: true, + }, + } + + recorder := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []string{"from-scheduler"}, codexHandlerManifestSlugs(t, recorder)) + require.Equal(t, []int64{1}, upstream.accountIDs()) +} + +func TestCodexModelsPinnedAccountsFallbackToSchedulerOnAllFailed(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(1, service.StatusActive, true, false), + newPinnedCodexAccount(2, service.StatusActive, true, false), + } + upstream := &codexModelsPinnedHTTPUpstream{ + bodies: map[int64]string{1: `{"models":[{"slug":"from-scheduler"}]}`}, + statuses: map[int64]int{2: http.StatusServiceUnavailable}, + } + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 83, + Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2}, + FallbackToScheduler: true, + }, + } + + recorder := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []string{"from-scheduler"}, codexHandlerManifestSlugs(t, recorder)) +} + +func TestCodexModelsPinnedAccountsStillApplyCustomModelsListFilter(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(2, service.StatusActive, true, false), + newPinnedCodexAccount(3, service.StatusActive, true, false), + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{ + 2: `{"models":[{"slug":"model-a"}]}`, + 3: `{"models":[{"slug":"model-b"}]}`, + }} + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 84, + Platform: service.PlatformOpenAI, + ModelsListConfig: service.GroupModelsListConfig{ + Enabled: true, + Models: []string{"model-b"}, + }, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2, 3}, + }, + } + + recorder := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []string{"model-b"}, codexHandlerManifestSlugs(t, recorder), "分组自定义模型列表过滤仍生效") +} + +func TestCodexModelsPinnedAccountsETagMatchReturns304(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(2, service.StatusActive, true, false), + newPinnedCodexAccount(3, service.StatusActive, true, false), + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{ + 2: `{"models":[{"slug":"model-a"}]}`, + 3: `{"models":[{"slug":"model-c"}]}`, + }} + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 85, + Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2, 3}, + }, + } + + first := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + etag := first.Header().Get("ETag") + require.NotEmpty(t, etag) + + second := performPinnedCodexModelsRequest(t, handler, group, etag) + require.Equal(t, http.StatusNotModified, second.Code, second.Body.String()) + require.Empty(t, second.Body.Bytes()) +} diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 20af0ad26c..974899652e 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -215,6 +215,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldDefaultMappedModel, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, + group.FieldCodexModelsManifestConfig, group.FieldRpmLimit, group.FieldMaxReasoningEffort, group.FieldMaxReasoningEffortOverLimit, @@ -1017,6 +1018,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { DefaultMappedModel: g.DefaultMappedModel, MessagesDispatchModelConfig: g.MessagesDispatchModelConfig, ModelsListConfig: g.ModelsListConfig, + CodexModelsManifestConfig: g.CodexModelsManifestConfig, RPMLimit: g.RpmLimit, MaxReasoningEffort: g.MaxReasoningEffort, MaxReasoningEffortOverLimit: g.MaxReasoningEffortOverLimit, diff --git a/backend/internal/repository/api_key_repo_codex_manifest_projection_integration_test.go b/backend/internal/repository/api_key_repo_codex_manifest_projection_integration_test.go new file mode 100644 index 0000000000..b2ac1d7c02 --- /dev/null +++ b/backend/internal/repository/api_key_repo_codex_manifest_projection_integration_test.go @@ -0,0 +1,52 @@ +//go:build integration + +package repository + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestGetByKeyForAuthCarriesGroupCodexModelsManifestConfig(t *testing.T) { + ctx := context.Background() + suffix := time.Now().UnixNano() + group := mustCreateGroup(t, integrationEntClient, &service.Group{ + Name: fmt.Sprintf("codex-manifest-proj-group-%d", suffix), Platform: service.PlatformOpenAI, + RateMultiplier: 1, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{11, 22}, + FallbackToScheduler: true, + }, + }) + user := mustCreateUser(t, integrationEntClient, &service.User{ + Email: fmt.Sprintf("codex-manifest-proj-%d@example.com", suffix), Concurrency: 5, + }) + groupID := group.ID + keyValue := fmt.Sprintf("sk-codex-manifest-proj-%d", suffix) + apiKeyRepo := NewAPIKeyRepository(integrationEntClient, integrationDB) + key := &service.APIKey{UserID: user.ID, GroupID: &groupID, Key: keyValue, Name: "codex-manifest-proj", Status: service.StatusActive} + require.NoError(t, apiKeyRepo.Create(ctx, key)) + t.Cleanup(func() { + _, err := integrationDB.ExecContext(ctx, "DELETE FROM auth_cache_invalidation_outbox WHERE cache_key = encode(sha256(convert_to($1, 'UTF8')), 'hex')", keyValue) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM api_keys WHERE id = $1", key.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM users WHERE id = $1", user.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", group.ID) + require.NoError(t, err) + }) + + got, err := apiKeyRepo.GetByKeyForAuth(ctx, keyValue) + require.NoError(t, err) + require.NotNil(t, got.Group) + require.True(t, got.Group.CodexModelsManifestConfig.Enabled, "codex_models_manifest_config 必须进入认证投影(投影漏列会让固定账号模式静默失效)") + require.Equal(t, []int64{11, 22}, got.Group.CodexModelsManifestConfig.AccountIDs) + require.True(t, got.Group.CodexModelsManifestConfig.FallbackToScheduler) +} diff --git a/backend/internal/repository/fixtures_integration_test.go b/backend/internal/repository/fixtures_integration_test.go index 005ec7d0ba..d8d808f90b 100644 --- a/backend/internal/repository/fixtures_integration_test.go +++ b/backend/internal/repository/fixtures_integration_test.go @@ -92,6 +92,7 @@ func mustCreateGroup(t *testing.T, client *dbent.Client, g *service.Group) *serv SetIsExclusive(g.IsExclusive). SetForceOpenaiFast(g.ForceOpenAIFast). SetFreeOpenaiFast(g.FreeOpenAIFast). + SetCodexModelsManifestConfig(g.CodexModelsManifestConfig). SetProfitControlEnabled(g.ProfitControlEnabled). SetProfitMinMargin(g.ProfitMinMargin). SetProfitSafetyBuffer(g.ProfitSafetyBuffer) diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index d70d8ce89c..79d0b4bac2 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -110,6 +110,7 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi SetDefaultMappedModel(groupIn.DefaultMappedModel). SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig). SetModelsListConfig(groupIn.ModelsListConfig). + SetCodexModelsManifestConfig(groupIn.CodexModelsManifestConfig). SetRpmLimit(groupIn.RPMLimit). SetMaxReasoningEffort(groupIn.MaxReasoningEffort). SetMaxReasoningEffortOverLimit(groupIn.MaxReasoningEffortOverLimit). @@ -289,6 +290,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetDefaultMappedModel(groupIn.DefaultMappedModel). SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig). SetModelsListConfig(groupIn.ModelsListConfig). + SetCodexModelsManifestConfig(groupIn.CodexModelsManifestConfig). SetRpmLimit(groupIn.RPMLimit). SetMaxReasoningEffort(groupIn.MaxReasoningEffort). SetMaxReasoningEffortOverLimit(groupIn.MaxReasoningEffortOverLimit). diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 8fcb6dd83b..698e63df8c 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -314,6 +314,11 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn } platform := NormalizeGroupPlatform(input.Platform) + // 固定账号 manifest 配置:账号绑定发生在创建之后,创建时无法校验成员关系, + // 拒绝开启并在创建后的编辑里配置。 + if normalizeCodexModelsManifestConfig(platform, input.CodexModelsManifestConfig).Enabled { + return nil, infraerrors.New(http.StatusBadRequest, "INVALID_CODEX_MODELS_MANIFEST_CONFIG", "codex models manifest config cannot be enabled at group creation; configure it after creation in the group editor") + } modelPricing, err := normalizeGroupModelPricing(platform, input.ModelPricing) if err != nil { return nil, err @@ -524,10 +529,13 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn DefaultMappedModel: input.DefaultMappedModel, MessagesDispatchModelConfig: normalizeOpenAIMessagesDispatchModelConfig(input.MessagesDispatchModelConfig), ModelsListConfig: normalizeGroupModelsListConfig(input.ModelsListConfig), - RPMLimit: input.RPMLimit, - MaxReasoningEffort: maxReasoningEffort, - MaxReasoningEffortOverLimit: maxReasoningEffortOverLimit, - ReasoningEffortMappings: reasoningEffortMappings, + // 固定账号 manifest 配置:账号绑定发生在分组创建之后,创建路径禁止开启, + // 成员关系无从校验(前端创建对话框也不展示)。 + CodexModelsManifestConfig: normalizeCodexModelsManifestConfig(platform, input.CodexModelsManifestConfig), + RPMLimit: input.RPMLimit, + MaxReasoningEffort: maxReasoningEffort, + MaxReasoningEffortOverLimit: maxReasoningEffortOverLimit, + ReasoningEffortMappings: reasoningEffortMappings, } sanitizeGroupMessagesDispatchFields(group) sanitizeGroupOpenAIFast(group) @@ -904,6 +912,9 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.ModelsListConfig != nil { group.ModelsListConfig = normalizeGroupModelsListConfig(*input.ModelsListConfig) } + if input.CodexModelsManifestConfig != nil { + group.CodexModelsManifestConfig = *input.CodexModelsManifestConfig + } if input.RPMLimit != nil { group.RPMLimit = *input.RPMLimit } @@ -934,6 +945,15 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd group.AllowLive = false } sanitizeGroupReasoningEffortPolicy(group) + // 固定账号 manifest 配置:按最终平台归一化(切出 openai 平台时静默归零, + // 与 ForceOpenAIFast 同一收口);校验仅在本次显式携带配置时进行, + // 避免脏 ID 阻塞无关字段更新。 + group.CodexModelsManifestConfig = normalizeCodexModelsManifestConfig(group.Platform, group.CodexModelsManifestConfig) + if input.CodexModelsManifestConfig != nil { + if err := s.validateCodexModelsManifestConfig(ctx, id, group.CodexModelsManifestConfig); err != nil { + return nil, err + } + } if err := s.groupRepo.Update(ctx, group); err != nil { return nil, err diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index b168f7773f..ba149013ca 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -154,6 +154,8 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { Enabled: source.ModelsListConfig.Enabled, Models: append([]string(nil), source.ModelsListConfig.Models...), }, + // 固定账号 manifest 配置指向源分组的账号 ID,复制后成员关系可能变化,重置为关闭且列表为空。 + CodexModelsManifestConfig: GroupCodexModelsManifestConfig{}, RPMLimit: source.RPMLimit, MaxReasoningEffort: source.MaxReasoningEffort, MaxReasoningEffortOverLimit: source.MaxReasoningEffortOverLimit, diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 392f8d5d46..fa1b44bf23 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -273,6 +273,8 @@ type CreateGroupInput struct { RequirePrivacySet bool MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig ModelsListConfig GroupModelsListConfig + // CodexModelsManifestConfig 固定账号 manifest 配置;创建路径禁止开启,仅编辑可配置。 + CodexModelsManifestConfig GroupCodexModelsManifestConfig // RPMLimit 分组 RPM 上限(0 = 不限制) RPMLimit int // MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。 @@ -352,6 +354,8 @@ type UpdateGroupInput struct { RequirePrivacySet *bool MessagesDispatchModelConfig *OpenAIMessagesDispatchModelConfig ModelsListConfig *GroupModelsListConfig + // CodexModelsManifestConfig nil 表示不修改;非 openai 平台会被归一化为关闭。 + CodexModelsManifestConfig *GroupCodexModelsManifestConfig // RPMLimit 分组 RPM 上限(0 = 不限制),nil 表示未提供不改动。 RPMLimit *int // MaxReasoningEffort 空字符串表示清除上限;nil 表示未提供不改动。 diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index e14c2a0b39..32b8b5f0d9 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -1901,3 +1901,208 @@ func TestAdminService_PreviewCompositeRouteUsesExplicitRoutes(t *testing.T) { require.NotNil(t, decision.Route) require.Equal(t, int64(11), decision.Route.ID) } + +// accountRepoStubForGroupCodexManifest 支撑固定账号 manifest 配置校验测试。 +type accountRepoStubForGroupCodexManifest struct { + AccountRepository + accounts []Account +} + +func (s *accountRepoStubForGroupCodexManifest) ListByGroup(_ context.Context, _ int64) ([]Account, error) { + return append([]Account(nil), s.accounts...), nil +} + +func openAIGroupCodexManifestAccounts(ids ...int64) []Account { + accounts := make([]Account, 0, len(ids)) + for _, id := range ids { + accounts = append(accounts, Account{ + ID: id, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, + }) + } + return accounts +} + +func TestAdminService_UpdateGroup_CodexModelsManifestConfigValidation(t *testing.T) { + t.Run("enabled with empty list rejected", func(t *testing.T) { + existing := &Group{ID: 1, Name: "g", Platform: PlatformOpenAI, Status: StatusActive} + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{ + groupRepo: repo, + accountRepo: &accountRepoStubForGroupCodexManifest{}, + } + + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ + CodexModelsManifestConfig: &GroupCodexModelsManifestConfig{Enabled: true}, + }) + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err)) + require.Equal(t, "INVALID_CODEX_MODELS_MANIFEST_CONFIG", infraerrors.Reason(err)) + require.Nil(t, repo.updated) + }) + + t.Run("non-member or non-openai account rejected", func(t *testing.T) { + existing := &Group{ID: 1, Name: "g", Platform: PlatformOpenAI, Status: StatusActive} + repo := &groupRepoStubForAdmin{getByID: existing} + anthropicMember := Account{ID: 30, Platform: PlatformAnthropic, Type: AccountTypeOAuth, Status: StatusActive} + inactiveMember := Account{ID: 40, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusDisabled} + svc := &adminServiceImpl{ + groupRepo: repo, + accountRepo: &accountRepoStubForGroupCodexManifest{accounts: append(openAIGroupCodexManifestAccounts(10, 20), anthropicMember, inactiveMember)}, + } + + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ + CodexModelsManifestConfig: &GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{10, 30, 40, 99}, + }, + }) + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err)) + require.Equal(t, "INVALID_CODEX_MODELS_MANIFEST_CONFIG", infraerrors.Reason(err)) + require.Contains(t, err.Error(), "30") + require.Contains(t, err.Error(), "40") + require.Contains(t, err.Error(), "99") + require.Nil(t, repo.updated) + }) + + t.Run("more than ten accounts rejected", func(t *testing.T) { + existing := &Group{ID: 1, Name: "g", Platform: PlatformOpenAI, Status: StatusActive} + repo := &groupRepoStubForAdmin{getByID: existing} + ids := make([]int64, 0, 11) + for id := int64(1); id <= 11; id++ { + ids = append(ids, id) + } + svc := &adminServiceImpl{ + groupRepo: repo, + accountRepo: &accountRepoStubForGroupCodexManifest{accounts: openAIGroupCodexManifestAccounts(ids...)}, + } + + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ + CodexModelsManifestConfig: &GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: ids}, + }) + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err)) + require.Equal(t, "INVALID_CODEX_MODELS_MANIFEST_CONFIG", infraerrors.Reason(err)) + require.Nil(t, repo.updated) + }) + + t.Run("duplicate ids deduplicated preserving order", func(t *testing.T) { + existing := &Group{ID: 1, Name: "g", Platform: PlatformOpenAI, Status: StatusActive} + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{ + groupRepo: repo, + accountRepo: &accountRepoStubForGroupCodexManifest{accounts: openAIGroupCodexManifestAccounts(1, 2, 3)}, + } + + group, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ + CodexModelsManifestConfig: &GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{3, 1, 3, 2, 1}, + }, + }) + + require.NoError(t, err) + require.Equal(t, []int64{3, 1, 2}, repo.updated.CodexModelsManifestConfig.AccountIDs) + require.True(t, group.CodexModelsManifestConfig.Enabled) + }) + + t.Run("valid config accepted", func(t *testing.T) { + existing := &Group{ID: 1, Name: "g", Platform: PlatformOpenAI, Status: StatusActive} + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{ + groupRepo: repo, + accountRepo: &accountRepoStubForGroupCodexManifest{accounts: openAIGroupCodexManifestAccounts(10, 20)}, + } + + group, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ + CodexModelsManifestConfig: &GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{10, 20}, + FallbackToScheduler: true, + }, + }) + + require.NoError(t, err) + require.True(t, group.CodexModelsManifestConfig.Enabled) + require.Equal(t, []int64{10, 20}, repo.updated.CodexModelsManifestConfig.AccountIDs) + require.True(t, repo.updated.CodexModelsManifestConfig.FallbackToScheduler) + }) +} + +func TestAdminService_UpdateGroup_CodexModelsManifestConfigZeroedForNonOpenAIPlatform(t *testing.T) { + existing := &Group{ID: 1, Name: "g", Platform: PlatformOpenAI, Status: StatusActive} + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{ + groupRepo: repo, + accountRepo: &accountRepoStubForGroupCodexManifest{}, + } + + group, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ + Platform: PlatformAnthropic, + CodexModelsManifestConfig: &GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{1, 2}, + FallbackToScheduler: true, + }, + }) + + require.NoError(t, err) + require.Equal(t, GroupCodexModelsManifestConfig{}, repo.updated.CodexModelsManifestConfig) + require.Equal(t, GroupCodexModelsManifestConfig{}, group.CodexModelsManifestConfig) +} + +func TestAdminService_UpdateGroup_CodexModelsManifestConfigUntouchedWhenOmitted(t *testing.T) { + existing := &Group{ + ID: 1, Name: "g", Platform: PlatformOpenAI, Status: StatusActive, + CodexModelsManifestConfig: GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{10}, + }, + } + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{Name: "renamed"}) + + require.NoError(t, err) + require.Equal(t, existing.CodexModelsManifestConfig, repo.updated.CodexModelsManifestConfig) +} + +func TestAdminService_CreateGroup_CodexModelsManifestConfigEnabledRejected(t *testing.T) { + repo := &groupRepoStubForAdmin{createID: 61} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "codex-pinned", Platform: PlatformOpenAI, RateMultiplier: 1, + CodexModelsManifestConfig: GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{1}, + }, + }) + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err)) + require.Equal(t, "INVALID_CODEX_MODELS_MANIFEST_CONFIG", infraerrors.Reason(err)) + require.Nil(t, repo.created) +} + +func TestAdminService_CreateGroup_CodexModelsManifestConfigDisabledAccepted(t *testing.T) { + repo := &groupRepoStubForAdmin{createID: 62} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "codex-pinned-off", Platform: PlatformOpenAI, RateMultiplier: 1, + CodexModelsManifestConfig: GroupCodexModelsManifestConfig{ + Enabled: false, + AccountIDs: []int64{1, 1, 2}, + }, + }) + + require.NoError(t, err) + require.Equal(t, []int64{1, 2}, group.CodexModelsManifestConfig.AccountIDs) + require.False(t, repo.created.CodexModelsManifestConfig.Enabled) +} diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 59d2774cd7..ce388f0a09 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -108,6 +108,9 @@ type APIKeyAuthGroupSnapshot struct { DefaultMappedModel string `json:"default_mapped_model,omitempty"` MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"` ModelsListConfig GroupModelsListConfig `json:"models_list_config,omitempty"` + // CodexModelsManifestConfig 与 ModelsListConfig 一样在认证快照分组里透传, + // Codex /models handler 直接读认证分组对象。 + CodexModelsManifestConfig GroupCodexModelsManifestConfig `json:"codex_models_manifest_config,omitempty"` // RPMLimit 分组级每分钟请求数上限(0 = 不限制);用于 billing_cache_service.checkRPM 级联判断。 RPMLimit int `json:"rpm_limit"` diff --git a/backend/internal/service/api_key_auth_cache_codex_manifest_test.go b/backend/internal/service/api_key_auth_cache_codex_manifest_test.go new file mode 100644 index 0000000000..d7970b8fac --- /dev/null +++ b/backend/internal/service/api_key_auth_cache_codex_manifest_test.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// 固定账号 manifest 配置必须在认证快照 JSON 往返后完整保留, +// 否则开关在缓存路径上会静默失效(投影对账由集成测试兜底,此处覆盖快照序列化)。 +func TestAPIKeyAuthSnapshotGroupCodexModelsManifestRoundtrip(t *testing.T) { + groupID := int64(60) + apiKey := &APIKey{ + ID: 92, UserID: 46, GroupID: &groupID, Key: "sk-codex-manifest-roundtrip", Status: StatusActive, + User: &User{ID: 46, Status: StatusActive}, + Group: &Group{ + ID: groupID, Name: "codex-manifest-roundtrip", Platform: PlatformOpenAI, Status: StatusActive, + Hydrated: true, + CodexModelsManifestConfig: GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{7, 8}, + FallbackToScheduler: true, + }, + }, + } + svc := &APIKeyService{} + + payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: svc.snapshotFromAPIKey(context.Background(), apiKey)}) + require.NoError(t, err) + var cached APIKeyAuthCacheEntry + require.NoError(t, json.Unmarshal(payload, &cached)) + + materialized, used, err := svc.applyAuthCacheEntry(apiKey.Key, &cached) + require.NoError(t, err) + require.True(t, used) + require.NotNil(t, materialized.Group) + require.True(t, materialized.Group.CodexModelsManifestConfig.Enabled) + require.Equal(t, []int64{7, 8}, materialized.Group.CodexModelsManifestConfig.AccountIDs) + require.True(t, materialized.Group.CodexModelsManifestConfig.FallbackToScheduler) + require.Equal(t, apiKeyAuthSnapshotVersion, cached.Snapshot.Version) +} diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 656a3ba6e7..f05b8909a2 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -14,7 +14,7 @@ import ( "github.com/dgraph-io/ristretto" ) -const apiKeyAuthSnapshotVersion = 22 // v22: group free_openai_fast field +const apiKeyAuthSnapshotVersion = 23 // v23: group codex_models_manifest_config field type apiKeyAuthCacheConfig struct { l1Size int @@ -423,6 +423,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) DefaultMappedModel: apiKey.Group.DefaultMappedModel, MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig, ModelsListConfig: apiKey.Group.ModelsListConfig, + CodexModelsManifestConfig: apiKey.Group.CodexModelsManifestConfig, RPMLimit: apiKey.Group.RPMLimit, MaxReasoningEffort: apiKey.Group.MaxReasoningEffort, MaxReasoningEffortOverLimit: apiKey.Group.MaxReasoningEffortOverLimit, @@ -524,6 +525,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho DefaultMappedModel: snapshot.Group.DefaultMappedModel, MessagesDispatchModelConfig: snapshot.Group.MessagesDispatchModelConfig, ModelsListConfig: snapshot.Group.ModelsListConfig, + CodexModelsManifestConfig: snapshot.Group.CodexModelsManifestConfig, RPMLimit: snapshot.Group.RPMLimit, MaxReasoningEffort: snapshot.Group.MaxReasoningEffort, MaxReasoningEffortOverLimit: snapshot.Group.MaxReasoningEffortOverLimit, diff --git a/backend/internal/service/api_key_auth_cache_openai_fast_test.go b/backend/internal/service/api_key_auth_cache_openai_fast_test.go index 07e906e4e7..2c39d5bca9 100644 --- a/backend/internal/service/api_key_auth_cache_openai_fast_test.go +++ b/backend/internal/service/api_key_auth_cache_openai_fast_test.go @@ -32,5 +32,5 @@ func TestAPIKeyAuthSnapshotGroupForceOpenAIFastRoundtrip(t *testing.T) { require.True(t, materialized.Group.Hydrated) require.True(t, materialized.Group.ForceOpenAIFast) require.True(t, materialized.Group.FreeOpenAIFast) - require.Equal(t, 22, cached.Snapshot.Version) + require.Equal(t, apiKeyAuthSnapshotVersion, cached.Snapshot.Version) } diff --git a/backend/internal/service/api_key_auth_cache_profit_test.go b/backend/internal/service/api_key_auth_cache_profit_test.go index 51347e0eab..6ce7ecfce6 100644 --- a/backend/internal/service/api_key_auth_cache_profit_test.go +++ b/backend/internal/service/api_key_auth_cache_profit_test.go @@ -53,7 +53,7 @@ func TestAPIKeyAuthSnapshotProfitControlRoundtrip(t *testing.T) { snapshot := svc.snapshotFromAPIKey(context.Background(), apiKey) require.NotNil(t, snapshot) require.Equal(t, apiKeyAuthSnapshotVersion, snapshot.Version) - require.Equal(t, 22, snapshot.Version, "v22 起认证快照携带分组免费 Fast 开关") + require.Equal(t, apiKeyAuthSnapshotVersion, snapshot.Version, "认证快照版本必须与当前常量一致") // 模拟 L2 缓存的完整 JSON 往返(与 apiKeyCache.SetAuthCache/GetAuthCache 同构)。 payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: snapshot}) diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index fc9996da3b..bc4c5efb01 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -13,6 +13,7 @@ import ( type OpenAIMessagesDispatchModelConfig = domain.OpenAIMessagesDispatchModelConfig type GroupModelsListConfig = domain.GroupModelsListConfig +type GroupCodexModelsManifestConfig = domain.GroupCodexModelsManifestConfig type ReasoningEffortMapping = domain.ReasoningEffortMapping type Group struct { @@ -107,6 +108,9 @@ type Group struct { DefaultMappedModel string MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig ModelsListConfig GroupModelsListConfig + // CodexModelsManifestConfig 开启后,该分组的 Codex /models manifest 请求只用 + // 固定账号列表拉取并合并,不经过调度器(仅 openai 平台)。 + CodexModelsManifestConfig GroupCodexModelsManifestConfig // RPMLimit 分组级每分钟请求数上限(0 = 不限制)。 // 一旦设置即接管该分组用户的限流(覆盖用户级 rpm_limit),可被 user-group rpm_override 进一步覆盖。 diff --git a/backend/internal/service/group_codex_models_manifest.go b/backend/internal/service/group_codex_models_manifest.go new file mode 100644 index 0000000000..65a12a3cec --- /dev/null +++ b/backend/internal/service/group_codex_models_manifest.go @@ -0,0 +1,86 @@ +package service + +import ( + "context" + "fmt" + "net/http" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// maxCodexModelsManifestAccounts is the upper bound of pinned accounts per group. +const maxCodexModelsManifestAccounts = 10 + +// normalizeCodexModelsManifestConfig normalizes a group's pinned-accounts Codex +// manifest config before persistence: +// - Non-OpenAI platforms always persist a disabled zero config (spec: silently +// normalize instead of rejecting). +// - Account IDs are de-duplicated preserving first-seen order; invalid IDs (<= 0) +// are dropped. The list is kept even when disabled so toggling enabled back +// on does not lose the previous selection. +func normalizeCodexModelsManifestConfig(platform string, cfg GroupCodexModelsManifestConfig) GroupCodexModelsManifestConfig { + if platform != PlatformOpenAI { + return GroupCodexModelsManifestConfig{} + } + out := GroupCodexModelsManifestConfig{ + Enabled: cfg.Enabled, + FallbackToScheduler: cfg.FallbackToScheduler, + } + if len(cfg.AccountIDs) == 0 { + return out + } + seen := make(map[int64]struct{}, len(cfg.AccountIDs)) + out.AccountIDs = make([]int64, 0, len(cfg.AccountIDs)) + for _, id := range cfg.AccountIDs { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out.AccountIDs = append(out.AccountIDs, id) + } + if len(out.AccountIDs) == 0 { + out.AccountIDs = nil + } + return out +} + +// validateCodexModelsManifestConfig enforces the admin contract for an enabled +// pinned-accounts config: at least one account, at most +// maxCodexModelsManifestAccounts, and every account must be an active OpenAI +// member of the group. Validation failures return 400 with reason +// INVALID_CODEX_MODELS_MANIFEST_CONFIG and never reach persistence. +func (s *adminServiceImpl) validateCodexModelsManifestConfig(ctx context.Context, groupID int64, cfg GroupCodexModelsManifestConfig) error { + if !cfg.Enabled { + return nil + } + if len(cfg.AccountIDs) == 0 { + return infraerrors.New(http.StatusBadRequest, "INVALID_CODEX_MODELS_MANIFEST_CONFIG", "codex models manifest config requires at least one account") + } + if len(cfg.AccountIDs) > maxCodexModelsManifestAccounts { + return infraerrors.Newf(http.StatusBadRequest, "INVALID_CODEX_MODELS_MANIFEST_CONFIG", "codex models manifest config allows at most %d accounts, got %d", maxCodexModelsManifestAccounts, len(cfg.AccountIDs)) + } + accounts, err := s.accountRepo.ListByGroup(ctx, groupID) + if err != nil { + return fmt.Errorf("load group accounts for codex models manifest config: %w", err) + } + members := make(map[int64]struct{}, len(accounts)) + for i := range accounts { + acc := &accounts[i] + if acc.IsActive() && acc.Platform == PlatformOpenAI { + members[acc.ID] = struct{}{} + } + } + var invalid []int64 + for _, id := range cfg.AccountIDs { + if _, ok := members[id]; !ok { + invalid = append(invalid, id) + } + } + if len(invalid) > 0 { + return infraerrors.Newf(http.StatusBadRequest, "INVALID_CODEX_MODELS_MANIFEST_CONFIG", "codex models manifest config contains accounts not in this group or not active openai accounts: %v", invalid) + } + return nil +} diff --git a/backend/internal/service/openai_codex_models_pinned.go b/backend/internal/service/openai_codex_models_pinned.go new file mode 100644 index 0000000000..4f45f3a1e8 --- /dev/null +++ b/backend/internal/service/openai_codex_models_pinned.go @@ -0,0 +1,214 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "sync" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// ErrNoPinnedCodexModelsAccounts signals that an enabled pinned-accounts +// manifest config has no usable account right now (inactive, unschedulable, +// or expired members are skipped, and remaining IDs are no longer bound). +var ErrNoPinnedCodexModelsAccounts = errors.New("no usable pinned codex models manifest accounts") + +// isPinnedCodexModelsAccountUsable decides whether a pinned account may fetch +// the manifest. Deliberately narrower than Account.IsSchedulable(): priority, +// load factor, rate-limit windows, overload windows and temporary +// unschedulable cooldowns are ignored, because the pinned mode exists to keep +// the manifest deterministic regardless of scheduler state. +func isPinnedCodexModelsAccountUsable(account *Account) bool { + if account == nil || !account.IsActive() || !account.Schedulable { + return false + } + if account.AutoPauseOnExpired && account.ExpiresAt != nil && !account.ExpiresAt.After(time.Now()) { + return false + } + return true +} + +// mergeCodexModelsManifestBodies merges manifest bodies from the pinned +// accounts: the first body's top-level envelope is the base, and the models +// arrays are unioned by slug with first-seen (config-order) entries winning. +// Entries without a usable slug are kept once in order of appearance. +func mergeCodexModelsManifestBodies(bodies [][]byte) ([]byte, error) { + if len(bodies) == 0 { + return nil, errors.New("no codex models manifest bodies to merge") + } + var base map[string]json.RawMessage + if err := json.Unmarshal(bodies[0], &base); err != nil { + return nil, fmt.Errorf("decode base codex models manifest: %w", err) + } + if base == nil { + return nil, errors.New("base codex models manifest is not a JSON object") + } + + mergedModels := make([]json.RawMessage, 0, len(bodies)*8) + seenSlug := make(map[string]struct{}) + seenSlugLess := make(map[string]struct{}) + for _, body := range bodies { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("decode codex models manifest body: %w", err) + } + modelsRaw, ok := envelope["models"] + if !ok { + continue + } + var models []json.RawMessage + if err := json.Unmarshal(modelsRaw, &models); err != nil { + return nil, fmt.Errorf("decode codex models manifest models array: %w", err) + } + for _, raw := range models { + var model struct { + Slug string `json:"slug"` + } + slug := "" + if err := json.Unmarshal(raw, &model); err == nil { + slug = strings.TrimSpace(model.Slug) + } + if slug != "" { + if _, exists := seenSlug[slug]; exists { + continue + } + seenSlug[slug] = struct{}{} + } else { + fingerprint := string(bytes.TrimSpace(raw)) + if _, exists := seenSlugLess[fingerprint]; exists { + continue + } + seenSlugLess[fingerprint] = struct{}{} + } + mergedModels = append(mergedModels, raw) + } + } + + encodedModels, err := json.Marshal(mergedModels) + if err != nil { + return nil, fmt.Errorf("encode merged codex models: %w", err) + } + base["models"] = encodedModels + merged, err := json.Marshal(base) + if err != nil { + return nil, fmt.Errorf("encode merged codex models manifest: %w", err) + } + return merged, nil +} + +// FetchPinnedCodexModelsManifest fetches the Codex models manifest using only +// the accounts pinned on the group's codex_models_manifest_config, ignoring +// scheduler priorities, load factors and rate-limit/overload windows. All +// usable accounts are fetched concurrently; successful bodies are merged by +// slug with config-order precedence. It returns the merged manifest and the +// first successful account (in config order) for ops attribution. +// +// - no usable account → ErrNoPinnedCodexModelsAccounts +// - every fetch failed → the last upstream error +// - partial failure → successful accounts are still merged and a warning +// naming the failed account IDs is logged. +func (s *OpenAIGatewayService) FetchPinnedCodexModelsManifest(ctx context.Context, group *Group, clientVersion string) (*CodexModelsManifest, *Account, error) { + if s == nil || s.accountRepo == nil || group == nil { + return nil, nil, ErrNoPinnedCodexModelsAccounts + } + cfg := group.CodexModelsManifestConfig + if group.Platform != PlatformOpenAI || !cfg.Enabled || len(cfg.AccountIDs) == 0 { + return nil, nil, ErrNoPinnedCodexModelsAccounts + } + + members, err := s.accountRepo.ListByGroup(ctx, group.ID) + if err != nil { + return nil, nil, fmt.Errorf("load pinned codex models manifest accounts: %w", err) + } + memberByID := make(map[int64]Account, len(members)) + for _, member := range members { + memberByID[member.ID] = member + } + + // 按配置顺序筛选可用账号;已解绑/已删除的 ID 直接跳过。 + usable := make([]Account, 0, len(cfg.AccountIDs)) + for _, id := range cfg.AccountIDs { + member, ok := memberByID[id] + if !ok || member.Platform != PlatformOpenAI { + continue + } + if !isPinnedCodexModelsAccountUsable(&member) { + continue + } + usable = append(usable, member) + } + if len(usable) == 0 { + return nil, nil, ErrNoPinnedCodexModelsAccounts + } + + bodies := make([][]byte, len(usable)) + fetchErrs := make([]error, len(usable)) + // 各自独立完成、不取消兄弟请求,错误按下标收集,普通 WaitGroup 足够。 + var fetchGroup sync.WaitGroup + for i := range usable { + account := usable[i] + index := i + fetchGroup.Add(1) + go func() { + defer fetchGroup.Done() + manifest, fetchErr := s.FetchCodexModelsManifest(ctx, &account, clientVersion, "") + if fetchErr != nil { + fetchErrs[index] = fetchErr + return + } + if completeErr := s.CompleteAPIKeyCodexModelsManifestForClient(manifest, &account); completeErr != nil { + fetchErrs[index] = completeErr + return + } + bodies[index] = manifest.Body + }() + } + fetchGroup.Wait() + + successBodies := make([][]byte, 0, len(usable)) + successAccounts := make([]*Account, 0, len(usable)) + failedIDs := make([]int64, 0) + for i := range usable { + if fetchErrs[i] != nil || bodies[i] == nil { + failedIDs = append(failedIDs, usable[i].ID) + continue + } + successBodies = append(successBodies, bodies[i]) + successAccounts = append(successAccounts, &usable[i]) + } + if len(successBodies) == 0 { + var lastErr error + for i := range fetchErrs { + if fetchErrs[i] != nil { + lastErr = fetchErrs[i] + } + } + if lastErr == nil { + lastErr = infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "pinned codex models manifest accounts all failed") + } + return nil, nil, lastErr + } + if len(failedIDs) > 0 { + slog.Warn("codex_models_manifest_pinned_partial_failure", + "group_id", group.ID, + "failed_account_ids", failedIDs, + ) + } + + merged, err := mergeCodexModelsManifestBodies(successBodies) + if err != nil { + return nil, nil, fmt.Errorf("merge pinned codex models manifests: %w", err) + } + firstAccount := *successAccounts[0] + return &CodexModelsManifest{ + Body: merged, + ETag: codexModelsManifestBodyETag(merged), + }, &firstAccount, nil +} diff --git a/backend/internal/service/openai_codex_models_pinned_test.go b/backend/internal/service/openai_codex_models_pinned_test.go new file mode 100644 index 0000000000..eee3100c76 --- /dev/null +++ b/backend/internal/service/openai_codex_models_pinned_test.go @@ -0,0 +1,82 @@ +//go:build unit + +package service + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMergeCodexModelsManifestBodiesUnionAndOrder(t *testing.T) { + first := `{"object":"codex.manifest","models":[{"slug":"model-a","display_name":"A from first"},{"slug":"model-b"}]}` + second := `{"object":"codex.manifest","models":[{"slug":"model-a","display_name":"A from second"},{"slug":"model-c"}]}` + + merged, err := mergeCodexModelsManifestBodies([][]byte{[]byte(first), []byte(second)}) + require.NoError(t, err) + + var envelope map[string]json.RawMessage + require.NoError(t, json.Unmarshal(merged, &envelope)) + var objectField string + require.NoError(t, json.Unmarshal(envelope["object"], &objectField)) + require.Equal(t, "codex.manifest", objectField) + + var models []map[string]any + require.NoError(t, json.Unmarshal(envelope["models"], &models)) + require.Len(t, models, 3) + require.Equal(t, "model-a", models[0]["slug"]) + require.Equal(t, "A from first", models[0]["display_name"], "重复 slug 必须取配置顺序靠前账号的条目") + require.Equal(t, "model-b", models[1]["slug"]) + require.Equal(t, "model-c", models[2]["slug"]) +} + +func TestMergeCodexModelsManifestBodiesEnvelopeFromFirstBody(t *testing.T) { + first := `{"object":"codex.manifest","extra_field":"from-first","models":[{"slug":"model-a"}]}` + second := `{"object":"other","extra_field":"from-second","another":"only-in-second","models":[]}` + + merged, err := mergeCodexModelsManifestBodies([][]byte{[]byte(first), []byte(second)}) + require.NoError(t, err) + + var envelope map[string]any + require.NoError(t, json.Unmarshal(merged, &envelope)) + require.Equal(t, "codex.manifest", envelope["object"]) + require.Equal(t, "from-first", envelope["extra_field"], "顶层字段以第一个信封为基底") + require.NotContains(t, envelope, "another") +} + +func TestMergeCodexModelsManifestBodiesHandlesSluglessEntries(t *testing.T) { + first := `{"models":[{"slug":"model-a"},{"display_name":"no slug one"}]}` + second := `{"models":[{"display_name":"no slug one"},{"display_name":"no slug two"},{"slug":"model-a"}]}` + + merged, err := mergeCodexModelsManifestBodies([][]byte{[]byte(first), []byte(second)}) + require.NoError(t, err) + + var envelope struct { + Models []map[string]any `json:"models"` + } + require.NoError(t, json.Unmarshal(merged, &envelope)) + require.Len(t, envelope.Models, 3) + require.Equal(t, "model-a", envelope.Models[0]["slug"]) + require.Equal(t, "no slug one", envelope.Models[1]["display_name"], "无 slug 条目按出现顺序保留一次") + require.Equal(t, "no slug two", envelope.Models[2]["display_name"]) +} + +func TestMergeCodexModelsManifestBodiesSingleBody(t *testing.T) { + body := `{"models":[{"slug":"model-a"}]}` + + merged, err := mergeCodexModelsManifestBodies([][]byte{[]byte(body)}) + require.NoError(t, err) + require.JSONEq(t, body, string(merged)) +} + +func TestMergeCodexModelsManifestBodiesRejectsInvalidInput(t *testing.T) { + _, err := mergeCodexModelsManifestBodies(nil) + require.Error(t, err) + + _, err = mergeCodexModelsManifestBodies([][]byte{[]byte(`{`)}) + require.Error(t, err) + + _, err = mergeCodexModelsManifestBodies([][]byte{[]byte(`{}`), []byte(`{"models":{}`)}) + require.Error(t, err, "models 非数组必须报错") +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 6c97eb9b84..a3d5c22cf1 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -30,12 +30,20 @@ import ( var chatgptCodexModelsURL = "https://chatgpt.com/backend-api/codex/models" const ( - codexModelsManifestCacheBodyLimit = 1 << 20 - codexModelsManifestCacheMaxEntries = 64 - codexModelsManifestCacheTTL = 30 * time.Second - codexModelsManifestCacheStaleTTL = 5 * time.Minute - codexModelsManifestRequestTimeout = 15 * time.Second - codexAutoModelPrefix = "codex-auto-" + codexModelsManifestCacheBodyLimit = 1 << 20 + // codexModelsManifestCacheMaxEntries 上限按「账号数 × 客户端版本数 × 代理形态」 + // 估算:缓存同时覆盖 OAuth 与 API Key 账号,且缓存键含 Authorization 与 + // Version 头,不同客户端版本各自占一条;64 条在大规模部署下会被淘汰导致 + // 额外上游请求。单条清单通常几十 KB,512 条最坏内存占用在几十 MB 量级。 + codexModelsManifestCacheMaxEntries = 512 + // 三段时效:≤TTL 为新鲜(直接返回缓存,零上游请求);TTL 到 StaleTTL 之间 + // 乐观返回旧值并后台单飞刷新(携带上游 ETag,304 续期);超过 StaleTTL 丢弃 + // 缓存同步等待刷新。TTL 取 1 分钟:manifest 变化低频,1 分钟内同账号重复 + // 请求完全吸收;StaleTTL 取 5 分钟,控制旧内容最长可见时间在分钟级。 + codexModelsManifestCacheTTL = 60 * time.Second + codexModelsManifestCacheStaleTTL = 5 * time.Minute + codexModelsManifestRequestTimeout = 15 * time.Second + codexAutoModelPrefix = "codex-auto-" ) // FilterCodexModelIDsForGroup removes dedicated media-generation models, @@ -1568,30 +1576,35 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc useAPIKeyUpstream: useAPIKeyUpstream, } if useAPIKeyUpstream { - return s.fetchCachedAPIKeyCodexModelsManifest(ctx, request, ifNoneMatch) + return s.fetchCachedCodexModelsManifest(ctx, request, s.fetchCodexModelsManifestUpstreamForRequest(request), ifNoneMatch) } - manifest, fetchErr := s.fetchCodexModelsManifestUpstream(ctx, request, ifNoneMatch) - if !credAccount.IsOpenAIAgentIdentity() || !isAgentIdentityTaskInvalidCodexModelsError(fetchErr) { - s.handleCodexModelsManifestAccountAuthError(ctx, account, credAccount, fetchErr) - return manifest, fetchErr - } - expectedTaskID := strings.TrimSpace(credAccount.GetCredential("task_id")) - if recoverErr := s.recoverAgentIdentityTask(ctx, credAccount, expectedTaskID); recoverErr != nil { - return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_AUTH_FAILED", "agent identity task recovery failed: %v", recoverErr) - } - authHeaders, authErr := s.buildOpenAIAuthenticationHeaders(ctx, credAccount, "") - if authErr != nil { - return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_AUTH_FAILED", "build Codex models authentication after task recovery: %v", authErr) - } - request.headers.Del("Authorization") - request.headers.Del("ChatGPT-Account-ID") - for key, values := range authHeaders { - for _, value := range values { - request.headers.Add(key, value) + // OAuth 账号同样经过账号级缓存;闭包保留 agent identity 任务恢复逻辑, + // 错误时仍交给 handleCodexModelsManifestAccountAuthError 处理账号状态。 + oauthFetch := func(fetchCtx context.Context, ifNoneMatch string) (*CodexModelsManifest, error) { + manifest, fetchErr := s.fetchCodexModelsManifestUpstream(fetchCtx, request, ifNoneMatch) + if !credAccount.IsOpenAIAgentIdentity() || !isAgentIdentityTaskInvalidCodexModelsError(fetchErr) { + s.handleCodexModelsManifestAccountAuthError(fetchCtx, account, credAccount, fetchErr) + return manifest, fetchErr } + expectedTaskID := strings.TrimSpace(credAccount.GetCredential("task_id")) + if recoverErr := s.recoverAgentIdentityTask(fetchCtx, credAccount, expectedTaskID); recoverErr != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_AUTH_FAILED", "agent identity task recovery failed: %v", recoverErr) + } + authHeaders, authErr := s.buildOpenAIAuthenticationHeaders(fetchCtx, credAccount, "") + if authErr != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_AUTH_FAILED", "build Codex models authentication after task recovery: %v", authErr) + } + request.headers.Del("Authorization") + request.headers.Del("ChatGPT-Account-ID") + for key, values := range authHeaders { + for _, value := range values { + request.headers.Add(key, value) + } + } + setOpenAIChatGPTAccountHeaders(request.headers, credAccount) + return s.fetchCodexModelsManifestUpstream(fetchCtx, request, ifNoneMatch) } - setOpenAIChatGPTAccountHeaders(request.headers, credAccount) - return s.fetchCodexModelsManifestUpstream(ctx, request, ifNoneMatch) + return s.fetchCachedCodexModelsManifest(ctx, request, oauthFetch, ifNoneMatch) } func isAgentIdentityTaskInvalidCodexModelsError(err error) bool { @@ -1631,7 +1644,7 @@ func (s *OpenAIGatewayService) handleCodexModelsManifestAccountAuthError(ctx con s.handleOpenAIAccountUpstreamError(ctx, account, upstreamErr.statusCode, headers, upstreamErr.body) } -func (s *OpenAIGatewayService) fetchCachedAPIKeyCodexModelsManifest(ctx context.Context, request codexModelsManifestRequest, ifNoneMatch string) (*CodexModelsManifest, error) { +func (s *OpenAIGatewayService) fetchCachedCodexModelsManifest(ctx context.Context, request codexModelsManifestRequest, fetch func(ctx context.Context, ifNoneMatch string) (*CodexModelsManifest, error), ifNoneMatch string) (*CodexModelsManifest, error) { if err := ctx.Err(); err != nil { return nil, err } @@ -1640,7 +1653,7 @@ func (s *OpenAIGatewayService) fetchCachedAPIKeyCodexModelsManifest(ctx context. if state == codexModelsManifestCacheFresh { return codexModelsManifestForClient(manifest, ifNoneMatch), nil } - resultCh := s.refreshCachedAPIKeyCodexModelsManifest(cacheKey, request) + resultCh := s.refreshCachedCodexModelsManifest(cacheKey, request, fetch) if state == codexModelsManifestCacheStale { return codexModelsManifestForClient(manifest, ifNoneMatch), nil } @@ -1659,14 +1672,16 @@ func (s *OpenAIGatewayService) fetchCachedAPIKeyCodexModelsManifest(ctx context. } } -func (s *OpenAIGatewayService) refreshCachedAPIKeyCodexModelsManifest(cacheKey string, request codexModelsManifestRequest) <-chan singleflight.Result { +func (s *OpenAIGatewayService) refreshCachedCodexModelsManifest(cacheKey string, request codexModelsManifestRequest, fetch func(ctx context.Context, ifNoneMatch string) (*CodexModelsManifest, error)) <-chan singleflight.Result { return s.codexModelsManifestCache.refresh.DoChan(cacheKey, func() (any, error) { + ctx, cancel := context.WithTimeout(context.Background(), codexModelsManifestRequestTimeout) + defer cancel() cached, _ := s.codexModelsManifestCache.get(cacheKey, time.Now()) ifNoneMatch := "" if cached != nil { ifNoneMatch = cached.upstreamETag } - manifest, err := s.fetchCodexModelsManifestUpstream(context.Background(), request, ifNoneMatch) + manifest, err := fetch(ctx, ifNoneMatch) if err != nil { return nil, err } @@ -1681,6 +1696,12 @@ func (s *OpenAIGatewayService) refreshCachedAPIKeyCodexModelsManifest(cacheKey s }) } +func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstreamForRequest(request codexModelsManifestRequest) func(ctx context.Context, ifNoneMatch string) (*CodexModelsManifest, error) { + return func(ctx context.Context, ifNoneMatch string) (*CodexModelsManifest, error) { + return s.fetchCodexModelsManifestUpstream(ctx, request, ifNoneMatch) + } +} + func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Context, request codexModelsManifestRequest, ifNoneMatch string) (*CodexModelsManifest, error) { reqCtx, cancel := context.WithTimeout(ctx, codexModelsManifestRequestTimeout) defer cancel() @@ -1810,14 +1831,12 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont manifest := &CodexModelsManifest{ Body: body, ETag: etag, + upstreamETag: etag, upstreamSourceBody: append([]byte(nil), upstreamBody...), convertedFromOpenAIModelList: convertedFromOpenAIModelList, } - if request.useAPIKeyUpstream { - manifest.upstreamETag = etag - if !bytes.Equal(body, upstreamBody) { - manifest.ETag = codexModelsManifestBodyETag(body) - } + if request.useAPIKeyUpstream && !bytes.Equal(body, upstreamBody) { + manifest.ETag = codexModelsManifestBodyETag(body) } return manifest, nil } diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 49b5f4e462..4bf61fceaa 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -1737,11 +1737,12 @@ func TestFetchCodexModelsManifestDefaultClientVersion(t *testing.T) { } func TestFetchCodexModelsManifestNotModified(t *testing.T) { - var gotIfNoneMatch string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotIfNoneMatch = r.Header.Get("If-None-Match") + manifestBody := `{"models":[{"slug":"gpt-5.5"}]}` + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) w.Header().Set("ETag", `W/"abc123"`) - w.WriteHeader(http.StatusNotModified) + _, _ = w.Write([]byte(manifestBody)) })) defer server.Close() @@ -1750,15 +1751,32 @@ func TestFetchCodexModelsManifestNotModified(t *testing.T) { defer func() { chatgptCodexModelsURL = original }() s := &OpenAIGatewayService{} - manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", `W/"abc123"`) + account := newCodexModelsTestAccount() + first, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") if err != nil { t.Fatalf("FetchCodexModelsManifest returned error: %v", err) } - if !manifest.NotModified { - t.Error("expected NotModified to be true") + if first.NotModified { + t.Fatal("cold fetch must return the manifest body") } - if gotIfNoneMatch != `W/"abc123"` { - t.Errorf("if-none-match header: got %q", gotIfNoneMatch) + if first.ETag != `W/"abc123"` { + t.Errorf("etag not passed through: got %q", first.ETag) + } + + // OAuth 路径接入缓存后,客户端 If-None-Match 与缓存内容 ETag 比较,不再透传上游: + // 新鲜期内命中缓存且匹配直接 304,零上游请求。 + second, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", `W/"abc123"`) + if err != nil { + t.Fatalf("cached fetch returned error: %v", err) + } + if !second.NotModified { + t.Fatal("matching cached ETag must return NotModified") + } + if second.ETag != `W/"abc123"` { + t.Errorf("NotModified etag: got %q", second.ETag) + } + if got := calls.Load(); got != 1 { + t.Errorf("upstream calls: got %d, want 1", got) } } @@ -2746,22 +2764,22 @@ func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing. t.Fatalf("body-size bounded cache calls: got %d, want 5", got) } - for i := int64(10); i < 75; i++ { + for i := int64(10); i < 523; i++ { account := newCodexModelsAPIKeyTestAccount("https://bounded.example") account.ID = i fetch(account) } last := newCodexModelsAPIKeyTestAccount("https://bounded.example") - last.ID = 74 + last.ID = 522 fetch(last) - if got := calls.Load(); got != 70 { - t.Fatalf("most recent cache entry was not retained: calls=%d, want 70", got) + if got := calls.Load(); got != 518 { + t.Fatalf("most recent cache entry was not retained: calls=%d, want 518", got) } first := newCodexModelsAPIKeyTestAccount("https://bounded.example") first.ID = 10 fetch(first) - if got := calls.Load(); got != 71 { - t.Errorf("oldest cache entry was not evicted: calls=%d, want 71", got) + if got := calls.Load(); got != 519 { + t.Errorf("oldest cache entry was not evicted: calls=%d, want 519", got) } } @@ -3255,3 +3273,271 @@ func TestFetchCodexModelsManifestAPIKeyUsesOfficialOpenAIModelsEndpoint(t *testi }) } } + +// --- OAuth manifest 账号级缓存(统一缓存策略) --- + +// newCodexModelsOAuthCacheServer 返回一个记录调用次数、返回给定 manifest 的 +// ChatGPT Codex models 测试上游。 +func newCodexModelsOAuthCacheServer(t *testing.T, body string) (*httptest.Server, *atomic.Int32) { + t.Helper() + calls := &atomic.Int32{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + t.Cleanup(func() { chatgptCodexModelsURL = original }) + return server, calls +} + +func expireCodexModelsManifestCache(s *OpenAIGatewayService, age time.Duration) { + s.codexModelsManifestCache.mu.Lock() + for key, entry := range s.codexModelsManifestCache.entries { + entry.expiresAt = time.Now().Add(-age) + entry.staleUntil = time.Now().Add(codexModelsManifestCacheStaleTTL - age) + s.codexModelsManifestCache.entries[key] = entry + } + s.codexModelsManifestCache.mu.Unlock() +} + +func TestFetchCodexModelsManifestOAuthFreshWindowZeroUpstreamRequests(t *testing.T) { + _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"gpt-5.5"}]}`) + s := &OpenAIGatewayService{} + account := newCodexModelsTestAccount() + + for i := 0; i < 10; i++ { + manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.NoError(t, err) + require.False(t, manifest.NotModified) + require.JSONEq(t, `{"models":[{"slug":"gpt-5.5"}]}`, string(manifest.Body)) + } + require.EqualValues(t, 1, calls.Load(), "新鲜期内重复请求不得打到 chatgpt.com") +} + +func TestFetchCodexModelsManifestOAuthStaleServesOldValueAndRefreshesInBackground(t *testing.T) { + calls := &atomic.Int32{} + refreshStarted := make(chan struct{}) + var refreshOnce sync.Once + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if calls.Add(1) == 2 { + refreshOnce.Do(func() { close(refreshStarted) }) + <-release + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":[{"slug":"new"}]}`)) + })) + t.Cleanup(server.Close) + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + t.Cleanup(func() { chatgptCodexModelsURL = original }) + + s := &OpenAIGatewayService{} + account := newCodexModelsTestAccount() + first, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.NoError(t, err) + require.Contains(t, string(first.Body), `"new"`) + + expireCodexModelsManifestCache(s, 2*time.Minute) + + resultCh := make(chan *CodexModelsManifest, 1) + errCh := make(chan error, 1) + go func() { + manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + resultCh <- manifest + errCh <- fetchErr + }() + select { + case <-refreshStarted: + case <-time.After(time.Second): + t.Fatal("乐观期后台刷新未触发") + } + + // 乐观期:立即返回旧值,不同步等待刷新。 + select { + case manifest := <-resultCh: + require.NoError(t, <-errCh) + require.Contains(t, string(manifest.Body), `"new"`) + case <-time.After(100 * time.Millisecond): + t.Fatal("乐观期必须立即返回缓存旧值") + } + require.EqualValues(t, 2, calls.Load()) + + close(release) + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + if fetchErr == nil && manifest != nil && !manifest.NotModified { + require.Contains(t, string(manifest.Body), `"new"`) + } + s.codexModelsManifestCache.mu.Lock() + fresh := len(s.codexModelsManifestCache.entries) > 0 + s.codexModelsManifestCache.mu.Unlock() + if fresh { + break + } + time.Sleep(10 * time.Millisecond) + } + require.EqualValues(t, 2, calls.Load(), "后台刷新必须单飞") +} + +func TestFetchCodexModelsManifestOAuthOverdueSynchronousRefreshAndFailure(t *testing.T) { + t.Run("overdue refresh waits and re-caches", func(t *testing.T) { + calls := &atomic.Int32{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if calls.Add(1) == 2 { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":[{"slug":"refreshed"}]}`)) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":[{"slug":"stale"}]}`)) + })) + t.Cleanup(server.Close) + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + t.Cleanup(func() { chatgptCodexModelsURL = original }) + + s := &OpenAIGatewayService{} + account := newCodexModelsTestAccount() + _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.NoError(t, err) + + // 超过 5 分钟:缓存条目被丢弃,同步等待上游刷新。 + expireCodexModelsManifestCache(s, 6*time.Minute) + + manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.NoError(t, err) + require.Contains(t, string(manifest.Body), `"refreshed"`) + require.EqualValues(t, 2, calls.Load()) + + // 新内容写回缓存并重新开始新鲜期。 + again, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.NoError(t, err) + require.Contains(t, string(again.Body), `"refreshed"`) + require.EqualValues(t, 2, calls.Load()) + }) + + t.Run("overdue upstream failure returns error not stale cache", func(t *testing.T) { + fail := &atomic.Bool{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if fail.Load() { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"detail":"boom"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":[{"slug":"stale"}]}`)) + })) + t.Cleanup(server.Close) + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + t.Cleanup(func() { chatgptCodexModelsURL = original }) + + s := &OpenAIGatewayService{} + account := newCodexModelsTestAccount() + _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.NoError(t, err) + + fail.Store(true) + expireCodexModelsManifestCache(s, 6*time.Minute) + + _, err = s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.Error(t, err, "超期后上游失败必须返回错误而非旧缓存") + require.Equal(t, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", infraerrors.Reason(err)) + }) +} + +func TestFetchCodexModelsManifestOAuthTokenChangeCacheMiss(t *testing.T) { + _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[]}`) + s := &OpenAIGatewayService{} + account := newCodexModelsTestAccount() + + _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.NoError(t, err) + require.EqualValues(t, 1, calls.Load()) + + // 令牌刷新后缓存键变化,下一次拉取视为未命中并同步请求上游。 + account.Credentials["access_token"] = "rotated-access-token" + manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.NoError(t, err) + require.Contains(t, string(manifest.Body), `"models"`) + require.EqualValues(t, 2, calls.Load()) +} + +func TestFetchCodexModelsManifestOAuthSharedAcrossGroupsWithIndependentFiltering(t *testing.T) { + started := make(chan struct{}) + var startedOnce sync.Once + release := make(chan struct{}) + calls := &atomic.Int32{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + startedOnce.Do(func() { close(started) }) + <-release + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":[{"slug":"model-a"},{"slug":"model-b"}]}`)) + })) + t.Cleanup(server.Close) + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + t.Cleanup(func() { chatgptCodexModelsURL = original }) + + s := &OpenAIGatewayService{ + accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + 91: {}, + 92: {}, + }, + }, + } + account := newCodexModelsTestAccount() + groupA := &Group{ID: 91, Platform: PlatformOpenAI, ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"model-a"}}} + groupB := &Group{ID: 92, Platform: PlatformOpenAI, ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"model-b"}}} + + begin := make(chan struct{}) + type result struct { + slugs []string + err error + } + results := make(chan result, 2) + for _, group := range []*Group{groupA, groupB} { + go func(g *Group) { + <-begin + manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + if err == nil { + err = s.MergeGroupConfiguredCodexModels(context.Background(), g, manifest, "") + } + slugs := []string{} + if err == nil { + slugs = codexManifestModelSlugs(t, manifest.Body) + } + results <- result{slugs: slugs, err: err} + }(group) + } + close(begin) + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("upstream request did not start") + } + close(release) + + got := map[int64][]string{91: nil, 92: nil} + for i := 0; i < 2; i++ { + r := <-results + require.NoError(t, r.err) + if len(r.slugs) == 1 && r.slugs[0] == "model-a" { + got[91] = r.slugs + } else if len(r.slugs) == 1 && r.slugs[0] == "model-b" { + got[92] = r.slugs + } else { + t.Fatalf("unexpected filtered slugs: %v", r.slugs) + } + } + require.Equal(t, []string{"model-a"}, got[91]) + require.Equal(t, []string{"model-b"}, got[92]) + require.EqualValues(t, 1, calls.Load(), "同一账号两个分组同时请求时只发一次上游请求") +} diff --git a/backend/migrations/234_group_codex_models_manifest_config.sql b/backend/migrations/234_group_codex_models_manifest_config.sql new file mode 100644 index 0000000000..137a9424ef --- /dev/null +++ b/backend/migrations/234_group_codex_models_manifest_config.sql @@ -0,0 +1,5 @@ +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS codex_models_manifest_config JSONB NOT NULL DEFAULT '{}'::jsonb; + +COMMENT ON COLUMN groups.codex_models_manifest_config IS + 'Pinned-accounts Codex models manifest config for OpenAI groups: {"enabled":bool,"account_ids":[int64],"fallback_to_scheduler":bool}; when enabled the Codex /models manifest is fetched only from the pinned accounts'; diff --git a/frontend/src/components/admin/group/CodexManifestAccountsField.vue b/frontend/src/components/admin/group/CodexManifestAccountsField.vue new file mode 100644 index 0000000000..b110699f29 --- /dev/null +++ b/frontend/src/components/admin/group/CodexManifestAccountsField.vue @@ -0,0 +1,290 @@ + + + diff --git a/frontend/src/components/admin/group/__tests__/CodexManifestAccountsField.spec.ts b/frontend/src/components/admin/group/__tests__/CodexManifestAccountsField.spec.ts new file mode 100644 index 0000000000..fdaacc23e4 --- /dev/null +++ b/frontend/src/components/admin/group/__tests__/CodexManifestAccountsField.spec.ts @@ -0,0 +1,212 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { defineComponent, h, ref } from "vue"; + +import CodexManifestAccountsField from "../CodexManifestAccountsField.vue"; +import { adminAPI } from "@/api/admin"; +import type { CodexModelsManifestConfig } from "@/types"; + +vi.mock("vue-i18n", async () => { + const actual = await vi.importActual("vue-i18n"); + return { + ...actual, + useI18n: () => ({ t: (key: string) => key }), + }; +}); + +vi.mock("@/api/admin", () => ({ + adminAPI: { + accounts: { + list: vi.fn(), + }, + }, +})); + +const disabledConfig = (): CodexModelsManifestConfig => ({ + enabled: false, + account_ids: [], + fallback_to_scheduler: false, +}); + +const enabledConfig = (): CodexModelsManifestConfig => ({ + enabled: true, + account_ids: [], + fallback_to_scheduler: false, +}); + +// 挂载一个 v-model 双向绑定的宿主,验证完整的选择→移除流程。 +const mountInteractive = (initial: CodexModelsManifestConfig, accountNames?: Record) => { + const host = defineComponent({ + setup() { + const config = ref(initial); + return () => + h(CodexManifestAccountsField, { + groupId: 7, + modelValue: config.value, + accountNames, + "onUpdate:modelValue": (value: CodexModelsManifestConfig) => { + config.value = value; + }, + }); + }, + }); + return mount(host, { + global: { + stubs: { + Icon: { props: ["name"], template: '' }, + }, + }, + }); +}; + +const mountField = (modelValue: CodexModelsManifestConfig, accountNames?: Record) => + mount(CodexManifestAccountsField, { + props: { + groupId: 7, + modelValue, + ...(accountNames ? { accountNames } : {}), + }, + global: { + stubs: { + Icon: { props: ["name"], template: '' }, + }, + }, + }); + +describe("CodexManifestAccountsField", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("hides account controls while disabled and reveals them after enabling", async () => { + const wrapper = mountField(disabledConfig()); + + expect(wrapper.find('[data-testid="codex-manifest-search"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="codex-manifest-fallback-toggle"]').exists()).toBe(false); + expect(wrapper.text()).toContain("admin.groups.codexModelsManifest.disabledHint"); + + await wrapper.find('[data-testid="codex-manifest-toggle"]').trigger("click"); + + const emitted = wrapper.emitted("update:modelValue"); + expect(emitted).toHaveLength(1); + expect(emitted?.[0]?.[0]).toMatchObject({ enabled: true }); + + const enabled = mountField(enabledConfig()); + expect(enabled.find('[data-testid="codex-manifest-search"]').exists()).toBe(true); + expect(enabled.find('[data-testid="codex-manifest-fallback-toggle"]').exists()).toBe(true); + expect(enabled.text()).toContain("admin.groups.codexModelsManifest.enabledHint"); + }); + + it("selects accounts from search results and removes them via tags", async () => { + vi.mocked(adminAPI.accounts.list).mockResolvedValue({ + items: [ + { id: 5, name: "oauth-five" }, + { id: 6, name: "apikey-six" }, + ], + } as never); + const wrapper = mountInteractive( + { ...enabledConfig(), account_ids: [5] }, + { 5: "oauth-five" }, + ); + + const initialTags = wrapper.find('[data-testid="codex-manifest-selected-tags"]'); + expect(initialTags.text()).toContain("oauth-five"); + + const search = wrapper.find('[data-testid="codex-manifest-search"]'); + await search.trigger("focus"); + await search.setValue("oauth"); + vi.advanceTimersByTime(300); + await flushPromises(); + + expect(adminAPI.accounts.list).toHaveBeenCalledWith( + 1, + 20, + { search: "oauth", platform: "openai", group: "7" }, + expect.anything(), + ); + + const dropdown = wrapper.find('[data-testid="codex-manifest-dropdown"]'); + expect(dropdown.exists()).toBe(true); + expect(dropdown.text()).toContain("apikey-six"); + + // 账号 6 未选中:点击加入。 + const option = dropdown.findAll("button").find((b) => b.text().includes("apikey-six")); + await option!.trigger("click"); + await flushPromises(); + + expect(wrapper.text()).toContain("apikey-six", "新选账号立即以名称展示"); + + // 移除账号 5:点击其标签上的删除按钮。 + const removeButton = wrapper + .find('[data-testid="codex-manifest-selected-tags"]') + .findAll("button") + .find((b) => b.attributes("aria-label") === "remove account 5"); + await removeButton!.trigger("click"); + await flushPromises(); + + const tags = wrapper.find('[data-testid="codex-manifest-selected-tags"]'); + expect(tags.text()).toContain("apikey-six"); + expect(tags.text()).not.toContain("oauth-five"); + }); + + it("fails validation when enabled without accounts", async () => { + const wrapper = mountField(enabledConfig()); + + expect(wrapper.vm.validate()).toBe(false); + await flushPromises(); + expect( + wrapper.find('[data-testid="codex-manifest-validation-error"]').text(), + ).toBe("admin.groups.codexModelsManifest.selectAtLeastOne"); + + // GroupsView 关闭对话框时通过可选链调用 resetValidation,必须真实存在且生效。 + expect(typeof wrapper.vm.resetValidation).toBe("function"); + wrapper.vm.resetValidation(); + await flushPromises(); + expect( + wrapper.find('[data-testid="codex-manifest-validation-error"]').exists(), + ).toBe(false); + + await wrapper.setProps({ + modelValue: { ...enabledConfig(), account_ids: [9] }, + }); + expect(wrapper.vm.validate()).toBe(true); + await flushPromises(); + expect( + wrapper.find('[data-testid="codex-manifest-validation-error"]').exists(), + ).toBe(false); + }); + + it("closes the dropdown on outside click and shows the empty state", async () => { + vi.mocked(adminAPI.accounts.list).mockResolvedValue({ items: [] } as never); + const wrapper = mountField(enabledConfig()); + + const search = wrapper.find('[data-testid="codex-manifest-search"]'); + await search.trigger("focus"); + await search.setValue("nonexistent"); + vi.advanceTimersByTime(300); + await flushPromises(); + + // 有关键词但无结果:展示空状态提示而非静默无反应。 + const dropdown = wrapper.find('[data-testid="codex-manifest-dropdown"]'); + expect(dropdown.exists()).toBe(true); + expect(wrapper.find('[data-testid="codex-manifest-search-empty"]').text()).toBe( + "admin.groups.codexModelsManifest.searchEmpty", + ); + + // 点击组件外部:收起下拉。 + document.body.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + expect(wrapper.find('[data-testid="codex-manifest-dropdown"]').exists()).toBe(false); + + // 点击输入框内部:不收起。 + await search.trigger("click"); + await search.trigger("focus"); + expect(wrapper.find('[data-testid="codex-manifest-dropdown"]').exists()).toBe(true); + wrapper.unmount(); + }); +}); diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index a31cb65e10..80be8c74e9 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -1078,6 +1078,19 @@ export default { selectAll: 'Select all', invertSelection: 'Invert' }, + codexModelsManifest: { + title: 'Pinned Accounts for Codex Model Manifest', + hint: 'When enabled, Codex client /models requests for this group are fetched only from the pinned accounts and merged by slug, bypassing the scheduler. Pinned accounts in rate-limit or overload windows are still used.', + enable: 'Fetch manifest with specific accounts', + enabledHint: 'Accounts are limited to OpenAI accounts bound to this group, at most 10.', + disabledHint: 'Not enabled: manifest requests go through scheduler account selection.', + accounts: 'Pinned accounts', + searchPlaceholder: 'Search accounts (OpenAI accounts in this group)', + searchEmpty: 'No matching accounts', + fallback: 'Fall back to the scheduler when all pinned accounts are unavailable', + fallbackHint: 'Off: return 503 / the upstream error. On: fall back to the existing scheduler path.', + selectAtLeastOne: 'Select at least one account after enabling pinned accounts' + }, compositeRoutes: { action: 'Routes', title: 'Composite Routes', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 344bbc41f7..72e2c56a96 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -1075,6 +1075,19 @@ export default { selectAll: '全选', invertSelection: '反选' }, + codexModelsManifest: { + title: '固定账号获取 Codex Model Manifest', + hint: '开启后,该分组的 Codex 客户端 /models 请求只用选定账号向上游拉取并按 slug 合并,不经过调度器;限流/过载中的选定账号仍会被使用。', + enable: '使用特定账号获取 manifest', + enabledHint: '账号来源限定为当前分组内的 OpenAI 账号,最多选择 10 个。', + disabledHint: '未启用:manifest 请求经由调度器选账。', + accounts: '选定账号', + searchPlaceholder: '搜索账号(当前分组内 OpenAI 账号)', + searchEmpty: '未找到匹配账号', + fallback: '选定账号全部不可用时回退调度器', + fallbackHint: '关闭时返回 503 / 上游错误;开启时回退到现有调度器选账路径。', + selectAtLeastOne: '开启固定账号后至少选择一个账号' + }, compositeRoutes: { action: '路由', title: 'Composite 路由', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1c2fcc6f18..5209361518 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -644,6 +644,7 @@ export interface AdminGroup extends Group { default_mapped_model?: string messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig models_list_config?: ModelsListConfig + codex_models_manifest_config?: CodexModelsManifestConfig // 分组排序 sort_order: number @@ -654,6 +655,13 @@ export interface ModelsListConfig { models: string[] } +// 固定账号获取 Codex Model Manifest 配置(仅 openai 分组) +export interface CodexModelsManifestConfig { + enabled: boolean + account_ids: number[] + fallback_to_scheduler: boolean +} + export type CompositeRouteMatchType = 'exact' | 'prefix' export type CompositeRouteEndpoint = @@ -819,6 +827,7 @@ export interface CreateGroupRequest { mcp_xml_inject?: boolean supported_model_scopes?: string[] models_list_config?: ModelsListConfig + codex_models_manifest_config?: CodexModelsManifestConfig allow_messages_dispatch?: boolean allow_live?: boolean default_mapped_model?: string @@ -884,6 +893,7 @@ export interface UpdateGroupRequest { mcp_xml_inject?: boolean supported_model_scopes?: string[] models_list_config?: ModelsListConfig + codex_models_manifest_config?: CodexModelsManifestConfig allow_messages_dispatch?: boolean allow_live?: boolean default_mapped_model?: string diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index a9e0e0debb..adf0e4cf12 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -3297,6 +3297,15 @@ + + +
@@ -4563,6 +4572,7 @@ import { useOnboardingStore } from "@/stores/onboarding"; import { adminAPI } from "@/api/admin"; import type { AdminGroup, + CodexModelsManifestConfig, CompositeModelRoute, CompositeModelRouteInput, CompositeRouteDecision, @@ -4590,6 +4600,7 @@ import GroupRateMultipliersModal from "@/components/admin/group/GroupRateMultipl import GroupRPMOverridesModal from "@/components/admin/group/GroupRPMOverridesModal.vue"; import GroupCapacityBadge from "@/components/common/GroupCapacityBadge.vue"; import ReasoningEffortPolicyFields from "@/components/admin/group/ReasoningEffortPolicyFields.vue"; +import CodexManifestAccountsField from "@/components/admin/group/CodexManifestAccountsField.vue"; import PricingEntryCard from "@/components/admin/channel/PricingEntryCard.vue"; import type { PricingFormEntry } from "@/components/admin/channel/types"; import { @@ -5175,6 +5186,20 @@ type ReasoningEffortPolicyFieldsExpose = { }; const createReasoningEffortPolicyRef = ref(null); const editReasoningEffortPolicyRef = ref(null); + +// 固定账号获取 Codex Model Manifest(仅 openai 分组编辑对话框) +type CodexManifestAccountsFieldExpose = { + validate: () => boolean; + resetValidation: () => void; +}; +const editCodexManifestRef = ref(null); +const createCodexManifestDefaults = (): CodexModelsManifestConfig => ({ + enabled: false, + account_ids: [], + fallback_to_scheduler: false, +}); +const editCodexManifestConfig = reactive(createCodexManifestDefaults()); +const editCodexManifestAccountNames = ref>({}); const modelsListCandidatesTracker = createModelsListCandidatesTracker(); const createModelsListSelectedCount = computed( () => createModelsListState.items.filter((item) => item.selected).length, @@ -6166,6 +6191,8 @@ const handleCreateGroup = async () => { createModelRoutingRules.value, ), models_list_config: buildModelsListConfig(createModelsListState), + // 创建时固定账号 manifest 固定发送关闭状态(后端创建路径禁止开启) + codex_models_manifest_config: createCodexManifestDefaults(), supported_model_scopes: normalizeSupportedModelScopesForPlatform( createForm.platform, createForm.supported_model_scopes, @@ -6351,6 +6378,28 @@ const handleEdit = async (group: AdminGroup) => { group.platform, ); resetModelsListState(editModelsListState, group.models_list_config); + // 固定账号 manifest 配置:回显配置并异步解析已存账号名称(失败显示 #) + const savedCodexManifestConfig = + group.codex_models_manifest_config ?? createCodexManifestDefaults(); + Object.assign(editCodexManifestConfig, { + enabled: savedCodexManifestConfig.enabled ?? false, + account_ids: [...(savedCodexManifestConfig.account_ids ?? [])], + fallback_to_scheduler: savedCodexManifestConfig.fallback_to_scheduler ?? false, + }); + editCodexManifestAccountNames.value = {}; + for (const id of editCodexManifestConfig.account_ids) { + adminAPI.accounts + .getById(id) + .then((account) => { + editCodexManifestAccountNames.value = { + ...editCodexManifestAccountNames.value, + [id]: account.name, + }; + }) + .catch(() => { + // 无法解析名称时由组件回退展示 #,提示管理员清理脏 ID。 + }); + } // 加载模型路由规则(异步加载账号名称) editModelRoutingRules.value = await convertApiFormatToRoutingRules( group.model_routing, @@ -6397,6 +6446,9 @@ const closeEditModal = () => { resetMessagesDispatchFormState(editForm); editForm.allow_live = false; resetModelsListState(editModelsListState); + Object.assign(editCodexManifestConfig, createCodexManifestDefaults()); + editCodexManifestAccountNames.value = {}; + editCodexManifestRef.value?.resetValidation?.(); }; const handleUpdateGroup = async () => { @@ -6415,6 +6467,16 @@ const handleUpdateGroup = async () => { if (!validateProfitControlForm(editForm)) { return; } + // 固定账号 manifest:开启后至少一个账号,前端阻止提交并提示。 + if ( + editForm.platform === "openai" && + editCodexManifestConfig.enabled && + editCodexManifestConfig.account_ids.length === 0 + ) { + appStore.showError(t("admin.groups.codexModelsManifest.selectAtLeastOne")); + editCodexManifestRef.value?.validate(); + return; + } submitting.value = true; try { @@ -6455,6 +6517,15 @@ const handleUpdateGroup = async () => { editModelRoutingRules.value, ), models_list_config: buildModelsListConfig(editModelsListState), + // 非 openai 平台提交关闭状态,与后端归一化一致 + codex_models_manifest_config: + editForm.platform === "openai" + ? { + enabled: editCodexManifestConfig.enabled, + account_ids: [...editCodexManifestConfig.account_ids], + fallback_to_scheduler: editCodexManifestConfig.fallback_to_scheduler, + } + : createCodexManifestDefaults(), supported_model_scopes: normalizeSupportedModelScopesForPlatform( editForm.platform, editForm.supported_model_scopes,