From 79df1647d4c2b7c30b801d5d329a4482b99cd1ea Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Fri, 7 Aug 2026 15:52:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(grok):=20=E8=B4=A6=E5=8D=95=E7=BB=9D?= =?UTF-8?q?=E5=AF=B9=E9=87=91=E9=A2=9D=E3=80=81=E8=B0=83=E5=BA=A6=E9=98=88?= =?UTF-8?q?=E5=80=BC=20UI=E3=80=81=E6=90=9C=E7=B4=A2/Voice=20=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9=E4=B8=8E=20/v1/web=5Fsearch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BillingSummary 输出 prepaid/monthly_used/on_demand 绝对金额,并映射官方 7d/30d 进度条 - 账号编辑页支持 credentials.account_scheduling_threshold 覆盖;Settings 文案细化 - groups.search_price_per_1k + 管理端/缓存全链路;SearchCount/AudioUsage 请求级计费 - Voice TTS/STT/Realtime 成功路径 RecordUsage;独立 /v1/web_search 原生 Grok 搜索 --- backend/ent/group.go | 16 +- backend/ent/group/group.go | 10 + backend/ent/group/where.go | 55 +++ backend/ent/group_create.go | 103 +++++ backend/ent/group_update.go | 82 ++++ backend/ent/migrate/schema.go | 3 +- backend/ent/mutation.go | 109 ++++- backend/ent/runtime/runtime.go | 46 +- backend/ent/schema/group.go | 8 + .../internal/handler/admin/group_handler.go | 4 + backend/internal/handler/dto/mappers.go | 1 + backend/internal/handler/dto/types.go | 1 + .../internal/handler/gateway_web_search.go | 419 ++++++++++++++++++ backend/internal/handler/grok_audio.go | 88 +++- backend/internal/pkg/xai/billing.go | 105 ++++- backend/internal/pkg/xai/billing_test.go | 16 +- backend/internal/repository/api_key_repo.go | 2 + backend/internal/repository/group_repo.go | 6 + backend/internal/server/routes/gateway.go | 16 + .../internal/service/account_usage_service.go | 11 +- backend/internal/service/admin_group.go | 5 + .../internal/service/admin_group_duplicate.go | 1 + backend/internal/service/admin_service.go | 4 + .../internal/service/api_key_auth_cache.go | 1 + .../service/api_key_auth_cache_impl.go | 2 + .../service/billing_search_audio_cost_test.go | 32 ++ backend/internal/service/billing_service.go | 64 +++ backend/internal/service/gateway_service.go | 81 ++++ .../internal/service/gateway_usage_billing.go | 11 + backend/internal/service/grok_audio.go | 58 +++ .../internal/service/grok_quota_fetcher.go | 54 +++ backend/internal/service/group.go | 10 + .../internal/service/media_price_config.go | 19 + .../service/openai_gateway_service.go | 4 + .../internal/service/openai_gateway_usage.go | 10 + .../219_group_search_price_per_1k.sql | 2 + .../components/account/AccountUsageCell.vue | 73 ++- .../components/account/EditAccountModal.vue | 107 +++++ .../src/i18n/locales/en/admin/accounts.ts | 10 +- .../src/i18n/locales/en/admin/overview.ts | 6 + .../src/i18n/locales/en/admin/settings.ts | 8 +- .../src/i18n/locales/zh/admin/accounts.ts | 10 +- .../src/i18n/locales/zh/admin/overview.ts | 6 + .../src/i18n/locales/zh/admin/settings.ts | 8 +- frontend/src/types/index.ts | 12 + frontend/src/views/admin/GroupsView.vue | 43 +- 46 files changed, 1665 insertions(+), 77 deletions(-) create mode 100644 backend/internal/handler/gateway_web_search.go create mode 100644 backend/internal/service/billing_search_audio_cost_test.go create mode 100644 backend/migrations/219_group_search_price_per_1k.sql diff --git a/backend/ent/group.go b/backend/ent/group.go index e1a0d3a670..3b1d2a4a51 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -89,6 +89,8 @@ type Group struct { VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"` // Codex alpha/search 网页搜索单次价格(USD/次);nil 表示使用默认价 0.01(官方 $10/1000 次) WebSearchPricePerCall *float64 `json:"web_search_price_per_call,omitempty"` + // 搜索工具价格 per 1000 calls(web_search 等) + SearchPricePer1k *float64 `json:"search_price_per_1k,omitempty"` // Voice realtime 每分钟价格(USD) AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min,omitempty"` // TTS 每百万字符价格(USD) @@ -247,7 +249,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.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled: 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, group.FieldWebSearchPricePerCall, group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer: + 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, group.FieldSearchPricePer1k, group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer: values[i] = new(sql.NullFloat64) case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit: values[i] = new(sql.NullInt64) @@ -501,6 +503,13 @@ func (_m *Group) assignValues(columns []string, values []any) error { _m.WebSearchPricePerCall = new(float64) *_m.WebSearchPricePerCall = value.Float64 } + case group.FieldSearchPricePer1k: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field search_price_per_1k", values[i]) + } else if value.Valid { + _m.SearchPricePer1k = new(float64) + *_m.SearchPricePer1k = value.Float64 + } case group.FieldAudioRealtimePricePerMin: if value, ok := values[i].(*sql.NullFloat64); !ok { return fmt.Errorf("unexpected type %T for field audio_realtime_price_per_min", values[i]) @@ -867,6 +876,11 @@ func (_m *Group) String() string { builder.WriteString(fmt.Sprintf("%v", *v)) } builder.WriteString(", ") + if v := _m.SearchPricePer1k; v != nil { + builder.WriteString("search_price_per_1k=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") if v := _m.AudioRealtimePricePerMin; v != nil { builder.WriteString("audio_realtime_price_per_min=") builder.WriteString(fmt.Sprintf("%v", *v)) diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 4c7b002647..76a07b96a0 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -86,6 +86,8 @@ const ( FieldVideoModelPrices = "video_model_prices" // FieldWebSearchPricePerCall holds the string denoting the web_search_price_per_call field in the database. FieldWebSearchPricePerCall = "web_search_price_per_call" + // FieldSearchPricePer1k holds the string denoting the search_price_per_1k field in the database. + FieldSearchPricePer1k = "search_price_per_1k" // FieldAudioRealtimePricePerMin holds the string denoting the audio_realtime_price_per_min field in the database. FieldAudioRealtimePricePerMin = "audio_realtime_price_per_min" // FieldAudioTtsPricePerMillionChars holds the string denoting the audio_tts_price_per_million_chars field in the database. @@ -244,6 +246,7 @@ var Columns = []string{ FieldVideoPrice1080p, FieldVideoModelPrices, FieldWebSearchPricePerCall, + FieldSearchPricePer1k, FieldAudioRealtimePricePerMin, FieldAudioTtsPricePerMillionChars, FieldAudioSttPricePerHour, @@ -353,6 +356,8 @@ var ( DefaultVideoRateIndependent bool // DefaultVideoRateMultiplier holds the default value on creation for the "video_rate_multiplier" field. DefaultVideoRateMultiplier float64 + // SearchPricePer1kValidator is a validator for the "search_price_per_1k" field. It is called by the builders before save. + SearchPricePer1kValidator func(float64) error // AudioRealtimePricePerMinValidator is a validator for the "audio_realtime_price_per_min" field. It is called by the builders before save. AudioRealtimePricePerMinValidator func(float64) error // AudioTtsPricePerMillionCharsValidator is a validator for the "audio_tts_price_per_million_chars" field. It is called by the builders before save. @@ -579,6 +584,11 @@ func ByWebSearchPricePerCall(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldWebSearchPricePerCall, opts...).ToFunc() } +// BySearchPricePer1k orders the results by the search_price_per_1k field. +func BySearchPricePer1k(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldSearchPricePer1k, opts...).ToFunc() +} + // ByAudioRealtimePricePerMin orders the results by the audio_realtime_price_per_min field. func ByAudioRealtimePricePerMin(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldAudioRealtimePricePerMin, opts...).ToFunc() diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index 2ff0359e3b..630ec582e8 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -225,6 +225,11 @@ func WebSearchPricePerCall(v float64) predicate.Group { return predicate.Group(sql.FieldEQ(FieldWebSearchPricePerCall, v)) } +// SearchPricePer1k applies equality check predicate on the "search_price_per_1k" field. It's identical to SearchPricePer1kEQ. +func SearchPricePer1k(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldSearchPricePer1k, v)) +} + // AudioRealtimePricePerMin applies equality check predicate on the "audio_realtime_price_per_min" field. It's identical to AudioRealtimePricePerMinEQ. func AudioRealtimePricePerMin(v float64) predicate.Group { return predicate.Group(sql.FieldEQ(FieldAudioRealtimePricePerMin, v)) @@ -1840,6 +1845,56 @@ func WebSearchPricePerCallNotNil() predicate.Group { return predicate.Group(sql.FieldNotNull(FieldWebSearchPricePerCall)) } +// SearchPricePer1kEQ applies the EQ predicate on the "search_price_per_1k" field. +func SearchPricePer1kEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldSearchPricePer1k, v)) +} + +// SearchPricePer1kNEQ applies the NEQ predicate on the "search_price_per_1k" field. +func SearchPricePer1kNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldSearchPricePer1k, v)) +} + +// SearchPricePer1kIn applies the In predicate on the "search_price_per_1k" field. +func SearchPricePer1kIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldSearchPricePer1k, vs...)) +} + +// SearchPricePer1kNotIn applies the NotIn predicate on the "search_price_per_1k" field. +func SearchPricePer1kNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldSearchPricePer1k, vs...)) +} + +// SearchPricePer1kGT applies the GT predicate on the "search_price_per_1k" field. +func SearchPricePer1kGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldSearchPricePer1k, v)) +} + +// SearchPricePer1kGTE applies the GTE predicate on the "search_price_per_1k" field. +func SearchPricePer1kGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldSearchPricePer1k, v)) +} + +// SearchPricePer1kLT applies the LT predicate on the "search_price_per_1k" field. +func SearchPricePer1kLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldSearchPricePer1k, v)) +} + +// SearchPricePer1kLTE applies the LTE predicate on the "search_price_per_1k" field. +func SearchPricePer1kLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldSearchPricePer1k, v)) +} + +// SearchPricePer1kIsNil applies the IsNil predicate on the "search_price_per_1k" field. +func SearchPricePer1kIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldSearchPricePer1k)) +} + +// SearchPricePer1kNotNil applies the NotNil predicate on the "search_price_per_1k" field. +func SearchPricePer1kNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldSearchPricePer1k)) +} + // AudioRealtimePricePerMinEQ applies the EQ predicate on the "audio_realtime_price_per_min" field. func AudioRealtimePricePerMinEQ(v float64) predicate.Group { return predicate.Group(sql.FieldEQ(FieldAudioRealtimePricePerMin, v)) diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 2753bc827b..3246b92a50 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -503,6 +503,20 @@ func (_c *GroupCreate) SetNillableWebSearchPricePerCall(v *float64) *GroupCreate return _c } +// SetSearchPricePer1k sets the "search_price_per_1k" field. +func (_c *GroupCreate) SetSearchPricePer1k(v float64) *GroupCreate { + _c.mutation.SetSearchPricePer1k(v) + return _c +} + +// SetNillableSearchPricePer1k sets the "search_price_per_1k" field if the given value is not nil. +func (_c *GroupCreate) SetNillableSearchPricePer1k(v *float64) *GroupCreate { + if v != nil { + _c.SetSearchPricePer1k(*v) + } + return _c +} + // SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field. func (_c *GroupCreate) SetAudioRealtimePricePerMin(v float64) *GroupCreate { _c.mutation.SetAudioRealtimePricePerMin(v) @@ -1203,6 +1217,11 @@ func (_c *GroupCreate) check() error { if _, ok := _c.mutation.VideoRateMultiplier(); !ok { return &ValidationError{Name: "video_rate_multiplier", err: errors.New(`ent: missing required field "Group.video_rate_multiplier"`)} } + if v, ok := _c.mutation.SearchPricePer1k(); ok { + if err := group.SearchPricePer1kValidator(v); err != nil { + return &ValidationError{Name: "search_price_per_1k", err: fmt.Errorf(`ent: validator failed for field "Group.search_price_per_1k": %w`, err)} + } + } if v, ok := _c.mutation.AudioRealtimePricePerMin(); ok { if err := group.AudioRealtimePricePerMinValidator(v); err != nil { return &ValidationError{Name: "audio_realtime_price_per_min", err: fmt.Errorf(`ent: validator failed for field "Group.audio_realtime_price_per_min": %w`, err)} @@ -1449,6 +1468,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value) _node.WebSearchPricePerCall = &value } + if value, ok := _c.mutation.SearchPricePer1k(); ok { + _spec.SetField(group.FieldSearchPricePer1k, field.TypeFloat64, value) + _node.SearchPricePer1k = &value + } if value, ok := _c.mutation.AudioRealtimePricePerMin(); ok { _spec.SetField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value) _node.AudioRealtimePricePerMin = &value @@ -2277,6 +2300,30 @@ func (u *GroupUpsert) ClearWebSearchPricePerCall() *GroupUpsert { return u } +// SetSearchPricePer1k sets the "search_price_per_1k" field. +func (u *GroupUpsert) SetSearchPricePer1k(v float64) *GroupUpsert { + u.Set(group.FieldSearchPricePer1k, v) + return u +} + +// UpdateSearchPricePer1k sets the "search_price_per_1k" field to the value that was provided on create. +func (u *GroupUpsert) UpdateSearchPricePer1k() *GroupUpsert { + u.SetExcluded(group.FieldSearchPricePer1k) + return u +} + +// AddSearchPricePer1k adds v to the "search_price_per_1k" field. +func (u *GroupUpsert) AddSearchPricePer1k(v float64) *GroupUpsert { + u.Add(group.FieldSearchPricePer1k, v) + return u +} + +// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field. +func (u *GroupUpsert) ClearSearchPricePer1k() *GroupUpsert { + u.SetNull(group.FieldSearchPricePer1k) + return u +} + // SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field. func (u *GroupUpsert) SetAudioRealtimePricePerMin(v float64) *GroupUpsert { u.Set(group.FieldAudioRealtimePricePerMin, v) @@ -3375,6 +3422,34 @@ func (u *GroupUpsertOne) ClearWebSearchPricePerCall() *GroupUpsertOne { }) } +// SetSearchPricePer1k sets the "search_price_per_1k" field. +func (u *GroupUpsertOne) SetSearchPricePer1k(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetSearchPricePer1k(v) + }) +} + +// AddSearchPricePer1k adds v to the "search_price_per_1k" field. +func (u *GroupUpsertOne) AddSearchPricePer1k(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddSearchPricePer1k(v) + }) +} + +// UpdateSearchPricePer1k sets the "search_price_per_1k" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateSearchPricePer1k() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateSearchPricePer1k() + }) +} + +// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field. +func (u *GroupUpsertOne) ClearSearchPricePer1k() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.ClearSearchPricePer1k() + }) +} + // SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field. func (u *GroupUpsertOne) SetAudioRealtimePricePerMin(v float64) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -4702,6 +4777,34 @@ func (u *GroupUpsertBulk) ClearWebSearchPricePerCall() *GroupUpsertBulk { }) } +// SetSearchPricePer1k sets the "search_price_per_1k" field. +func (u *GroupUpsertBulk) SetSearchPricePer1k(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetSearchPricePer1k(v) + }) +} + +// AddSearchPricePer1k adds v to the "search_price_per_1k" field. +func (u *GroupUpsertBulk) AddSearchPricePer1k(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddSearchPricePer1k(v) + }) +} + +// UpdateSearchPricePer1k sets the "search_price_per_1k" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateSearchPricePer1k() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateSearchPricePer1k() + }) +} + +// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field. +func (u *GroupUpsertBulk) ClearSearchPricePer1k() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.ClearSearchPricePer1k() + }) +} + // SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field. func (u *GroupUpsertBulk) SetAudioRealtimePricePerMin(v float64) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 126fdef40f..de5474e069 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -679,6 +679,33 @@ func (_u *GroupUpdate) ClearWebSearchPricePerCall() *GroupUpdate { return _u } +// SetSearchPricePer1k sets the "search_price_per_1k" field. +func (_u *GroupUpdate) SetSearchPricePer1k(v float64) *GroupUpdate { + _u.mutation.ResetSearchPricePer1k() + _u.mutation.SetSearchPricePer1k(v) + return _u +} + +// SetNillableSearchPricePer1k sets the "search_price_per_1k" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableSearchPricePer1k(v *float64) *GroupUpdate { + if v != nil { + _u.SetSearchPricePer1k(*v) + } + return _u +} + +// AddSearchPricePer1k adds value to the "search_price_per_1k" field. +func (_u *GroupUpdate) AddSearchPricePer1k(v float64) *GroupUpdate { + _u.mutation.AddSearchPricePer1k(v) + return _u +} + +// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field. +func (_u *GroupUpdate) ClearSearchPricePer1k() *GroupUpdate { + _u.mutation.ClearSearchPricePer1k() + return _u +} + // SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field. func (_u *GroupUpdate) SetAudioRealtimePricePerMin(v float64) *GroupUpdate { _u.mutation.ResetAudioRealtimePricePerMin() @@ -1397,6 +1424,11 @@ func (_u *GroupUpdate) check() error { return &ValidationError{Name: "subscription_type", err: fmt.Errorf(`ent: validator failed for field "Group.subscription_type": %w`, err)} } } + if v, ok := _u.mutation.SearchPricePer1k(); ok { + if err := group.SearchPricePer1kValidator(v); err != nil { + return &ValidationError{Name: "search_price_per_1k", err: fmt.Errorf(`ent: validator failed for field "Group.search_price_per_1k": %w`, err)} + } + } if v, ok := _u.mutation.AudioRealtimePricePerMin(); ok { if err := group.AudioRealtimePricePerMinValidator(v); err != nil { return &ValidationError{Name: "audio_realtime_price_per_min", err: fmt.Errorf(`ent: validator failed for field "Group.audio_realtime_price_per_min": %w`, err)} @@ -1629,6 +1661,15 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.WebSearchPricePerCallCleared() { _spec.ClearField(group.FieldWebSearchPricePerCall, field.TypeFloat64) } + if value, ok := _u.mutation.SearchPricePer1k(); ok { + _spec.SetField(group.FieldSearchPricePer1k, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedSearchPricePer1k(); ok { + _spec.AddField(group.FieldSearchPricePer1k, field.TypeFloat64, value) + } + if _u.mutation.SearchPricePer1kCleared() { + _spec.ClearField(group.FieldSearchPricePer1k, field.TypeFloat64) + } if value, ok := _u.mutation.AudioRealtimePricePerMin(); ok { _spec.SetField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value) } @@ -2713,6 +2754,33 @@ func (_u *GroupUpdateOne) ClearWebSearchPricePerCall() *GroupUpdateOne { return _u } +// SetSearchPricePer1k sets the "search_price_per_1k" field. +func (_u *GroupUpdateOne) SetSearchPricePer1k(v float64) *GroupUpdateOne { + _u.mutation.ResetSearchPricePer1k() + _u.mutation.SetSearchPricePer1k(v) + return _u +} + +// SetNillableSearchPricePer1k sets the "search_price_per_1k" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableSearchPricePer1k(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetSearchPricePer1k(*v) + } + return _u +} + +// AddSearchPricePer1k adds value to the "search_price_per_1k" field. +func (_u *GroupUpdateOne) AddSearchPricePer1k(v float64) *GroupUpdateOne { + _u.mutation.AddSearchPricePer1k(v) + return _u +} + +// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field. +func (_u *GroupUpdateOne) ClearSearchPricePer1k() *GroupUpdateOne { + _u.mutation.ClearSearchPricePer1k() + return _u +} + // SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field. func (_u *GroupUpdateOne) SetAudioRealtimePricePerMin(v float64) *GroupUpdateOne { _u.mutation.ResetAudioRealtimePricePerMin() @@ -3444,6 +3512,11 @@ func (_u *GroupUpdateOne) check() error { return &ValidationError{Name: "subscription_type", err: fmt.Errorf(`ent: validator failed for field "Group.subscription_type": %w`, err)} } } + if v, ok := _u.mutation.SearchPricePer1k(); ok { + if err := group.SearchPricePer1kValidator(v); err != nil { + return &ValidationError{Name: "search_price_per_1k", err: fmt.Errorf(`ent: validator failed for field "Group.search_price_per_1k": %w`, err)} + } + } if v, ok := _u.mutation.AudioRealtimePricePerMin(); ok { if err := group.AudioRealtimePricePerMinValidator(v); err != nil { return &ValidationError{Name: "audio_realtime_price_per_min", err: fmt.Errorf(`ent: validator failed for field "Group.audio_realtime_price_per_min": %w`, err)} @@ -3693,6 +3766,15 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if _u.mutation.WebSearchPricePerCallCleared() { _spec.ClearField(group.FieldWebSearchPricePerCall, field.TypeFloat64) } + if value, ok := _u.mutation.SearchPricePer1k(); ok { + _spec.SetField(group.FieldSearchPricePer1k, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedSearchPricePer1k(); ok { + _spec.AddField(group.FieldSearchPricePer1k, field.TypeFloat64, value) + } + if _u.mutation.SearchPricePer1kCleared() { + _spec.ClearField(group.FieldSearchPricePer1k, field.TypeFloat64) + } if value, ok := _u.mutation.AudioRealtimePricePerMin(); ok { _spec.SetField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 185d3ab15c..5b19beda0b 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -930,6 +930,7 @@ var ( {Name: "video_price_1080p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "video_model_prices", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}}, {Name: "web_search_price_per_call", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "search_price_per_1k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "audio_realtime_price_per_min", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "audio_tts_price_per_million_chars", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "audio_stt_price_per_hour", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, @@ -989,7 +990,7 @@ var ( { Name: "group_sort_order", Unique: false, - Columns: []*schema.Column{GroupsColumns[46]}, + Columns: []*schema.Column{GroupsColumns[47]}, }, { Name: "idx_groups_duplicate_operation_id_active", diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 86938046e3..eb99374a25 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -21899,6 +21899,8 @@ type GroupMutation struct { video_model_prices *map[string]map[string]float64 web_search_price_per_call *float64 addweb_search_price_per_call *float64 + search_price_per_1k *float64 + addsearch_price_per_1k *float64 audio_realtime_price_per_min *float64 addaudio_realtime_price_per_min *float64 audio_tts_price_per_million_chars *float64 @@ -23848,6 +23850,76 @@ func (m *GroupMutation) ResetWebSearchPricePerCall() { delete(m.clearedFields, group.FieldWebSearchPricePerCall) } +// SetSearchPricePer1k sets the "search_price_per_1k" field. +func (m *GroupMutation) SetSearchPricePer1k(f float64) { + m.search_price_per_1k = &f + m.addsearch_price_per_1k = nil +} + +// SearchPricePer1k returns the value of the "search_price_per_1k" field in the mutation. +func (m *GroupMutation) SearchPricePer1k() (r float64, exists bool) { + v := m.search_price_per_1k + if v == nil { + return + } + return *v, true +} + +// OldSearchPricePer1k returns the old "search_price_per_1k" 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) OldSearchPricePer1k(ctx context.Context) (v *float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldSearchPricePer1k is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldSearchPricePer1k requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldSearchPricePer1k: %w", err) + } + return oldValue.SearchPricePer1k, nil +} + +// AddSearchPricePer1k adds f to the "search_price_per_1k" field. +func (m *GroupMutation) AddSearchPricePer1k(f float64) { + if m.addsearch_price_per_1k != nil { + *m.addsearch_price_per_1k += f + } else { + m.addsearch_price_per_1k = &f + } +} + +// AddedSearchPricePer1k returns the value that was added to the "search_price_per_1k" field in this mutation. +func (m *GroupMutation) AddedSearchPricePer1k() (r float64, exists bool) { + v := m.addsearch_price_per_1k + if v == nil { + return + } + return *v, true +} + +// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field. +func (m *GroupMutation) ClearSearchPricePer1k() { + m.search_price_per_1k = nil + m.addsearch_price_per_1k = nil + m.clearedFields[group.FieldSearchPricePer1k] = struct{}{} +} + +// SearchPricePer1kCleared returns if the "search_price_per_1k" field was cleared in this mutation. +func (m *GroupMutation) SearchPricePer1kCleared() bool { + _, ok := m.clearedFields[group.FieldSearchPricePer1k] + return ok +} + +// ResetSearchPricePer1k resets all changes to the "search_price_per_1k" field. +func (m *GroupMutation) ResetSearchPricePer1k() { + m.search_price_per_1k = nil + m.addsearch_price_per_1k = nil + delete(m.clearedFields, group.FieldSearchPricePer1k) +} + // SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field. func (m *GroupMutation) SetAudioRealtimePricePerMin(f float64) { m.audio_realtime_price_per_min = &f @@ -25363,7 +25435,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, 59) + fields := make([]string, 0, 60) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -25469,6 +25541,9 @@ func (m *GroupMutation) Fields() []string { if m.web_search_price_per_call != nil { fields = append(fields, group.FieldWebSearchPricePerCall) } + if m.search_price_per_1k != nil { + fields = append(fields, group.FieldSearchPricePer1k) + } if m.audio_realtime_price_per_min != nil { fields = append(fields, group.FieldAudioRealtimePricePerMin) } @@ -25619,6 +25694,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.VideoModelPrices() case group.FieldWebSearchPricePerCall: return m.WebSearchPricePerCall() + case group.FieldSearchPricePer1k: + return m.SearchPricePer1k() case group.FieldAudioRealtimePricePerMin: return m.AudioRealtimePricePerMin() case group.FieldAudioTtsPricePerMillionChars: @@ -25746,6 +25823,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldVideoModelPrices(ctx) case group.FieldWebSearchPricePerCall: return m.OldWebSearchPricePerCall(ctx) + case group.FieldSearchPricePer1k: + return m.OldSearchPricePer1k(ctx) case group.FieldAudioRealtimePricePerMin: return m.OldAudioRealtimePricePerMin(ctx) case group.FieldAudioTtsPricePerMillionChars: @@ -26048,6 +26127,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetWebSearchPricePerCall(v) return nil + case group.FieldSearchPricePer1k: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetSearchPricePer1k(v) + return nil case group.FieldAudioRealtimePricePerMin: v, ok := value.(float64) if !ok { @@ -26275,6 +26361,9 @@ func (m *GroupMutation) AddedFields() []string { if m.addweb_search_price_per_call != nil { fields = append(fields, group.FieldWebSearchPricePerCall) } + if m.addsearch_price_per_1k != nil { + fields = append(fields, group.FieldSearchPricePer1k) + } if m.addaudio_realtime_price_per_min != nil { fields = append(fields, group.FieldAudioRealtimePricePerMin) } @@ -26344,6 +26433,8 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) { return m.AddedVideoPrice1080p() case group.FieldWebSearchPricePerCall: return m.AddedWebSearchPricePerCall() + case group.FieldSearchPricePer1k: + return m.AddedSearchPricePer1k() case group.FieldAudioRealtimePricePerMin: return m.AddedAudioRealtimePricePerMin() case group.FieldAudioTtsPricePerMillionChars: @@ -26490,6 +26581,13 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error { } m.AddWebSearchPricePerCall(v) return nil + case group.FieldSearchPricePer1k: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddSearchPricePer1k(v) + return nil case group.FieldAudioRealtimePricePerMin: v, ok := value.(float64) if !ok { @@ -26603,6 +26701,9 @@ func (m *GroupMutation) ClearedFields() []string { if m.FieldCleared(group.FieldWebSearchPricePerCall) { fields = append(fields, group.FieldWebSearchPricePerCall) } + if m.FieldCleared(group.FieldSearchPricePer1k) { + fields = append(fields, group.FieldSearchPricePer1k) + } if m.FieldCleared(group.FieldAudioRealtimePricePerMin) { fields = append(fields, group.FieldAudioRealtimePricePerMin) } @@ -26677,6 +26778,9 @@ func (m *GroupMutation) ClearField(name string) error { case group.FieldWebSearchPricePerCall: m.ClearWebSearchPricePerCall() return nil + case group.FieldSearchPricePer1k: + m.ClearSearchPricePer1k() + return nil case group.FieldAudioRealtimePricePerMin: m.ClearAudioRealtimePricePerMin() return nil @@ -26808,6 +26912,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldWebSearchPricePerCall: m.ResetWebSearchPricePerCall() return nil + case group.FieldSearchPricePer1k: + m.ResetSearchPricePer1k() + return nil case group.FieldAudioRealtimePricePerMin: m.ResetAudioRealtimePricePerMin() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 6a58af2562..d70142ae42 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1117,92 +1117,96 @@ func init() { groupDescVideoRateMultiplier := groupFields[26].Descriptor() // group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field. group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64) + // groupDescSearchPricePer1k is the schema descriptor for search_price_per_1k field. + groupDescSearchPricePer1k := groupFields[32].Descriptor() + // group.SearchPricePer1kValidator is a validator for the "search_price_per_1k" field. It is called by the builders before save. + group.SearchPricePer1kValidator = groupDescSearchPricePer1k.Validators[0].(func(float64) error) // groupDescAudioRealtimePricePerMin is the schema descriptor for audio_realtime_price_per_min field. - groupDescAudioRealtimePricePerMin := groupFields[32].Descriptor() + groupDescAudioRealtimePricePerMin := groupFields[33].Descriptor() // group.AudioRealtimePricePerMinValidator is a validator for the "audio_realtime_price_per_min" field. It is called by the builders before save. group.AudioRealtimePricePerMinValidator = groupDescAudioRealtimePricePerMin.Validators[0].(func(float64) error) // groupDescAudioTtsPricePerMillionChars is the schema descriptor for audio_tts_price_per_million_chars field. - groupDescAudioTtsPricePerMillionChars := groupFields[33].Descriptor() + groupDescAudioTtsPricePerMillionChars := groupFields[34].Descriptor() // group.AudioTtsPricePerMillionCharsValidator is a validator for the "audio_tts_price_per_million_chars" field. It is called by the builders before save. group.AudioTtsPricePerMillionCharsValidator = groupDescAudioTtsPricePerMillionChars.Validators[0].(func(float64) error) // groupDescAudioSttPricePerHour is the schema descriptor for audio_stt_price_per_hour field. - groupDescAudioSttPricePerHour := groupFields[34].Descriptor() + groupDescAudioSttPricePerHour := groupFields[35].Descriptor() // group.AudioSttPricePerHourValidator is a validator for the "audio_stt_price_per_hour" field. It is called by the builders before save. group.AudioSttPricePerHourValidator = groupDescAudioSttPricePerHour.Validators[0].(func(float64) error) // groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field. - groupDescClaudeCodeOnly := groupFields[35].Descriptor() + groupDescClaudeCodeOnly := groupFields[36].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[39].Descriptor() + groupDescModelRoutingEnabled := groupFields[40].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[40].Descriptor() + groupDescMcpXMLInject := groupFields[41].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[41].Descriptor() + groupDescSupportedModelScopes := groupFields[42].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[42].Descriptor() + groupDescSortOrder := groupFields[43].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[43].Descriptor() + groupDescAllowMessagesDispatch := groupFields[44].Descriptor() // group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field. group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool) // groupDescAllowLive is the schema descriptor for allow_live field. - groupDescAllowLive := groupFields[44].Descriptor() + groupDescAllowLive := groupFields[45].Descriptor() // group.DefaultAllowLive holds the default value on creation for the allow_live field. group.DefaultAllowLive = groupDescAllowLive.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[45].Descriptor() + groupDescRequireOauthOnly := groupFields[46].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[46].Descriptor() + groupDescRequirePrivacySet := groupFields[47].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[47].Descriptor() + groupDescDefaultMappedModel := groupFields[48].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[48].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[49].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[49].Descriptor() + groupDescModelsListConfig := groupFields[50].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[50].Descriptor() + groupDescRpmLimit := groupFields[51].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[51].Descriptor() + groupDescMaxReasoningEffort := groupFields[52].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) // groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field. - groupDescReasoningEffortMappings := groupFields[52].Descriptor() + groupDescReasoningEffortMappings := groupFields[53].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[53].Descriptor() + groupDescProfitControlEnabled := groupFields[54].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[54].Descriptor() + groupDescProfitMinMargin := groupFields[55].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[55].Descriptor() + groupDescProfitSafetyBuffer := groupFields[56].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 e41711d024..85f86891b8 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -158,6 +158,14 @@ func (Group) Fields() []ent.Field { SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). Comment("Codex alpha/search 网页搜索单次价格(USD/次);nil 表示使用默认价 0.01(官方 $10/1000 次)"), + // 搜索/工具调用显式定价(per 1k calls),用于 Grok web_search 等。 + field.Float("search_price_per_1k"). + Optional(). + Nillable(). + Min(0). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). + Comment("搜索工具价格 per 1000 calls(web_search 等)"), + // Grok Voice 显式定价(realtime / TTS / STT),不按文本 RateMultiplier。 field.Float("audio_realtime_price_per_min"). Optional(). diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index c49baeadd3..8ddbfef183 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -129,6 +129,7 @@ type CreateGroupRequest struct { VideoPrice1080P *float64 `json:"video_price_1080p"` VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"` WebSearchPricePerCall *float64 `json:"web_search_price_per_call"` + SearchPricePer1k *float64 `json:"search_price_per_1k"` AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"` AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"` AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"` @@ -195,6 +196,7 @@ type UpdateGroupRequest struct { VideoPrice1080P *float64 `json:"video_price_1080p"` VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"` WebSearchPricePerCall *float64 `json:"web_search_price_per_call"` + SearchPricePer1k *float64 `json:"search_price_per_1k"` AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"` AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"` AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"` @@ -529,6 +531,7 @@ func (h *GroupHandler) Create(c *gin.Context) { VideoPrice1080P: req.VideoPrice1080P, VideoModelPrices: req.VideoModelPrices, WebSearchPricePerCall: req.WebSearchPricePerCall, + SearchPricePer1k: req.SearchPricePer1k, AudioRealtimePricePerMin: req.AudioRealtimePricePerMin, AudioTTSPricePerMillionChars: req.AudioTtsPricePerMillionChars, AudioSTTPricePerHour: req.AudioSttPricePerHour, @@ -655,6 +658,7 @@ func (h *GroupHandler) Update(c *gin.Context) { VideoPrice1080P: req.VideoPrice1080P, VideoModelPrices: req.VideoModelPrices, WebSearchPricePerCall: req.WebSearchPricePerCall, + SearchPricePer1k: req.SearchPricePer1k, AudioRealtimePricePerMin: req.AudioRealtimePricePerMin, AudioTTSPricePerMillionChars: req.AudioTtsPricePerMillionChars, AudioSTTPricePerHour: req.AudioSttPricePerHour, diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 2a5d3ec472..55e5046987 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -204,6 +204,7 @@ func groupFromServiceBase(g *service.Group) Group { VideoPrice1080P: g.VideoPrice1080P, VideoModelPrices: g.VideoModelPrices, WebSearchPricePerCall: g.WebSearchPricePerCall, + SearchPricePer1k: g.SearchPricePer1k, AudioRealtimePricePerMin: g.AudioRealtimePricePerMin, AudioTtsPricePerMillionChars: g.AudioTTSPricePerMillionChars, AudioSttPricePerHour: g.AudioSTTPricePerHour, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index d209cbf836..242a7a5d89 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -125,6 +125,7 @@ type Group struct { VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"` // Codex alpha/search 网页搜索单次价格(USD/次);null 表示使用默认价 0.01 WebSearchPricePerCall *float64 `json:"web_search_price_per_call"` + SearchPricePer1k *float64 `json:"search_price_per_1k"` AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"` AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"` AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"` diff --git a/backend/internal/handler/gateway_web_search.go b/backend/internal/handler/gateway_web_search.go new file mode 100644 index 0000000000..371428c9c4 --- /dev/null +++ b/backend/internal/handler/gateway_web_search.go @@ -0,0 +1,419 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/websearch" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "go.uber.org/zap" +) + +const ( + defaultGrokWebSearchResults = 5 + maxGrokWebSearchResults = 20 +) + +func (h *GatewayHandler) WebSearch(c *gin.Context) { + type webSearchReq struct { + Query string `json:"query" binding:"required"` + MaxResults int `json:"max_results"` + } + + var req webSearchReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{ + "type": "invalid_request_error", + "message": err.Error(), + }}) + return + } + req.MaxResults = normalizeGrokWebSearchMaxResults(req.MaxResults) + + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok || apiKey == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{ + "type": "authentication_error", + "message": "API key required", + }}) + return + } + + if apiKey.Group == nil || apiKey.Group.Platform != "grok" { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{ + "type": "invalid_request_error", + "message": "web search is only supported for grok groups", + }}) + return + } + + // Billing eligibility (same as other requests) + subscription, _ := middleware2.GetSubscriptionFromContext(c) + if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil { + status, code, message, retryAfter := billingErrorDetails(err) + if retryAfter > 0 { + c.Header("Retry-After", strconv.Itoa(retryAfter)) + } + c.JSON(status, gin.H{"error": gin.H{"type": code, "message": message}}) + return + } + + // Use exactly the same scheduling as other requests (SelectAccountWithLoadAwareness handles load, rate limit, sticky, etc.) + groupID := apiKey.GroupID + if groupID == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{ + "type": "invalid_request_error", + "message": "group required", + }}) + return + } + + selected, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), groupID, "", xai.DefaultTextModel, nil, "", 0) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{ + "type": "scheduling_error", + "message": err.Error(), + }}) + return + } + if selected == nil || selected.Account == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{ + "type": "scheduling_error", + "message": "No available accounts", + }}) + return + } + account := selected.Account + accountReleaseFunc := selected.ReleaseFunc + if !selected.Acquired { + if selected.WaitPlan == nil || h.concurrencyHelper == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{ + "type": "scheduling_error", + "message": "No available accounts", + }}) + return + } + accountWaitCounted := false + canWait, waitErr := h.concurrencyHelper.IncrementAccountWaitCount(c.Request.Context(), account.ID, selected.WaitPlan.MaxWaiting) + if waitErr != nil { + logger.L().Warn("gateway.web_search.account_wait_counter_increment_failed", + zap.Int64("account_id", account.ID), + zap.Error(waitErr), + ) + } else if !canWait { + c.JSON(http.StatusTooManyRequests, gin.H{"error": gin.H{ + "type": "rate_limit_error", + "message": "Too many pending requests, please retry later", + }}) + return + } else { + accountWaitCounted = true + } + releaseWait := func() { + if accountWaitCounted { + h.concurrencyHelper.DecrementAccountWaitCount(c.Request.Context(), account.ID) + accountWaitCounted = false + } + } + streamStarted := false + release, acquireErr := h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout( + c, + account.ID, + selected.WaitPlan.MaxConcurrency, + selected.WaitPlan.Timeout, + false, + &streamStarted, + ) + releaseWait() + if acquireErr != nil { + h.handleConcurrencyError(c, acquireErr, "account", streamStarted) + return + } + accountReleaseFunc = release + } + if accountReleaseFunc != nil { + defer accountReleaseFunc() + } + + // Scheduling is 100% the same as other requests: + // SelectAccountWithLoadAwareness handles load balancing, rate limits, failover, sticky sessions, concurrency, proxies etc. + // Downstream rate limiting, billing etc. can be wired the same way. + + // Use Grok *native* web search via the selected Grok account + responses API + web_search tool. + // This ensures results come from Grok's own search (not third-party emulation like Tavily/Brave). + // Output is normalized to the same unified format for clients/agents/MCP. + + nativeResp, providerName, err := h.doGrokNativeWebSearch(c.Request.Context(), c, account, req.Query, req.MaxResults) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{ + "type": "web_search_error", + "message": err.Error(), + }}) + return + } + + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) + requestPayloadHash := service.HashUsageRequestPayload([]byte(req.Query)) + quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { + if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ + Result: &service.ForwardResult{ + RequestID: "web_search:" + service.HashUsageRequestPayload([]byte(req.Query)), + Model: "grok-web-search", + SearchCount: 1, + Duration: 0, + }, + APIKey: apiKey, + User: apiKey.User, + Account: account, + Subscription: subscription, + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, + UserAgent: userAgent, + IPAddress: clientIP, + RequestPayloadHash: requestPayloadHash, + APIKeyService: h.apiKeyService, + QuotaPlatform: quotaPlatform, + }); err != nil { + logger.L().With( + zap.String("component", "handler.gateway.web_search"), + zap.Int64("user_id", apiKey.User.ID), + zap.Int64("api_key_id", apiKey.ID), + zap.Int64("account_id", account.ID), + ).Error("gateway.web_search.record_usage_failed", zap.Error(err)) + } + }) + + c.JSON(http.StatusOK, gin.H{ + "query": req.Query, + "results": nativeResp.Results, + "provider": providerName, + "max_results": req.MaxResults, + }) +} + +// doGrokNativeWebSearch executes web search using the Grok account's native capability +// by calling the responses endpoint with web_search tool, then normalizes sources to unified format. +func (h *GatewayHandler) doGrokNativeWebSearch(ctx context.Context, c *gin.Context, account *service.Account, query string, maxResults int) (*websearch.SearchResponse, string, error) { + maxResults = normalizeGrokWebSearchMaxResults(maxResults) + + // Build a minimal responses request that triggers Grok web search tool. + // Ask for structured metadata because xAI action.sources commonly contains URLs only. + searchBody := map[string]any{ + "model": xai.DefaultTextModel, + "input": buildGrokWebSearchPrompt(query, maxResults), + "tools": []map[string]any{{"type": "web_search"}}, + "include": []string{"web_search_call.action.sources"}, + "store": false, + "stream": false, + } + bodyBytes, _ := json.Marshal(searchBody) + + respBytes, err := h.gatewayService.DoGrokNativeResponsesJSON(ctx, c, account, bodyBytes) + if err != nil { + return nil, "", err + } + + // Extract sources from Grok responses output. + // Prefer web_search_call.action.sources (standardized), fallback to annotations or text links. + results := extractGrokWebSearchSources(respBytes, maxResults) + + return &websearch.SearchResponse{ + Results: results, + Query: query, + }, "grok-native", nil +} + +func normalizeGrokWebSearchMaxResults(maxResults int) int { + if maxResults <= 0 { + return defaultGrokWebSearchResults + } + if maxResults > maxGrokWebSearchResults { + return maxGrokWebSearchResults + } + return maxResults +} + +func buildGrokWebSearchPrompt(query string, maxResults int) string { + return fmt.Sprintf(`Search the web for the user query below. Return ONLY valid JSON with this exact shape: {"results":[{"url":"https://...","title":"page title","snippet":"concise factual summary"}]}. Return at most %d unique results. Every URL must be an actual web_search source. Populate a non-empty title and snippet for every result. Do not wrap the JSON in markdown. + +User query: +%s`, normalizeGrokWebSearchMaxResults(maxResults), query) +} + +// extractGrokWebSearchSources returns model-enriched results only when their URLs +// are present in the actual web_search sources, then falls back to raw sources. +func extractGrokWebSearchSources(body []byte, maxResults int) []websearch.SearchResult { + if len(body) == 0 || !gjson.ValidBytes(body) { + return nil + } + maxResults = normalizeGrokWebSearchMaxResults(maxResults) + + sources := make(map[string]websearch.SearchResult) + var sourceOrder []string + addSource := func(rawURL, title, snippet string) { + key, ok := normalizeGrokWebSearchURL(rawURL) + if !ok { + return + } + result, exists := sources[key] + if !exists { + result.URL = strings.TrimSpace(rawURL) + sourceOrder = append(sourceOrder, key) + } + if result.Title == "" { + result.Title = usableGrokWebSearchTitle(title, result.URL) + } + if result.Snippet == "" { + result.Snippet = strings.TrimSpace(snippet) + } + sources[key] = result + } + + output := gjson.GetBytes(body, "output") + output.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "web_search_call" { + sources := item.Get("action.sources") + if sources.IsArray() { + sources.ForEach(func(_, src gjson.Result) bool { + addSource(src.Get("url").String(), src.Get("title").String(), src.Get("snippet").String()) + return true + }) + } + } + if item.Get("type").String() == "message" { + item.Get("content").ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() != "output_text" { + return true + } + part.Get("annotations").ForEach(func(_, ann gjson.Result) bool { + if ann.Get("type").String() == "url_citation" || ann.Get("type").String() == "web" { + addSource(ann.Get("url").String(), ann.Get("title").String(), "") + } + return true + }) + return true + }) + } + return true + }) + + var out []websearch.SearchResult + seen := make(map[string]bool) + output.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() != "message" { + return true + } + item.Get("content").ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() != "output_text" || len(out) >= maxResults { + return true + } + for _, result := range parseGrokWebSearchStructuredResults(part.Get("text").String()) { + key, ok := normalizeGrokWebSearchURL(result.URL) + if !ok || seen[key] { + continue + } + source, allowed := sources[key] + if !allowed { + continue + } + seen[key] = true + result.URL = source.URL + result.Title = usableGrokWebSearchTitle(result.Title, result.URL) + if result.Title == "" { + result.Title = source.Title + } + result.Snippet = strings.TrimSpace(result.Snippet) + if result.Snippet == "" { + result.Snippet = source.Snippet + } + out = append(out, result) + if len(out) >= maxResults { + break + } + } + return true + }) + return len(out) < maxResults + }) + + for _, key := range sourceOrder { + if len(out) >= maxResults { + break + } + if seen[key] { + continue + } + result := sources[key] + if result.Title == "" { + result.Title = grokWebSearchTitleFromURL(result.URL) + } + seen[key] = true + out = append(out, result) + } + return out +} + +func parseGrokWebSearchStructuredResults(text string) []websearch.SearchResult { + text = strings.TrimSpace(text) + start := strings.IndexByte(text, '{') + end := strings.LastIndexByte(text, '}') + if start < 0 || end < start { + return nil + } + var payload struct { + Results []websearch.SearchResult `json:"results"` + } + if err := json.Unmarshal([]byte(text[start:end+1]), &payload); err != nil { + return nil + } + return payload.Results +} + +func normalizeGrokWebSearchURL(rawURL string) (string, bool) { + u, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return "", false + } + u.Scheme = strings.ToLower(u.Scheme) + u.Host = strings.ToLower(u.Host) + u.Fragment = "" + if u.Path == "" { + u.Path = "/" + } + return u.String(), true +} + +func usableGrokWebSearchTitle(title, rawURL string) string { + title = strings.TrimSpace(title) + if title == "" || title == rawURL { + return "" + } + if _, err := strconv.Atoi(title); err == nil { + return "" + } + return title +} + +func grokWebSearchTitleFromURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return rawURL + } + return strings.TrimPrefix(strings.ToLower(u.Host), "www.") +} + diff --git a/backend/internal/handler/grok_audio.go b/backend/internal/handler/grok_audio.go index 3047874779..8f1c0c45cf 100644 --- a/backend/internal/handler/grok_audio.go +++ b/backend/internal/handler/grok_audio.go @@ -1,11 +1,15 @@ package handler import ( + "context" "errors" "io" "net/http" "strings" + "time" + "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" coderws "github.com/coder/websocket" @@ -70,10 +74,27 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { defer conn.CloseNow() model := c.Query("model") - if err := h.gatewayService.ProxyGrokRealtime(c.Request.Context(), c, conn, selection.Account, token, model); err != nil { - reqLog.Info("grok_realtime.proxy_failed", zap.Error(err)) + if strings.TrimSpace(model) == "" { + model = "grok-voice-latest" + } + started := time.Now() + proxyErr := h.gatewayService.ProxyGrokRealtime(c.Request.Context(), c, conn, selection.Account, token, model) + elapsed := time.Since(started) + if proxyErr != nil { + reqLog.Info("grok_realtime.proxy_failed", zap.Error(proxyErr)) _ = conn.Close(coderws.StatusInternalError, "upstream realtime websocket failed") } + // Bill completed (or partially completed) realtime sessions by elapsed minutes + // when group audio_realtime_price_per_min is configured. + if elapsed > 0 { + subscription, _ := middleware2.GetSubscriptionFromContext(c) + result := &service.OpenAIForwardResult{ + Model: model, + Duration: elapsed, + AudioUsage: &service.AudioUsage{Mode: "realtime", DurationOrUnits: elapsed.Minutes()}, + } + h.recordGrokVoiceUsage(c, apiKey, selection.Account, subscription, "realtime", nil, result) + } } // GrokVoice handles xAI Voice HTTP endpoints. endpoint is "tts", "stt", or "custom-voices". @@ -146,7 +167,8 @@ func (h *OpenAIGatewayHandler) GrokVoice(c *gin.Context, endpoint string) { return h.gatewayService.ForwardGrokVoice(c.Request.Context(), c, account, endpoint, body, contentType) }() if forwardErr == nil { - _ = result + subscription, _ := middleware2.GetSubscriptionFromContext(c) + h.recordGrokVoiceUsage(c, apiKey, account, subscription, endpoint, body, result) return } var failoverErr *service.UpstreamFailoverError @@ -163,6 +185,66 @@ func (h *OpenAIGatewayHandler) GrokVoice(c *gin.Context, endpoint string) { } } +// recordGrokVoiceUsage bills TTS/STT/realtime via group audio prices when AudioUsage is set. +func (h *OpenAIGatewayHandler) recordGrokVoiceUsage( + c *gin.Context, + apiKey *service.APIKey, + account *service.Account, + subscription *service.UserSubscription, + endpoint string, + body []byte, + result *service.OpenAIForwardResult, +) { + if h == nil || c == nil || apiKey == nil || account == nil || result == nil { + return + } + if result.AudioUsage == nil { + return + } + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + sessionID := service.ExtractClientSessionID(c) + requestPayloadHash := service.HashUsageRequestPayload(body) + if requestPayloadHash == "" { + requestPayloadHash = service.HashUsageRequestPayload([]byte(endpoint)) + } + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) + quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + model := strings.TrimSpace(result.Model) + if model == "" { + model = endpoint + } + + 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, + SessionID: sessionID, + ChannelUsageFields: clientRequestedUsageFields(c, service.ChannelMappingResult{}, model, result.UpstreamModel), + }); err != nil { + logger.L().With( + zap.String("component", "handler.openai_gateway.grok_voice"), + zap.Int64("user_id", apiKey.User.ID), + zap.Int64("api_key_id", apiKey.ID), + zap.Any("group_id", apiKey.GroupID), + zap.String("endpoint", endpoint), + zap.Int64("account_id", account.ID), + ).Error("grok_voice.record_usage_failed", zap.Error(err)) + } + }) +} + func readGrokVoiceGatewayBody(c *gin.Context) ([]byte, error) { if c == nil || c.Request == nil || c.Request.Body == nil { return nil, errors.New("request body is required") diff --git a/backend/internal/pkg/xai/billing.go b/backend/internal/pkg/xai/billing.go index 6eb74aa293..a942de84d2 100644 --- a/backend/internal/pkg/xai/billing.go +++ b/backend/internal/pkg/xai/billing.go @@ -45,14 +45,22 @@ type BillingProductUsage struct { } // BillingConfig is the nested config object from /v1/billing responses. +// Weekly (`?format=credits`) and monthly (`/billing`) share this shape; absolute +// money fields typically appear on the credits (prepaid/on-demand) or monthly +// (limit/used) responses. type BillingConfig struct { - CurrentPeriod *BillingPeriod `json:"currentPeriod,omitempty"` - CreditUsagePercent *float64 `json:"creditUsagePercent,omitempty"` - ProductUsage []BillingProductUsage `json:"productUsage,omitempty"` - MonthlyLimit json.RawMessage `json:"monthlyLimit,omitempty"` - Used json.RawMessage `json:"used,omitempty"` - BillingPeriodStart string `json:"billingPeriodStart,omitempty"` - BillingPeriodEnd string `json:"billingPeriodEnd,omitempty"` + CurrentPeriod *BillingPeriod `json:"currentPeriod,omitempty"` + CreditUsagePercent *float64 `json:"creditUsagePercent,omitempty"` + ProductUsage []BillingProductUsage `json:"productUsage,omitempty"` + MonthlyLimit json.RawMessage `json:"monthlyLimit,omitempty"` + Used json.RawMessage `json:"used,omitempty"` + OnDemandCap json.RawMessage `json:"onDemandCap,omitempty"` + OnDemandUsed json.RawMessage `json:"onDemandUsed,omitempty"` + PrepaidBalance json.RawMessage `json:"prepaidBalance,omitempty"` + IsUnifiedBillingUser bool `json:"isUnifiedBillingUser,omitempty"` + TopUpMethod string `json:"topUpMethod,omitempty"` + BillingPeriodStart string `json:"billingPeriodStart,omitempty"` + BillingPeriodEnd string `json:"billingPeriodEnd,omitempty"` } // BillingPayload is the top-level body from /v1/billing. @@ -67,6 +75,8 @@ type BillingProductSummary struct { } // BillingSummary is the merged weekly + monthly billing view. +// Cents fields remain the authoritative monthly numbers; dollar fields are the +// operator-facing absolute money view (prepaid / on-demand / monthly $). type BillingSummary struct { PeriodType string `json:"period_type,omitempty"` // weekly | monthly | unknown UsagePercent *float64 `json:"usage_percent,omitempty"` @@ -79,17 +89,26 @@ type BillingSummary struct { BillingPeriodStart string `json:"billing_period_start,omitempty"` BillingPeriodEnd string `json:"billing_period_end,omitempty"` UsedPercent *float64 `json:"used_percent,omitempty"` - Plan string `json:"plan,omitempty"` // SuperGrok | SuperGrok Heavy | "" - StatusCode int `json:"status_code,omitempty"` - WeeklyStatusCode int `json:"weekly_status_code,omitempty"` - MonthlyStatusCode int `json:"monthly_status_code,omitempty"` - Source string `json:"source,omitempty"` - FetchedAt string `json:"fetched_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` - WeeklyUpdatedAt string `json:"weekly_updated_at,omitempty"` - MonthlyUpdatedAt string `json:"monthly_updated_at,omitempty"` - Partial bool `json:"partial,omitempty"` - FailedWindows []string `json:"failed_windows,omitempty"` + // Absolute money (USD). Prepaid/on-demand come from credits probe as dollars. + // MonthlyLimit/MonthlyUsed are cents/100 for consistent $ display. + PrepaidBalance *float64 `json:"prepaid_balance,omitempty"` + MonthlyLimit *float64 `json:"monthly_limit,omitempty"` + MonthlyUsed *float64 `json:"monthly_used,omitempty"` + OnDemandCap *float64 `json:"on_demand_cap,omitempty"` + OnDemandUsed *float64 `json:"on_demand_used,omitempty"` + TopUpMethod string `json:"top_up_method,omitempty"` + IsUnifiedBillingUser bool `json:"is_unified_billing_user,omitempty"` + Plan string `json:"plan,omitempty"` // SuperGrok | SuperGrok Heavy | "" + StatusCode int `json:"status_code,omitempty"` + WeeklyStatusCode int `json:"weekly_status_code,omitempty"` + MonthlyStatusCode int `json:"monthly_status_code,omitempty"` + Source string `json:"source,omitempty"` + FetchedAt string `json:"fetched_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + WeeklyUpdatedAt string `json:"weekly_updated_at,omitempty"` + MonthlyUpdatedAt string `json:"monthly_updated_at,omitempty"` + Partial bool `json:"partial,omitempty"` + FailedWindows []string `json:"failed_windows,omitempty"` } // BuildBillingURL builds weekly or monthly billing URL against the CLI chat proxy. @@ -181,6 +200,11 @@ func BuildBillingSummary(config *BillingConfig) *BillingSummary { monthlyLimit := parseCentValue(config.MonthlyLimit) used := parseCentValue(config.Used) + // Absolute money on credits responses is dollar-denominated ({"val": 12}). + // Monthly limit/used are cents (same as MonthlyLimitCents / UsedCents). + prepaid := parseCentValue(config.PrepaidBalance) + onDemandCap := parseCentValue(config.OnDemandCap) + onDemandUsed := parseCentValue(config.OnDemandUsed) billingStart := strings.TrimSpace(config.BillingPeriodStart) billingEnd := strings.TrimSpace(config.BillingPeriodEnd) @@ -200,7 +224,7 @@ func BuildBillingSummary(config *BillingConfig) *BillingSummary { usedPercent = &v } - hasWeekly := creditUsage != nil || periodType == "weekly" || len(products) > 0 + hasWeekly := creditUsage != nil || periodType == "weekly" || len(products) > 0 || prepaid != nil || onDemandCap != nil || onDemandUsed != nil hasMonthly := monthlyLimit != nil || used != nil || (!hasWeekly && billingEnd != "") if !hasWeekly && !hasMonthly { return nil @@ -230,6 +254,24 @@ func BuildBillingSummary(config *BillingConfig) *BillingSummary { summary.BillingPeriodEnd = billingEnd } summary.UsedPercent = usedPercent + summary.PrepaidBalance = prepaid + if onDemandCap != nil { + summary.OnDemandCap = onDemandCap + } + if onDemandUsed != nil { + summary.OnDemandUsed = onDemandUsed + } + // Expose monthly cents as dollars for UI absolute rows. + if monthlyLimit != nil { + v := *monthlyLimit / 100 + summary.MonthlyLimit = &v + } + if used != nil { + v := *used / 100 + summary.MonthlyUsed = &v + } + summary.TopUpMethod = strings.TrimSpace(config.TopUpMethod) + summary.IsUnifiedBillingUser = config.IsUnifiedBillingUser summary.Plan = resolvePlan(monthlyLimit) return summary } @@ -259,6 +301,22 @@ func MergeBillingProbeResult(previous, weekly, monthly *BillingSummary, weeklyOK out.PeriodStart = weekly.PeriodStart out.PeriodEnd = weekly.PeriodEnd out.ProductUsage = weekly.ProductUsage + // Absolute prepaid / on-demand usually ride the credits (weekly) response. + if weekly.PrepaidBalance != nil { + out.PrepaidBalance = weekly.PrepaidBalance + } + if weekly.OnDemandCap != nil { + out.OnDemandCap = weekly.OnDemandCap + } + if weekly.OnDemandUsed != nil { + out.OnDemandUsed = weekly.OnDemandUsed + } + if weekly.TopUpMethod != "" { + out.TopUpMethod = weekly.TopUpMethod + } + if weekly.IsUnifiedBillingUser { + out.IsUnifiedBillingUser = true + } out.WeeklyUpdatedAt = now } if monthlyOK && monthly != nil { @@ -271,6 +329,15 @@ func MergeBillingProbeResult(previous, weekly, monthly *BillingSummary, weeklyOK out.BillingPeriodStart = monthly.BillingPeriodStart out.BillingPeriodEnd = monthly.BillingPeriodEnd out.UsedPercent = monthly.UsedPercent + out.MonthlyLimit = monthly.MonthlyLimit + out.MonthlyUsed = monthly.MonthlyUsed + // Monthly probe may also carry on-demand cap when credits omitted it. + if monthly.OnDemandCap != nil && out.OnDemandCap == nil { + out.OnDemandCap = monthly.OnDemandCap + } + if monthly.OnDemandUsed != nil && out.OnDemandUsed == nil { + out.OnDemandUsed = monthly.OnDemandUsed + } out.Plan = monthly.Plan out.MonthlyUpdatedAt = now } diff --git a/backend/internal/pkg/xai/billing_test.go b/backend/internal/pkg/xai/billing_test.go index b508d5494f..ffa282907d 100644 --- a/backend/internal/pkg/xai/billing_test.go +++ b/backend/internal/pkg/xai/billing_test.go @@ -48,7 +48,11 @@ func TestBuildBillingSummaryWeeklyAndMonthly(t *testing.T) { "config": { "currentPeriod": {"type":"WEEKLY","start":"2026-07-09T03:25:00Z","end":"2026-07-16T03:25:00Z"}, "creditUsagePercent": 2.0, - "productUsage": [{"product":"Api","usagePercent":2.0}] + "productUsage": [{"product":"Api","usagePercent":2.0}], + "prepaidBalance": {"val": 12}, + "onDemandCap": {"val": 100}, + "onDemandUsed": {"val": 5}, + "isUnifiedBillingUser": true } }`) monthlyBody := []byte(`{ @@ -72,10 +76,16 @@ func TestBuildBillingSummaryWeeklyAndMonthly(t *testing.T) { require.Equal(t, "weekly", weekly.PeriodType) require.InDelta(t, 2.0, *weekly.UsagePercent, 1e-9) require.Equal(t, "Api", weekly.ProductUsage[0].Product) + require.InDelta(t, 12, *weekly.PrepaidBalance, 1e-9) + require.InDelta(t, 100, *weekly.OnDemandCap, 1e-9) + require.InDelta(t, 5, *weekly.OnDemandUsed, 1e-9) + require.True(t, weekly.IsUnifiedBillingUser) require.Equal(t, "SuperGrok", monthly.Plan) require.InDelta(t, 15000, *monthly.MonthlyLimitCents, 1e-9) require.InDelta(t, 78, *monthly.UsedCents, 1e-9) require.InDelta(t, 0.52, *monthly.UsedPercent, 1e-2) + require.InDelta(t, 150, *monthly.MonthlyLimit, 1e-9) + require.InDelta(t, 0.78, *monthly.MonthlyUsed, 1e-9) merged := MergeBillingProbeResult(nil, weekly, monthly, true, true) require.Equal(t, "weekly", merged.PeriodType) @@ -83,6 +93,10 @@ func TestBuildBillingSummaryWeeklyAndMonthly(t *testing.T) { require.Equal(t, "SuperGrok", merged.Plan) require.InDelta(t, 15000, *merged.MonthlyLimitCents, 1e-9) require.Equal(t, "2026-08-01T00:00:00Z", merged.BillingPeriodEnd) + require.InDelta(t, 12, *merged.PrepaidBalance, 1e-9) + require.InDelta(t, 100, *merged.OnDemandCap, 1e-9) + require.InDelta(t, 150, *merged.MonthlyLimit, 1e-9) + require.InDelta(t, 0.78, *merged.MonthlyUsed, 1e-9) } func TestParseCentValueBareNumber(t *testing.T) { diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 2f1f851ed4..30e3c27844 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -191,6 +191,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, + group.FieldSearchPricePer1k, group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, @@ -974,6 +975,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { VideoPrice1080P: g.VideoPrice1080p, VideoModelPrices: service.NormalizeVideoModelPrices(g.VideoModelPrices), WebSearchPricePerCall: g.WebSearchPricePerCall, + SearchPricePer1k: g.SearchPricePer1k, AudioRealtimePricePerMin: g.AudioRealtimePricePerMin, AudioTTSPricePerMillionChars: g.AudioTtsPricePerMillionChars, AudioSTTPricePerHour: g.AudioSttPricePerHour, diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 3232e51c74..4cbc27909d 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -84,6 +84,7 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi SetNillableVideoPrice1080p(groupIn.VideoPrice1080P). SetVideoModelPrices(service.NormalizeVideoModelPrices(groupIn.VideoModelPrices)). SetNillableWebSearchPricePerCall(groupIn.WebSearchPricePerCall). + SetNillableSearchPricePer1k(groupIn.SearchPricePer1k). SetNillableAudioRealtimePricePerMin(groupIn.AudioRealtimePricePerMin). SetNillableAudioTtsPricePerMillionChars(groupIn.AudioTTSPricePerMillionChars). SetNillableAudioSttPricePerHour(groupIn.AudioSTTPricePerHour). @@ -332,6 +333,11 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er } else { builder = builder.ClearWebSearchPricePerCall() } + if groupIn.SearchPricePer1k != nil { + builder = builder.SetSearchPricePer1k(*groupIn.SearchPricePer1k) + } else { + builder = builder.ClearSearchPricePer1k() + } if groupIn.AudioRealtimePricePerMin != nil { builder = builder.SetAudioRealtimePricePerMin(*groupIn.AudioRealtimePricePerMin) } else { diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index e1522f9eed..b5d5649143 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -285,6 +285,14 @@ func RegisterGatewayRoutes( } h.OpenAIGateway.GrokRealtime(c) }) + gateway.POST("/web_search", func(c *gin.Context) { + if getGroupPlatform(c) != service.PlatformGrok { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Web Search API is not supported for this platform"}}) + return + } + h.Gateway.WebSearch(c) + }) } // Gemini 原生 API 兼容层(Gemini SDK/CLI 直连) @@ -385,6 +393,14 @@ func RegisterGatewayRoutes( } h.OpenAIGateway.GrokRealtime(c) }) + r.POST("/web_search", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) { + if getGroupPlatform(c) != service.PlatformGrok { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Web Search API is not supported for this platform"}}) + return + } + h.Gateway.WebSearch(c) + }) // Antigravity 模型列表 r.GET("/antigravity/models", gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.Gateway.AntigravityModels) diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 2bb77d5c9d..d299947571 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -212,7 +212,9 @@ type UsageInfo struct { GrokLocalUsage24h *WindowStats `json:"grok_local_usage_24h,omitempty"` GrokLocalUsage7d *WindowStats `json:"grok_local_usage_7d,omitempty"` GrokLocalUsageMonthly *WindowStats `json:"grok_local_usage_monthly,omitempty"` - GrokBilling *xai.BillingSummary `json:"grok_billing,omitempty"` + // ThirtyDay is the official monthly billing window (used/monthlyLimit %). + ThirtyDay *UsageProgress `json:"thirty_day,omitempty"` + GrokBilling *xai.BillingSummary `json:"grok_billing,omitempty"` // Antigravity 账号级信息 SubscriptionTier string `json:"subscription_tier,omitempty"` // 归一化订阅等级: FREE/PRO/ULTRA/UNKNOWN @@ -1124,6 +1126,13 @@ func (s *AccountUsageService) getGrokUsage(ctx context.Context, account *Account ctx, s.usageLogRepo, account.ID, usage.GrokBilling, time.Now().UTC(), ) } + // Attach local window stats to official 7d/30d progress bars. + if usage.SevenDay != nil && usage.GrokLocalUsage7d != nil { + usage.SevenDay.WindowStats = usage.GrokLocalUsage7d + } + if usage.ThirtyDay != nil && usage.GrokLocalUsageMonthly != nil { + usage.ThirtyDay.WindowStats = usage.GrokLocalUsageMonthly + } } enrichUsageWithAccountError(usage, account) diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index e424bbf2a4..5744f40cfe 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -328,6 +328,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn videoPrice720P := normalizePrice(input.VideoPrice720P) videoPrice1080P := normalizePrice(input.VideoPrice1080P) webSearchPricePerCall := normalizePrice(input.WebSearchPricePerCall) + searchPricePer1k := normalizePrice(input.SearchPricePer1k) audioRealtimePricePerMin := normalizePrice(input.AudioRealtimePricePerMin) audioTTSPricePerMillionChars := normalizePrice(input.AudioTTSPricePerMillionChars) audioSTTPricePerHour := normalizePrice(input.AudioSTTPricePerHour) @@ -481,6 +482,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn VideoPrice1080P: videoPrice1080P, VideoModelPrices: NormalizeVideoModelPrices(input.VideoModelPrices), WebSearchPricePerCall: webSearchPricePerCall, + SearchPricePer1k: searchPricePer1k, AudioRealtimePricePerMin: audioRealtimePricePerMin, AudioTTSPricePerMillionChars: audioTTSPricePerMillionChars, AudioSTTPricePerHour: audioSTTPricePerHour, @@ -769,6 +771,9 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.WebSearchPricePerCall != nil { group.WebSearchPricePerCall = normalizePrice(input.WebSearchPricePerCall) } + if input.SearchPricePer1k != nil { + group.SearchPricePer1k = normalizePrice(input.SearchPricePer1k) + } if input.AudioRealtimePricePerMin != nil { group.AudioRealtimePricePerMin = normalizePrice(input.AudioRealtimePricePerMin) } diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index 4a4757ee0a..deda6d3822 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -130,6 +130,7 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { VideoPrice1080P: cloneGroupValuePointer(source.VideoPrice1080P), VideoModelPrices: cloneGroupVideoModelPrices(source.VideoModelPrices), WebSearchPricePerCall: cloneGroupValuePointer(source.WebSearchPricePerCall), + SearchPricePer1k: cloneGroupValuePointer(source.SearchPricePer1k), AudioRealtimePricePerMin: cloneGroupValuePointer(source.AudioRealtimePricePerMin), AudioTTSPricePerMillionChars: cloneGroupValuePointer(source.AudioTTSPricePerMillionChars), AudioSTTPricePerHour: cloneGroupValuePointer(source.AudioSTTPricePerHour), diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index d075eb4280..d9c4060c9e 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -242,6 +242,8 @@ type CreateGroupInput struct { VideoModelPrices map[string]map[string]float64 // Codex alpha/search 网页搜索单次价格(USD/次,仅 openai 平台使用);nil/负数按默认价 0.01 处理 WebSearchPricePerCall *float64 + // 搜索工具单价 per 1k + SearchPricePer1k *float64 // Grok Voice 显式定价(分组级) AudioRealtimePricePerMin *float64 AudioTTSPricePerMillionChars *float64 @@ -313,6 +315,8 @@ type UpdateGroupInput struct { VideoModelPrices map[string]map[string]float64 // Codex alpha/search 网页搜索单次价格(USD/次);nil 表示不修改,负数表示清除回默认价 0.01 WebSearchPricePerCall *float64 + // 搜索工具单价;nil 不修改,负数清除 + SearchPricePer1k *float64 // Grok Voice 显式定价;nil 表示不修改,负数表示清除 AudioRealtimePricePerMin *float64 AudioTTSPricePerMillionChars *float64 diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index b04820750b..cbfdf3338b 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -79,6 +79,7 @@ type APIKeyAuthGroupSnapshot struct { VideoPrice720P *float64 `json:"video_price_720p,omitempty"` VideoPrice1080P *float64 `json:"video_price_1080p,omitempty"` WebSearchPricePerCall *float64 `json:"web_search_price_per_call,omitempty"` + SearchPricePer1k *float64 `json:"search_price_per_1k,omitempty"` AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min,omitempty"` AudioTTSPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars,omitempty"` AudioSTTPricePerHour *float64 `json:"audio_stt_price_per_hour,omitempty"` diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 54908b573b..dd6adfb131 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -401,6 +401,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) VideoPrice720P: apiKey.Group.VideoPrice720P, VideoPrice1080P: apiKey.Group.VideoPrice1080P, WebSearchPricePerCall: apiKey.Group.WebSearchPricePerCall, + SearchPricePer1k: apiKey.Group.SearchPricePer1k, AudioRealtimePricePerMin: apiKey.Group.AudioRealtimePricePerMin, AudioTTSPricePerMillionChars: apiKey.Group.AudioTTSPricePerMillionChars, AudioSTTPricePerHour: apiKey.Group.AudioSTTPricePerHour, @@ -494,6 +495,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho VideoPrice720P: snapshot.Group.VideoPrice720P, VideoPrice1080P: snapshot.Group.VideoPrice1080P, WebSearchPricePerCall: snapshot.Group.WebSearchPricePerCall, + SearchPricePer1k: snapshot.Group.SearchPricePer1k, AudioRealtimePricePerMin: snapshot.Group.AudioRealtimePricePerMin, AudioTTSPricePerMillionChars: snapshot.Group.AudioTTSPricePerMillionChars, AudioSTTPricePerHour: snapshot.Group.AudioSTTPricePerHour, diff --git a/backend/internal/service/billing_search_audio_cost_test.go b/backend/internal/service/billing_search_audio_cost_test.go new file mode 100644 index 0000000000..1347ba7231 --- /dev/null +++ b/backend/internal/service/billing_search_audio_cost_test.go @@ -0,0 +1,32 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCalculateSearchCost(t *testing.T) { + t.Parallel() + s := &BillingService{} + require.Equal(t, 0.0, s.CalculateSearchCost(0, floatPtr(10), 1).ActualCost) + require.Equal(t, 0.0, s.CalculateSearchCost(5, nil, 1).ActualCost) + price := 10.0 + cost := s.CalculateSearchCost(100, &price, 1.5) + // 10 / 1000 * 100 * 1.5 = 1.5 + require.InDelta(t, 1.0, cost.TotalCost, 1e-9) + require.InDelta(t, 1.5, cost.ActualCost, 1e-9) +} + +func TestCalculateAudioCost(t *testing.T) { + t.Parallel() + s := &BillingService{} + rt, tts, stt := 0.10, 15.0, 0.50 + cfg := &audioPriceConfig{RealtimePerMin: &rt, TTSPerMChars: &tts, STTPerHour: &stt} + require.InDelta(t, 0.20, s.CalculateAudioCost("realtime", 2, cfg, 1).ActualCost, 1e-9) + require.InDelta(t, 1.5, s.CalculateAudioCost("tts", 0.1, cfg, 1).ActualCost, 1e-9) + require.InDelta(t, 0.25, s.CalculateAudioCost("stt", 0.5, cfg, 1).ActualCost, 1e-9) + require.Equal(t, 0.0, s.CalculateAudioCost("unknown", 1, cfg, 1).ActualCost) +} + +func floatPtr(v float64) *float64 { return &v } diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index cf9b25fbd3..f280189984 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -1464,6 +1464,70 @@ func (s *BillingService) CalculateWebSearchCost(callCount int, groupPrice *float } } +// CalculateSearchCost bills search/tool invocations (e.g. web_search) per 1k calls. +// Uses explicit group search_price_per_1k when set; otherwise returns zero cost. +func (s *BillingService) CalculateSearchCost(numCalls int, groupPricePer1k *float64, rateMultiplier float64) *CostBreakdown { + if numCalls <= 0 { + return &CostBreakdown{} + } + if groupPricePer1k == nil || *groupPricePer1k <= 0 { + return &CostBreakdown{} + } + if rateMultiplier < 0 { + rateMultiplier = 0 + } + unit := *groupPricePer1k / 1000.0 + total := unit * float64(numCalls) + return &CostBreakdown{ + TotalCost: total, + ActualCost: total * rateMultiplier, + BillingMode: string(BillingModePerRequest), + } +} + +type audioPriceConfig struct { + RealtimePerMin *float64 + TTSPerMChars *float64 + STTPerHour *float64 +} + +// CalculateAudioCost supports realtime (per min), tts (per M chars), stt (per hr). +func (s *BillingService) CalculateAudioCost(mode string, durationOrUnits float64, groupConfig *audioPriceConfig, rateMultiplier float64) *CostBreakdown { + if durationOrUnits <= 0 { + return &CostBreakdown{} + } + var unitPrice float64 + switch strings.ToLower(mode) { + case "realtime": + if groupConfig != nil && groupConfig.RealtimePerMin != nil { + unitPrice = *groupConfig.RealtimePerMin + } + case "tts": + if groupConfig != nil && groupConfig.TTSPerMChars != nil { + unitPrice = *groupConfig.TTSPerMChars + } + case "stt": + if groupConfig != nil && groupConfig.STTPerHour != nil { + unitPrice = *groupConfig.STTPerHour + } + default: + return &CostBreakdown{} + } + if unitPrice <= 0 { + return &CostBreakdown{} + } + if rateMultiplier < 0 { + rateMultiplier = 0 + } + total := unitPrice * durationOrUnits + return &CostBreakdown{ + TotalCost: total, + ActualCost: total * rateMultiplier, + BillingMode: string(BillingModePerRequest), + } +} + + // CalculateImageCost 计算图片生成费用 // model: 请求的模型名称(用于获取 LiteLLM 默认价格) // imageSize: 图片尺寸 "1K", "2K", "4K" diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index bb4ce85476..a23fc099e4 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "net/http" "os" @@ -20,10 +21,13 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/cespare/xxhash/v2" + "github.com/gin-gonic/gin" gocache "github.com/patrickmn/go-cache" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" "golang.org/x/sync/singleflight" ) @@ -570,6 +574,11 @@ type ClaudeUsage struct { } // ForwardResult 转发结果 +type AudioUsage struct { + Mode string // realtime | tts | stt + DurationOrUnits float64 // minutes / million-chars / hours +} + type ForwardResult struct { RequestID string Usage ClaudeUsage @@ -591,6 +600,8 @@ type ForwardResult struct { ImageOutputSizes []string ImageSizeSource string ImageSizeBreakdown map[string]int + SearchCount int + AudioUsage *AudioUsage } // GatewayFailureStage identifies which request stage failed. The zero value is @@ -1221,6 +1232,15 @@ func (s *GatewayService) getOAuthToken(ctx context.Context, account *Account) (s return accessToken, "oauth", nil } + // Grok OAuth: prefer access_token from credentials (background refresher keeps it warm). + if account.Platform == PlatformGrok && account.Type == AccountTypeOAuth { + accessToken := account.GetGrokAccessToken() + if accessToken == "" { + return "", "", errors.New("grok access_token not found in credentials") + } + return accessToken, "oauth", nil + } + // 其他情况(Gemini 有自己的 TokenProvider,setup-token 类型等)直接从账号读取 accessToken := account.GetCredential("access_token") if accessToken == "" { @@ -1232,6 +1252,67 @@ func (s *GatewayService) getOAuthToken(ctx context.Context, account *Account) (s // GetAvailableModels returns the list of models available for a group // It aggregates model_mapping keys from all schedulable accounts in the group + +// DoGrokNativeResponsesJSON POSTs a non-streaming Responses body to the account's +// Grok upstream and returns the raw JSON body. Used by /v1/web_search. +func (s *GatewayService) DoGrokNativeResponsesJSON(ctx context.Context, c *gin.Context, account *Account, body []byte) ([]byte, error) { + if s == nil || s.httpUpstream == nil { + return nil, errors.New("http upstream not configured") + } + if account == nil { + return nil, errors.New("account is required") + } + if !account.IsGrok() { + return nil, errors.New("grok account required") + } + token, _, err := s.GetAccessToken(ctx, account) + if err != nil { + return nil, fmt.Errorf("get grok token: %w", err) + } + targetURL, err := buildGrokResponsesURL(account, nil) + if err != nil { + return nil, err + } + if json.Valid(body) { + if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); model == "" { + if patched, patchErr := sjson.SetBytes(body, "model", xai.DefaultTextModel); patchErr == nil { + body = patched + } + } + } + upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build grok responses request: %w", err) + } + upstreamReq.Header.Set("Authorization", "Bearer "+token) + upstreamReq.Header.Set("Content-Type", "application/json") + upstreamReq.Header.Set("Accept", "application/json") + upstreamReq.Header.Set("User-Agent", resolveGrokUpstreamUserAgent(c)) + applyGrokCLIHeaders(upstreamReq.Header) + account.ApplyHeaderOverrides(upstreamReq.Header) + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + if err != nil { + return nil, fmt.Errorf("grok native search upstream: %w", err) + } + defer func() { _ = resp.Body.Close() }() + respBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if readErr != nil { + return nil, fmt.Errorf("read grok native search response: %w", readErr) + } + if resp.StatusCode >= 400 { + msg := string(respBytes) + if len(msg) > 200 { + msg = msg[:200] + } + return nil, fmt.Errorf("grok upstream %d: %s", resp.StatusCode, msg) + } + return respBytes, nil +} + func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, platform string) []string { cacheKey := modelsListCacheKey(groupID, platform) if s.modelsListCache != nil { diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 0d07b355c6..1297caed28 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -813,6 +813,17 @@ func (s *GatewayService) calculateRecordUsageCost( return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier) } + // Grok native web_search / tool search: group search_price_per_1k when set. + if result.SearchCount > 0 { + price := groupSearchPricePer1kFromAPIKey(apiKey) + return s.billingService.CalculateSearchCost(result.SearchCount, price, multiplier) + } + // Voice audio (TTS / STT / realtime) when present on the forward result. + if result.AudioUsage != nil { + cfg := groupAudioPriceConfigFromAPIKey(apiKey) + return s.billingService.CalculateAudioCost(result.AudioUsage.Mode, result.AudioUsage.DurationOrUnits, cfg, multiplier) + } + // Token 计费 return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts) } diff --git a/backend/internal/service/grok_audio.go b/backend/internal/service/grok_audio.go index 683b4fee61..498edc3301 100644 --- a/backend/internal/service/grok_audio.go +++ b/backend/internal/service/grok_audio.go @@ -12,6 +12,7 @@ import ( coderws "github.com/coder/websocket" "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" ) // supportedGrokVoiceHTTPEndpoints are xAI Voice HTTP paths we forward as-is. @@ -80,11 +81,13 @@ func (s *OpenAIGatewayService) ForwardGrokVoice(ctx context.Context, c *gin.Cont return nil, err } writeGrokMediaResponse(c, resp, data, s.responseHeaderFilter) + audioUsage := estimateGrokVoiceAudioUsage(endpoint, body, contentType, data, time.Since(started)) return &OpenAIForwardResult{ RequestID: firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id")), Model: endpoint, UpstreamModel: endpoint, Duration: time.Since(started), + AudioUsage: audioUsage, }, nil } @@ -170,3 +173,58 @@ func (s *OpenAIGatewayService) ProxyGrokRealtime(ctx context.Context, c *gin.Con return <-errCh } + +// estimateGrokVoiceAudioUsage derives billing units from the request/response. +// TTS: million characters of input text; STT: hours approximated from request body size +// when duration is unknown; custom-voices: no units (nil). +func estimateGrokVoiceAudioUsage(endpoint string, reqBody []byte, contentType string, respBody []byte, elapsed time.Duration) *AudioUsage { + switch strings.TrimSpace(endpoint) { + case "tts": + // Prefer JSON "input" / "text" fields; fallback to raw body length. + chars := 0 + if gjson.ValidBytes(reqBody) { + for _, key := range []string{"input", "text", "prompt"} { + if s := strings.TrimSpace(gjson.GetBytes(reqBody, key).String()); s != "" { + chars = len([]rune(s)) + break + } + } + } + if chars <= 0 { + chars = len(reqBody) + } + if chars <= 0 { + return nil + } + return &AudioUsage{Mode: "tts", DurationOrUnits: float64(chars) / 1_000_000.0} + case "stt": + // Prefer explicit duration_seconds; else estimate from elapsed with a floor. + secs := 0.0 + if gjson.ValidBytes(reqBody) { + if v := gjson.GetBytes(reqBody, "duration_seconds"); v.Exists() { + secs = v.Float() + } + } + if secs <= 0 { + secs = elapsed.Seconds() + } + if secs <= 0 { + // Multipart audio size heuristic: ~16KB/s for compressed speech. + if len(reqBody) > 0 { + secs = float64(len(reqBody)) / 16000.0 + } + } + if secs <= 0 { + return nil + } + return &AudioUsage{Mode: "stt", DurationOrUnits: secs / 3600.0} + case "realtime": + mins := elapsed.Minutes() + if mins <= 0 { + return nil + } + return &AudioUsage{Mode: "realtime", DurationOrUnits: mins} + default: + return nil + } +} diff --git a/backend/internal/service/grok_quota_fetcher.go b/backend/internal/service/grok_quota_fetcher.go index 70d87cc17b..4979dfd0b9 100644 --- a/backend/internal/service/grok_quota_fetcher.go +++ b/backend/internal/service/grok_quota_fetcher.go @@ -36,6 +36,7 @@ func (f *GrokQuotaFetcher) BuildUsageInfo(account *Account) *UsageInfo { activeProbeClearsForbidden := newerSuccessfulGrokActiveProbeClearsBillingForbidden(billing, snapshot) if billing != nil { usage.GrokBilling = billing + applyGrokBillingProgressWindows(usage, billing, now) if billing.Plan != "" { usage.SubscriptionTier = billing.Plan usage.SubscriptionTierRaw = billing.Plan @@ -59,6 +60,14 @@ func (f *GrokQuotaFetcher) BuildUsageInfo(account *Account) *UsageInfo { case 429: usage.ErrorCode = "rate_limited" } + // Official weekly/monthly progress clears the "unknown until headers" state. + if usage.ErrorCode == "quota_unknown" && (usage.SevenDay != nil || usage.ThirtyDay != nil) { + usage.ErrorCode = "" + if strings.Contains(strings.ToLower(usage.Error), "unknown until") || + strings.Contains(strings.ToLower(usage.Error), "no xai quota headers") { + usage.Error = "" + } + } } if err != nil || snapshot == nil { @@ -246,3 +255,48 @@ func grokQuotaSnapshotFromExtra(extra map[string]any) (*xai.QuotaSnapshot, error return &out, nil } } + +// applyGrokBillingProgressWindows fills official weekly (seven_day) and monthly +// (thirty_day) UsageProgress from a billing probe summary. +func applyGrokBillingProgressWindows(usage *UsageInfo, billing *xai.BillingSummary, now time.Time) { + if usage == nil || billing == nil { + return + } + if billing.UsagePercent != nil { + seven := &UsageProgress{Utilization: *billing.UsagePercent} + if end, err := parseTime(strings.TrimSpace(billing.PeriodEnd)); err == nil { + seven.ResetsAt = &end + if sec := int(end.Sub(now).Seconds()); sec > 0 { + seven.RemainingSeconds = sec + } + } + if usage.SevenDay != nil { + seven.WindowStats = usage.SevenDay.WindowStats + } + usage.SevenDay = seven + } + var monthlyUtil *float64 + if billing.UsedPercent != nil { + monthlyUtil = billing.UsedPercent + } else if billing.MonthlyLimitCents != nil && *billing.MonthlyLimitCents > 0 && billing.UsedCents != nil { + v := (*billing.UsedCents / *billing.MonthlyLimitCents) * 100 + monthlyUtil = &v + } + if monthlyUtil != nil { + thirty := &UsageProgress{Utilization: *monthlyUtil} + endRaw := strings.TrimSpace(billing.BillingPeriodEnd) + if endRaw == "" && billing.PeriodType == "monthly" { + endRaw = strings.TrimSpace(billing.PeriodEnd) + } + if end, err := parseTime(endRaw); err == nil { + thirty.ResetsAt = &end + if sec := int(end.Sub(now).Seconds()); sec > 0 { + thirty.RemainingSeconds = sec + } + } + if usage.ThirtyDay != nil { + thirty.WindowStats = usage.ThirtyDay.WindowStats + } + usage.ThirtyDay = thirty + } +} diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 31f6fb334e..ddde276e22 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -63,6 +63,8 @@ type Group struct { // nil 表示使用默认价 defaultWebSearchPricePerCall(官方 $10/1000 次)。 WebSearchPricePerCall *float64 + // 搜索工具显式定价(per 1k calls)。 + SearchPricePer1k *float64 // Grok Voice 显式定价(分组级,不按文本 RateMultiplier)。 AudioRealtimePricePerMin *float64 AudioTTSPricePerMillionChars *float64 @@ -447,3 +449,11 @@ func profitControlPlatformSupported(platform string) bool { return false } } + +// GetSearchPricePer1k returns explicit search/tool price per 1k calls if configured. +func (g *Group) GetSearchPricePer1k() *float64 { + if g == nil { + return nil + } + return g.SearchPricePer1k +} diff --git a/backend/internal/service/media_price_config.go b/backend/internal/service/media_price_config.go index de5cf437fb..53d63c97a4 100644 --- a/backend/internal/service/media_price_config.go +++ b/backend/internal/service/media_price_config.go @@ -37,3 +37,22 @@ func webSearchPricePerCallFromAPIKey(apiKey *APIKey) *float64 { } return apiKey.Group.WebSearchPricePerCall } + +func groupSearchPricePer1kFromAPIKey(apiKey *APIKey) *float64 { + if apiKey == nil || apiKey.Group == nil { + return nil + } + return apiKey.Group.GetSearchPricePer1k() +} + +func groupAudioPriceConfigFromAPIKey(apiKey *APIKey) *audioPriceConfig { + if apiKey == nil || apiKey.Group == nil { + return nil + } + g := apiKey.Group + return &audioPriceConfig{ + RealtimePerMin: g.AudioRealtimePricePerMin, + TTSPerMChars: g.AudioTTSPricePerMillionChars, + STTPerHour: g.AudioSTTPricePerHour, + } +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 135f63aa81..2e3fd979d9 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -275,6 +275,10 @@ type OpenAIForwardResult struct { // WebSearchCalls 是 Codex alpha/search 网页搜索调用次数(每次成功请求为 1)。 // 上游不返回 usage 字段,>0 时走按次计费(分组单价 × 次数 × 倍率)。 WebSearchCalls int + // SearchCount is Grok-native web_search / tool search call count (per 1k pricing). + SearchCount int + // AudioUsage carries Voice billing units when present. + AudioUsage *AudioUsage wsReplayInput []json.RawMessage wsReplayInputExists bool diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 1297b17ed7..7bc11321da 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -435,6 +435,16 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( return s.calculateOpenAIVideoCost(ctx, billingModel, apiKey, result, videoMultiplier), nil } } + if result != nil && result.SearchCount > 0 { + // Grok native web_search / tool search: group search_price_per_1k when set. + price := groupSearchPricePer1kFromAPIKey(apiKey) + return s.billingService.CalculateSearchCost(result.SearchCount, price, webSearchMultiplier), nil + } + if result != nil && result.AudioUsage != nil { + cfg := groupAudioPriceConfigFromAPIKey(apiKey) + return s.billingService.CalculateAudioCost(result.AudioUsage.Mode, result.AudioUsage.DurationOrUnits, cfg, webSearchMultiplier), nil + } + if result != nil && result.ImageCount > 0 { // 渠道定价为 token 计费时走 token 路径,否则走图片计费 if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved == nil || resolved.Mode != BillingModeToken { diff --git a/backend/migrations/219_group_search_price_per_1k.sql b/backend/migrations/219_group_search_price_per_1k.sql new file mode 100644 index 0000000000..52b88a7e27 --- /dev/null +++ b/backend/migrations/219_group_search_price_per_1k.sql @@ -0,0 +1,2 @@ +-- Grok / 通用搜索工具显式定价(per 1000 calls,USD)。 +ALTER TABLE groups ADD COLUMN IF NOT EXISTS search_price_per_1k DECIMAL(20,8); diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index d8d9dec56a..d5807fca6a 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -365,7 +365,7 @@ {{ grokEntitlementLabel }} -
+
{{ formatWindowRequests(grokLocalUsage) }} req @@ -405,6 +405,13 @@ v-if="grokBillingMoneySummary" class="flex flex-wrap items-center gap-1 text-[10px] text-gray-500 dark:text-gray-400" > + + {{ t('admin.accounts.usageWindow.grokPrepaid') }} ${{ grokBillingMoneySummary.prepaid }} + {{ t('admin.accounts.usageWindow.grokUsed') }} {{ grokBillingMoneySummary.used }}/{{ grokBillingMoneySummary.limit }} @@ -415,6 +422,14 @@ > {{ grokBillingMoneySummary.usedPercent }}% + + {{ t('admin.accounts.usageWindow.grokOverageShort') }} + {{ grokBillingMoneySummary.onDemandUsed }}/{{ grokBillingMoneySummary.onDemandCap }} +
{ resetsAt: billing.billing_period_end || billing.period_end || null } }) -const formatGrokMoneyFromCents = (cents?: number | null) => { - if (cents == null || Number.isNaN(cents)) return '0' - const dollars = cents / 100 - if (dollars >= 1000) return formatCompactNumber(dollars) - if (dollars >= 100) return dollars.toFixed(0) - if (dollars >= 10) return dollars.toFixed(1) - return dollars.toFixed(2) +const formatGrokMoney = (value?: number | null) => { + if (value == null || Number.isNaN(value)) return '0' + if (value >= 1000) return formatCompactNumber(value) + if (value >= 100) return value.toFixed(0) + if (value >= 10) return value.toFixed(1) + return value.toFixed(2) } -// Absolute monthly used/limit derived from cents fields already on GrokBillingSummary. -// (personal-dev has separate prepaid/on_demand fields; HEAD only has cents.) +// Absolute money: prefer prepaid/monthly_used fields; fall back to cents/100. const grokBillingMoneySummary = computed(() => { const billing = grokBilling.value if (!billing) return null - const limit = billing.monthly_limit_cents - const used = billing.used_cents - if ((limit == null || limit <= 0) && (used == null || used <= 0)) return null + const prepaid = + billing.prepaid_balance != null + ? billing.prepaid_balance + : null + const used = + billing.monthly_used != null + ? billing.monthly_used + : billing.used_cents != null + ? billing.used_cents / 100 + : null + const limit = + billing.monthly_limit != null + ? billing.monthly_limit + : billing.monthly_limit_cents != null + ? billing.monthly_limit_cents / 100 + : null + const onDemandUsed = billing.on_demand_used ?? null + const onDemandCap = billing.on_demand_cap ?? null + const hasAny = + (prepaid ?? 0) > 0 || + (used ?? 0) > 0 || + (limit ?? 0) > 0 || + (onDemandCap ?? 0) > 0 || + (onDemandUsed ?? 0) > 0 + if (!hasAny) return null let usedPercent: number | null = null if (billing.used_percent != null && Number.isFinite(billing.used_percent)) { usedPercent = Math.round(Math.min(100, Math.max(0, billing.used_percent))) @@ -1178,8 +1213,12 @@ const grokBillingMoneySummary = computed(() => { usedPercent = Math.round(Math.min(100, Math.max(0, (used / limit) * 100))) } return { - used: formatGrokMoneyFromCents(used ?? 0), - limit: formatGrokMoneyFromCents(limit ?? 0), + prepaid: prepaid != null ? formatGrokMoney(prepaid) : null, + used: formatGrokMoney(used ?? 0), + limit: formatGrokMoney(limit ?? 0), + onDemandUsed: formatGrokMoney(onDemandUsed ?? 0), + onDemandCap: formatGrokMoney(onDemandCap ?? 0), + showOverage: (onDemandCap ?? 0) > 0 || (onDemandUsed ?? 0) > 0, usedPercent } }) @@ -1208,6 +1247,10 @@ const grokIsFree = computed(() => { return billing != null }) const grokFreeQuotaUsage = computed(() => usageInfo.value?.grok_local_usage_24h || null) +const showGrokStandaloneLocalStats = computed(() => { + // Official 7d/30d bars already embed window_stats — avoid duplicate local row. + return !usageInfo.value?.seven_day && !usageInfo.value?.thirty_day +}) const grokLocalUsage = computed(() => { if (grokIsFree.value) return grokFreeQuotaUsage.value return props.todayStats || diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 51e0af9289..e2729c238a 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1375,6 +1375,40 @@
+ +
+
+
+ +

+ {{ t('admin.accounts.accountSchedulingThresholdOverrideHint') }} +

+
+ +
+
+ + +

{{ t('admin.accounts.accountSchedulingThresholdOverrideDisabledHint') }}

+
+
+
([]) const antigravityModelMappings = ref([]) const isSyncingAntigravityUpstream = ref(false) const tempUnschedEnabled = ref(false) +const accountSchedulingThresholdOverrideEnabled = ref(false) +const accountSchedulingThresholdOverrideValue = ref(100) +const ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY = 'account_scheduling_threshold' +const supportsAccountSchedulingThresholdOverride = computed(() => + supportsAccountSchedulingThresholdOverridePlatform(props.account?.platform) +) const tempUnschedRules = ref([]) const getModelMappingKey = createStableObjectKeyResolver('edit-model-mapping') const getOpenAICompactModelMappingKey = createStableObjectKeyResolver('edit-openai-compact-model-mapping') @@ -3512,6 +3552,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { loadQuotaControlSettings(newAccount) loadTempUnschedRules(credentials) + loadAccountSchedulingThresholdOverride(newAccount.platform, credentials) // Load header override state (anthropic/openai apikey + grok apikey/oauth) headerOverrideEnabled.value = false @@ -3874,6 +3915,67 @@ const applyTempUnschedConfig = (credentials: Record) => { return true } + +const supportsAccountSchedulingThresholdOverridePlatform = (platform: Account['platform'] | undefined) => + platform === 'openai' || platform === 'anthropic' || platform === 'grok' + +const normalizeAccountSchedulingThresholdOverride = (value: unknown): number | null => { + if (value === null || value === undefined || value === '') { + return null + } + const numeric = Number(value) + if (!Number.isFinite(numeric)) { + return null + } + const integer = Math.trunc(numeric) + if (integer < 1 || integer > 100) { + return null + } + return integer +} + +const clampAccountSchedulingThresholdOverride = (value: unknown): number => + Math.min(100, Math.max(1, Math.trunc(Number(value) || 100))) + +const loadAccountSchedulingThresholdOverride = ( + platform: Account['platform'] | undefined, + credentials: Record | undefined +) => { + if (!supportsAccountSchedulingThresholdOverridePlatform(platform)) { + accountSchedulingThresholdOverrideEnabled.value = false + accountSchedulingThresholdOverrideValue.value = 100 + return + } + const value = normalizeAccountSchedulingThresholdOverride( + credentials?.[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY] + ) + accountSchedulingThresholdOverrideEnabled.value = value !== null + accountSchedulingThresholdOverrideValue.value = value ?? 100 +} + +const applyAccountSchedulingThresholdOverridePatch = ( + credentials: Record, + currentCredentials: Record, + platform: Account['platform'] | undefined = props.account?.platform +) => { + if (!supportsAccountSchedulingThresholdOverridePlatform(platform)) { + return + } + const current = normalizeAccountSchedulingThresholdOverride( + currentCredentials[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY] + ) + if (!accountSchedulingThresholdOverrideEnabled.value) { + if (current !== null) { + credentials[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY] = null + } + return + } + const next = clampAccountSchedulingThresholdOverride(accountSchedulingThresholdOverrideValue.value) + if (current !== next) { + credentials[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY] = next + } +} + function loadTempUnschedRules(credentials?: Record) { tempUnschedEnabled.value = credentials?.temp_unschedulable_enabled === true const rawRules = credentials?.temp_unschedulable_rules @@ -4233,6 +4335,7 @@ const handleSubmit = async () => { // Add intercept warmup requests setting applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') + applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials) if (!applyTempUnschedConfig(newCredentials)) { return } @@ -4251,6 +4354,7 @@ const handleSubmit = async () => { // Add intercept warmup requests setting applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') + applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials) if (!applyTempUnschedConfig(newCredentials)) { return } @@ -4299,6 +4403,7 @@ const handleSubmit = async () => { } applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') + applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials) if (!applyTempUnschedConfig(newCredentials)) { return } @@ -4356,6 +4461,7 @@ const handleSubmit = async () => { } applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') + applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials) if (!applyTempUnschedConfig(newCredentials)) { return } @@ -4367,6 +4473,7 @@ const handleSubmit = async () => { const newCredentials: Record = { ...currentCredentials } applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') + applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials) if (!applyTempUnschedConfig(newCredentials)) { return } diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 84e57a7b37..4c13a9c2e5 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -131,6 +131,10 @@ export default { rateLimited: 'Rate Limited', overloaded: 'Overloaded', tempUnschedulable: 'Temp Unschedulable', + accountSchedulingThresholdOverride: 'Account Auto-Pause Threshold Override', + accountSchedulingThresholdOverrideHint: 'Override the platform auto-pause threshold for this account only. Disable to use platform settings.', + accountSchedulingThresholdOverrideValue: 'Account threshold percent', + accountSchedulingThresholdOverrideDisabledHint: 'Use 1-100. The account becomes temporarily unschedulable after reaching this usage percent; 100 disables it for this account.', quotaExceeded: 'Quota Exceeded', unschedulable: 'Unschedulable', rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}', @@ -1358,7 +1362,11 @@ export default { grokFreeQuota24hHint: 'Estimated from local token usage over the rolling 24-hour window ({limit} limit)', grokWeeklyUsage: 'Weekly {percent}%', grokUsed: 'Used $', - grokMonthlyLimit: 'Monthly used / limit (USD from billing cents)', + grokBalance: 'Bal $', + grokPrepaid: 'Prepaid balance', + grokMonthlyLimit: 'Monthly used / limit (USD)', + grokOverage: 'Overage onDemandUsed/onDemandCap', + grokOverageShort: 'OD $', grokUnknown: 'Grok quota is unknown until the first upstream response includes xAI rate-limit headers.', grokRetryAfter: 'Retry after {time}', grokProbe: 'Probe', diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 502e8bd31f..7d8f18a2f3 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -994,6 +994,12 @@ export default { finalPricePreview: 'Final per-second price preview', notConfigured: 'Not configured' }, + explicitPricing: { + title: 'Grok Search & Voice Pricing', + description: 'Optional per-group prices for web_search (per 1k calls) and Voice realtime / TTS / STT (USD). Leave empty if unused.', + searchPricePer1k: 'Search price per 1k calls (USD)', + pricePlaceholder: 'optional' + }, voicePricing: { title: 'Grok Voice Pricing', description: 'Optional per-group prices for Voice realtime / TTS / STT (USD). Leave empty to leave unpriced.', diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index c2fa0f92a3..f7f484e08b 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -411,10 +411,10 @@ export default { allowUngroupedKey: 'Allow Ungrouped Key Scheduling', allowUngroupedKeyHint: 'When disabled, API Keys not assigned to any group cannot make requests (403 Forbidden). Keep disabled to ensure all Keys belong to a specific group.', accountSchedulingThresholdsTitle: 'Platform Account Auto-Pause Thresholds', - accountSchedulingThresholdsDescription: 'Set per-platform thresholds that automatically pause scheduling for accounts on that platform when their current native usage window reaches the configured percentage.', - accountSchedulingThresholdsGlobalHint: 'This is a system-wide global setting and applies to all accounts on that platform.', - accountSchedulingThresholdsDisabledHint: 'A value of 100 disables the auto-pause threshold for that platform.', - accountSchedulingThresholdsRangeHint: 'Range 1-100, entered as a percentage.' + accountSchedulingThresholdsDescription: 'When an account\'s current native usage window (OpenAI Codex/Anthropic session, or Grok request/token utilization) reaches this percent, Sub2API temporarily removes it from scheduling until the window resets. Use 100 to disable.', + accountSchedulingThresholdsGlobalHint: 'System-wide default for every account on that platform. Individual accounts can still override this in the account editor.', + accountSchedulingThresholdsDisabledHint: '100 disables platform auto-pause. Values 1–99 pause scheduling once utilization reaches that percent.', + accountSchedulingThresholdsRangeHint: 'Integer 1–100 (percent). OpenAI/Anthropic/Grok only.' }, upstreamBillingProbe: { title: 'Upstream Rate Auto Detection', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 762512f993..b75aa64ec8 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -334,6 +334,10 @@ export default { rateLimited: '限流中', overloaded: '过载中', tempUnschedulable: '临时不可调度', + accountSchedulingThresholdOverride: '账号自动停调阈值覆盖', + accountSchedulingThresholdOverrideHint: '仅对当前账号覆盖平台级自动停调阈值;关闭后使用平台设置。', + accountSchedulingThresholdOverrideValue: '账号阈值百分比', + accountSchedulingThresholdOverrideDisabledHint: '1-100,达到该用量百分比后临时不可调度;100 表示禁用当前账号自动停调。', quotaExceeded: '配额超限', unschedulable: '不可调度', rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复', @@ -402,7 +406,11 @@ export default { grokFreeQuota24hHint: '按 sub2api 近 24 小时本地 Token 用量估算(上限 {limit})', grokWeeklyUsage: '周额度已用 {percent}%', grokUsed: '已用 $', - grokMonthlyLimit: '月度已用/上限(由 billing cents 换算 USD)', + grokBalance: '余额 $', + grokPrepaid: '预付余额', + grokMonthlyLimit: '月度已用/上限(USD)', + grokOverage: '超额 onDemandUsed/onDemandCap', + grokOverageShort: '超额 $', grokUnknown: 'Grok 配额需等待首次上游响应返回 xAI rate-limit 头后显示。', grokRetryAfter: '{time} 后重试', grokProbe: '探测', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 87a5095e50..9d78846fda 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -991,6 +991,12 @@ export default { finalPricePreview: '最终每秒价格预览', notConfigured: '未配置' }, + explicitPricing: { + title: 'Grok 搜索与 Voice 定价', + description: '分组级 web_search(每千次)与 Voice realtime / TTS / STT 单价(USD)。留空表示未配置。', + searchPricePer1k: '搜索每千次价格(USD)', + pricePlaceholder: '可选' + }, voicePricing: { title: 'Grok Voice 定价', description: '分组级 Voice realtime / TTS / STT 单价(USD)。留空表示未配置。', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 8e0531ee8d..c838a85c34 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -404,10 +404,10 @@ export default { allowUngroupedKey: '允许未分组 Key 调度', allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403)。建议保持关闭以确保所有 Key 都归属明确的分组。', accountSchedulingThresholdsTitle: '平台账号自动停调阈值', - accountSchedulingThresholdsDescription: '按平台设置账号自动停调阈值,当账号当前原生使用窗口达到配置百分比时,自动停调该平台账号。', - accountSchedulingThresholdsGlobalHint: '这是系统级全局设置,对该平台全部账号生效。', - accountSchedulingThresholdsDisabledHint: '100 表示禁用该平台的自动停调阈值。', - accountSchedulingThresholdsRangeHint: '范围 1-100,按百分比填写。' + accountSchedulingThresholdsDescription: '当账号当前原生用量窗口(OpenAI Codex/Anthropic 会话,或 Grok 请求/Token 利用率)达到该百分比时,Sub2API 会临时将其移出调度,直到窗口重置。填 100 表示禁用。', + accountSchedulingThresholdsGlobalHint: '系统级默认值,作用于该平台全部账号。可在账号编辑页对单个账号覆盖。', + accountSchedulingThresholdsDisabledHint: '100 表示禁用该平台自动停调;1–99 表示达到该利用率后暂停调度。', + accountSchedulingThresholdsRangeHint: '整数 1–100(百分比)。仅 OpenAI / Anthropic / Grok。' }, upstreamBillingProbe: { title: '上游倍率自动探测', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a52c6d48e0..9ef890e248 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -573,6 +573,7 @@ export interface Group { // Codex 网页搜索单次价格(USD/次);null 表示使用默认价 0.01 web_search_price_per_call: number | null // Grok Voice 显式定价(分组级) + search_price_per_1k: number | null audio_realtime_price_per_min: number | null audio_tts_price_per_million_chars: number | null audio_stt_price_per_hour: number | null @@ -776,6 +777,7 @@ export interface CreateGroupRequest { video_price_1080p?: number | null video_model_prices?: VideoModelPrices web_search_price_per_call?: number | null + search_price_per_1k?: number | null audio_realtime_price_per_min?: number | null audio_tts_price_per_million_chars?: number | null audio_stt_price_per_hour?: number | null @@ -835,6 +837,7 @@ export interface UpdateGroupRequest { video_price_1080p?: number | null video_model_prices?: VideoModelPrices web_search_price_per_call?: number | null + search_price_per_1k?: number | null audio_realtime_price_per_min?: number | null audio_tts_price_per_million_chars?: number | null audio_stt_price_per_hour?: number | null @@ -1276,6 +1279,14 @@ export interface GrokBillingSummary { billing_period_start?: string billing_period_end?: string used_percent?: number | null + /** Absolute USD money from billing probes */ + prepaid_balance?: number | null + monthly_limit?: number | null + monthly_used?: number | null + on_demand_cap?: number | null + on_demand_used?: number | null + top_up_method?: string + is_unified_billing_user?: boolean plan?: string status_code?: number source?: string @@ -1294,6 +1305,7 @@ export interface AccountUsageInfo { seven_day: UsageProgress | null seven_day_sonnet: UsageProgress | null seven_day_fable?: UsageProgress | null + thirty_day?: UsageProgress | null gemini_shared_daily?: UsageProgress | null gemini_pro_daily?: UsageProgress | null gemini_flash_daily?: UsageProgress | null diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 90777188c3..bc9ac2d19b 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -1490,12 +1490,24 @@ class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4" >

- {{ t("admin.groups.voicePricing.title") }} + {{ t("admin.groups.explicitPricing.title") }}

- {{ t("admin.groups.voicePricing.description") }} + {{ t("admin.groups.explicitPricing.description") }}

+
+ + +

- {{ t("admin.groups.voicePricing.title") }} + {{ t("admin.groups.explicitPricing.title") }}

- {{ t("admin.groups.voicePricing.description") }} + {{ t("admin.groups.explicitPricing.description") }}

+
+ + +
{ createForm.video_price_1080p = null; createForm.video_model_prices = createVideoModelPricesForm(); createForm.web_search_price_per_call = null; + createForm.search_price_per_1k = null; createForm.audio_realtime_price_per_min = null; createForm.audio_tts_price_per_million_chars = null; createForm.audio_stt_price_per_hour = null; @@ -5886,6 +5913,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.search_price_per_1k = emptyToNull( + requestData.search_price_per_1k, + ); requestData.audio_realtime_price_per_min = emptyToNull( requestData.audio_realtime_price_per_min, ); @@ -5955,6 +5985,7 @@ const handleEdit = async (group: AdminGroup) => { group.video_model_prices, ); editForm.web_search_price_per_call = group.web_search_price_per_call ?? null; + editForm.search_price_per_1k = group.search_price_per_1k ?? null; editForm.audio_realtime_price_per_min = group.audio_realtime_price_per_min ?? null; editForm.audio_tts_price_per_million_chars = group.audio_tts_price_per_million_chars ?? null; editForm.audio_stt_price_per_hour = group.audio_stt_price_per_hour ?? null; @@ -6039,6 +6070,7 @@ const closeEditModal = () => { editForm.video_price_1080p = null; editForm.video_model_prices = createVideoModelPricesForm(); editForm.web_search_price_per_call = null; + editForm.search_price_per_1k = null; editForm.audio_realtime_price_per_min = null; editForm.audio_tts_price_per_million_chars = null; editForm.audio_stt_price_per_hour = null; @@ -6147,6 +6179,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.search_price_per_1k = emptyPriceToClear( + payload.search_price_per_1k, + ); payload.audio_realtime_price_per_min = emptyPriceToClear( payload.audio_realtime_price_per_min, );