mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-27 16:07:02 +00:00
feat(grok): 账单绝对金额、调度阈值 UI、搜索/Voice 计费与 /v1/web_search
- 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 搜索
This commit is contained in:
+15
-1
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+108
-1
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Grok / 通用搜索工具显式定价(per 1000 calls,USD)。
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS search_price_per_1k DECIMAL(20,8);
|
||||
Reference in New Issue
Block a user