diff --git a/backend/ent/group.go b/backend/ent/group.go index 5bec594977..088da069d6 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -83,6 +83,8 @@ type Group struct { VideoPrice720p *float64 `json:"video_price_720p,omitempty"` // VideoPrice1080p holds the value of the "video_price_1080p" field. VideoPrice1080p *float64 `json:"video_price_1080p,omitempty"` + // Codex alpha/search 网页搜索单次价格(USD/次);nil 表示使用默认价 0.01(官方 $10/1000 次) + WebSearchPricePerCall *float64 `json:"web_search_price_per_call,omitempty"` // 是否仅允许 Claude Code 客户端 ClaudeCodeOnly bool `json:"claude_code_only,omitempty"` // 非 Claude Code 请求降级使用的分组 ID @@ -223,7 +225,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { values[i] = new([]byte) case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet: values[i] = new(sql.NullBool) - case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p: + case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall: values[i] = new(sql.NullFloat64) case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit: values[i] = new(sql.NullInt64) @@ -455,6 +457,13 @@ func (_m *Group) assignValues(columns []string, values []any) error { _m.VideoPrice1080p = new(float64) *_m.VideoPrice1080p = value.Float64 } + case group.FieldWebSearchPricePerCall: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field web_search_price_per_call", values[i]) + } else if value.Valid { + _m.WebSearchPricePerCall = new(float64) + *_m.WebSearchPricePerCall = value.Float64 + } case group.FieldClaudeCodeOnly: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field claude_code_only", values[i]) @@ -749,6 +758,11 @@ func (_m *Group) String() string { builder.WriteString(fmt.Sprintf("%v", *v)) } builder.WriteString(", ") + if v := _m.WebSearchPricePerCall; v != nil { + builder.WriteString("web_search_price_per_call=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") builder.WriteString("claude_code_only=") builder.WriteString(fmt.Sprintf("%v", _m.ClaudeCodeOnly)) builder.WriteString(", ") diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 769c63e6b1..61d7a21d67 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -80,6 +80,8 @@ const ( FieldVideoPrice720p = "video_price_720p" // FieldVideoPrice1080p holds the string denoting the video_price_1080p field in the database. FieldVideoPrice1080p = "video_price_1080p" + // FieldWebSearchPricePerCall holds the string denoting the web_search_price_per_call field in the database. + FieldWebSearchPricePerCall = "web_search_price_per_call" // FieldClaudeCodeOnly holds the string denoting the claude_code_only field in the database. FieldClaudeCodeOnly = "claude_code_only" // FieldFallbackGroupID holds the string denoting the fallback_group_id field in the database. @@ -217,6 +219,7 @@ var Columns = []string{ FieldVideoPrice480p, FieldVideoPrice720p, FieldVideoPrice1080p, + FieldWebSearchPricePerCall, FieldClaudeCodeOnly, FieldFallbackGroupID, FieldFallbackGroupIDOnInvalidRequest, @@ -511,6 +514,11 @@ func ByVideoPrice1080p(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldVideoPrice1080p, opts...).ToFunc() } +// ByWebSearchPricePerCall orders the results by the web_search_price_per_call field. +func ByWebSearchPricePerCall(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldWebSearchPricePerCall, opts...).ToFunc() +} + // ByClaudeCodeOnly orders the results by the claude_code_only field. func ByClaudeCodeOnly(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldClaudeCodeOnly, opts...).ToFunc() diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index 5a9d92d0f4..b9a52a2eb6 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -215,6 +215,11 @@ func VideoPrice1080p(v float64) predicate.Group { return predicate.Group(sql.FieldEQ(FieldVideoPrice1080p, v)) } +// WebSearchPricePerCall applies equality check predicate on the "web_search_price_per_call" field. It's identical to WebSearchPricePerCallEQ. +func WebSearchPricePerCall(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldWebSearchPricePerCall, v)) +} + // ClaudeCodeOnly applies equality check predicate on the "claude_code_only" field. It's identical to ClaudeCodeOnlyEQ. func ClaudeCodeOnly(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v)) @@ -1655,6 +1660,56 @@ func VideoPrice1080pNotNil() predicate.Group { return predicate.Group(sql.FieldNotNull(FieldVideoPrice1080p)) } +// WebSearchPricePerCallEQ applies the EQ predicate on the "web_search_price_per_call" field. +func WebSearchPricePerCallEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldWebSearchPricePerCall, v)) +} + +// WebSearchPricePerCallNEQ applies the NEQ predicate on the "web_search_price_per_call" field. +func WebSearchPricePerCallNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldWebSearchPricePerCall, v)) +} + +// WebSearchPricePerCallIn applies the In predicate on the "web_search_price_per_call" field. +func WebSearchPricePerCallIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldWebSearchPricePerCall, vs...)) +} + +// WebSearchPricePerCallNotIn applies the NotIn predicate on the "web_search_price_per_call" field. +func WebSearchPricePerCallNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldWebSearchPricePerCall, vs...)) +} + +// WebSearchPricePerCallGT applies the GT predicate on the "web_search_price_per_call" field. +func WebSearchPricePerCallGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldWebSearchPricePerCall, v)) +} + +// WebSearchPricePerCallGTE applies the GTE predicate on the "web_search_price_per_call" field. +func WebSearchPricePerCallGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldWebSearchPricePerCall, v)) +} + +// WebSearchPricePerCallLT applies the LT predicate on the "web_search_price_per_call" field. +func WebSearchPricePerCallLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldWebSearchPricePerCall, v)) +} + +// WebSearchPricePerCallLTE applies the LTE predicate on the "web_search_price_per_call" field. +func WebSearchPricePerCallLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldWebSearchPricePerCall, v)) +} + +// WebSearchPricePerCallIsNil applies the IsNil predicate on the "web_search_price_per_call" field. +func WebSearchPricePerCallIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldWebSearchPricePerCall)) +} + +// WebSearchPricePerCallNotNil applies the NotNil predicate on the "web_search_price_per_call" field. +func WebSearchPricePerCallNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldWebSearchPricePerCall)) +} + // ClaudeCodeOnlyEQ applies the EQ predicate on the "claude_code_only" field. func ClaudeCodeOnlyEQ(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v)) diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 2a6c18e67d..53fd733c1f 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -469,6 +469,20 @@ func (_c *GroupCreate) SetNillableVideoPrice1080p(v *float64) *GroupCreate { return _c } +// SetWebSearchPricePerCall sets the "web_search_price_per_call" field. +func (_c *GroupCreate) SetWebSearchPricePerCall(v float64) *GroupCreate { + _c.mutation.SetWebSearchPricePerCall(v) + return _c +} + +// SetNillableWebSearchPricePerCall sets the "web_search_price_per_call" field if the given value is not nil. +func (_c *GroupCreate) SetNillableWebSearchPricePerCall(v *float64) *GroupCreate { + if v != nil { + _c.SetWebSearchPricePerCall(*v) + } + return _c +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (_c *GroupCreate) SetClaudeCodeOnly(v bool) *GroupCreate { _c.mutation.SetClaudeCodeOnly(v) @@ -1218,6 +1232,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldVideoPrice1080p, field.TypeFloat64, value) _node.VideoPrice1080p = &value } + if value, ok := _c.mutation.WebSearchPricePerCall(); ok { + _spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value) + _node.WebSearchPricePerCall = &value + } if value, ok := _c.mutation.ClaudeCodeOnly(); ok { _spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value) _node.ClaudeCodeOnly = value @@ -1968,6 +1986,30 @@ func (u *GroupUpsert) ClearVideoPrice1080p() *GroupUpsert { return u } +// SetWebSearchPricePerCall sets the "web_search_price_per_call" field. +func (u *GroupUpsert) SetWebSearchPricePerCall(v float64) *GroupUpsert { + u.Set(group.FieldWebSearchPricePerCall, v) + return u +} + +// UpdateWebSearchPricePerCall sets the "web_search_price_per_call" field to the value that was provided on create. +func (u *GroupUpsert) UpdateWebSearchPricePerCall() *GroupUpsert { + u.SetExcluded(group.FieldWebSearchPricePerCall) + return u +} + +// AddWebSearchPricePerCall adds v to the "web_search_price_per_call" field. +func (u *GroupUpsert) AddWebSearchPricePerCall(v float64) *GroupUpsert { + u.Add(group.FieldWebSearchPricePerCall, v) + return u +} + +// ClearWebSearchPricePerCall clears the value of the "web_search_price_per_call" field. +func (u *GroupUpsert) ClearWebSearchPricePerCall() *GroupUpsert { + u.SetNull(group.FieldWebSearchPricePerCall) + return u +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (u *GroupUpsert) SetClaudeCodeOnly(v bool) *GroupUpsert { u.Set(group.FieldClaudeCodeOnly, v) @@ -2858,6 +2900,34 @@ func (u *GroupUpsertOne) ClearVideoPrice1080p() *GroupUpsertOne { }) } +// SetWebSearchPricePerCall sets the "web_search_price_per_call" field. +func (u *GroupUpsertOne) SetWebSearchPricePerCall(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetWebSearchPricePerCall(v) + }) +} + +// AddWebSearchPricePerCall adds v to the "web_search_price_per_call" field. +func (u *GroupUpsertOne) AddWebSearchPricePerCall(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddWebSearchPricePerCall(v) + }) +} + +// UpdateWebSearchPricePerCall sets the "web_search_price_per_call" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateWebSearchPricePerCall() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateWebSearchPricePerCall() + }) +} + +// ClearWebSearchPricePerCall clears the value of the "web_search_price_per_call" field. +func (u *GroupUpsertOne) ClearWebSearchPricePerCall() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.ClearWebSearchPricePerCall() + }) +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (u *GroupUpsertOne) SetClaudeCodeOnly(v bool) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -3951,6 +4021,34 @@ func (u *GroupUpsertBulk) ClearVideoPrice1080p() *GroupUpsertBulk { }) } +// SetWebSearchPricePerCall sets the "web_search_price_per_call" field. +func (u *GroupUpsertBulk) SetWebSearchPricePerCall(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetWebSearchPricePerCall(v) + }) +} + +// AddWebSearchPricePerCall adds v to the "web_search_price_per_call" field. +func (u *GroupUpsertBulk) AddWebSearchPricePerCall(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddWebSearchPricePerCall(v) + }) +} + +// UpdateWebSearchPricePerCall sets the "web_search_price_per_call" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateWebSearchPricePerCall() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateWebSearchPricePerCall() + }) +} + +// ClearWebSearchPricePerCall clears the value of the "web_search_price_per_call" field. +func (u *GroupUpsertBulk) ClearWebSearchPricePerCall() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.ClearWebSearchPricePerCall() + }) +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (u *GroupUpsertBulk) SetClaudeCodeOnly(v bool) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 3bb18d3e1a..1e767a139b 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -640,6 +640,33 @@ func (_u *GroupUpdate) ClearVideoPrice1080p() *GroupUpdate { return _u } +// SetWebSearchPricePerCall sets the "web_search_price_per_call" field. +func (_u *GroupUpdate) SetWebSearchPricePerCall(v float64) *GroupUpdate { + _u.mutation.ResetWebSearchPricePerCall() + _u.mutation.SetWebSearchPricePerCall(v) + return _u +} + +// SetNillableWebSearchPricePerCall sets the "web_search_price_per_call" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableWebSearchPricePerCall(v *float64) *GroupUpdate { + if v != nil { + _u.SetWebSearchPricePerCall(*v) + } + return _u +} + +// AddWebSearchPricePerCall adds value to the "web_search_price_per_call" field. +func (_u *GroupUpdate) AddWebSearchPricePerCall(v float64) *GroupUpdate { + _u.mutation.AddWebSearchPricePerCall(v) + return _u +} + +// ClearWebSearchPricePerCall clears the value of the "web_search_price_per_call" field. +func (_u *GroupUpdate) ClearWebSearchPricePerCall() *GroupUpdate { + _u.mutation.ClearWebSearchPricePerCall() + return _u +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (_u *GroupUpdate) SetClaudeCodeOnly(v bool) *GroupUpdate { _u.mutation.SetClaudeCodeOnly(v) @@ -1375,6 +1402,15 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.VideoPrice1080pCleared() { _spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64) } + if value, ok := _u.mutation.WebSearchPricePerCall(); ok { + _spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedWebSearchPricePerCall(); ok { + _spec.AddField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value) + } + if _u.mutation.WebSearchPricePerCallCleared() { + _spec.ClearField(group.FieldWebSearchPricePerCall, field.TypeFloat64) + } if value, ok := _u.mutation.ClaudeCodeOnly(); ok { _spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value) } @@ -2364,6 +2400,33 @@ func (_u *GroupUpdateOne) ClearVideoPrice1080p() *GroupUpdateOne { return _u } +// SetWebSearchPricePerCall sets the "web_search_price_per_call" field. +func (_u *GroupUpdateOne) SetWebSearchPricePerCall(v float64) *GroupUpdateOne { + _u.mutation.ResetWebSearchPricePerCall() + _u.mutation.SetWebSearchPricePerCall(v) + return _u +} + +// SetNillableWebSearchPricePerCall sets the "web_search_price_per_call" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableWebSearchPricePerCall(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetWebSearchPricePerCall(*v) + } + return _u +} + +// AddWebSearchPricePerCall adds value to the "web_search_price_per_call" field. +func (_u *GroupUpdateOne) AddWebSearchPricePerCall(v float64) *GroupUpdateOne { + _u.mutation.AddWebSearchPricePerCall(v) + return _u +} + +// ClearWebSearchPricePerCall clears the value of the "web_search_price_per_call" field. +func (_u *GroupUpdateOne) ClearWebSearchPricePerCall() *GroupUpdateOne { + _u.mutation.ClearWebSearchPricePerCall() + return _u +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (_u *GroupUpdateOne) SetClaudeCodeOnly(v bool) *GroupUpdateOne { _u.mutation.SetClaudeCodeOnly(v) @@ -3129,6 +3192,15 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if _u.mutation.VideoPrice1080pCleared() { _spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64) } + if value, ok := _u.mutation.WebSearchPricePerCall(); ok { + _spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedWebSearchPricePerCall(); ok { + _spec.AddField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value) + } + if _u.mutation.WebSearchPricePerCallCleared() { + _spec.ClearField(group.FieldWebSearchPricePerCall, field.TypeFloat64) + } if value, ok := _u.mutation.ClaudeCodeOnly(); ok { _spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index d3e8bc5448..3441afec04 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -865,6 +865,7 @@ var ( {Name: "video_price_480p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "video_price_720p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "video_price_1080p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "web_search_price_per_call", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "claude_code_only", Type: field.TypeBool, Default: false}, {Name: "fallback_group_id", Type: field.TypeInt64, Nullable: true}, {Name: "fallback_group_id_on_invalid_request", Type: field.TypeInt64, Nullable: true}, @@ -915,7 +916,7 @@ var ( { Name: "group_sort_order", Unique: false, - Columns: []*schema.Column{GroupsColumns[40]}, + Columns: []*schema.Column{GroupsColumns[41]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 8d32773050..ab7c424a47 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -20842,6 +20842,8 @@ type GroupMutation struct { addvideo_price_720p *float64 video_price_1080p *float64 addvideo_price_1080p *float64 + web_search_price_per_call *float64 + addweb_search_price_per_call *float64 claude_code_only *bool fallback_group_id *int64 addfallback_group_id *int64 @@ -22608,6 +22610,76 @@ func (m *GroupMutation) ResetVideoPrice1080p() { delete(m.clearedFields, group.FieldVideoPrice1080p) } +// SetWebSearchPricePerCall sets the "web_search_price_per_call" field. +func (m *GroupMutation) SetWebSearchPricePerCall(f float64) { + m.web_search_price_per_call = &f + m.addweb_search_price_per_call = nil +} + +// WebSearchPricePerCall returns the value of the "web_search_price_per_call" field in the mutation. +func (m *GroupMutation) WebSearchPricePerCall() (r float64, exists bool) { + v := m.web_search_price_per_call + if v == nil { + return + } + return *v, true +} + +// OldWebSearchPricePerCall returns the old "web_search_price_per_call" 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) OldWebSearchPricePerCall(ctx context.Context) (v *float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldWebSearchPricePerCall is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldWebSearchPricePerCall requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldWebSearchPricePerCall: %w", err) + } + return oldValue.WebSearchPricePerCall, nil +} + +// AddWebSearchPricePerCall adds f to the "web_search_price_per_call" field. +func (m *GroupMutation) AddWebSearchPricePerCall(f float64) { + if m.addweb_search_price_per_call != nil { + *m.addweb_search_price_per_call += f + } else { + m.addweb_search_price_per_call = &f + } +} + +// AddedWebSearchPricePerCall returns the value that was added to the "web_search_price_per_call" field in this mutation. +func (m *GroupMutation) AddedWebSearchPricePerCall() (r float64, exists bool) { + v := m.addweb_search_price_per_call + if v == nil { + return + } + return *v, true +} + +// ClearWebSearchPricePerCall clears the value of the "web_search_price_per_call" field. +func (m *GroupMutation) ClearWebSearchPricePerCall() { + m.web_search_price_per_call = nil + m.addweb_search_price_per_call = nil + m.clearedFields[group.FieldWebSearchPricePerCall] = struct{}{} +} + +// WebSearchPricePerCallCleared returns if the "web_search_price_per_call" field was cleared in this mutation. +func (m *GroupMutation) WebSearchPricePerCallCleared() bool { + _, ok := m.clearedFields[group.FieldWebSearchPricePerCall] + return ok +} + +// ResetWebSearchPricePerCall resets all changes to the "web_search_price_per_call" field. +func (m *GroupMutation) ResetWebSearchPricePerCall() { + m.web_search_price_per_call = nil + m.addweb_search_price_per_call = nil + delete(m.clearedFields, group.FieldWebSearchPricePerCall) +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (m *GroupMutation) SetClaudeCodeOnly(b bool) { m.claude_code_only = &b @@ -23642,7 +23714,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, 47) + fields := make([]string, 0, 48) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -23739,6 +23811,9 @@ func (m *GroupMutation) Fields() []string { if m.video_price_1080p != nil { fields = append(fields, group.FieldVideoPrice1080p) } + if m.web_search_price_per_call != nil { + fields = append(fields, group.FieldWebSearchPricePerCall) + } if m.claude_code_only != nil { fields = append(fields, group.FieldClaudeCodeOnly) } @@ -23856,6 +23931,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.VideoPrice720p() case group.FieldVideoPrice1080p: return m.VideoPrice1080p() + case group.FieldWebSearchPricePerCall: + return m.WebSearchPricePerCall() case group.FieldClaudeCodeOnly: return m.ClaudeCodeOnly() case group.FieldFallbackGroupID: @@ -23959,6 +24036,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldVideoPrice720p(ctx) case group.FieldVideoPrice1080p: return m.OldVideoPrice1080p(ctx) + case group.FieldWebSearchPricePerCall: + return m.OldWebSearchPricePerCall(ctx) case group.FieldClaudeCodeOnly: return m.OldClaudeCodeOnly(ctx) case group.FieldFallbackGroupID: @@ -24222,6 +24301,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetVideoPrice1080p(v) return nil + case group.FieldWebSearchPricePerCall: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetWebSearchPricePerCall(v) + return nil case group.FieldClaudeCodeOnly: v, ok := value.(bool) if !ok { @@ -24383,6 +24469,9 @@ func (m *GroupMutation) AddedFields() []string { if m.addvideo_price_1080p != nil { fields = append(fields, group.FieldVideoPrice1080p) } + if m.addweb_search_price_per_call != nil { + fields = append(fields, group.FieldWebSearchPricePerCall) + } if m.addfallback_group_id != nil { fields = append(fields, group.FieldFallbackGroupID) } @@ -24435,6 +24524,8 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) { return m.AddedVideoPrice720p() case group.FieldVideoPrice1080p: return m.AddedVideoPrice1080p() + case group.FieldWebSearchPricePerCall: + return m.AddedWebSearchPricePerCall() case group.FieldFallbackGroupID: return m.AddedFallbackGroupID() case group.FieldFallbackGroupIDOnInvalidRequest: @@ -24564,6 +24655,13 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error { } m.AddVideoPrice1080p(v) return nil + case group.FieldWebSearchPricePerCall: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddWebSearchPricePerCall(v) + return nil case group.FieldFallbackGroupID: v, ok := value.(int64) if !ok { @@ -24633,6 +24731,9 @@ func (m *GroupMutation) ClearedFields() []string { if m.FieldCleared(group.FieldVideoPrice1080p) { fields = append(fields, group.FieldVideoPrice1080p) } + if m.FieldCleared(group.FieldWebSearchPricePerCall) { + fields = append(fields, group.FieldWebSearchPricePerCall) + } if m.FieldCleared(group.FieldFallbackGroupID) { fields = append(fields, group.FieldFallbackGroupID) } @@ -24689,6 +24790,9 @@ func (m *GroupMutation) ClearField(name string) error { case group.FieldVideoPrice1080p: m.ClearVideoPrice1080p() return nil + case group.FieldWebSearchPricePerCall: + m.ClearWebSearchPricePerCall() + return nil case group.FieldFallbackGroupID: m.ClearFallbackGroupID() return nil @@ -24802,6 +24906,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldVideoPrice1080p: m.ResetVideoPrice1080p() return nil + case group.FieldWebSearchPricePerCall: + m.ResetWebSearchPricePerCall() + return nil case group.FieldClaudeCodeOnly: m.ResetClaudeCodeOnly() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index d47e7d143b..4cb3f800f8 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1044,53 +1044,53 @@ func init() { // group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field. group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64) // groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field. - groupDescClaudeCodeOnly := groupFields[29].Descriptor() + groupDescClaudeCodeOnly := groupFields[30].Descriptor() // group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field. group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool) // groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field. - groupDescModelRoutingEnabled := groupFields[33].Descriptor() + groupDescModelRoutingEnabled := groupFields[34].Descriptor() // group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field. group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool) // groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field. - groupDescMcpXMLInject := groupFields[34].Descriptor() + groupDescMcpXMLInject := groupFields[35].Descriptor() // group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field. group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool) // groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field. - groupDescSupportedModelScopes := groupFields[35].Descriptor() + groupDescSupportedModelScopes := groupFields[36].Descriptor() // group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field. group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string) // groupDescSortOrder is the schema descriptor for sort_order field. - groupDescSortOrder := groupFields[36].Descriptor() + groupDescSortOrder := groupFields[37].Descriptor() // group.DefaultSortOrder holds the default value on creation for the sort_order field. group.DefaultSortOrder = groupDescSortOrder.Default.(int) // groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field. - groupDescAllowMessagesDispatch := groupFields[37].Descriptor() + groupDescAllowMessagesDispatch := groupFields[38].Descriptor() // group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field. group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[38].Descriptor() + groupDescRequireOauthOnly := groupFields[39].Descriptor() // group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field. group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool) // groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field. - groupDescRequirePrivacySet := groupFields[39].Descriptor() + groupDescRequirePrivacySet := groupFields[40].Descriptor() // group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field. group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool) // groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field. - groupDescDefaultMappedModel := groupFields[40].Descriptor() + groupDescDefaultMappedModel := groupFields[41].Descriptor() // group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field. group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string) // group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save. group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error) // groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field. - groupDescMessagesDispatchModelConfig := groupFields[41].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[42].Descriptor() // group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field. group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig) // groupDescModelsListConfig is the schema descriptor for models_list_config field. - groupDescModelsListConfig := groupFields[42].Descriptor() + groupDescModelsListConfig := groupFields[43].Descriptor() // group.DefaultModelsListConfig holds the default value on creation for the models_list_config field. group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig) // groupDescRpmLimit is the schema descriptor for rpm_limit field. - groupDescRpmLimit := groupFields[43].Descriptor() + groupDescRpmLimit := groupFields[44].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index b104609a1b..70093a3cfa 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -142,6 +142,11 @@ func (Group) Fields() []ent.Field { Optional(). Nillable(). SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}), + field.Float("web_search_price_per_call"). + Optional(). + Nillable(). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). + Comment("Codex alpha/search 网页搜索单次价格(USD/次);nil 表示使用默认价 0.01(官方 $10/1000 次)"), // Claude Code 客户端限制 (added by migration 029) field.Bool("claude_code_only"). diff --git a/backend/go.sum b/backend/go.sum index 4738443bb9..3d9989bb43 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -220,6 +220,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -253,6 +255,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -282,6 +286,8 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= @@ -314,6 +320,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 56a0b29ed0..02a4446846 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -110,6 +110,7 @@ type CreateGroupRequest struct { VideoPrice480P *float64 `json:"video_price_480p"` VideoPrice720P *float64 `json:"video_price_720p"` VideoPrice1080P *float64 `json:"video_price_1080p"` + WebSearchPricePerCall *float64 `json:"web_search_price_per_call"` ClaudeCodeOnly bool `json:"claude_code_only"` FallbackGroupID *int64 `json:"fallback_group_id"` FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"` @@ -163,6 +164,7 @@ type UpdateGroupRequest struct { VideoPrice480P *float64 `json:"video_price_480p"` VideoPrice720P *float64 `json:"video_price_720p"` VideoPrice1080P *float64 `json:"video_price_1080p"` + WebSearchPricePerCall *float64 `json:"web_search_price_per_call"` ClaudeCodeOnly *bool `json:"claude_code_only"` FallbackGroupID *int64 `json:"fallback_group_id"` FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"` @@ -334,6 +336,7 @@ func (h *GroupHandler) Create(c *gin.Context) { VideoPrice480P: req.VideoPrice480P, VideoPrice720P: req.VideoPrice720P, VideoPrice1080P: req.VideoPrice1080P, + WebSearchPricePerCall: req.WebSearchPricePerCall, ClaudeCodeOnly: req.ClaudeCodeOnly, FallbackGroupID: req.FallbackGroupID, FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest, @@ -402,6 +405,7 @@ func (h *GroupHandler) Update(c *gin.Context) { VideoPrice480P: req.VideoPrice480P, VideoPrice720P: req.VideoPrice720P, VideoPrice1080P: req.VideoPrice1080P, + WebSearchPricePerCall: req.WebSearchPricePerCall, ClaudeCodeOnly: req.ClaudeCodeOnly, FallbackGroupID: req.FallbackGroupID, FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest, diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 6270c2b982..e770bcf036 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -199,6 +199,7 @@ func groupFromServiceBase(g *service.Group) Group { VideoPrice480P: g.VideoPrice480P, VideoPrice720P: g.VideoPrice720P, VideoPrice1080P: g.VideoPrice1080P, + WebSearchPricePerCall: g.WebSearchPricePerCall, ClaudeCodeOnly: g.ClaudeCodeOnly, FallbackGroupID: g.FallbackGroupID, FallbackGroupIDOnInvalidRequest: g.FallbackGroupIDOnInvalidRequest, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 7cfd102880..0418e5bc3e 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -120,6 +120,8 @@ type Group struct { VideoPrice480P *float64 `json:"video_price_480p"` VideoPrice720P *float64 `json:"video_price_720p"` VideoPrice1080P *float64 `json:"video_price_1080p"` + // Codex alpha/search 网页搜索单次价格(USD/次);null 表示使用默认价 0.01 + WebSearchPricePerCall *float64 `json:"web_search_price_per_call"` // Claude Code 客户端限制 ClaudeCodeOnly bool `json:"claude_code_only"` diff --git a/backend/internal/handler/openai_alpha_search.go b/backend/internal/handler/openai_alpha_search.go index 3532e42363..a808145235 100644 --- a/backend/internal/handler/openai_alpha_search.go +++ b/backend/internal/handler/openai_alpha_search.go @@ -1,6 +1,7 @@ package handler import ( + "context" "errors" "net/http" "strconv" @@ -8,6 +9,8 @@ import ( "time" pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -145,7 +148,8 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds()) writerSizeBeforeForward := c.Writer.Size() forwardStart := time.Now() - err = func() error { + var result *service.OpenAIForwardResult + result, err = func() (*service.OpenAIForwardResult, error) { if accountRelease != nil { defer accountRelease() } @@ -155,6 +159,9 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { if err == nil { h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil) + if result != nil { + h.recordAlphaSearchUsage(c, apiKey, account, subscription, channelMapping, requestedModel, body, result, subject.UserID) + } return } @@ -192,3 +199,52 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { ) } } + +// recordAlphaSearchUsage 为一次成功的 alpha/search 网页搜索落按次计费用量行 +// (上游不返回 usage 字段,按 WebSearchCalls 走分组单价 × 倍率的按次口径)。 +// 与 images 一致使用 mandatory 池提交,池满时同步兜底执行,保证扣费不丢。 +func (h *OpenAIGatewayHandler) recordAlphaSearchUsage( + c *gin.Context, + apiKey *service.APIKey, + account *service.Account, + subscription *service.UserSubscription, + channelMapping service.ChannelMappingResult, + requestedModel string, + body []byte, + result *service.OpenAIForwardResult, + userID int64, +) { + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + requestPayloadHash := service.HashUsageRequestPayload(body) + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) + quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + + h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) { + if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ + Result: result, + APIKey: apiKey, + User: apiKey.User, + Account: account, + Subscription: subscription, + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, + UserAgent: userAgent, + IPAddress: clientIP, + RequestPayloadHash: requestPayloadHash, + APIKeyService: h.apiKeyService, + QuotaPlatform: quotaPlatform, + ChannelUsageFields: channelMapping.ToUsageFields(requestedModel, result.UpstreamModel), + }); err != nil { + logger.L().With( + zap.String("component", "handler.openai_gateway.alpha_search"), + zap.Int64("user_id", userID), + zap.Int64("api_key_id", apiKey.ID), + zap.Any("group_id", apiKey.GroupID), + zap.String("model", requestedModel), + zap.Int64("account_id", account.ID), + ).Error("openai_alpha_search.record_usage_failed", zap.Error(err)) + } + }) +} diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index d348ef29b7..ee4ed4785f 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -190,6 +190,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, + group.FieldWebSearchPricePerCall, group.FieldClaudeCodeOnly, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, @@ -943,6 +944,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { VideoPrice480P: g.VideoPrice480p, VideoPrice720P: g.VideoPrice720p, VideoPrice1080P: g.VideoPrice1080p, + WebSearchPricePerCall: g.WebSearchPricePerCall, DefaultValidityDays: g.DefaultValidityDays, ClaudeCodeOnly: g.ClaudeCodeOnly, FallbackGroupID: g.FallbackGroupID, diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 37529c60be..47efb72dd8 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -63,6 +63,7 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er SetNillableVideoPrice480p(groupIn.VideoPrice480P). SetNillableVideoPrice720p(groupIn.VideoPrice720P). SetNillableVideoPrice1080p(groupIn.VideoPrice1080P). + SetNillableWebSearchPricePerCall(groupIn.WebSearchPricePerCall). SetDefaultValidityDays(groupIn.DefaultValidityDays). SetClaudeCodeOnly(groupIn.ClaudeCodeOnly). SetNillableFallbackGroupID(groupIn.FallbackGroupID). @@ -215,6 +216,11 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er } else { builder = builder.ClearVideoPrice1080p() } + if groupIn.WebSearchPricePerCall != nil { + builder = builder.SetWebSearchPricePerCall(*groupIn.WebSearchPricePerCall) + } else { + builder = builder.ClearWebSearchPricePerCall() + } // 处理 FallbackGroupID:nil 时清除,否则设置 if groupIn.FallbackGroupID != nil { diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index d260afe738..372cc46bbf 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -366,6 +366,7 @@ func TestAPIContracts(t *testing.T) { "video_price_480p": null, "video_price_720p": null, "video_price_1080p": null, + "web_search_price_per_call": null, "allow_image_generation": false, "allow_batch_image_generation": false, "batch_image_discount_multiplier": 0, diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index c85056d623..d622d547b0 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -156,6 +156,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn videoPrice480P := normalizePrice(input.VideoPrice480P) videoPrice720P := normalizePrice(input.VideoPrice720P) videoPrice1080P := normalizePrice(input.VideoPrice1080P) + webSearchPricePerCall := normalizePrice(input.WebSearchPricePerCall) imageRateMultiplier := 1.0 if input.ImageRateMultiplier != nil { if *input.ImageRateMultiplier < 0 { @@ -287,6 +288,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn VideoPrice480P: videoPrice480P, VideoPrice720P: videoPrice720P, VideoPrice1080P: videoPrice1080P, + WebSearchPricePerCall: webSearchPricePerCall, ClaudeCodeOnly: input.ClaudeCodeOnly, FallbackGroupID: input.FallbackGroupID, FallbackGroupIDOnInvalidRequest: fallbackOnInvalidRequest, @@ -543,6 +545,9 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.VideoPrice1080P != nil { group.VideoPrice1080P = normalizePrice(input.VideoPrice1080P) } + if input.WebSearchPricePerCall != nil { + group.WebSearchPricePerCall = normalizePrice(input.WebSearchPricePerCall) + } // Claude Code 客户端限制 if input.ClaudeCodeOnly != nil { diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 07b85ab827..2a7125f51f 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -220,8 +220,10 @@ type CreateGroupInput struct { VideoPrice480P *float64 VideoPrice720P *float64 VideoPrice1080P *float64 - ClaudeCodeOnly bool // 仅允许 Claude Code 客户端 - FallbackGroupID *int64 // 降级分组 ID + // Codex alpha/search 网页搜索单次价格(USD/次,仅 openai 平台使用);nil/负数按默认价 0.01 处理 + WebSearchPricePerCall *float64 + ClaudeCodeOnly bool // 仅允许 Claude Code 客户端 + FallbackGroupID *int64 // 降级分组 ID // 无效请求兜底分组 ID(仅 anthropic 平台使用) FallbackGroupIDOnInvalidRequest *int64 // 模型路由配置(仅 anthropic 平台使用) @@ -274,8 +276,10 @@ type UpdateGroupInput struct { VideoPrice480P *float64 VideoPrice720P *float64 VideoPrice1080P *float64 - ClaudeCodeOnly *bool // 仅允许 Claude Code 客户端 - FallbackGroupID *int64 // 降级分组 ID + // Codex alpha/search 网页搜索单次价格(USD/次);nil 表示不修改,负数表示清除回默认价 0.01 + WebSearchPricePerCall *float64 + ClaudeCodeOnly *bool // 仅允许 Claude Code 客户端 + FallbackGroupID *int64 // 降级分组 ID // 无效请求兜底分组 ID(仅 anthropic 平台使用) FallbackGroupIDOnInvalidRequest *int64 // 模型路由配置(仅 anthropic 平台使用) diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 11b5246a1d..0cd1c6d584 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -78,6 +78,7 @@ type APIKeyAuthGroupSnapshot struct { VideoPrice480P *float64 `json:"video_price_480p,omitempty"` VideoPrice720P *float64 `json:"video_price_720p,omitempty"` VideoPrice1080P *float64 `json:"video_price_1080p,omitempty"` + WebSearchPricePerCall *float64 `json:"web_search_price_per_call,omitempty"` ClaudeCodeOnly bool `json:"claude_code_only"` FallbackGroupID *int64 `json:"fallback_group_id,omitempty"` FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request,omitempty"` diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 539c7375d9..5c45408659 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 = 14 // v14: include group video pricing fields +const apiKeyAuthSnapshotVersion = 15 // v15: include group web search per-call pricing type apiKeyAuthCacheConfig struct { l1Size int @@ -270,6 +270,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) VideoPrice480P: apiKey.Group.VideoPrice480P, VideoPrice720P: apiKey.Group.VideoPrice720P, VideoPrice1080P: apiKey.Group.VideoPrice1080P, + WebSearchPricePerCall: apiKey.Group.WebSearchPricePerCall, ClaudeCodeOnly: apiKey.Group.ClaudeCodeOnly, FallbackGroupID: apiKey.Group.FallbackGroupID, FallbackGroupIDOnInvalidRequest: apiKey.Group.FallbackGroupIDOnInvalidRequest, @@ -353,6 +354,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho VideoPrice480P: snapshot.Group.VideoPrice480P, VideoPrice720P: snapshot.Group.VideoPrice720P, VideoPrice1080P: snapshot.Group.VideoPrice1080P, + WebSearchPricePerCall: snapshot.Group.WebSearchPricePerCall, ClaudeCodeOnly: snapshot.Group.ClaudeCodeOnly, FallbackGroupID: snapshot.Group.FallbackGroupID, FallbackGroupIDOnInvalidRequest: snapshot.Group.FallbackGroupIDOnInvalidRequest, diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 3dfd500b05..e6bcaa1db8 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -1320,8 +1320,36 @@ const ( defaultGrokImagineVideo15Price480P = 0.08 defaultGrokImagineVideo15Price720P = 0.14 defaultGrokImagineVideo15Price1080P = 0.25 + + // Codex alpha/search 网页搜索单次默认价:OpenAI 官方 web search 定价 $10/1000 次。 + defaultWebSearchPricePerCall = 0.01 ) +// CalculateWebSearchCost 计算 Codex alpha/search 网页搜索按次费用。 +// callCount: 搜索调用次数(每次请求为 1) +// groupPrice: 分组配置的单次价格(nil 表示使用默认价 0.01;0 表示免费) +// rateMultiplier: 分组费率倍数 +func (s *BillingService) CalculateWebSearchCost(callCount int, groupPrice *float64, rateMultiplier float64) *CostBreakdown { + if callCount <= 0 { + return &CostBreakdown{} + } + unitPrice := defaultWebSearchPricePerCall + if groupPrice != nil && *groupPrice >= 0 { + unitPrice = *groupPrice + } + totalCost := unitPrice * float64(callCount) + + // 应用倍率(保存时强制 > 0;负数按 0 处理避免按 1x 误扣) + if rateMultiplier < 0 { + rateMultiplier = 0 + } + return &CostBreakdown{ + TotalCost: totalCost, + ActualCost: totalCost * rateMultiplier, + BillingMode: string(BillingModePerRequest), + } +} + // CalculateImageCost 计算图片生成费用 // model: 请求的模型名称(用于获取 LiteLLM 默认价格) // imageSize: 图片尺寸 "1K", "2K", "4K" diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index a61e356a01..346eef7b95 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -50,6 +50,9 @@ type Group struct { VideoPrice480P *float64 VideoPrice720P *float64 VideoPrice1080P *float64 + // Codex alpha/search 网页搜索单次价格(USD/次,仅 openai 平台使用); + // nil 表示使用默认价 defaultWebSearchPricePerCall(官方 $10/1000 次)。 + WebSearchPricePerCall *float64 // Claude Code 客户端限制 ClaudeCodeOnly bool diff --git a/backend/internal/service/media_price_config.go b/backend/internal/service/media_price_config.go index ed84998906..0a583b6c72 100644 --- a/backend/internal/service/media_price_config.go +++ b/backend/internal/service/media_price_config.go @@ -29,3 +29,10 @@ func videoPriceConfigFromAPIKey(apiKey *APIKey) *VideoPriceConfig { func apiKeyHasConfiguredVideoPrice(apiKey *APIKey, resolution string) bool { return apiKey != nil && apiKey.Group != nil && apiKey.Group.GetVideoPrice(resolution) != nil } + +func webSearchPricePerCallFromAPIKey(apiKey *APIKey) *float64 { + if apiKey == nil || apiKey.Group == nil { + return nil + } + return apiKey.Group.WebSearchPricePerCall +} diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index ecc4496e66..50d37f95bb 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -21,14 +21,18 @@ const ( // ForwardAlphaSearch proxies Codex standalone web search without binding the // evolving alpha request or response schema. -func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Context, account *Account, body []byte) error { +// +// 返回值约定:仅当上游返回 2xx(一次真实成功的搜索)时返回非 nil 的 +// *OpenAIForwardResult(WebSearchCalls=1,供按次计费);上游错误被原样透传 +// 给客户端时返回 (nil, nil),不产生计费。 +func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) { if s == nil || c == nil || account == nil { - return fmt.Errorf("service, context, and account are required") + return nil, fmt.Errorf("service, context, and account are required") } modelResult := gjson.GetBytes(body, "model") requestedModel := strings.TrimSpace(modelResult.String()) if modelResult.Type != gjson.String || requestedModel == "" { - return fmt.Errorf("model is required") + return nil, fmt.Errorf("model is required") } upstreamModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(requestedModel)) @@ -38,12 +42,12 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co token, _, err := s.GetAccessToken(ctx, account) if err != nil { - return err + return nil, err } req, err := s.buildOpenAIAlphaSearchRequest(ctx, c, account, body, token) if err != nil { - return err + return nil, err } proxyURL := "" @@ -54,13 +58,13 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if err != nil { - return s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) } defer func() { _ = resp.Body.Close() }() respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) if err != nil { - return fmt.Errorf("read alpha search response: %w", err) + return nil, fmt.Errorf("read alpha search response: %w", err) } if resp.StatusCode >= http.StatusBadRequest { @@ -68,7 +72,7 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMessage, respBody) { resp.Body = io.NopCloser(bytes.NewReader(respBody)) s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel) - return &UpstreamFailoverError{ + return nil, &UpstreamFailoverError{ StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), @@ -85,7 +89,17 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co contentType = "application/json" } c.Data(resp.StatusCode, contentType, respBody) - return nil + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + // 非 2xx(错误/重定向)已原样透传给客户端:不是一次成功的搜索,不计费。 + return nil, nil + } + return &OpenAIForwardResult{ + RequestID: strings.TrimSpace(resp.Header.Get("x-request-id")), + Model: requestedModel, + UpstreamModel: upstreamModel, + Duration: time.Since(upstreamStart), + WebSearchCalls: 1, + }, nil } func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string) (*http.Request, error) { diff --git a/backend/internal/service/openai_alpha_search_billing_test.go b/backend/internal/service/openai_alpha_search_billing_test.go new file mode 100644 index 0000000000..1251ee43f9 --- /dev/null +++ b/backend/internal/service/openai_alpha_search_billing_test.go @@ -0,0 +1,101 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestCalculateWebSearchCostDefaultAndOverride(t *testing.T) { + t.Parallel() + s := &BillingService{} + + // 默认价:官方 $10/1000 次 = 0.01/次 + cost := s.CalculateWebSearchCost(1, nil, 1.0) + require.InDelta(t, 0.01, cost.TotalCost, 1e-12) + require.InDelta(t, 0.01, cost.ActualCost, 1e-12) + require.Equal(t, string(BillingModePerRequest), cost.BillingMode) + + // 分组覆盖价 + 倍率 + cost = s.CalculateWebSearchCost(1, float64Ptr(0.02), 2.5) + require.InDelta(t, 0.02, cost.TotalCost, 1e-12) + require.InDelta(t, 0.05, cost.ActualCost, 1e-12) + + // 0 = 免费(区别于 nil = 默认价) + cost = s.CalculateWebSearchCost(1, float64Ptr(0), 3.0) + require.Zero(t, cost.TotalCost) + require.Zero(t, cost.ActualCost) + + // 负数倍率按 0 处理,避免按 1x 误扣 + cost = s.CalculateWebSearchCost(1, nil, -1) + require.InDelta(t, 0.01, cost.TotalCost, 1e-12) + require.Zero(t, cost.ActualCost) + + // 次数 <= 0 不产生费用 + cost = s.CalculateWebSearchCost(0, float64Ptr(0.02), 1.0) + require.Zero(t, cost.TotalCost) + require.Empty(t, cost.BillingMode) +} + +func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { + t.Parallel() + svc := &OpenAIGatewayService{billingService: &BillingService{}} + groupID := int64(11) + + // 分组未配置单价:默认 0.01。按次搜索使用不含高峰因子的基础倍率(第 4 个倍率参数 2.0), + // 即使 token 倍率(含高峰,3.0)更高也不采用。 + apiKey := &APIKey{ID: 1, GroupID: &groupID, Group: &Group{ID: groupID, Platform: PlatformOpenAI}} + result := &OpenAIForwardResult{Model: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol", WebSearchCalls: 1} + cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 3.0, 1.0, 1.0, 2.0, UsageTokens{}, "") + require.NoError(t, err) + require.Equal(t, string(BillingModePerRequest), cost.BillingMode) + require.InDelta(t, 0.01, cost.TotalCost, 1e-12) + require.InDelta(t, 0.02, cost.ActualCost, 1e-12) + + // 分组配置单价 0.005 + apiKey.Group.WebSearchPricePerCall = float64Ptr(0.005) + cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{}, "") + require.NoError(t, err) + require.InDelta(t, 0.005, cost.TotalCost, 1e-12) + require.InDelta(t, 0.005, cost.ActualCost, 1e-12) + + // WebSearchCalls = 0 时不得走按次分支(无定价数据会返回 pricing 错误, + // 证明回落到了 token 路径而不是被按次分支吞掉)。 + result.WebSearchCalls = 0 + _, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "") + require.Error(t, err) +} + +func TestAPIKeyService_SnapshotRoundTrip_PreservesWebSearchPricePerCall(t *testing.T) { + svc := NewAPIKeyService(nil, nil, nil, nil, nil, nil, &config.Config{}) + groupID := int64(9) + apiKey := &APIKey{ + ID: 1, + UserID: 2, + GroupID: &groupID, + Key: "k-websearch", + Status: StatusActive, + User: &User{ID: 2, Status: StatusActive, Role: RoleUser}, + Group: &Group{ + ID: groupID, + Name: "openai", + Platform: PlatformOpenAI, + Status: StatusActive, + SubscriptionType: SubscriptionTypeStandard, + RateMultiplier: 1, + WebSearchPricePerCall: float64Ptr(0.008), + }, + } + + snapshot := svc.snapshotFromAPIKey(context.Background(), apiKey) + roundTrip := svc.snapshotToAPIKey(apiKey.Key, snapshot) + + require.NotNil(t, roundTrip) + require.NotNil(t, roundTrip.Group) + require.NotNil(t, roundTrip.Group.WebSearchPricePerCall) + require.InDelta(t, 0.008, *roundTrip.Group.WebSearchPricePerCall, 1e-12) +} diff --git a/backend/internal/service/openai_alpha_search_test.go b/backend/internal/service/openai_alpha_search_test.go index 52e5bc36ca..458dbe9b5b 100644 --- a/backend/internal/service/openai_alpha_search_test.go +++ b/backend/internal/service/openai_alpha_search_test.go @@ -52,9 +52,12 @@ func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) { }, } - err := service.ForwardAlphaSearch(context.Background(), c, account, body) + result, err := service.ForwardAlphaSearch(context.Background(), c, account, body) require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, result.WebSearchCalls) + require.Equal(t, "gpt-5.6-sol", result.Model) require.Equal(t, http.StatusOK, recorder.Code) require.JSONEq(t, `{"encrypted_output":"ciphertext","output":"search result"}`, recorder.Body.String()) require.Equal(t, chatgptCodexAlphaSearchURL+"?feature=standalone", upstream.lastReq.URL.String()) @@ -95,9 +98,11 @@ func TestForwardAlphaSearchAPIKeyMapsModelAndPassesThroughError(t *testing.T) { }, } - err := service.ForwardAlphaSearch(context.Background(), c, account, body) + result, err := service.ForwardAlphaSearch(context.Background(), c, account, body) require.NoError(t, err) + // 上游错误透传不是一次成功的搜索:不返回 result、不产生按次计费。 + require.Nil(t, result) require.Equal(t, http.StatusBadRequest, recorder.Code) require.JSONEq(t, upstreamBody, recorder.Body.String()) require.Equal(t, "https://compat.example/v4/alpha/search", upstream.lastReq.URL.String()) @@ -128,8 +133,9 @@ func TestForwardAlphaSearchReturnsFailoverBeforeWriting(t *testing.T) { }, } - err := service.ForwardAlphaSearch(context.Background(), c, account, body) + result, err := service.ForwardAlphaSearch(context.Background(), c, account, body) + require.Nil(t, result) var failoverErr *UpstreamFailoverError require.ErrorAs(t, err, &failoverErr) require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 42198e7468..29c7d968a2 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -252,6 +252,9 @@ type OpenAIForwardResult struct { VideoResolution string // VideoDurationSeconds 是提交时请求的生成时长(xAI 按输出秒数计费),已归一化到 1-15 秒。 VideoDurationSeconds int + // WebSearchCalls 是 Codex alpha/search 网页搜索调用次数(每次成功请求为 1)。 + // 上游不返回 usage 字段,>0 时走按次计费(分组单价 × 次数 × 倍率)。 + WebSearchCalls int wsReplayInput []json.RawMessage wsReplayInputExists bool diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index f96b679cf6..9431b73ecb 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -178,7 +178,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec if result.ServiceTier != nil { serviceTier = strings.TrimSpace(*result.ServiceTier) } - cost, err = s.calculateOpenAIRecordUsageCost(ctx, result, apiKey, billingModels, multiplier, imageMultiplier, videoMultiplier, tokens, serviceTier) + cost, err = s.calculateOpenAIRecordUsageCost(ctx, result, apiKey, billingModels, multiplier, imageMultiplier, videoMultiplier, baseMultiplier, tokens, serviceTier) if err != nil { if !isUsagePricingUnavailableError(err) { return err @@ -363,10 +363,18 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( multiplier float64, imageMultiplier float64, videoMultiplier float64, + webSearchMultiplier float64, tokens UsageTokens, serviceTier string, ) (*CostBreakdown, error) { billingModel := firstUsageBillingModel(billingModels) + if result != nil && result.WebSearchCalls > 0 { + // Codex alpha/search 网页搜索按次计费:上游不返回 usage/token 字段,单价只取 + // 分组覆盖价(nil 时默认 0.01 = 官方 $10/1000 次),不参与渠道级模型定价。 + // 倍率与 image/video 按次口径一致:使用不含高峰因子的基础倍率 + //(用户专属 > 分组 rate_multiplier > 系统默认),与分组表单的价格预览承诺一致。 + return s.billingService.CalculateWebSearchCost(result.WebSearchCalls, webSearchPricePerCallFromAPIKey(apiKey), webSearchMultiplier), nil + } if isGrokVideoUsageResult(result, billingModels) { if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved == nil || resolved.Mode != BillingModeToken { return s.calculateOpenAIVideoCost(ctx, billingModel, apiKey, result, videoMultiplier), nil diff --git a/backend/migrations/174_group_web_search_price_per_call.sql b/backend/migrations/174_group_web_search_price_per_call.sql new file mode 100644 index 0000000000..da9c90fed8 --- /dev/null +++ b/backend/migrations/174_group_web_search_price_per_call.sql @@ -0,0 +1,3 @@ +-- Codex alpha/search 网页搜索按次计费:分组级单次价格覆盖。 +-- NULL 表示使用内置默认价 0.01 USD/次(OpenAI 官方 web search 定价 $10/1000 次)。 +ALTER TABLE groups ADD COLUMN IF NOT EXISTS web_search_price_per_call DECIMAL(20,8); diff --git a/frontend/src/i18n/__tests__/opsLocaleKeys.spec.ts b/frontend/src/i18n/__tests__/opsLocaleKeys.spec.ts index c396991a0c..d44a47dbc8 100644 --- a/frontend/src/i18n/__tests__/opsLocaleKeys.spec.ts +++ b/frontend/src/i18n/__tests__/opsLocaleKeys.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import en from '@/i18n/locales/en' +import zh from '@/i18n/locales/zh' function flattenKeys(obj: Record, prefix = ''): string[] { const keys: string[] = [] @@ -35,4 +36,18 @@ describe('groups locale key completeness', () => { const enKeys = flattenKeys(en) expect(enKeys).toContain('admin.groups.failedToSave') }) + + const webSearchPricingKeys = [ + 'admin.groups.webSearchPricing.title', + 'admin.groups.webSearchPricing.pricePerCall', + 'admin.groups.webSearchPricing.pricePerCallHint', + 'admin.groups.webSearchPricing.finalPricePreview', + ] + + for (const key of webSearchPricingKeys) { + it(`en and zh locales both have ${key}`, () => { + expect(flattenKeys(en)).toContain(key) + expect(flattenKeys(zh)).toContain(key) + }) + } }) diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 7012911065..2a643ae192 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -939,6 +939,13 @@ export default { finalPricePreview: 'Final per-second price preview', notConfigured: 'Not configured' }, + webSearchPricing: { + title: 'Codex Web Search Pricing', + pricePerCall: 'Price per search call (USD)', + pricePerCallHint: + 'Leave empty to use the default $0.01 per call (official pricing: $10 per 1,000 calls); 0 means free. The group rate multiplier is applied on top.', + finalPricePreview: 'Per-call price after current multiplier: {price}' + }, peakRate: { enable: 'Enable peak rate multiplier', peakStart: 'Peak start', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index a40fe4afe2..d2638bb79d 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -930,6 +930,13 @@ export default { finalPricePreview: '最终每秒价格预览', notConfigured: '未配置' }, + webSearchPricing: { + title: 'Codex 网页搜索计费', + pricePerCall: '搜索单次价格(USD/次)', + pricePerCallHint: + '留空使用默认价 $0.01/次(官方定价 $10/1000 次);填 0 表示免费。实际扣费会叠加分组费率倍数。', + finalPricePreview: '应用当前倍率后的单次价格:{price}' + }, peakRate: { enable: '启用高峰倍率', peakStart: '高峰开始', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a60c70116c..b7095ebe03 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -531,6 +531,8 @@ export interface Group { video_price_480p: number | null video_price_720p: number | null video_price_1080p: number | null + // Codex 网页搜索单次价格(USD/次);null 表示使用默认价 0.01 + web_search_price_per_call: number | null // 高峰时段倍率配置 peak_rate_enabled: boolean peak_start: string @@ -664,6 +666,7 @@ export interface CreateGroupRequest { video_price_480p?: number | null video_price_720p?: number | null video_price_1080p?: number | null + web_search_price_per_call?: number | null peak_rate_enabled?: boolean peak_start?: string peak_end?: string @@ -711,6 +714,7 @@ export interface UpdateGroupRequest { video_price_480p?: number | null video_price_720p?: number | null video_price_1080p?: number | null + web_search_price_per_call?: number | null peak_rate_enabled?: boolean peak_start?: string peak_end?: string diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 1f22216100..d7cf9dd717 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -1315,6 +1315,41 @@ + +
+

+ {{ t("admin.groups.webSearchPricing.title") }} +

+
+ + +

+ {{ t("admin.groups.webSearchPricing.pricePerCallHint") }} +

+
+ {{ + t("admin.groups.webSearchPricing.finalPricePreview", { + price: createWebSearchFinalPricePreview, + }) + }} +
+
+
+
+ +
+

+ {{ t("admin.groups.webSearchPricing.title") }} +

+
+ + +

+ {{ t("admin.groups.webSearchPricing.pricePerCallHint") }} +

+
+ {{ + t("admin.groups.webSearchPricing.finalPricePreview", { + price: editWebSearchFinalPricePreview, + }) + }} +
+
+
+
buildVideoFinalPricePreview(editForm), ); +// Codex 网页搜索单次默认价(与后端 defaultWebSearchPricePerCall 一致,官方 $10/1000 次) +const DEFAULT_WEB_SEARCH_PRICE_PER_CALL = 0.01; + +const buildWebSearchFinalPricePreview = (form: { + web_search_price_per_call: number | string | null; + rate_multiplier: number | string | null; +}) => { + const basePrice = + parsePreviewPrice(form.web_search_price_per_call) ?? + DEFAULT_WEB_SEARCH_PRICE_PER_CALL; + const multiplier = normalizePreviewNumber(form.rate_multiplier, 1); + return formatImagePricePreview(basePrice * multiplier); +}; + +const createWebSearchFinalPricePreview = computed(() => + buildWebSearchFinalPricePreview(createForm), +); +const editWebSearchFinalPricePreview = computed(() => + buildWebSearchFinalPricePreview(editForm), +); + const resetDisabledBatchImagePricing = ( form: Pick< ImagePricingFormState, @@ -4615,6 +4710,7 @@ const closeCreateModal = () => { createForm.video_price_480p = null; createForm.video_price_720p = null; createForm.video_price_1080p = null; + createForm.web_search_price_per_call = null; createForm.peak_rate_enabled = false; createForm.peak_start = ""; createForm.peak_end = ""; @@ -4726,6 +4822,9 @@ const handleCreateGroup = async () => { requestData.video_price_480p = emptyToNull(requestData.video_price_480p); requestData.video_price_720p = emptyToNull(requestData.video_price_720p); requestData.video_price_1080p = emptyToNull(requestData.video_price_1080p); + requestData.web_search_price_per_call = emptyToNull( + requestData.web_search_price_per_call, + ); requestData.peak_rate_enabled = createForm.peak_rate_enabled; requestData.peak_start = createForm.peak_start; requestData.peak_end = createForm.peak_end; @@ -4779,6 +4878,7 @@ const handleEdit = async (group: AdminGroup) => { editForm.video_price_480p = group.video_price_480p; editForm.video_price_720p = group.video_price_720p; editForm.video_price_1080p = group.video_price_1080p; + editForm.web_search_price_per_call = group.web_search_price_per_call ?? null; editForm.peak_rate_enabled = group.peak_rate_enabled ?? false; editForm.peak_start = group.peak_start ?? ""; editForm.peak_end = group.peak_end ?? ""; @@ -4836,6 +4936,7 @@ const closeEditModal = () => { editForm.video_price_480p = null; editForm.video_price_720p = null; editForm.video_price_1080p = null; + editForm.web_search_price_per_call = null; resetMessagesDispatchFormState(editForm); resetModelsListState(editModelsListState); }; @@ -4914,6 +5015,9 @@ const handleUpdateGroup = async () => { payload.video_price_480p = emptyPriceToClear(payload.video_price_480p); payload.video_price_720p = emptyPriceToClear(payload.video_price_720p); payload.video_price_1080p = emptyPriceToClear(payload.video_price_1080p); + payload.web_search_price_per_call = emptyPriceToClear( + payload.web_search_price_per_call, + ); payload.peak_rate_enabled = editForm.peak_rate_enabled; payload.peak_start = editForm.peak_start; payload.peak_end = editForm.peak_end;