Merge origin/main into worktree/brave-valley-9578

Resolve conflict in api_key_repo.go: keep both ListAllByUserID (this PR)
and attachLastUsedIPs/latestUsageLogIPs (main), and have ListAllByUserID
attach last-used IPs so the current_concurrency sort path keeps the
last_used_ip column populated.
This commit is contained in:
shaw
2026-07-09 17:17:16 +08:00
144 changed files with 6978 additions and 300 deletions
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
cache-dependency-path: backend/go.sum
- name: Verify Go version
run: |
go version | grep -q 'go1.26.4'
go version | grep -q 'go1.26.5'
- name: Unit tests
working-directory: backend
run: make test-unit
@@ -60,7 +60,7 @@ jobs:
cache-dependency-path: backend/go.sum
- name: Verify Go version
run: |
go version | grep -q 'go1.26.4'
go version | grep -q 'go1.26.5'
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
+1 -1
View File
@@ -115,7 +115,7 @@ jobs:
- name: Verify Go version
run: |
go version | grep -q 'go1.26.4'
go version | grep -q 'go1.26.5'
# Docker setup for GoReleaser
- name: Set up QEMU
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
cache-dependency-path: backend/go.sum
- name: Verify Go version
run: |
go version | grep -q 'go1.26.4'
go version | grep -q 'go1.26.5'
- name: Run govulncheck
working-directory: backend
run: |
+1 -1
View File
@@ -8,7 +8,7 @@
# =============================================================================
ARG NODE_IMAGE=node:24-alpine
ARG GOLANG_IMAGE=golang:1.26.4-alpine
ARG GOLANG_IMAGE=golang:1.26.5-alpine
ARG ALPINE_IMAGE=alpine:3.21
ARG POSTGRES_IMAGE=postgres:18-alpine
ARG GOPROXY=https://goproxy.cn,direct
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.26.4-alpine
FROM golang:1.26.5-alpine
WORKDIR /app
+66 -2
View File
@@ -73,6 +73,16 @@ type Group struct {
BatchImageDiscountMultiplier float64 `json:"batch_image_discount_multiplier,omitempty"`
// 批量图片生成冻结价格比例,按普通生图原价乘以该比例冻结,结算后释放差额
BatchImageHoldMultiplier float64 `json:"batch_image_hold_multiplier,omitempty"`
// 视频生成是否使用独立倍率;false 表示共享分组有效倍率
VideoRateIndependent bool `json:"video_rate_independent,omitempty"`
// 视频生成独立倍率,仅 video_rate_independent=true 时生效
VideoRateMultiplier float64 `json:"video_rate_multiplier,omitempty"`
// VideoPrice480p holds the value of the "video_price_480p" field.
VideoPrice480p *float64 `json:"video_price_480p,omitempty"`
// VideoPrice720p holds the value of the "video_price_720p" field.
VideoPrice720p *float64 `json:"video_price_720p,omitempty"`
// VideoPrice1080p holds the value of the "video_price_1080p" field.
VideoPrice1080p *float64 `json:"video_price_1080p,omitempty"`
// 是否仅允许 Claude Code 客户端
ClaudeCodeOnly bool `json:"claude_code_only,omitempty"`
// 非 Claude Code 请求降级使用的分组 ID
@@ -211,9 +221,9 @@ func (*Group) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig:
values[i] = new([]byte)
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet:
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet:
values[i] = new(sql.NullBool)
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier:
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:
values[i] = new(sql.NullFloat64)
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
values[i] = new(sql.NullInt64)
@@ -412,6 +422,39 @@ func (_m *Group) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.BatchImageHoldMultiplier = value.Float64
}
case group.FieldVideoRateIndependent:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field video_rate_independent", values[i])
} else if value.Valid {
_m.VideoRateIndependent = value.Bool
}
case group.FieldVideoRateMultiplier:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field video_rate_multiplier", values[i])
} else if value.Valid {
_m.VideoRateMultiplier = value.Float64
}
case group.FieldVideoPrice480p:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field video_price_480p", values[i])
} else if value.Valid {
_m.VideoPrice480p = new(float64)
*_m.VideoPrice480p = value.Float64
}
case group.FieldVideoPrice720p:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field video_price_720p", values[i])
} else if value.Valid {
_m.VideoPrice720p = new(float64)
*_m.VideoPrice720p = value.Float64
}
case group.FieldVideoPrice1080p:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field video_price_1080p", values[i])
} else if value.Valid {
_m.VideoPrice1080p = new(float64)
*_m.VideoPrice1080p = value.Float64
}
case group.FieldClaudeCodeOnly:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field claude_code_only", values[i])
@@ -685,6 +728,27 @@ func (_m *Group) String() string {
builder.WriteString("batch_image_hold_multiplier=")
builder.WriteString(fmt.Sprintf("%v", _m.BatchImageHoldMultiplier))
builder.WriteString(", ")
builder.WriteString("video_rate_independent=")
builder.WriteString(fmt.Sprintf("%v", _m.VideoRateIndependent))
builder.WriteString(", ")
builder.WriteString("video_rate_multiplier=")
builder.WriteString(fmt.Sprintf("%v", _m.VideoRateMultiplier))
builder.WriteString(", ")
if v := _m.VideoPrice480p; v != nil {
builder.WriteString("video_price_480p=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
if v := _m.VideoPrice720p; v != nil {
builder.WriteString("video_price_720p=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
if v := _m.VideoPrice1080p; v != nil {
builder.WriteString("video_price_1080p=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
builder.WriteString("claude_code_only=")
builder.WriteString(fmt.Sprintf("%v", _m.ClaudeCodeOnly))
builder.WriteString(", ")
+44
View File
@@ -70,6 +70,16 @@ const (
FieldBatchImageDiscountMultiplier = "batch_image_discount_multiplier"
// FieldBatchImageHoldMultiplier holds the string denoting the batch_image_hold_multiplier field in the database.
FieldBatchImageHoldMultiplier = "batch_image_hold_multiplier"
// FieldVideoRateIndependent holds the string denoting the video_rate_independent field in the database.
FieldVideoRateIndependent = "video_rate_independent"
// FieldVideoRateMultiplier holds the string denoting the video_rate_multiplier field in the database.
FieldVideoRateMultiplier = "video_rate_multiplier"
// FieldVideoPrice480p holds the string denoting the video_price_480p field in the database.
FieldVideoPrice480p = "video_price_480p"
// FieldVideoPrice720p holds the string denoting the video_price_720p field in the database.
FieldVideoPrice720p = "video_price_720p"
// FieldVideoPrice1080p holds the string denoting the video_price_1080p field in the database.
FieldVideoPrice1080p = "video_price_1080p"
// FieldClaudeCodeOnly holds the string denoting the claude_code_only field in the database.
FieldClaudeCodeOnly = "claude_code_only"
// FieldFallbackGroupID holds the string denoting the fallback_group_id field in the database.
@@ -202,6 +212,11 @@ var Columns = []string{
FieldImagePrice4k,
FieldBatchImageDiscountMultiplier,
FieldBatchImageHoldMultiplier,
FieldVideoRateIndependent,
FieldVideoRateMultiplier,
FieldVideoPrice480p,
FieldVideoPrice720p,
FieldVideoPrice1080p,
FieldClaudeCodeOnly,
FieldFallbackGroupID,
FieldFallbackGroupIDOnInvalidRequest,
@@ -296,6 +311,10 @@ var (
DefaultBatchImageDiscountMultiplier float64
// DefaultBatchImageHoldMultiplier holds the default value on creation for the "batch_image_hold_multiplier" field.
DefaultBatchImageHoldMultiplier float64
// DefaultVideoRateIndependent holds the default value on creation for the "video_rate_independent" field.
DefaultVideoRateIndependent bool
// DefaultVideoRateMultiplier holds the default value on creation for the "video_rate_multiplier" field.
DefaultVideoRateMultiplier float64
// DefaultClaudeCodeOnly holds the default value on creation for the "claude_code_only" field.
DefaultClaudeCodeOnly bool
// DefaultModelRoutingEnabled holds the default value on creation for the "model_routing_enabled" field.
@@ -467,6 +486,31 @@ func ByBatchImageHoldMultiplier(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldBatchImageHoldMultiplier, opts...).ToFunc()
}
// ByVideoRateIndependent orders the results by the video_rate_independent field.
func ByVideoRateIndependent(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldVideoRateIndependent, opts...).ToFunc()
}
// ByVideoRateMultiplier orders the results by the video_rate_multiplier field.
func ByVideoRateMultiplier(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldVideoRateMultiplier, opts...).ToFunc()
}
// ByVideoPrice480p orders the results by the video_price_480p field.
func ByVideoPrice480p(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldVideoPrice480p, opts...).ToFunc()
}
// ByVideoPrice720p orders the results by the video_price_720p field.
func ByVideoPrice720p(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldVideoPrice720p, opts...).ToFunc()
}
// ByVideoPrice1080p orders the results by the video_price_1080p field.
func ByVideoPrice1080p(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldVideoPrice1080p, opts...).ToFunc()
}
// ByClaudeCodeOnly orders the results by the claude_code_only field.
func ByClaudeCodeOnly(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldClaudeCodeOnly, opts...).ToFunc()
+225
View File
@@ -190,6 +190,31 @@ func BatchImageHoldMultiplier(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldBatchImageHoldMultiplier, v))
}
// VideoRateIndependent applies equality check predicate on the "video_rate_independent" field. It's identical to VideoRateIndependentEQ.
func VideoRateIndependent(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldVideoRateIndependent, v))
}
// VideoRateMultiplier applies equality check predicate on the "video_rate_multiplier" field. It's identical to VideoRateMultiplierEQ.
func VideoRateMultiplier(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldVideoRateMultiplier, v))
}
// VideoPrice480p applies equality check predicate on the "video_price_480p" field. It's identical to VideoPrice480pEQ.
func VideoPrice480p(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldVideoPrice480p, v))
}
// VideoPrice720p applies equality check predicate on the "video_price_720p" field. It's identical to VideoPrice720pEQ.
func VideoPrice720p(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldVideoPrice720p, v))
}
// VideoPrice1080p applies equality check predicate on the "video_price_1080p" field. It's identical to VideoPrice1080pEQ.
func VideoPrice1080p(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldVideoPrice1080p, v))
}
// ClaudeCodeOnly applies equality check predicate on the "claude_code_only" field. It's identical to ClaudeCodeOnlyEQ.
func ClaudeCodeOnly(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v))
@@ -1430,6 +1455,206 @@ func BatchImageHoldMultiplierLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldBatchImageHoldMultiplier, v))
}
// VideoRateIndependentEQ applies the EQ predicate on the "video_rate_independent" field.
func VideoRateIndependentEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldVideoRateIndependent, v))
}
// VideoRateIndependentNEQ applies the NEQ predicate on the "video_rate_independent" field.
func VideoRateIndependentNEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldVideoRateIndependent, v))
}
// VideoRateMultiplierEQ applies the EQ predicate on the "video_rate_multiplier" field.
func VideoRateMultiplierEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldVideoRateMultiplier, v))
}
// VideoRateMultiplierNEQ applies the NEQ predicate on the "video_rate_multiplier" field.
func VideoRateMultiplierNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldVideoRateMultiplier, v))
}
// VideoRateMultiplierIn applies the In predicate on the "video_rate_multiplier" field.
func VideoRateMultiplierIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldVideoRateMultiplier, vs...))
}
// VideoRateMultiplierNotIn applies the NotIn predicate on the "video_rate_multiplier" field.
func VideoRateMultiplierNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldVideoRateMultiplier, vs...))
}
// VideoRateMultiplierGT applies the GT predicate on the "video_rate_multiplier" field.
func VideoRateMultiplierGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldVideoRateMultiplier, v))
}
// VideoRateMultiplierGTE applies the GTE predicate on the "video_rate_multiplier" field.
func VideoRateMultiplierGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldVideoRateMultiplier, v))
}
// VideoRateMultiplierLT applies the LT predicate on the "video_rate_multiplier" field.
func VideoRateMultiplierLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldVideoRateMultiplier, v))
}
// VideoRateMultiplierLTE applies the LTE predicate on the "video_rate_multiplier" field.
func VideoRateMultiplierLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldVideoRateMultiplier, v))
}
// VideoPrice480pEQ applies the EQ predicate on the "video_price_480p" field.
func VideoPrice480pEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldVideoPrice480p, v))
}
// VideoPrice480pNEQ applies the NEQ predicate on the "video_price_480p" field.
func VideoPrice480pNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldVideoPrice480p, v))
}
// VideoPrice480pIn applies the In predicate on the "video_price_480p" field.
func VideoPrice480pIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldVideoPrice480p, vs...))
}
// VideoPrice480pNotIn applies the NotIn predicate on the "video_price_480p" field.
func VideoPrice480pNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldVideoPrice480p, vs...))
}
// VideoPrice480pGT applies the GT predicate on the "video_price_480p" field.
func VideoPrice480pGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldVideoPrice480p, v))
}
// VideoPrice480pGTE applies the GTE predicate on the "video_price_480p" field.
func VideoPrice480pGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldVideoPrice480p, v))
}
// VideoPrice480pLT applies the LT predicate on the "video_price_480p" field.
func VideoPrice480pLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldVideoPrice480p, v))
}
// VideoPrice480pLTE applies the LTE predicate on the "video_price_480p" field.
func VideoPrice480pLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldVideoPrice480p, v))
}
// VideoPrice480pIsNil applies the IsNil predicate on the "video_price_480p" field.
func VideoPrice480pIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldVideoPrice480p))
}
// VideoPrice480pNotNil applies the NotNil predicate on the "video_price_480p" field.
func VideoPrice480pNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldVideoPrice480p))
}
// VideoPrice720pEQ applies the EQ predicate on the "video_price_720p" field.
func VideoPrice720pEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldVideoPrice720p, v))
}
// VideoPrice720pNEQ applies the NEQ predicate on the "video_price_720p" field.
func VideoPrice720pNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldVideoPrice720p, v))
}
// VideoPrice720pIn applies the In predicate on the "video_price_720p" field.
func VideoPrice720pIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldVideoPrice720p, vs...))
}
// VideoPrice720pNotIn applies the NotIn predicate on the "video_price_720p" field.
func VideoPrice720pNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldVideoPrice720p, vs...))
}
// VideoPrice720pGT applies the GT predicate on the "video_price_720p" field.
func VideoPrice720pGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldVideoPrice720p, v))
}
// VideoPrice720pGTE applies the GTE predicate on the "video_price_720p" field.
func VideoPrice720pGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldVideoPrice720p, v))
}
// VideoPrice720pLT applies the LT predicate on the "video_price_720p" field.
func VideoPrice720pLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldVideoPrice720p, v))
}
// VideoPrice720pLTE applies the LTE predicate on the "video_price_720p" field.
func VideoPrice720pLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldVideoPrice720p, v))
}
// VideoPrice720pIsNil applies the IsNil predicate on the "video_price_720p" field.
func VideoPrice720pIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldVideoPrice720p))
}
// VideoPrice720pNotNil applies the NotNil predicate on the "video_price_720p" field.
func VideoPrice720pNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldVideoPrice720p))
}
// VideoPrice1080pEQ applies the EQ predicate on the "video_price_1080p" field.
func VideoPrice1080pEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldVideoPrice1080p, v))
}
// VideoPrice1080pNEQ applies the NEQ predicate on the "video_price_1080p" field.
func VideoPrice1080pNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldVideoPrice1080p, v))
}
// VideoPrice1080pIn applies the In predicate on the "video_price_1080p" field.
func VideoPrice1080pIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldVideoPrice1080p, vs...))
}
// VideoPrice1080pNotIn applies the NotIn predicate on the "video_price_1080p" field.
func VideoPrice1080pNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldVideoPrice1080p, vs...))
}
// VideoPrice1080pGT applies the GT predicate on the "video_price_1080p" field.
func VideoPrice1080pGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldVideoPrice1080p, v))
}
// VideoPrice1080pGTE applies the GTE predicate on the "video_price_1080p" field.
func VideoPrice1080pGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldVideoPrice1080p, v))
}
// VideoPrice1080pLT applies the LT predicate on the "video_price_1080p" field.
func VideoPrice1080pLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldVideoPrice1080p, v))
}
// VideoPrice1080pLTE applies the LTE predicate on the "video_price_1080p" field.
func VideoPrice1080pLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldVideoPrice1080p, v))
}
// VideoPrice1080pIsNil applies the IsNil predicate on the "video_price_1080p" field.
func VideoPrice1080pIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldVideoPrice1080p))
}
// VideoPrice1080pNotNil applies the NotNil predicate on the "video_price_1080p" field.
func VideoPrice1080pNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldVideoPrice1080p))
}
// ClaudeCodeOnlyEQ applies the EQ predicate on the "claude_code_only" field.
func ClaudeCodeOnlyEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v))
+444
View File
@@ -399,6 +399,76 @@ func (_c *GroupCreate) SetNillableBatchImageHoldMultiplier(v *float64) *GroupCre
return _c
}
// SetVideoRateIndependent sets the "video_rate_independent" field.
func (_c *GroupCreate) SetVideoRateIndependent(v bool) *GroupCreate {
_c.mutation.SetVideoRateIndependent(v)
return _c
}
// SetNillableVideoRateIndependent sets the "video_rate_independent" field if the given value is not nil.
func (_c *GroupCreate) SetNillableVideoRateIndependent(v *bool) *GroupCreate {
if v != nil {
_c.SetVideoRateIndependent(*v)
}
return _c
}
// SetVideoRateMultiplier sets the "video_rate_multiplier" field.
func (_c *GroupCreate) SetVideoRateMultiplier(v float64) *GroupCreate {
_c.mutation.SetVideoRateMultiplier(v)
return _c
}
// SetNillableVideoRateMultiplier sets the "video_rate_multiplier" field if the given value is not nil.
func (_c *GroupCreate) SetNillableVideoRateMultiplier(v *float64) *GroupCreate {
if v != nil {
_c.SetVideoRateMultiplier(*v)
}
return _c
}
// SetVideoPrice480p sets the "video_price_480p" field.
func (_c *GroupCreate) SetVideoPrice480p(v float64) *GroupCreate {
_c.mutation.SetVideoPrice480p(v)
return _c
}
// SetNillableVideoPrice480p sets the "video_price_480p" field if the given value is not nil.
func (_c *GroupCreate) SetNillableVideoPrice480p(v *float64) *GroupCreate {
if v != nil {
_c.SetVideoPrice480p(*v)
}
return _c
}
// SetVideoPrice720p sets the "video_price_720p" field.
func (_c *GroupCreate) SetVideoPrice720p(v float64) *GroupCreate {
_c.mutation.SetVideoPrice720p(v)
return _c
}
// SetNillableVideoPrice720p sets the "video_price_720p" field if the given value is not nil.
func (_c *GroupCreate) SetNillableVideoPrice720p(v *float64) *GroupCreate {
if v != nil {
_c.SetVideoPrice720p(*v)
}
return _c
}
// SetVideoPrice1080p sets the "video_price_1080p" field.
func (_c *GroupCreate) SetVideoPrice1080p(v float64) *GroupCreate {
_c.mutation.SetVideoPrice1080p(v)
return _c
}
// SetNillableVideoPrice1080p sets the "video_price_1080p" field if the given value is not nil.
func (_c *GroupCreate) SetNillableVideoPrice1080p(v *float64) *GroupCreate {
if v != nil {
_c.SetVideoPrice1080p(*v)
}
return _c
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (_c *GroupCreate) SetClaudeCodeOnly(v bool) *GroupCreate {
_c.mutation.SetClaudeCodeOnly(v)
@@ -798,6 +868,14 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultBatchImageHoldMultiplier
_c.mutation.SetBatchImageHoldMultiplier(v)
}
if _, ok := _c.mutation.VideoRateIndependent(); !ok {
v := group.DefaultVideoRateIndependent
_c.mutation.SetVideoRateIndependent(v)
}
if _, ok := _c.mutation.VideoRateMultiplier(); !ok {
v := group.DefaultVideoRateMultiplier
_c.mutation.SetVideoRateMultiplier(v)
}
if _, ok := _c.mutation.ClaudeCodeOnly(); !ok {
v := group.DefaultClaudeCodeOnly
_c.mutation.SetClaudeCodeOnly(v)
@@ -938,6 +1016,12 @@ func (_c *GroupCreate) check() error {
if _, ok := _c.mutation.BatchImageHoldMultiplier(); !ok {
return &ValidationError{Name: "batch_image_hold_multiplier", err: errors.New(`ent: missing required field "Group.batch_image_hold_multiplier"`)}
}
if _, ok := _c.mutation.VideoRateIndependent(); !ok {
return &ValidationError{Name: "video_rate_independent", err: errors.New(`ent: missing required field "Group.video_rate_independent"`)}
}
if _, ok := _c.mutation.VideoRateMultiplier(); !ok {
return &ValidationError{Name: "video_rate_multiplier", err: errors.New(`ent: missing required field "Group.video_rate_multiplier"`)}
}
if _, ok := _c.mutation.ClaudeCodeOnly(); !ok {
return &ValidationError{Name: "claude_code_only", err: errors.New(`ent: missing required field "Group.claude_code_only"`)}
}
@@ -1114,6 +1198,26 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldBatchImageHoldMultiplier, field.TypeFloat64, value)
_node.BatchImageHoldMultiplier = value
}
if value, ok := _c.mutation.VideoRateIndependent(); ok {
_spec.SetField(group.FieldVideoRateIndependent, field.TypeBool, value)
_node.VideoRateIndependent = value
}
if value, ok := _c.mutation.VideoRateMultiplier(); ok {
_spec.SetField(group.FieldVideoRateMultiplier, field.TypeFloat64, value)
_node.VideoRateMultiplier = value
}
if value, ok := _c.mutation.VideoPrice480p(); ok {
_spec.SetField(group.FieldVideoPrice480p, field.TypeFloat64, value)
_node.VideoPrice480p = &value
}
if value, ok := _c.mutation.VideoPrice720p(); ok {
_spec.SetField(group.FieldVideoPrice720p, field.TypeFloat64, value)
_node.VideoPrice720p = &value
}
if value, ok := _c.mutation.VideoPrice1080p(); ok {
_spec.SetField(group.FieldVideoPrice1080p, field.TypeFloat64, value)
_node.VideoPrice1080p = &value
}
if value, ok := _c.mutation.ClaudeCodeOnly(); ok {
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
_node.ClaudeCodeOnly = value
@@ -1762,6 +1866,108 @@ func (u *GroupUpsert) AddBatchImageHoldMultiplier(v float64) *GroupUpsert {
return u
}
// SetVideoRateIndependent sets the "video_rate_independent" field.
func (u *GroupUpsert) SetVideoRateIndependent(v bool) *GroupUpsert {
u.Set(group.FieldVideoRateIndependent, v)
return u
}
// UpdateVideoRateIndependent sets the "video_rate_independent" field to the value that was provided on create.
func (u *GroupUpsert) UpdateVideoRateIndependent() *GroupUpsert {
u.SetExcluded(group.FieldVideoRateIndependent)
return u
}
// SetVideoRateMultiplier sets the "video_rate_multiplier" field.
func (u *GroupUpsert) SetVideoRateMultiplier(v float64) *GroupUpsert {
u.Set(group.FieldVideoRateMultiplier, v)
return u
}
// UpdateVideoRateMultiplier sets the "video_rate_multiplier" field to the value that was provided on create.
func (u *GroupUpsert) UpdateVideoRateMultiplier() *GroupUpsert {
u.SetExcluded(group.FieldVideoRateMultiplier)
return u
}
// AddVideoRateMultiplier adds v to the "video_rate_multiplier" field.
func (u *GroupUpsert) AddVideoRateMultiplier(v float64) *GroupUpsert {
u.Add(group.FieldVideoRateMultiplier, v)
return u
}
// SetVideoPrice480p sets the "video_price_480p" field.
func (u *GroupUpsert) SetVideoPrice480p(v float64) *GroupUpsert {
u.Set(group.FieldVideoPrice480p, v)
return u
}
// UpdateVideoPrice480p sets the "video_price_480p" field to the value that was provided on create.
func (u *GroupUpsert) UpdateVideoPrice480p() *GroupUpsert {
u.SetExcluded(group.FieldVideoPrice480p)
return u
}
// AddVideoPrice480p adds v to the "video_price_480p" field.
func (u *GroupUpsert) AddVideoPrice480p(v float64) *GroupUpsert {
u.Add(group.FieldVideoPrice480p, v)
return u
}
// ClearVideoPrice480p clears the value of the "video_price_480p" field.
func (u *GroupUpsert) ClearVideoPrice480p() *GroupUpsert {
u.SetNull(group.FieldVideoPrice480p)
return u
}
// SetVideoPrice720p sets the "video_price_720p" field.
func (u *GroupUpsert) SetVideoPrice720p(v float64) *GroupUpsert {
u.Set(group.FieldVideoPrice720p, v)
return u
}
// UpdateVideoPrice720p sets the "video_price_720p" field to the value that was provided on create.
func (u *GroupUpsert) UpdateVideoPrice720p() *GroupUpsert {
u.SetExcluded(group.FieldVideoPrice720p)
return u
}
// AddVideoPrice720p adds v to the "video_price_720p" field.
func (u *GroupUpsert) AddVideoPrice720p(v float64) *GroupUpsert {
u.Add(group.FieldVideoPrice720p, v)
return u
}
// ClearVideoPrice720p clears the value of the "video_price_720p" field.
func (u *GroupUpsert) ClearVideoPrice720p() *GroupUpsert {
u.SetNull(group.FieldVideoPrice720p)
return u
}
// SetVideoPrice1080p sets the "video_price_1080p" field.
func (u *GroupUpsert) SetVideoPrice1080p(v float64) *GroupUpsert {
u.Set(group.FieldVideoPrice1080p, v)
return u
}
// UpdateVideoPrice1080p sets the "video_price_1080p" field to the value that was provided on create.
func (u *GroupUpsert) UpdateVideoPrice1080p() *GroupUpsert {
u.SetExcluded(group.FieldVideoPrice1080p)
return u
}
// AddVideoPrice1080p adds v to the "video_price_1080p" field.
func (u *GroupUpsert) AddVideoPrice1080p(v float64) *GroupUpsert {
u.Add(group.FieldVideoPrice1080p, v)
return u
}
// ClearVideoPrice1080p clears the value of the "video_price_1080p" field.
func (u *GroupUpsert) ClearVideoPrice1080p() *GroupUpsert {
u.SetNull(group.FieldVideoPrice1080p)
return u
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (u *GroupUpsert) SetClaudeCodeOnly(v bool) *GroupUpsert {
u.Set(group.FieldClaudeCodeOnly, v)
@@ -2533,6 +2739,125 @@ func (u *GroupUpsertOne) UpdateBatchImageHoldMultiplier() *GroupUpsertOne {
})
}
// SetVideoRateIndependent sets the "video_rate_independent" field.
func (u *GroupUpsertOne) SetVideoRateIndependent(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetVideoRateIndependent(v)
})
}
// UpdateVideoRateIndependent sets the "video_rate_independent" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateVideoRateIndependent() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateVideoRateIndependent()
})
}
// SetVideoRateMultiplier sets the "video_rate_multiplier" field.
func (u *GroupUpsertOne) SetVideoRateMultiplier(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetVideoRateMultiplier(v)
})
}
// AddVideoRateMultiplier adds v to the "video_rate_multiplier" field.
func (u *GroupUpsertOne) AddVideoRateMultiplier(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddVideoRateMultiplier(v)
})
}
// UpdateVideoRateMultiplier sets the "video_rate_multiplier" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateVideoRateMultiplier() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateVideoRateMultiplier()
})
}
// SetVideoPrice480p sets the "video_price_480p" field.
func (u *GroupUpsertOne) SetVideoPrice480p(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetVideoPrice480p(v)
})
}
// AddVideoPrice480p adds v to the "video_price_480p" field.
func (u *GroupUpsertOne) AddVideoPrice480p(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddVideoPrice480p(v)
})
}
// UpdateVideoPrice480p sets the "video_price_480p" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateVideoPrice480p() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateVideoPrice480p()
})
}
// ClearVideoPrice480p clears the value of the "video_price_480p" field.
func (u *GroupUpsertOne) ClearVideoPrice480p() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearVideoPrice480p()
})
}
// SetVideoPrice720p sets the "video_price_720p" field.
func (u *GroupUpsertOne) SetVideoPrice720p(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetVideoPrice720p(v)
})
}
// AddVideoPrice720p adds v to the "video_price_720p" field.
func (u *GroupUpsertOne) AddVideoPrice720p(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddVideoPrice720p(v)
})
}
// UpdateVideoPrice720p sets the "video_price_720p" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateVideoPrice720p() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateVideoPrice720p()
})
}
// ClearVideoPrice720p clears the value of the "video_price_720p" field.
func (u *GroupUpsertOne) ClearVideoPrice720p() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearVideoPrice720p()
})
}
// SetVideoPrice1080p sets the "video_price_1080p" field.
func (u *GroupUpsertOne) SetVideoPrice1080p(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetVideoPrice1080p(v)
})
}
// AddVideoPrice1080p adds v to the "video_price_1080p" field.
func (u *GroupUpsertOne) AddVideoPrice1080p(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddVideoPrice1080p(v)
})
}
// UpdateVideoPrice1080p sets the "video_price_1080p" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateVideoPrice1080p() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateVideoPrice1080p()
})
}
// ClearVideoPrice1080p clears the value of the "video_price_1080p" field.
func (u *GroupUpsertOne) ClearVideoPrice1080p() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearVideoPrice1080p()
})
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (u *GroupUpsertOne) SetClaudeCodeOnly(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
@@ -3507,6 +3832,125 @@ func (u *GroupUpsertBulk) UpdateBatchImageHoldMultiplier() *GroupUpsertBulk {
})
}
// SetVideoRateIndependent sets the "video_rate_independent" field.
func (u *GroupUpsertBulk) SetVideoRateIndependent(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetVideoRateIndependent(v)
})
}
// UpdateVideoRateIndependent sets the "video_rate_independent" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateVideoRateIndependent() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateVideoRateIndependent()
})
}
// SetVideoRateMultiplier sets the "video_rate_multiplier" field.
func (u *GroupUpsertBulk) SetVideoRateMultiplier(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetVideoRateMultiplier(v)
})
}
// AddVideoRateMultiplier adds v to the "video_rate_multiplier" field.
func (u *GroupUpsertBulk) AddVideoRateMultiplier(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddVideoRateMultiplier(v)
})
}
// UpdateVideoRateMultiplier sets the "video_rate_multiplier" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateVideoRateMultiplier() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateVideoRateMultiplier()
})
}
// SetVideoPrice480p sets the "video_price_480p" field.
func (u *GroupUpsertBulk) SetVideoPrice480p(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetVideoPrice480p(v)
})
}
// AddVideoPrice480p adds v to the "video_price_480p" field.
func (u *GroupUpsertBulk) AddVideoPrice480p(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddVideoPrice480p(v)
})
}
// UpdateVideoPrice480p sets the "video_price_480p" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateVideoPrice480p() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateVideoPrice480p()
})
}
// ClearVideoPrice480p clears the value of the "video_price_480p" field.
func (u *GroupUpsertBulk) ClearVideoPrice480p() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearVideoPrice480p()
})
}
// SetVideoPrice720p sets the "video_price_720p" field.
func (u *GroupUpsertBulk) SetVideoPrice720p(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetVideoPrice720p(v)
})
}
// AddVideoPrice720p adds v to the "video_price_720p" field.
func (u *GroupUpsertBulk) AddVideoPrice720p(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddVideoPrice720p(v)
})
}
// UpdateVideoPrice720p sets the "video_price_720p" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateVideoPrice720p() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateVideoPrice720p()
})
}
// ClearVideoPrice720p clears the value of the "video_price_720p" field.
func (u *GroupUpsertBulk) ClearVideoPrice720p() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearVideoPrice720p()
})
}
// SetVideoPrice1080p sets the "video_price_1080p" field.
func (u *GroupUpsertBulk) SetVideoPrice1080p(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetVideoPrice1080p(v)
})
}
// AddVideoPrice1080p adds v to the "video_price_1080p" field.
func (u *GroupUpsertBulk) AddVideoPrice1080p(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddVideoPrice1080p(v)
})
}
// UpdateVideoPrice1080p sets the "video_price_1080p" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateVideoPrice1080p() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateVideoPrice1080p()
})
}
// ClearVideoPrice1080p clears the value of the "video_price_1080p" field.
func (u *GroupUpsertBulk) ClearVideoPrice1080p() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearVideoPrice1080p()
})
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (u *GroupUpsertBulk) SetClaudeCodeOnly(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
+304
View File
@@ -524,6 +524,122 @@ func (_u *GroupUpdate) AddBatchImageHoldMultiplier(v float64) *GroupUpdate {
return _u
}
// SetVideoRateIndependent sets the "video_rate_independent" field.
func (_u *GroupUpdate) SetVideoRateIndependent(v bool) *GroupUpdate {
_u.mutation.SetVideoRateIndependent(v)
return _u
}
// SetNillableVideoRateIndependent sets the "video_rate_independent" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableVideoRateIndependent(v *bool) *GroupUpdate {
if v != nil {
_u.SetVideoRateIndependent(*v)
}
return _u
}
// SetVideoRateMultiplier sets the "video_rate_multiplier" field.
func (_u *GroupUpdate) SetVideoRateMultiplier(v float64) *GroupUpdate {
_u.mutation.ResetVideoRateMultiplier()
_u.mutation.SetVideoRateMultiplier(v)
return _u
}
// SetNillableVideoRateMultiplier sets the "video_rate_multiplier" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableVideoRateMultiplier(v *float64) *GroupUpdate {
if v != nil {
_u.SetVideoRateMultiplier(*v)
}
return _u
}
// AddVideoRateMultiplier adds value to the "video_rate_multiplier" field.
func (_u *GroupUpdate) AddVideoRateMultiplier(v float64) *GroupUpdate {
_u.mutation.AddVideoRateMultiplier(v)
return _u
}
// SetVideoPrice480p sets the "video_price_480p" field.
func (_u *GroupUpdate) SetVideoPrice480p(v float64) *GroupUpdate {
_u.mutation.ResetVideoPrice480p()
_u.mutation.SetVideoPrice480p(v)
return _u
}
// SetNillableVideoPrice480p sets the "video_price_480p" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableVideoPrice480p(v *float64) *GroupUpdate {
if v != nil {
_u.SetVideoPrice480p(*v)
}
return _u
}
// AddVideoPrice480p adds value to the "video_price_480p" field.
func (_u *GroupUpdate) AddVideoPrice480p(v float64) *GroupUpdate {
_u.mutation.AddVideoPrice480p(v)
return _u
}
// ClearVideoPrice480p clears the value of the "video_price_480p" field.
func (_u *GroupUpdate) ClearVideoPrice480p() *GroupUpdate {
_u.mutation.ClearVideoPrice480p()
return _u
}
// SetVideoPrice720p sets the "video_price_720p" field.
func (_u *GroupUpdate) SetVideoPrice720p(v float64) *GroupUpdate {
_u.mutation.ResetVideoPrice720p()
_u.mutation.SetVideoPrice720p(v)
return _u
}
// SetNillableVideoPrice720p sets the "video_price_720p" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableVideoPrice720p(v *float64) *GroupUpdate {
if v != nil {
_u.SetVideoPrice720p(*v)
}
return _u
}
// AddVideoPrice720p adds value to the "video_price_720p" field.
func (_u *GroupUpdate) AddVideoPrice720p(v float64) *GroupUpdate {
_u.mutation.AddVideoPrice720p(v)
return _u
}
// ClearVideoPrice720p clears the value of the "video_price_720p" field.
func (_u *GroupUpdate) ClearVideoPrice720p() *GroupUpdate {
_u.mutation.ClearVideoPrice720p()
return _u
}
// SetVideoPrice1080p sets the "video_price_1080p" field.
func (_u *GroupUpdate) SetVideoPrice1080p(v float64) *GroupUpdate {
_u.mutation.ResetVideoPrice1080p()
_u.mutation.SetVideoPrice1080p(v)
return _u
}
// SetNillableVideoPrice1080p sets the "video_price_1080p" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableVideoPrice1080p(v *float64) *GroupUpdate {
if v != nil {
_u.SetVideoPrice1080p(*v)
}
return _u
}
// AddVideoPrice1080p adds value to the "video_price_1080p" field.
func (_u *GroupUpdate) AddVideoPrice1080p(v float64) *GroupUpdate {
_u.mutation.AddVideoPrice1080p(v)
return _u
}
// ClearVideoPrice1080p clears the value of the "video_price_1080p" field.
func (_u *GroupUpdate) ClearVideoPrice1080p() *GroupUpdate {
_u.mutation.ClearVideoPrice1080p()
return _u
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (_u *GroupUpdate) SetClaudeCodeOnly(v bool) *GroupUpdate {
_u.mutation.SetClaudeCodeOnly(v)
@@ -1223,6 +1339,42 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.AddedBatchImageHoldMultiplier(); ok {
_spec.AddField(group.FieldBatchImageHoldMultiplier, field.TypeFloat64, value)
}
if value, ok := _u.mutation.VideoRateIndependent(); ok {
_spec.SetField(group.FieldVideoRateIndependent, field.TypeBool, value)
}
if value, ok := _u.mutation.VideoRateMultiplier(); ok {
_spec.SetField(group.FieldVideoRateMultiplier, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedVideoRateMultiplier(); ok {
_spec.AddField(group.FieldVideoRateMultiplier, field.TypeFloat64, value)
}
if value, ok := _u.mutation.VideoPrice480p(); ok {
_spec.SetField(group.FieldVideoPrice480p, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedVideoPrice480p(); ok {
_spec.AddField(group.FieldVideoPrice480p, field.TypeFloat64, value)
}
if _u.mutation.VideoPrice480pCleared() {
_spec.ClearField(group.FieldVideoPrice480p, field.TypeFloat64)
}
if value, ok := _u.mutation.VideoPrice720p(); ok {
_spec.SetField(group.FieldVideoPrice720p, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedVideoPrice720p(); ok {
_spec.AddField(group.FieldVideoPrice720p, field.TypeFloat64, value)
}
if _u.mutation.VideoPrice720pCleared() {
_spec.ClearField(group.FieldVideoPrice720p, field.TypeFloat64)
}
if value, ok := _u.mutation.VideoPrice1080p(); ok {
_spec.SetField(group.FieldVideoPrice1080p, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedVideoPrice1080p(); ok {
_spec.AddField(group.FieldVideoPrice1080p, field.TypeFloat64, value)
}
if _u.mutation.VideoPrice1080pCleared() {
_spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64)
}
if value, ok := _u.mutation.ClaudeCodeOnly(); ok {
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
}
@@ -2096,6 +2248,122 @@ func (_u *GroupUpdateOne) AddBatchImageHoldMultiplier(v float64) *GroupUpdateOne
return _u
}
// SetVideoRateIndependent sets the "video_rate_independent" field.
func (_u *GroupUpdateOne) SetVideoRateIndependent(v bool) *GroupUpdateOne {
_u.mutation.SetVideoRateIndependent(v)
return _u
}
// SetNillableVideoRateIndependent sets the "video_rate_independent" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableVideoRateIndependent(v *bool) *GroupUpdateOne {
if v != nil {
_u.SetVideoRateIndependent(*v)
}
return _u
}
// SetVideoRateMultiplier sets the "video_rate_multiplier" field.
func (_u *GroupUpdateOne) SetVideoRateMultiplier(v float64) *GroupUpdateOne {
_u.mutation.ResetVideoRateMultiplier()
_u.mutation.SetVideoRateMultiplier(v)
return _u
}
// SetNillableVideoRateMultiplier sets the "video_rate_multiplier" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableVideoRateMultiplier(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetVideoRateMultiplier(*v)
}
return _u
}
// AddVideoRateMultiplier adds value to the "video_rate_multiplier" field.
func (_u *GroupUpdateOne) AddVideoRateMultiplier(v float64) *GroupUpdateOne {
_u.mutation.AddVideoRateMultiplier(v)
return _u
}
// SetVideoPrice480p sets the "video_price_480p" field.
func (_u *GroupUpdateOne) SetVideoPrice480p(v float64) *GroupUpdateOne {
_u.mutation.ResetVideoPrice480p()
_u.mutation.SetVideoPrice480p(v)
return _u
}
// SetNillableVideoPrice480p sets the "video_price_480p" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableVideoPrice480p(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetVideoPrice480p(*v)
}
return _u
}
// AddVideoPrice480p adds value to the "video_price_480p" field.
func (_u *GroupUpdateOne) AddVideoPrice480p(v float64) *GroupUpdateOne {
_u.mutation.AddVideoPrice480p(v)
return _u
}
// ClearVideoPrice480p clears the value of the "video_price_480p" field.
func (_u *GroupUpdateOne) ClearVideoPrice480p() *GroupUpdateOne {
_u.mutation.ClearVideoPrice480p()
return _u
}
// SetVideoPrice720p sets the "video_price_720p" field.
func (_u *GroupUpdateOne) SetVideoPrice720p(v float64) *GroupUpdateOne {
_u.mutation.ResetVideoPrice720p()
_u.mutation.SetVideoPrice720p(v)
return _u
}
// SetNillableVideoPrice720p sets the "video_price_720p" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableVideoPrice720p(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetVideoPrice720p(*v)
}
return _u
}
// AddVideoPrice720p adds value to the "video_price_720p" field.
func (_u *GroupUpdateOne) AddVideoPrice720p(v float64) *GroupUpdateOne {
_u.mutation.AddVideoPrice720p(v)
return _u
}
// ClearVideoPrice720p clears the value of the "video_price_720p" field.
func (_u *GroupUpdateOne) ClearVideoPrice720p() *GroupUpdateOne {
_u.mutation.ClearVideoPrice720p()
return _u
}
// SetVideoPrice1080p sets the "video_price_1080p" field.
func (_u *GroupUpdateOne) SetVideoPrice1080p(v float64) *GroupUpdateOne {
_u.mutation.ResetVideoPrice1080p()
_u.mutation.SetVideoPrice1080p(v)
return _u
}
// SetNillableVideoPrice1080p sets the "video_price_1080p" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableVideoPrice1080p(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetVideoPrice1080p(*v)
}
return _u
}
// AddVideoPrice1080p adds value to the "video_price_1080p" field.
func (_u *GroupUpdateOne) AddVideoPrice1080p(v float64) *GroupUpdateOne {
_u.mutation.AddVideoPrice1080p(v)
return _u
}
// ClearVideoPrice1080p clears the value of the "video_price_1080p" field.
func (_u *GroupUpdateOne) ClearVideoPrice1080p() *GroupUpdateOne {
_u.mutation.ClearVideoPrice1080p()
return _u
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (_u *GroupUpdateOne) SetClaudeCodeOnly(v bool) *GroupUpdateOne {
_u.mutation.SetClaudeCodeOnly(v)
@@ -2825,6 +3093,42 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if value, ok := _u.mutation.AddedBatchImageHoldMultiplier(); ok {
_spec.AddField(group.FieldBatchImageHoldMultiplier, field.TypeFloat64, value)
}
if value, ok := _u.mutation.VideoRateIndependent(); ok {
_spec.SetField(group.FieldVideoRateIndependent, field.TypeBool, value)
}
if value, ok := _u.mutation.VideoRateMultiplier(); ok {
_spec.SetField(group.FieldVideoRateMultiplier, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedVideoRateMultiplier(); ok {
_spec.AddField(group.FieldVideoRateMultiplier, field.TypeFloat64, value)
}
if value, ok := _u.mutation.VideoPrice480p(); ok {
_spec.SetField(group.FieldVideoPrice480p, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedVideoPrice480p(); ok {
_spec.AddField(group.FieldVideoPrice480p, field.TypeFloat64, value)
}
if _u.mutation.VideoPrice480pCleared() {
_spec.ClearField(group.FieldVideoPrice480p, field.TypeFloat64)
}
if value, ok := _u.mutation.VideoPrice720p(); ok {
_spec.SetField(group.FieldVideoPrice720p, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedVideoPrice720p(); ok {
_spec.AddField(group.FieldVideoPrice720p, field.TypeFloat64, value)
}
if _u.mutation.VideoPrice720pCleared() {
_spec.ClearField(group.FieldVideoPrice720p, field.TypeFloat64)
}
if value, ok := _u.mutation.VideoPrice1080p(); ok {
_spec.SetField(group.FieldVideoPrice1080p, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedVideoPrice1080p(); ok {
_spec.AddField(group.FieldVideoPrice1080p, field.TypeFloat64, value)
}
if _u.mutation.VideoPrice1080pCleared() {
_spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64)
}
if value, ok := _u.mutation.ClaudeCodeOnly(); ok {
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
}
+23 -15
View File
@@ -860,6 +860,11 @@ var (
{Name: "image_price_4k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "batch_image_discount_multiplier", Type: field.TypeFloat64, Default: 0.5, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
{Name: "batch_image_hold_multiplier", Type: field.TypeFloat64, Default: 0.6, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
{Name: "video_rate_independent", Type: field.TypeBool, Default: false},
{Name: "video_rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
{Name: "video_price_480p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "video_price_720p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "video_price_1080p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "claude_code_only", Type: field.TypeBool, Default: false},
{Name: "fallback_group_id", Type: field.TypeInt64, Nullable: true},
{Name: "fallback_group_id_on_invalid_request", Type: field.TypeInt64, Nullable: true},
@@ -910,7 +915,7 @@ var (
{
Name: "group_sort_order",
Unique: false,
Columns: []*schema.Column{GroupsColumns[35]},
Columns: []*schema.Column{GroupsColumns[40]},
},
},
}
@@ -1567,6 +1572,9 @@ var (
{Name: "image_output_size", Type: field.TypeString, Nullable: true, Size: 32},
{Name: "image_size_source", Type: field.TypeString, Nullable: true, Size: 16},
{Name: "image_size_breakdown", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "video_count", Type: field.TypeInt, Default: 0},
{Name: "video_resolution", Type: field.TypeString, Nullable: true, Size: 10},
{Name: "video_duration_seconds", Type: field.TypeInt, Nullable: true},
{Name: "cache_ttl_overridden", Type: field.TypeBool, Default: false},
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "api_key_id", Type: field.TypeInt64},
@@ -1583,31 +1591,31 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "usage_logs_api_keys_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[37]},
Columns: []*schema.Column{UsageLogsColumns[40]},
RefColumns: []*schema.Column{APIKeysColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_accounts_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[38]},
Columns: []*schema.Column{UsageLogsColumns[41]},
RefColumns: []*schema.Column{AccountsColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_groups_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[39]},
Columns: []*schema.Column{UsageLogsColumns[42]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "usage_logs_users_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[40]},
Columns: []*schema.Column{UsageLogsColumns[43]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_user_subscriptions_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[41]},
Columns: []*schema.Column{UsageLogsColumns[44]},
RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
OnDelete: schema.SetNull,
},
@@ -1616,32 +1624,32 @@ var (
{
Name: "usagelog_user_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[40]},
Columns: []*schema.Column{UsageLogsColumns[43]},
},
{
Name: "usagelog_api_key_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[37]},
Columns: []*schema.Column{UsageLogsColumns[40]},
},
{
Name: "usagelog_account_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[38]},
Columns: []*schema.Column{UsageLogsColumns[41]},
},
{
Name: "usagelog_group_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[39]},
Columns: []*schema.Column{UsageLogsColumns[42]},
},
{
Name: "usagelog_subscription_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[41]},
Columns: []*schema.Column{UsageLogsColumns[44]},
},
{
Name: "usagelog_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[36]},
Columns: []*schema.Column{UsageLogsColumns[39]},
},
{
Name: "usagelog_model",
@@ -1661,17 +1669,17 @@ var (
{
Name: "usagelog_user_id_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[40], UsageLogsColumns[36]},
Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[39]},
},
{
Name: "usagelog_api_key_id_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[37], UsageLogsColumns[36]},
Columns: []*schema.Column{UsageLogsColumns[40], UsageLogsColumns[39]},
},
{
Name: "usagelog_group_id_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[39], UsageLogsColumns[36]},
Columns: []*schema.Column{UsageLogsColumns[42], UsageLogsColumns[39]},
},
},
}
+731 -2
View File
@@ -20833,6 +20833,15 @@ type GroupMutation struct {
addbatch_image_discount_multiplier *float64
batch_image_hold_multiplier *float64
addbatch_image_hold_multiplier *float64
video_rate_independent *bool
video_rate_multiplier *float64
addvideo_rate_multiplier *float64
video_price_480p *float64
addvideo_price_480p *float64
video_price_720p *float64
addvideo_price_720p *float64
video_price_1080p *float64
addvideo_price_1080p *float64
claude_code_only *bool
fallback_group_id *int64
addfallback_group_id *int64
@@ -22297,6 +22306,308 @@ func (m *GroupMutation) ResetBatchImageHoldMultiplier() {
m.addbatch_image_hold_multiplier = nil
}
// SetVideoRateIndependent sets the "video_rate_independent" field.
func (m *GroupMutation) SetVideoRateIndependent(b bool) {
m.video_rate_independent = &b
}
// VideoRateIndependent returns the value of the "video_rate_independent" field in the mutation.
func (m *GroupMutation) VideoRateIndependent() (r bool, exists bool) {
v := m.video_rate_independent
if v == nil {
return
}
return *v, true
}
// OldVideoRateIndependent returns the old "video_rate_independent" 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) OldVideoRateIndependent(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldVideoRateIndependent is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldVideoRateIndependent requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldVideoRateIndependent: %w", err)
}
return oldValue.VideoRateIndependent, nil
}
// ResetVideoRateIndependent resets all changes to the "video_rate_independent" field.
func (m *GroupMutation) ResetVideoRateIndependent() {
m.video_rate_independent = nil
}
// SetVideoRateMultiplier sets the "video_rate_multiplier" field.
func (m *GroupMutation) SetVideoRateMultiplier(f float64) {
m.video_rate_multiplier = &f
m.addvideo_rate_multiplier = nil
}
// VideoRateMultiplier returns the value of the "video_rate_multiplier" field in the mutation.
func (m *GroupMutation) VideoRateMultiplier() (r float64, exists bool) {
v := m.video_rate_multiplier
if v == nil {
return
}
return *v, true
}
// OldVideoRateMultiplier returns the old "video_rate_multiplier" 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) OldVideoRateMultiplier(ctx context.Context) (v float64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldVideoRateMultiplier is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldVideoRateMultiplier requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldVideoRateMultiplier: %w", err)
}
return oldValue.VideoRateMultiplier, nil
}
// AddVideoRateMultiplier adds f to the "video_rate_multiplier" field.
func (m *GroupMutation) AddVideoRateMultiplier(f float64) {
if m.addvideo_rate_multiplier != nil {
*m.addvideo_rate_multiplier += f
} else {
m.addvideo_rate_multiplier = &f
}
}
// AddedVideoRateMultiplier returns the value that was added to the "video_rate_multiplier" field in this mutation.
func (m *GroupMutation) AddedVideoRateMultiplier() (r float64, exists bool) {
v := m.addvideo_rate_multiplier
if v == nil {
return
}
return *v, true
}
// ResetVideoRateMultiplier resets all changes to the "video_rate_multiplier" field.
func (m *GroupMutation) ResetVideoRateMultiplier() {
m.video_rate_multiplier = nil
m.addvideo_rate_multiplier = nil
}
// SetVideoPrice480p sets the "video_price_480p" field.
func (m *GroupMutation) SetVideoPrice480p(f float64) {
m.video_price_480p = &f
m.addvideo_price_480p = nil
}
// VideoPrice480p returns the value of the "video_price_480p" field in the mutation.
func (m *GroupMutation) VideoPrice480p() (r float64, exists bool) {
v := m.video_price_480p
if v == nil {
return
}
return *v, true
}
// OldVideoPrice480p returns the old "video_price_480p" 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) OldVideoPrice480p(ctx context.Context) (v *float64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldVideoPrice480p is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldVideoPrice480p requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldVideoPrice480p: %w", err)
}
return oldValue.VideoPrice480p, nil
}
// AddVideoPrice480p adds f to the "video_price_480p" field.
func (m *GroupMutation) AddVideoPrice480p(f float64) {
if m.addvideo_price_480p != nil {
*m.addvideo_price_480p += f
} else {
m.addvideo_price_480p = &f
}
}
// AddedVideoPrice480p returns the value that was added to the "video_price_480p" field in this mutation.
func (m *GroupMutation) AddedVideoPrice480p() (r float64, exists bool) {
v := m.addvideo_price_480p
if v == nil {
return
}
return *v, true
}
// ClearVideoPrice480p clears the value of the "video_price_480p" field.
func (m *GroupMutation) ClearVideoPrice480p() {
m.video_price_480p = nil
m.addvideo_price_480p = nil
m.clearedFields[group.FieldVideoPrice480p] = struct{}{}
}
// VideoPrice480pCleared returns if the "video_price_480p" field was cleared in this mutation.
func (m *GroupMutation) VideoPrice480pCleared() bool {
_, ok := m.clearedFields[group.FieldVideoPrice480p]
return ok
}
// ResetVideoPrice480p resets all changes to the "video_price_480p" field.
func (m *GroupMutation) ResetVideoPrice480p() {
m.video_price_480p = nil
m.addvideo_price_480p = nil
delete(m.clearedFields, group.FieldVideoPrice480p)
}
// SetVideoPrice720p sets the "video_price_720p" field.
func (m *GroupMutation) SetVideoPrice720p(f float64) {
m.video_price_720p = &f
m.addvideo_price_720p = nil
}
// VideoPrice720p returns the value of the "video_price_720p" field in the mutation.
func (m *GroupMutation) VideoPrice720p() (r float64, exists bool) {
v := m.video_price_720p
if v == nil {
return
}
return *v, true
}
// OldVideoPrice720p returns the old "video_price_720p" 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) OldVideoPrice720p(ctx context.Context) (v *float64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldVideoPrice720p is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldVideoPrice720p requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldVideoPrice720p: %w", err)
}
return oldValue.VideoPrice720p, nil
}
// AddVideoPrice720p adds f to the "video_price_720p" field.
func (m *GroupMutation) AddVideoPrice720p(f float64) {
if m.addvideo_price_720p != nil {
*m.addvideo_price_720p += f
} else {
m.addvideo_price_720p = &f
}
}
// AddedVideoPrice720p returns the value that was added to the "video_price_720p" field in this mutation.
func (m *GroupMutation) AddedVideoPrice720p() (r float64, exists bool) {
v := m.addvideo_price_720p
if v == nil {
return
}
return *v, true
}
// ClearVideoPrice720p clears the value of the "video_price_720p" field.
func (m *GroupMutation) ClearVideoPrice720p() {
m.video_price_720p = nil
m.addvideo_price_720p = nil
m.clearedFields[group.FieldVideoPrice720p] = struct{}{}
}
// VideoPrice720pCleared returns if the "video_price_720p" field was cleared in this mutation.
func (m *GroupMutation) VideoPrice720pCleared() bool {
_, ok := m.clearedFields[group.FieldVideoPrice720p]
return ok
}
// ResetVideoPrice720p resets all changes to the "video_price_720p" field.
func (m *GroupMutation) ResetVideoPrice720p() {
m.video_price_720p = nil
m.addvideo_price_720p = nil
delete(m.clearedFields, group.FieldVideoPrice720p)
}
// SetVideoPrice1080p sets the "video_price_1080p" field.
func (m *GroupMutation) SetVideoPrice1080p(f float64) {
m.video_price_1080p = &f
m.addvideo_price_1080p = nil
}
// VideoPrice1080p returns the value of the "video_price_1080p" field in the mutation.
func (m *GroupMutation) VideoPrice1080p() (r float64, exists bool) {
v := m.video_price_1080p
if v == nil {
return
}
return *v, true
}
// OldVideoPrice1080p returns the old "video_price_1080p" 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) OldVideoPrice1080p(ctx context.Context) (v *float64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldVideoPrice1080p is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldVideoPrice1080p requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldVideoPrice1080p: %w", err)
}
return oldValue.VideoPrice1080p, nil
}
// AddVideoPrice1080p adds f to the "video_price_1080p" field.
func (m *GroupMutation) AddVideoPrice1080p(f float64) {
if m.addvideo_price_1080p != nil {
*m.addvideo_price_1080p += f
} else {
m.addvideo_price_1080p = &f
}
}
// AddedVideoPrice1080p returns the value that was added to the "video_price_1080p" field in this mutation.
func (m *GroupMutation) AddedVideoPrice1080p() (r float64, exists bool) {
v := m.addvideo_price_1080p
if v == nil {
return
}
return *v, true
}
// ClearVideoPrice1080p clears the value of the "video_price_1080p" field.
func (m *GroupMutation) ClearVideoPrice1080p() {
m.video_price_1080p = nil
m.addvideo_price_1080p = nil
m.clearedFields[group.FieldVideoPrice1080p] = struct{}{}
}
// VideoPrice1080pCleared returns if the "video_price_1080p" field was cleared in this mutation.
func (m *GroupMutation) VideoPrice1080pCleared() bool {
_, ok := m.clearedFields[group.FieldVideoPrice1080p]
return ok
}
// ResetVideoPrice1080p resets all changes to the "video_price_1080p" field.
func (m *GroupMutation) ResetVideoPrice1080p() {
m.video_price_1080p = nil
m.addvideo_price_1080p = nil
delete(m.clearedFields, group.FieldVideoPrice1080p)
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (m *GroupMutation) SetClaudeCodeOnly(b bool) {
m.claude_code_only = &b
@@ -23331,7 +23642,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, 42)
fields := make([]string, 0, 47)
if m.created_at != nil {
fields = append(fields, group.FieldCreatedAt)
}
@@ -23413,6 +23724,21 @@ func (m *GroupMutation) Fields() []string {
if m.batch_image_hold_multiplier != nil {
fields = append(fields, group.FieldBatchImageHoldMultiplier)
}
if m.video_rate_independent != nil {
fields = append(fields, group.FieldVideoRateIndependent)
}
if m.video_rate_multiplier != nil {
fields = append(fields, group.FieldVideoRateMultiplier)
}
if m.video_price_480p != nil {
fields = append(fields, group.FieldVideoPrice480p)
}
if m.video_price_720p != nil {
fields = append(fields, group.FieldVideoPrice720p)
}
if m.video_price_1080p != nil {
fields = append(fields, group.FieldVideoPrice1080p)
}
if m.claude_code_only != nil {
fields = append(fields, group.FieldClaudeCodeOnly)
}
@@ -23520,6 +23846,16 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
return m.BatchImageDiscountMultiplier()
case group.FieldBatchImageHoldMultiplier:
return m.BatchImageHoldMultiplier()
case group.FieldVideoRateIndependent:
return m.VideoRateIndependent()
case group.FieldVideoRateMultiplier:
return m.VideoRateMultiplier()
case group.FieldVideoPrice480p:
return m.VideoPrice480p()
case group.FieldVideoPrice720p:
return m.VideoPrice720p()
case group.FieldVideoPrice1080p:
return m.VideoPrice1080p()
case group.FieldClaudeCodeOnly:
return m.ClaudeCodeOnly()
case group.FieldFallbackGroupID:
@@ -23613,6 +23949,16 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldBatchImageDiscountMultiplier(ctx)
case group.FieldBatchImageHoldMultiplier:
return m.OldBatchImageHoldMultiplier(ctx)
case group.FieldVideoRateIndependent:
return m.OldVideoRateIndependent(ctx)
case group.FieldVideoRateMultiplier:
return m.OldVideoRateMultiplier(ctx)
case group.FieldVideoPrice480p:
return m.OldVideoPrice480p(ctx)
case group.FieldVideoPrice720p:
return m.OldVideoPrice720p(ctx)
case group.FieldVideoPrice1080p:
return m.OldVideoPrice1080p(ctx)
case group.FieldClaudeCodeOnly:
return m.OldClaudeCodeOnly(ctx)
case group.FieldFallbackGroupID:
@@ -23841,6 +24187,41 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetBatchImageHoldMultiplier(v)
return nil
case group.FieldVideoRateIndependent:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetVideoRateIndependent(v)
return nil
case group.FieldVideoRateMultiplier:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetVideoRateMultiplier(v)
return nil
case group.FieldVideoPrice480p:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetVideoPrice480p(v)
return nil
case group.FieldVideoPrice720p:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetVideoPrice720p(v)
return nil
case group.FieldVideoPrice1080p:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetVideoPrice1080p(v)
return nil
case group.FieldClaudeCodeOnly:
v, ok := value.(bool)
if !ok {
@@ -23990,6 +24371,18 @@ func (m *GroupMutation) AddedFields() []string {
if m.addbatch_image_hold_multiplier != nil {
fields = append(fields, group.FieldBatchImageHoldMultiplier)
}
if m.addvideo_rate_multiplier != nil {
fields = append(fields, group.FieldVideoRateMultiplier)
}
if m.addvideo_price_480p != nil {
fields = append(fields, group.FieldVideoPrice480p)
}
if m.addvideo_price_720p != nil {
fields = append(fields, group.FieldVideoPrice720p)
}
if m.addvideo_price_1080p != nil {
fields = append(fields, group.FieldVideoPrice1080p)
}
if m.addfallback_group_id != nil {
fields = append(fields, group.FieldFallbackGroupID)
}
@@ -24034,6 +24427,14 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) {
return m.AddedBatchImageDiscountMultiplier()
case group.FieldBatchImageHoldMultiplier:
return m.AddedBatchImageHoldMultiplier()
case group.FieldVideoRateMultiplier:
return m.AddedVideoRateMultiplier()
case group.FieldVideoPrice480p:
return m.AddedVideoPrice480p()
case group.FieldVideoPrice720p:
return m.AddedVideoPrice720p()
case group.FieldVideoPrice1080p:
return m.AddedVideoPrice1080p()
case group.FieldFallbackGroupID:
return m.AddedFallbackGroupID()
case group.FieldFallbackGroupIDOnInvalidRequest:
@@ -24135,6 +24536,34 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error {
}
m.AddBatchImageHoldMultiplier(v)
return nil
case group.FieldVideoRateMultiplier:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddVideoRateMultiplier(v)
return nil
case group.FieldVideoPrice480p:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddVideoPrice480p(v)
return nil
case group.FieldVideoPrice720p:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddVideoPrice720p(v)
return nil
case group.FieldVideoPrice1080p:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddVideoPrice1080p(v)
return nil
case group.FieldFallbackGroupID:
v, ok := value.(int64)
if !ok {
@@ -24195,6 +24624,15 @@ func (m *GroupMutation) ClearedFields() []string {
if m.FieldCleared(group.FieldImagePrice4k) {
fields = append(fields, group.FieldImagePrice4k)
}
if m.FieldCleared(group.FieldVideoPrice480p) {
fields = append(fields, group.FieldVideoPrice480p)
}
if m.FieldCleared(group.FieldVideoPrice720p) {
fields = append(fields, group.FieldVideoPrice720p)
}
if m.FieldCleared(group.FieldVideoPrice1080p) {
fields = append(fields, group.FieldVideoPrice1080p)
}
if m.FieldCleared(group.FieldFallbackGroupID) {
fields = append(fields, group.FieldFallbackGroupID)
}
@@ -24242,6 +24680,15 @@ func (m *GroupMutation) ClearField(name string) error {
case group.FieldImagePrice4k:
m.ClearImagePrice4k()
return nil
case group.FieldVideoPrice480p:
m.ClearVideoPrice480p()
return nil
case group.FieldVideoPrice720p:
m.ClearVideoPrice720p()
return nil
case group.FieldVideoPrice1080p:
m.ClearVideoPrice1080p()
return nil
case group.FieldFallbackGroupID:
m.ClearFallbackGroupID()
return nil
@@ -24340,6 +24787,21 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldBatchImageHoldMultiplier:
m.ResetBatchImageHoldMultiplier()
return nil
case group.FieldVideoRateIndependent:
m.ResetVideoRateIndependent()
return nil
case group.FieldVideoRateMultiplier:
m.ResetVideoRateMultiplier()
return nil
case group.FieldVideoPrice480p:
m.ResetVideoPrice480p()
return nil
case group.FieldVideoPrice720p:
m.ResetVideoPrice720p()
return nil
case group.FieldVideoPrice1080p:
m.ResetVideoPrice1080p()
return nil
case group.FieldClaudeCodeOnly:
m.ResetClaudeCodeOnly()
return nil
@@ -41250,6 +41712,11 @@ type UsageLogMutation struct {
image_output_size *string
image_size_source *string
image_size_breakdown *map[string]int
video_count *int
addvideo_count *int
video_resolution *string
video_duration_seconds *int
addvideo_duration_seconds *int
cache_ttl_overridden *bool
created_at *time.Time
clearedFields map[string]struct{}
@@ -43388,6 +43855,181 @@ func (m *UsageLogMutation) ResetImageSizeBreakdown() {
delete(m.clearedFields, usagelog.FieldImageSizeBreakdown)
}
// SetVideoCount sets the "video_count" field.
func (m *UsageLogMutation) SetVideoCount(i int) {
m.video_count = &i
m.addvideo_count = nil
}
// VideoCount returns the value of the "video_count" field in the mutation.
func (m *UsageLogMutation) VideoCount() (r int, exists bool) {
v := m.video_count
if v == nil {
return
}
return *v, true
}
// OldVideoCount returns the old "video_count" field's value of the UsageLog entity.
// If the UsageLog 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 *UsageLogMutation) OldVideoCount(ctx context.Context) (v int, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldVideoCount is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldVideoCount requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldVideoCount: %w", err)
}
return oldValue.VideoCount, nil
}
// AddVideoCount adds i to the "video_count" field.
func (m *UsageLogMutation) AddVideoCount(i int) {
if m.addvideo_count != nil {
*m.addvideo_count += i
} else {
m.addvideo_count = &i
}
}
// AddedVideoCount returns the value that was added to the "video_count" field in this mutation.
func (m *UsageLogMutation) AddedVideoCount() (r int, exists bool) {
v := m.addvideo_count
if v == nil {
return
}
return *v, true
}
// ResetVideoCount resets all changes to the "video_count" field.
func (m *UsageLogMutation) ResetVideoCount() {
m.video_count = nil
m.addvideo_count = nil
}
// SetVideoResolution sets the "video_resolution" field.
func (m *UsageLogMutation) SetVideoResolution(s string) {
m.video_resolution = &s
}
// VideoResolution returns the value of the "video_resolution" field in the mutation.
func (m *UsageLogMutation) VideoResolution() (r string, exists bool) {
v := m.video_resolution
if v == nil {
return
}
return *v, true
}
// OldVideoResolution returns the old "video_resolution" field's value of the UsageLog entity.
// If the UsageLog 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 *UsageLogMutation) OldVideoResolution(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldVideoResolution is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldVideoResolution requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldVideoResolution: %w", err)
}
return oldValue.VideoResolution, nil
}
// ClearVideoResolution clears the value of the "video_resolution" field.
func (m *UsageLogMutation) ClearVideoResolution() {
m.video_resolution = nil
m.clearedFields[usagelog.FieldVideoResolution] = struct{}{}
}
// VideoResolutionCleared returns if the "video_resolution" field was cleared in this mutation.
func (m *UsageLogMutation) VideoResolutionCleared() bool {
_, ok := m.clearedFields[usagelog.FieldVideoResolution]
return ok
}
// ResetVideoResolution resets all changes to the "video_resolution" field.
func (m *UsageLogMutation) ResetVideoResolution() {
m.video_resolution = nil
delete(m.clearedFields, usagelog.FieldVideoResolution)
}
// SetVideoDurationSeconds sets the "video_duration_seconds" field.
func (m *UsageLogMutation) SetVideoDurationSeconds(i int) {
m.video_duration_seconds = &i
m.addvideo_duration_seconds = nil
}
// VideoDurationSeconds returns the value of the "video_duration_seconds" field in the mutation.
func (m *UsageLogMutation) VideoDurationSeconds() (r int, exists bool) {
v := m.video_duration_seconds
if v == nil {
return
}
return *v, true
}
// OldVideoDurationSeconds returns the old "video_duration_seconds" field's value of the UsageLog entity.
// If the UsageLog 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 *UsageLogMutation) OldVideoDurationSeconds(ctx context.Context) (v *int, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldVideoDurationSeconds is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldVideoDurationSeconds requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldVideoDurationSeconds: %w", err)
}
return oldValue.VideoDurationSeconds, nil
}
// AddVideoDurationSeconds adds i to the "video_duration_seconds" field.
func (m *UsageLogMutation) AddVideoDurationSeconds(i int) {
if m.addvideo_duration_seconds != nil {
*m.addvideo_duration_seconds += i
} else {
m.addvideo_duration_seconds = &i
}
}
// AddedVideoDurationSeconds returns the value that was added to the "video_duration_seconds" field in this mutation.
func (m *UsageLogMutation) AddedVideoDurationSeconds() (r int, exists bool) {
v := m.addvideo_duration_seconds
if v == nil {
return
}
return *v, true
}
// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field.
func (m *UsageLogMutation) ClearVideoDurationSeconds() {
m.video_duration_seconds = nil
m.addvideo_duration_seconds = nil
m.clearedFields[usagelog.FieldVideoDurationSeconds] = struct{}{}
}
// VideoDurationSecondsCleared returns if the "video_duration_seconds" field was cleared in this mutation.
func (m *UsageLogMutation) VideoDurationSecondsCleared() bool {
_, ok := m.clearedFields[usagelog.FieldVideoDurationSeconds]
return ok
}
// ResetVideoDurationSeconds resets all changes to the "video_duration_seconds" field.
func (m *UsageLogMutation) ResetVideoDurationSeconds() {
m.video_duration_seconds = nil
m.addvideo_duration_seconds = nil
delete(m.clearedFields, usagelog.FieldVideoDurationSeconds)
}
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
func (m *UsageLogMutation) SetCacheTTLOverridden(b bool) {
m.cache_ttl_overridden = &b
@@ -43629,7 +44271,7 @@ func (m *UsageLogMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *UsageLogMutation) Fields() []string {
fields := make([]string, 0, 41)
fields := make([]string, 0, 44)
if m.user != nil {
fields = append(fields, usagelog.FieldUserID)
}
@@ -43747,6 +44389,15 @@ func (m *UsageLogMutation) Fields() []string {
if m.image_size_breakdown != nil {
fields = append(fields, usagelog.FieldImageSizeBreakdown)
}
if m.video_count != nil {
fields = append(fields, usagelog.FieldVideoCount)
}
if m.video_resolution != nil {
fields = append(fields, usagelog.FieldVideoResolution)
}
if m.video_duration_seconds != nil {
fields = append(fields, usagelog.FieldVideoDurationSeconds)
}
if m.cache_ttl_overridden != nil {
fields = append(fields, usagelog.FieldCacheTTLOverridden)
}
@@ -43839,6 +44490,12 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) {
return m.ImageSizeSource()
case usagelog.FieldImageSizeBreakdown:
return m.ImageSizeBreakdown()
case usagelog.FieldVideoCount:
return m.VideoCount()
case usagelog.FieldVideoResolution:
return m.VideoResolution()
case usagelog.FieldVideoDurationSeconds:
return m.VideoDurationSeconds()
case usagelog.FieldCacheTTLOverridden:
return m.CacheTTLOverridden()
case usagelog.FieldCreatedAt:
@@ -43930,6 +44587,12 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value
return m.OldImageSizeSource(ctx)
case usagelog.FieldImageSizeBreakdown:
return m.OldImageSizeBreakdown(ctx)
case usagelog.FieldVideoCount:
return m.OldVideoCount(ctx)
case usagelog.FieldVideoResolution:
return m.OldVideoResolution(ctx)
case usagelog.FieldVideoDurationSeconds:
return m.OldVideoDurationSeconds(ctx)
case usagelog.FieldCacheTTLOverridden:
return m.OldCacheTTLOverridden(ctx)
case usagelog.FieldCreatedAt:
@@ -44216,6 +44879,27 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error {
}
m.SetImageSizeBreakdown(v)
return nil
case usagelog.FieldVideoCount:
v, ok := value.(int)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetVideoCount(v)
return nil
case usagelog.FieldVideoResolution:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetVideoResolution(v)
return nil
case usagelog.FieldVideoDurationSeconds:
v, ok := value.(int)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetVideoDurationSeconds(v)
return nil
case usagelog.FieldCacheTTLOverridden:
v, ok := value.(bool)
if !ok {
@@ -44295,6 +44979,12 @@ func (m *UsageLogMutation) AddedFields() []string {
if m.addimage_count != nil {
fields = append(fields, usagelog.FieldImageCount)
}
if m.addvideo_count != nil {
fields = append(fields, usagelog.FieldVideoCount)
}
if m.addvideo_duration_seconds != nil {
fields = append(fields, usagelog.FieldVideoDurationSeconds)
}
return fields
}
@@ -44341,6 +45031,10 @@ func (m *UsageLogMutation) AddedField(name string) (ent.Value, bool) {
return m.AddedFirstTokenMs()
case usagelog.FieldImageCount:
return m.AddedImageCount()
case usagelog.FieldVideoCount:
return m.AddedVideoCount()
case usagelog.FieldVideoDurationSeconds:
return m.AddedVideoDurationSeconds()
}
return nil, false
}
@@ -44483,6 +45177,20 @@ func (m *UsageLogMutation) AddField(name string, value ent.Value) error {
}
m.AddImageCount(v)
return nil
case usagelog.FieldVideoCount:
v, ok := value.(int)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddVideoCount(v)
return nil
case usagelog.FieldVideoDurationSeconds:
v, ok := value.(int)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddVideoDurationSeconds(v)
return nil
}
return fmt.Errorf("unknown UsageLog numeric field %s", name)
}
@@ -44545,6 +45253,12 @@ func (m *UsageLogMutation) ClearedFields() []string {
if m.FieldCleared(usagelog.FieldImageSizeBreakdown) {
fields = append(fields, usagelog.FieldImageSizeBreakdown)
}
if m.FieldCleared(usagelog.FieldVideoResolution) {
fields = append(fields, usagelog.FieldVideoResolution)
}
if m.FieldCleared(usagelog.FieldVideoDurationSeconds) {
fields = append(fields, usagelog.FieldVideoDurationSeconds)
}
return fields
}
@@ -44613,6 +45327,12 @@ func (m *UsageLogMutation) ClearField(name string) error {
case usagelog.FieldImageSizeBreakdown:
m.ClearImageSizeBreakdown()
return nil
case usagelog.FieldVideoResolution:
m.ClearVideoResolution()
return nil
case usagelog.FieldVideoDurationSeconds:
m.ClearVideoDurationSeconds()
return nil
}
return fmt.Errorf("unknown UsageLog nullable field %s", name)
}
@@ -44738,6 +45458,15 @@ func (m *UsageLogMutation) ResetField(name string) error {
case usagelog.FieldImageSizeBreakdown:
m.ResetImageSizeBreakdown()
return nil
case usagelog.FieldVideoCount:
m.ResetVideoCount()
return nil
case usagelog.FieldVideoResolution:
m.ResetVideoResolution()
return nil
case usagelog.FieldVideoDurationSeconds:
m.ResetVideoDurationSeconds()
return nil
case usagelog.FieldCacheTTLOverridden:
m.ResetCacheTTLOverridden()
return nil
+30 -14
View File
@@ -1035,54 +1035,62 @@ func init() {
groupDescBatchImageHoldMultiplier := groupFields[23].Descriptor()
// group.DefaultBatchImageHoldMultiplier holds the default value on creation for the batch_image_hold_multiplier field.
group.DefaultBatchImageHoldMultiplier = groupDescBatchImageHoldMultiplier.Default.(float64)
// groupDescVideoRateIndependent is the schema descriptor for video_rate_independent field.
groupDescVideoRateIndependent := groupFields[24].Descriptor()
// group.DefaultVideoRateIndependent holds the default value on creation for the video_rate_independent field.
group.DefaultVideoRateIndependent = groupDescVideoRateIndependent.Default.(bool)
// groupDescVideoRateMultiplier is the schema descriptor for video_rate_multiplier field.
groupDescVideoRateMultiplier := groupFields[25].Descriptor()
// group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field.
group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64)
// groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field.
groupDescClaudeCodeOnly := groupFields[24].Descriptor()
groupDescClaudeCodeOnly := groupFields[29].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[28].Descriptor()
groupDescModelRoutingEnabled := groupFields[33].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[29].Descriptor()
groupDescMcpXMLInject := groupFields[34].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[30].Descriptor()
groupDescSupportedModelScopes := groupFields[35].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[31].Descriptor()
groupDescSortOrder := groupFields[36].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[32].Descriptor()
groupDescAllowMessagesDispatch := groupFields[37].Descriptor()
// group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field.
group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool)
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
groupDescRequireOauthOnly := groupFields[33].Descriptor()
groupDescRequireOauthOnly := groupFields[38].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[34].Descriptor()
groupDescRequirePrivacySet := groupFields[39].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[35].Descriptor()
groupDescDefaultMappedModel := groupFields[40].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[36].Descriptor()
groupDescMessagesDispatchModelConfig := groupFields[41].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[37].Descriptor()
groupDescModelsListConfig := groupFields[42].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[38].Descriptor()
groupDescRpmLimit := groupFields[43].Descriptor()
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
@@ -1968,12 +1976,20 @@ func init() {
usagelogDescImageSizeSource := usagelogFields[37].Descriptor()
// usagelog.ImageSizeSourceValidator is a validator for the "image_size_source" field. It is called by the builders before save.
usagelog.ImageSizeSourceValidator = usagelogDescImageSizeSource.Validators[0].(func(string) error)
// usagelogDescVideoCount is the schema descriptor for video_count field.
usagelogDescVideoCount := usagelogFields[39].Descriptor()
// usagelog.DefaultVideoCount holds the default value on creation for the video_count field.
usagelog.DefaultVideoCount = usagelogDescVideoCount.Default.(int)
// usagelogDescVideoResolution is the schema descriptor for video_resolution field.
usagelogDescVideoResolution := usagelogFields[40].Descriptor()
// usagelog.VideoResolutionValidator is a validator for the "video_resolution" field. It is called by the builders before save.
usagelog.VideoResolutionValidator = usagelogDescVideoResolution.Validators[0].(func(string) error)
// usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field.
usagelogDescCacheTTLOverridden := usagelogFields[39].Descriptor()
usagelogDescCacheTTLOverridden := usagelogFields[42].Descriptor()
// usagelog.DefaultCacheTTLOverridden holds the default value on creation for the cache_ttl_overridden field.
usagelog.DefaultCacheTTLOverridden = usagelogDescCacheTTLOverridden.Default.(bool)
// usagelogDescCreatedAt is the schema descriptor for created_at field.
usagelogDescCreatedAt := usagelogFields[40].Descriptor()
usagelogDescCreatedAt := usagelogFields[43].Descriptor()
// usagelog.DefaultCreatedAt holds the default value on creation for the created_at field.
usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time)
userMixin := schema.User{}.Mixin()
+19
View File
@@ -123,6 +123,25 @@ func (Group) Fields() []ent.Field {
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
Default(0.6).
Comment("批量图片生成冻结价格比例,按普通生图原价乘以该比例冻结,结算后释放差额"),
field.Bool("video_rate_independent").
Default(false).
Comment("视频生成是否使用独立倍率;false 表示共享分组有效倍率"),
field.Float("video_rate_multiplier").
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
Default(1.0).
Comment("视频生成独立倍率,仅 video_rate_independent=true 时生效"),
field.Float("video_price_480p").
Optional().
Nillable().
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
field.Float("video_price_720p").
Optional().
Nillable().
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
field.Float("video_price_1080p").
Optional().
Nillable().
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
// Claude Code 客户端限制 (added by migration 029)
field.Bool("claude_code_only").
+14
View File
@@ -149,6 +149,20 @@ func (UsageLog) Fields() []ent.Field {
field.JSON("image_size_breakdown", map[string]int{}).
Optional().
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
// 视频生成字段(Grok 视频按秒计费;billing_mode 走 token/其他模式时这些列仍标记视频用量)
field.Int("video_count").
Default(0).
Comment("视频生成数量;>0 表示本行是视频生成用量"),
field.String("video_resolution").
MaxLen(10).
Optional().
Nillable().
Comment("计费用视频分辨率 480p/720p/1080p"),
field.Int("video_duration_seconds").
Optional().
Nillable().
Comment("提交时请求的视频时长(秒),按秒计费的乘数"),
// Cache TTL Override 标记(管理员强制替换了缓存 TTL 计费)
field.Bool("cache_ttl_overridden").
Default(false),
+41 -2
View File
@@ -101,6 +101,12 @@ type UsageLog struct {
ImageSizeSource *string `json:"image_size_source,omitempty"`
// ImageSizeBreakdown holds the value of the "image_size_breakdown" field.
ImageSizeBreakdown map[string]int `json:"image_size_breakdown,omitempty"`
// 视频生成数量;>0 表示本行是视频生成用量
VideoCount int `json:"video_count,omitempty"`
// 计费用视频分辨率 480p/720p/1080p
VideoResolution *string `json:"video_resolution,omitempty"`
// 提交时请求的视频时长(秒),按秒计费的乘数
VideoDurationSeconds *int `json:"video_duration_seconds,omitempty"`
// CacheTTLOverridden holds the value of the "cache_ttl_overridden" field.
CacheTTLOverridden bool `json:"cache_ttl_overridden,omitempty"`
// CreatedAt holds the value of the "created_at" field.
@@ -194,9 +200,9 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullBool)
case usagelog.FieldInputCost, usagelog.FieldOutputCost, usagelog.FieldCacheCreationCost, usagelog.FieldCacheReadCost, usagelog.FieldTotalCost, usagelog.FieldActualCost, usagelog.FieldRateMultiplier, usagelog.FieldAccountRateMultiplier:
values[i] = new(sql.NullFloat64)
case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldChannelID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount:
case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldChannelID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount, usagelog.FieldVideoCount, usagelog.FieldVideoDurationSeconds:
values[i] = new(sql.NullInt64)
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource:
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution:
values[i] = new(sql.NullString)
case usagelog.FieldCreatedAt:
values[i] = new(sql.NullTime)
@@ -474,6 +480,26 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error {
return fmt.Errorf("unmarshal field image_size_breakdown: %w", err)
}
}
case usagelog.FieldVideoCount:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field video_count", values[i])
} else if value.Valid {
_m.VideoCount = int(value.Int64)
}
case usagelog.FieldVideoResolution:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field video_resolution", values[i])
} else if value.Valid {
_m.VideoResolution = new(string)
*_m.VideoResolution = value.String
}
case usagelog.FieldVideoDurationSeconds:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field video_duration_seconds", values[i])
} else if value.Valid {
_m.VideoDurationSeconds = new(int)
*_m.VideoDurationSeconds = int(value.Int64)
}
case usagelog.FieldCacheTTLOverridden:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field cache_ttl_overridden", values[i])
@@ -698,6 +724,19 @@ func (_m *UsageLog) String() string {
builder.WriteString("image_size_breakdown=")
builder.WriteString(fmt.Sprintf("%v", _m.ImageSizeBreakdown))
builder.WriteString(", ")
builder.WriteString("video_count=")
builder.WriteString(fmt.Sprintf("%v", _m.VideoCount))
builder.WriteString(", ")
if v := _m.VideoResolution; v != nil {
builder.WriteString("video_resolution=")
builder.WriteString(*v)
}
builder.WriteString(", ")
if v := _m.VideoDurationSeconds; v != nil {
builder.WriteString("video_duration_seconds=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
builder.WriteString("cache_ttl_overridden=")
builder.WriteString(fmt.Sprintf("%v", _m.CacheTTLOverridden))
builder.WriteString(", ")
+28
View File
@@ -92,6 +92,12 @@ const (
FieldImageSizeSource = "image_size_source"
// FieldImageSizeBreakdown holds the string denoting the image_size_breakdown field in the database.
FieldImageSizeBreakdown = "image_size_breakdown"
// FieldVideoCount holds the string denoting the video_count field in the database.
FieldVideoCount = "video_count"
// FieldVideoResolution holds the string denoting the video_resolution field in the database.
FieldVideoResolution = "video_resolution"
// FieldVideoDurationSeconds holds the string denoting the video_duration_seconds field in the database.
FieldVideoDurationSeconds = "video_duration_seconds"
// FieldCacheTTLOverridden holds the string denoting the cache_ttl_overridden field in the database.
FieldCacheTTLOverridden = "cache_ttl_overridden"
// FieldCreatedAt holds the string denoting the created_at field in the database.
@@ -187,6 +193,9 @@ var Columns = []string{
FieldImageOutputSize,
FieldImageSizeSource,
FieldImageSizeBreakdown,
FieldVideoCount,
FieldVideoResolution,
FieldVideoDurationSeconds,
FieldCacheTTLOverridden,
FieldCreatedAt,
}
@@ -260,6 +269,10 @@ var (
ImageOutputSizeValidator func(string) error
// ImageSizeSourceValidator is a validator for the "image_size_source" field. It is called by the builders before save.
ImageSizeSourceValidator func(string) error
// DefaultVideoCount holds the default value on creation for the "video_count" field.
DefaultVideoCount int
// VideoResolutionValidator is a validator for the "video_resolution" field. It is called by the builders before save.
VideoResolutionValidator func(string) error
// DefaultCacheTTLOverridden holds the default value on creation for the "cache_ttl_overridden" field.
DefaultCacheTTLOverridden bool
// DefaultCreatedAt holds the default value on creation for the "created_at" field.
@@ -464,6 +477,21 @@ func ByImageSizeSource(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldImageSizeSource, opts...).ToFunc()
}
// ByVideoCount orders the results by the video_count field.
func ByVideoCount(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldVideoCount, opts...).ToFunc()
}
// ByVideoResolution orders the results by the video_resolution field.
func ByVideoResolution(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldVideoResolution, opts...).ToFunc()
}
// ByVideoDurationSeconds orders the results by the video_duration_seconds field.
func ByVideoDurationSeconds(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldVideoDurationSeconds, opts...).ToFunc()
}
// ByCacheTTLOverridden orders the results by the cache_ttl_overridden field.
func ByCacheTTLOverridden(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldCacheTTLOverridden, opts...).ToFunc()
+180
View File
@@ -245,6 +245,21 @@ func ImageSizeSource(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldImageSizeSource, v))
}
// VideoCount applies equality check predicate on the "video_count" field. It's identical to VideoCountEQ.
func VideoCount(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldVideoCount, v))
}
// VideoResolution applies equality check predicate on the "video_resolution" field. It's identical to VideoResolutionEQ.
func VideoResolution(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldVideoResolution, v))
}
// VideoDurationSeconds applies equality check predicate on the "video_duration_seconds" field. It's identical to VideoDurationSecondsEQ.
func VideoDurationSeconds(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldVideoDurationSeconds, v))
}
// CacheTTLOverridden applies equality check predicate on the "cache_ttl_overridden" field. It's identical to CacheTTLOverriddenEQ.
func CacheTTLOverridden(v bool) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldCacheTTLOverridden, v))
@@ -2150,6 +2165,171 @@ func ImageSizeBreakdownNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldImageSizeBreakdown))
}
// VideoCountEQ applies the EQ predicate on the "video_count" field.
func VideoCountEQ(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldVideoCount, v))
}
// VideoCountNEQ applies the NEQ predicate on the "video_count" field.
func VideoCountNEQ(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNEQ(FieldVideoCount, v))
}
// VideoCountIn applies the In predicate on the "video_count" field.
func VideoCountIn(vs ...int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldIn(FieldVideoCount, vs...))
}
// VideoCountNotIn applies the NotIn predicate on the "video_count" field.
func VideoCountNotIn(vs ...int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotIn(FieldVideoCount, vs...))
}
// VideoCountGT applies the GT predicate on the "video_count" field.
func VideoCountGT(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGT(FieldVideoCount, v))
}
// VideoCountGTE applies the GTE predicate on the "video_count" field.
func VideoCountGTE(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGTE(FieldVideoCount, v))
}
// VideoCountLT applies the LT predicate on the "video_count" field.
func VideoCountLT(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLT(FieldVideoCount, v))
}
// VideoCountLTE applies the LTE predicate on the "video_count" field.
func VideoCountLTE(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLTE(FieldVideoCount, v))
}
// VideoResolutionEQ applies the EQ predicate on the "video_resolution" field.
func VideoResolutionEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldVideoResolution, v))
}
// VideoResolutionNEQ applies the NEQ predicate on the "video_resolution" field.
func VideoResolutionNEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNEQ(FieldVideoResolution, v))
}
// VideoResolutionIn applies the In predicate on the "video_resolution" field.
func VideoResolutionIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldIn(FieldVideoResolution, vs...))
}
// VideoResolutionNotIn applies the NotIn predicate on the "video_resolution" field.
func VideoResolutionNotIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotIn(FieldVideoResolution, vs...))
}
// VideoResolutionGT applies the GT predicate on the "video_resolution" field.
func VideoResolutionGT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGT(FieldVideoResolution, v))
}
// VideoResolutionGTE applies the GTE predicate on the "video_resolution" field.
func VideoResolutionGTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGTE(FieldVideoResolution, v))
}
// VideoResolutionLT applies the LT predicate on the "video_resolution" field.
func VideoResolutionLT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLT(FieldVideoResolution, v))
}
// VideoResolutionLTE applies the LTE predicate on the "video_resolution" field.
func VideoResolutionLTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLTE(FieldVideoResolution, v))
}
// VideoResolutionContains applies the Contains predicate on the "video_resolution" field.
func VideoResolutionContains(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContains(FieldVideoResolution, v))
}
// VideoResolutionHasPrefix applies the HasPrefix predicate on the "video_resolution" field.
func VideoResolutionHasPrefix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasPrefix(FieldVideoResolution, v))
}
// VideoResolutionHasSuffix applies the HasSuffix predicate on the "video_resolution" field.
func VideoResolutionHasSuffix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasSuffix(FieldVideoResolution, v))
}
// VideoResolutionIsNil applies the IsNil predicate on the "video_resolution" field.
func VideoResolutionIsNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldIsNull(FieldVideoResolution))
}
// VideoResolutionNotNil applies the NotNil predicate on the "video_resolution" field.
func VideoResolutionNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldVideoResolution))
}
// VideoResolutionEqualFold applies the EqualFold predicate on the "video_resolution" field.
func VideoResolutionEqualFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEqualFold(FieldVideoResolution, v))
}
// VideoResolutionContainsFold applies the ContainsFold predicate on the "video_resolution" field.
func VideoResolutionContainsFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContainsFold(FieldVideoResolution, v))
}
// VideoDurationSecondsEQ applies the EQ predicate on the "video_duration_seconds" field.
func VideoDurationSecondsEQ(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldVideoDurationSeconds, v))
}
// VideoDurationSecondsNEQ applies the NEQ predicate on the "video_duration_seconds" field.
func VideoDurationSecondsNEQ(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNEQ(FieldVideoDurationSeconds, v))
}
// VideoDurationSecondsIn applies the In predicate on the "video_duration_seconds" field.
func VideoDurationSecondsIn(vs ...int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldIn(FieldVideoDurationSeconds, vs...))
}
// VideoDurationSecondsNotIn applies the NotIn predicate on the "video_duration_seconds" field.
func VideoDurationSecondsNotIn(vs ...int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotIn(FieldVideoDurationSeconds, vs...))
}
// VideoDurationSecondsGT applies the GT predicate on the "video_duration_seconds" field.
func VideoDurationSecondsGT(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGT(FieldVideoDurationSeconds, v))
}
// VideoDurationSecondsGTE applies the GTE predicate on the "video_duration_seconds" field.
func VideoDurationSecondsGTE(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGTE(FieldVideoDurationSeconds, v))
}
// VideoDurationSecondsLT applies the LT predicate on the "video_duration_seconds" field.
func VideoDurationSecondsLT(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLT(FieldVideoDurationSeconds, v))
}
// VideoDurationSecondsLTE applies the LTE predicate on the "video_duration_seconds" field.
func VideoDurationSecondsLTE(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLTE(FieldVideoDurationSeconds, v))
}
// VideoDurationSecondsIsNil applies the IsNil predicate on the "video_duration_seconds" field.
func VideoDurationSecondsIsNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldIsNull(FieldVideoDurationSeconds))
}
// VideoDurationSecondsNotNil applies the NotNil predicate on the "video_duration_seconds" field.
func VideoDurationSecondsNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldVideoDurationSeconds))
}
// CacheTTLOverriddenEQ applies the EQ predicate on the "cache_ttl_overridden" field.
func CacheTTLOverriddenEQ(v bool) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldCacheTTLOverridden, v))
+266
View File
@@ -525,6 +525,48 @@ func (_c *UsageLogCreate) SetImageSizeBreakdown(v map[string]int) *UsageLogCreat
return _c
}
// SetVideoCount sets the "video_count" field.
func (_c *UsageLogCreate) SetVideoCount(v int) *UsageLogCreate {
_c.mutation.SetVideoCount(v)
return _c
}
// SetNillableVideoCount sets the "video_count" field if the given value is not nil.
func (_c *UsageLogCreate) SetNillableVideoCount(v *int) *UsageLogCreate {
if v != nil {
_c.SetVideoCount(*v)
}
return _c
}
// SetVideoResolution sets the "video_resolution" field.
func (_c *UsageLogCreate) SetVideoResolution(v string) *UsageLogCreate {
_c.mutation.SetVideoResolution(v)
return _c
}
// SetNillableVideoResolution sets the "video_resolution" field if the given value is not nil.
func (_c *UsageLogCreate) SetNillableVideoResolution(v *string) *UsageLogCreate {
if v != nil {
_c.SetVideoResolution(*v)
}
return _c
}
// SetVideoDurationSeconds sets the "video_duration_seconds" field.
func (_c *UsageLogCreate) SetVideoDurationSeconds(v int) *UsageLogCreate {
_c.mutation.SetVideoDurationSeconds(v)
return _c
}
// SetNillableVideoDurationSeconds sets the "video_duration_seconds" field if the given value is not nil.
func (_c *UsageLogCreate) SetNillableVideoDurationSeconds(v *int) *UsageLogCreate {
if v != nil {
_c.SetVideoDurationSeconds(*v)
}
return _c
}
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
func (_c *UsageLogCreate) SetCacheTTLOverridden(v bool) *UsageLogCreate {
_c.mutation.SetCacheTTLOverridden(v)
@@ -677,6 +719,10 @@ func (_c *UsageLogCreate) defaults() {
v := usagelog.DefaultImageCount
_c.mutation.SetImageCount(v)
}
if _, ok := _c.mutation.VideoCount(); !ok {
v := usagelog.DefaultVideoCount
_c.mutation.SetVideoCount(v)
}
if _, ok := _c.mutation.CacheTTLOverridden(); !ok {
v := usagelog.DefaultCacheTTLOverridden
_c.mutation.SetCacheTTLOverridden(v)
@@ -817,6 +863,14 @@ func (_c *UsageLogCreate) check() error {
return &ValidationError{Name: "image_size_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size_source": %w`, err)}
}
}
if _, ok := _c.mutation.VideoCount(); !ok {
return &ValidationError{Name: "video_count", err: errors.New(`ent: missing required field "UsageLog.video_count"`)}
}
if v, ok := _c.mutation.VideoResolution(); ok {
if err := usagelog.VideoResolutionValidator(v); err != nil {
return &ValidationError{Name: "video_resolution", err: fmt.Errorf(`ent: validator failed for field "UsageLog.video_resolution": %w`, err)}
}
}
if _, ok := _c.mutation.CacheTTLOverridden(); !ok {
return &ValidationError{Name: "cache_ttl_overridden", err: errors.New(`ent: missing required field "UsageLog.cache_ttl_overridden"`)}
}
@@ -995,6 +1049,18 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) {
_spec.SetField(usagelog.FieldImageSizeBreakdown, field.TypeJSON, value)
_node.ImageSizeBreakdown = value
}
if value, ok := _c.mutation.VideoCount(); ok {
_spec.SetField(usagelog.FieldVideoCount, field.TypeInt, value)
_node.VideoCount = value
}
if value, ok := _c.mutation.VideoResolution(); ok {
_spec.SetField(usagelog.FieldVideoResolution, field.TypeString, value)
_node.VideoResolution = &value
}
if value, ok := _c.mutation.VideoDurationSeconds(); ok {
_spec.SetField(usagelog.FieldVideoDurationSeconds, field.TypeInt, value)
_node.VideoDurationSeconds = &value
}
if value, ok := _c.mutation.CacheTTLOverridden(); ok {
_spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value)
_node.CacheTTLOverridden = value
@@ -1830,6 +1896,66 @@ func (u *UsageLogUpsert) ClearImageSizeBreakdown() *UsageLogUpsert {
return u
}
// SetVideoCount sets the "video_count" field.
func (u *UsageLogUpsert) SetVideoCount(v int) *UsageLogUpsert {
u.Set(usagelog.FieldVideoCount, v)
return u
}
// UpdateVideoCount sets the "video_count" field to the value that was provided on create.
func (u *UsageLogUpsert) UpdateVideoCount() *UsageLogUpsert {
u.SetExcluded(usagelog.FieldVideoCount)
return u
}
// AddVideoCount adds v to the "video_count" field.
func (u *UsageLogUpsert) AddVideoCount(v int) *UsageLogUpsert {
u.Add(usagelog.FieldVideoCount, v)
return u
}
// SetVideoResolution sets the "video_resolution" field.
func (u *UsageLogUpsert) SetVideoResolution(v string) *UsageLogUpsert {
u.Set(usagelog.FieldVideoResolution, v)
return u
}
// UpdateVideoResolution sets the "video_resolution" field to the value that was provided on create.
func (u *UsageLogUpsert) UpdateVideoResolution() *UsageLogUpsert {
u.SetExcluded(usagelog.FieldVideoResolution)
return u
}
// ClearVideoResolution clears the value of the "video_resolution" field.
func (u *UsageLogUpsert) ClearVideoResolution() *UsageLogUpsert {
u.SetNull(usagelog.FieldVideoResolution)
return u
}
// SetVideoDurationSeconds sets the "video_duration_seconds" field.
func (u *UsageLogUpsert) SetVideoDurationSeconds(v int) *UsageLogUpsert {
u.Set(usagelog.FieldVideoDurationSeconds, v)
return u
}
// UpdateVideoDurationSeconds sets the "video_duration_seconds" field to the value that was provided on create.
func (u *UsageLogUpsert) UpdateVideoDurationSeconds() *UsageLogUpsert {
u.SetExcluded(usagelog.FieldVideoDurationSeconds)
return u
}
// AddVideoDurationSeconds adds v to the "video_duration_seconds" field.
func (u *UsageLogUpsert) AddVideoDurationSeconds(v int) *UsageLogUpsert {
u.Add(usagelog.FieldVideoDurationSeconds, v)
return u
}
// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field.
func (u *UsageLogUpsert) ClearVideoDurationSeconds() *UsageLogUpsert {
u.SetNull(usagelog.FieldVideoDurationSeconds)
return u
}
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
func (u *UsageLogUpsert) SetCacheTTLOverridden(v bool) *UsageLogUpsert {
u.Set(usagelog.FieldCacheTTLOverridden, v)
@@ -2692,6 +2818,76 @@ func (u *UsageLogUpsertOne) ClearImageSizeBreakdown() *UsageLogUpsertOne {
})
}
// SetVideoCount sets the "video_count" field.
func (u *UsageLogUpsertOne) SetVideoCount(v int) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.SetVideoCount(v)
})
}
// AddVideoCount adds v to the "video_count" field.
func (u *UsageLogUpsertOne) AddVideoCount(v int) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.AddVideoCount(v)
})
}
// UpdateVideoCount sets the "video_count" field to the value that was provided on create.
func (u *UsageLogUpsertOne) UpdateVideoCount() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateVideoCount()
})
}
// SetVideoResolution sets the "video_resolution" field.
func (u *UsageLogUpsertOne) SetVideoResolution(v string) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.SetVideoResolution(v)
})
}
// UpdateVideoResolution sets the "video_resolution" field to the value that was provided on create.
func (u *UsageLogUpsertOne) UpdateVideoResolution() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateVideoResolution()
})
}
// ClearVideoResolution clears the value of the "video_resolution" field.
func (u *UsageLogUpsertOne) ClearVideoResolution() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.ClearVideoResolution()
})
}
// SetVideoDurationSeconds sets the "video_duration_seconds" field.
func (u *UsageLogUpsertOne) SetVideoDurationSeconds(v int) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.SetVideoDurationSeconds(v)
})
}
// AddVideoDurationSeconds adds v to the "video_duration_seconds" field.
func (u *UsageLogUpsertOne) AddVideoDurationSeconds(v int) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.AddVideoDurationSeconds(v)
})
}
// UpdateVideoDurationSeconds sets the "video_duration_seconds" field to the value that was provided on create.
func (u *UsageLogUpsertOne) UpdateVideoDurationSeconds() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateVideoDurationSeconds()
})
}
// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field.
func (u *UsageLogUpsertOne) ClearVideoDurationSeconds() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.ClearVideoDurationSeconds()
})
}
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
func (u *UsageLogUpsertOne) SetCacheTTLOverridden(v bool) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
@@ -3722,6 +3918,76 @@ func (u *UsageLogUpsertBulk) ClearImageSizeBreakdown() *UsageLogUpsertBulk {
})
}
// SetVideoCount sets the "video_count" field.
func (u *UsageLogUpsertBulk) SetVideoCount(v int) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.SetVideoCount(v)
})
}
// AddVideoCount adds v to the "video_count" field.
func (u *UsageLogUpsertBulk) AddVideoCount(v int) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.AddVideoCount(v)
})
}
// UpdateVideoCount sets the "video_count" field to the value that was provided on create.
func (u *UsageLogUpsertBulk) UpdateVideoCount() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateVideoCount()
})
}
// SetVideoResolution sets the "video_resolution" field.
func (u *UsageLogUpsertBulk) SetVideoResolution(v string) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.SetVideoResolution(v)
})
}
// UpdateVideoResolution sets the "video_resolution" field to the value that was provided on create.
func (u *UsageLogUpsertBulk) UpdateVideoResolution() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateVideoResolution()
})
}
// ClearVideoResolution clears the value of the "video_resolution" field.
func (u *UsageLogUpsertBulk) ClearVideoResolution() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.ClearVideoResolution()
})
}
// SetVideoDurationSeconds sets the "video_duration_seconds" field.
func (u *UsageLogUpsertBulk) SetVideoDurationSeconds(v int) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.SetVideoDurationSeconds(v)
})
}
// AddVideoDurationSeconds adds v to the "video_duration_seconds" field.
func (u *UsageLogUpsertBulk) AddVideoDurationSeconds(v int) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.AddVideoDurationSeconds(v)
})
}
// UpdateVideoDurationSeconds sets the "video_duration_seconds" field to the value that was provided on create.
func (u *UsageLogUpsertBulk) UpdateVideoDurationSeconds() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateVideoDurationSeconds()
})
}
// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field.
func (u *UsageLogUpsertBulk) ClearVideoDurationSeconds() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.ClearVideoDurationSeconds()
})
}
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
func (u *UsageLogUpsertBulk) SetCacheTTLOverridden(v bool) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
+188
View File
@@ -811,6 +811,74 @@ func (_u *UsageLogUpdate) ClearImageSizeBreakdown() *UsageLogUpdate {
return _u
}
// SetVideoCount sets the "video_count" field.
func (_u *UsageLogUpdate) SetVideoCount(v int) *UsageLogUpdate {
_u.mutation.ResetVideoCount()
_u.mutation.SetVideoCount(v)
return _u
}
// SetNillableVideoCount sets the "video_count" field if the given value is not nil.
func (_u *UsageLogUpdate) SetNillableVideoCount(v *int) *UsageLogUpdate {
if v != nil {
_u.SetVideoCount(*v)
}
return _u
}
// AddVideoCount adds value to the "video_count" field.
func (_u *UsageLogUpdate) AddVideoCount(v int) *UsageLogUpdate {
_u.mutation.AddVideoCount(v)
return _u
}
// SetVideoResolution sets the "video_resolution" field.
func (_u *UsageLogUpdate) SetVideoResolution(v string) *UsageLogUpdate {
_u.mutation.SetVideoResolution(v)
return _u
}
// SetNillableVideoResolution sets the "video_resolution" field if the given value is not nil.
func (_u *UsageLogUpdate) SetNillableVideoResolution(v *string) *UsageLogUpdate {
if v != nil {
_u.SetVideoResolution(*v)
}
return _u
}
// ClearVideoResolution clears the value of the "video_resolution" field.
func (_u *UsageLogUpdate) ClearVideoResolution() *UsageLogUpdate {
_u.mutation.ClearVideoResolution()
return _u
}
// SetVideoDurationSeconds sets the "video_duration_seconds" field.
func (_u *UsageLogUpdate) SetVideoDurationSeconds(v int) *UsageLogUpdate {
_u.mutation.ResetVideoDurationSeconds()
_u.mutation.SetVideoDurationSeconds(v)
return _u
}
// SetNillableVideoDurationSeconds sets the "video_duration_seconds" field if the given value is not nil.
func (_u *UsageLogUpdate) SetNillableVideoDurationSeconds(v *int) *UsageLogUpdate {
if v != nil {
_u.SetVideoDurationSeconds(*v)
}
return _u
}
// AddVideoDurationSeconds adds value to the "video_duration_seconds" field.
func (_u *UsageLogUpdate) AddVideoDurationSeconds(v int) *UsageLogUpdate {
_u.mutation.AddVideoDurationSeconds(v)
return _u
}
// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field.
func (_u *UsageLogUpdate) ClearVideoDurationSeconds() *UsageLogUpdate {
_u.mutation.ClearVideoDurationSeconds()
return _u
}
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
func (_u *UsageLogUpdate) SetCacheTTLOverridden(v bool) *UsageLogUpdate {
_u.mutation.SetCacheTTLOverridden(v)
@@ -979,6 +1047,11 @@ func (_u *UsageLogUpdate) check() error {
return &ValidationError{Name: "image_size_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size_source": %w`, err)}
}
}
if v, ok := _u.mutation.VideoResolution(); ok {
if err := usagelog.VideoResolutionValidator(v); err != nil {
return &ValidationError{Name: "video_resolution", err: fmt.Errorf(`ent: validator failed for field "UsageLog.video_resolution": %w`, err)}
}
}
if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 {
return errors.New(`ent: clearing a required unique edge "UsageLog.user"`)
}
@@ -1210,6 +1283,27 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.ImageSizeBreakdownCleared() {
_spec.ClearField(usagelog.FieldImageSizeBreakdown, field.TypeJSON)
}
if value, ok := _u.mutation.VideoCount(); ok {
_spec.SetField(usagelog.FieldVideoCount, field.TypeInt, value)
}
if value, ok := _u.mutation.AddedVideoCount(); ok {
_spec.AddField(usagelog.FieldVideoCount, field.TypeInt, value)
}
if value, ok := _u.mutation.VideoResolution(); ok {
_spec.SetField(usagelog.FieldVideoResolution, field.TypeString, value)
}
if _u.mutation.VideoResolutionCleared() {
_spec.ClearField(usagelog.FieldVideoResolution, field.TypeString)
}
if value, ok := _u.mutation.VideoDurationSeconds(); ok {
_spec.SetField(usagelog.FieldVideoDurationSeconds, field.TypeInt, value)
}
if value, ok := _u.mutation.AddedVideoDurationSeconds(); ok {
_spec.AddField(usagelog.FieldVideoDurationSeconds, field.TypeInt, value)
}
if _u.mutation.VideoDurationSecondsCleared() {
_spec.ClearField(usagelog.FieldVideoDurationSeconds, field.TypeInt)
}
if value, ok := _u.mutation.CacheTTLOverridden(); ok {
_spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value)
}
@@ -2157,6 +2251,74 @@ func (_u *UsageLogUpdateOne) ClearImageSizeBreakdown() *UsageLogUpdateOne {
return _u
}
// SetVideoCount sets the "video_count" field.
func (_u *UsageLogUpdateOne) SetVideoCount(v int) *UsageLogUpdateOne {
_u.mutation.ResetVideoCount()
_u.mutation.SetVideoCount(v)
return _u
}
// SetNillableVideoCount sets the "video_count" field if the given value is not nil.
func (_u *UsageLogUpdateOne) SetNillableVideoCount(v *int) *UsageLogUpdateOne {
if v != nil {
_u.SetVideoCount(*v)
}
return _u
}
// AddVideoCount adds value to the "video_count" field.
func (_u *UsageLogUpdateOne) AddVideoCount(v int) *UsageLogUpdateOne {
_u.mutation.AddVideoCount(v)
return _u
}
// SetVideoResolution sets the "video_resolution" field.
func (_u *UsageLogUpdateOne) SetVideoResolution(v string) *UsageLogUpdateOne {
_u.mutation.SetVideoResolution(v)
return _u
}
// SetNillableVideoResolution sets the "video_resolution" field if the given value is not nil.
func (_u *UsageLogUpdateOne) SetNillableVideoResolution(v *string) *UsageLogUpdateOne {
if v != nil {
_u.SetVideoResolution(*v)
}
return _u
}
// ClearVideoResolution clears the value of the "video_resolution" field.
func (_u *UsageLogUpdateOne) ClearVideoResolution() *UsageLogUpdateOne {
_u.mutation.ClearVideoResolution()
return _u
}
// SetVideoDurationSeconds sets the "video_duration_seconds" field.
func (_u *UsageLogUpdateOne) SetVideoDurationSeconds(v int) *UsageLogUpdateOne {
_u.mutation.ResetVideoDurationSeconds()
_u.mutation.SetVideoDurationSeconds(v)
return _u
}
// SetNillableVideoDurationSeconds sets the "video_duration_seconds" field if the given value is not nil.
func (_u *UsageLogUpdateOne) SetNillableVideoDurationSeconds(v *int) *UsageLogUpdateOne {
if v != nil {
_u.SetVideoDurationSeconds(*v)
}
return _u
}
// AddVideoDurationSeconds adds value to the "video_duration_seconds" field.
func (_u *UsageLogUpdateOne) AddVideoDurationSeconds(v int) *UsageLogUpdateOne {
_u.mutation.AddVideoDurationSeconds(v)
return _u
}
// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field.
func (_u *UsageLogUpdateOne) ClearVideoDurationSeconds() *UsageLogUpdateOne {
_u.mutation.ClearVideoDurationSeconds()
return _u
}
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
func (_u *UsageLogUpdateOne) SetCacheTTLOverridden(v bool) *UsageLogUpdateOne {
_u.mutation.SetCacheTTLOverridden(v)
@@ -2338,6 +2500,11 @@ func (_u *UsageLogUpdateOne) check() error {
return &ValidationError{Name: "image_size_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size_source": %w`, err)}
}
}
if v, ok := _u.mutation.VideoResolution(); ok {
if err := usagelog.VideoResolutionValidator(v); err != nil {
return &ValidationError{Name: "video_resolution", err: fmt.Errorf(`ent: validator failed for field "UsageLog.video_resolution": %w`, err)}
}
}
if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 {
return errors.New(`ent: clearing a required unique edge "UsageLog.user"`)
}
@@ -2586,6 +2753,27 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err
if _u.mutation.ImageSizeBreakdownCleared() {
_spec.ClearField(usagelog.FieldImageSizeBreakdown, field.TypeJSON)
}
if value, ok := _u.mutation.VideoCount(); ok {
_spec.SetField(usagelog.FieldVideoCount, field.TypeInt, value)
}
if value, ok := _u.mutation.AddedVideoCount(); ok {
_spec.AddField(usagelog.FieldVideoCount, field.TypeInt, value)
}
if value, ok := _u.mutation.VideoResolution(); ok {
_spec.SetField(usagelog.FieldVideoResolution, field.TypeString, value)
}
if _u.mutation.VideoResolutionCleared() {
_spec.ClearField(usagelog.FieldVideoResolution, field.TypeString)
}
if value, ok := _u.mutation.VideoDurationSeconds(); ok {
_spec.SetField(usagelog.FieldVideoDurationSeconds, field.TypeInt, value)
}
if value, ok := _u.mutation.AddedVideoDurationSeconds(); ok {
_spec.AddField(usagelog.FieldVideoDurationSeconds, field.TypeInt, value)
}
if _u.mutation.VideoDurationSecondsCleared() {
_spec.ClearField(usagelog.FieldVideoDurationSeconds, field.TypeInt)
}
if value, ok := _u.mutation.CacheTTLOverridden(); ok {
_spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value)
}
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/Wei-Shaw/sub2api
go 1.26.4
go 1.26.5
require (
entgo.io/ent v0.14.5
@@ -98,6 +98,8 @@ type CreateGroupRequest struct {
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
VideoRateIndependent bool `json:"video_rate_independent"`
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
@@ -105,6 +107,9 @@ type CreateGroupRequest struct {
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
ClaudeCodeOnly bool `json:"claude_code_only"`
FallbackGroupID *int64 `json:"fallback_group_id"`
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
@@ -146,6 +151,8 @@ type UpdateGroupRequest struct {
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
VideoRateIndependent *bool `json:"video_rate_independent"`
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
PeakRateEnabled *bool `json:"peak_rate_enabled"`
PeakStart *string `json:"peak_start"`
PeakEnd *string `json:"peak_end"`
@@ -153,6 +160,9 @@ type UpdateGroupRequest struct {
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
ClaudeCodeOnly *bool `json:"claude_code_only"`
FallbackGroupID *int64 `json:"fallback_group_id"`
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
@@ -312,6 +322,8 @@ func (h *GroupHandler) Create(c *gin.Context) {
ImageRateMultiplier: req.ImageRateMultiplier,
BatchImageDiscountMultiplier: req.BatchImageDiscountMultiplier,
BatchImageHoldMultiplier: req.BatchImageHoldMultiplier,
VideoRateIndependent: req.VideoRateIndependent,
VideoRateMultiplier: req.VideoRateMultiplier,
PeakRateEnabled: req.PeakRateEnabled,
PeakStart: req.PeakStart,
PeakEnd: req.PeakEnd,
@@ -319,6 +331,9 @@ func (h *GroupHandler) Create(c *gin.Context) {
ImagePrice1K: req.ImagePrice1K,
ImagePrice2K: req.ImagePrice2K,
ImagePrice4K: req.ImagePrice4K,
VideoPrice480P: req.VideoPrice480P,
VideoPrice720P: req.VideoPrice720P,
VideoPrice1080P: req.VideoPrice1080P,
ClaudeCodeOnly: req.ClaudeCodeOnly,
FallbackGroupID: req.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest,
@@ -375,6 +390,8 @@ func (h *GroupHandler) Update(c *gin.Context) {
ImageRateMultiplier: req.ImageRateMultiplier,
BatchImageDiscountMultiplier: req.BatchImageDiscountMultiplier,
BatchImageHoldMultiplier: req.BatchImageHoldMultiplier,
VideoRateIndependent: req.VideoRateIndependent,
VideoRateMultiplier: req.VideoRateMultiplier,
PeakRateEnabled: req.PeakRateEnabled,
PeakStart: req.PeakStart,
PeakEnd: req.PeakEnd,
@@ -382,6 +399,9 @@ func (h *GroupHandler) Update(c *gin.Context) {
ImagePrice1K: req.ImagePrice1K,
ImagePrice2K: req.ImagePrice2K,
ImagePrice4K: req.ImagePrice4K,
VideoPrice480P: req.VideoPrice480P,
VideoPrice720P: req.VideoPrice720P,
VideoPrice1080P: req.VideoPrice1080P,
ClaudeCodeOnly: req.ClaudeCodeOnly,
FallbackGroupID: req.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest,
@@ -1,6 +1,7 @@
package admin
import (
"html"
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
@@ -163,7 +164,7 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) {
<body>
<div class="container">
<div class="header">
<h1>` + siteName + `</h1>
<h1>` + html.EscapeString(siteName) + `</h1>
</div>
<div class="content">
<div class="success">✓</div>
@@ -10,6 +10,7 @@ import (
func TestAPIKeyFromService_MapsLastUsedAt(t *testing.T) {
lastUsed := time.Now().UTC().Truncate(time.Second)
lastUsedIP := "203.0.113.10"
src := &service.APIKey{
ID: 1,
UserID: 2,
@@ -17,6 +18,7 @@ func TestAPIKeyFromService_MapsLastUsedAt(t *testing.T) {
Name: "Mapper",
Status: service.StatusActive,
LastUsedAt: &lastUsed,
LastUsedIP: &lastUsedIP,
CurrentConcurrency: 3,
}
@@ -24,6 +26,8 @@ func TestAPIKeyFromService_MapsLastUsedAt(t *testing.T) {
require.NotNil(t, out)
require.NotNil(t, out.LastUsedAt)
require.WithinDuration(t, lastUsed, *out.LastUsedAt, time.Second)
require.NotNil(t, out.LastUsedIP)
require.Equal(t, lastUsedIP, *out.LastUsedIP)
require.Equal(t, 3, out.CurrentConcurrency)
}
@@ -39,4 +43,5 @@ func TestAPIKeyFromService_MapsNilLastUsedAt(t *testing.T) {
out := APIKeyFromService(src)
require.NotNil(t, out)
require.Nil(t, out.LastUsedAt)
require.Nil(t, out.LastUsedIP)
}
+6
View File
@@ -89,6 +89,7 @@ func APIKeyFromService(k *service.APIKey) *APIKey {
IPWhitelist: k.IPWhitelist,
IPBlacklist: k.IPBlacklist,
LastUsedAt: k.LastUsedAt,
LastUsedIP: k.LastUsedIP,
Quota: k.Quota,
QuotaUsed: k.QuotaUsed,
ExpiresAt: k.ExpiresAt,
@@ -186,6 +187,8 @@ func groupFromServiceBase(g *service.Group) Group {
ImageRateMultiplier: g.ImageRateMultiplier,
BatchImageDiscountMultiplier: g.BatchImageDiscountMultiplier,
BatchImageHoldMultiplier: g.BatchImageHoldMultiplier,
VideoRateIndependent: g.VideoRateIndependent,
VideoRateMultiplier: g.VideoRateMultiplier,
PeakRateEnabled: g.PeakRateEnabled,
PeakStart: g.PeakStart,
PeakEnd: g.PeakEnd,
@@ -193,6 +196,9 @@ func groupFromServiceBase(g *service.Group) Group {
ImagePrice1K: g.ImagePrice1K,
ImagePrice2K: g.ImagePrice2K,
ImagePrice4K: g.ImagePrice4K,
VideoPrice480P: g.VideoPrice480P,
VideoPrice720P: g.VideoPrice720P,
VideoPrice1080P: g.VideoPrice1080P,
ClaudeCodeOnly: g.ClaudeCodeOnly,
FallbackGroupID: g.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: g.FallbackGroupIDOnInvalidRequest,
+6
View File
@@ -59,6 +59,7 @@ type APIKey struct {
IPWhitelist []string `json:"ip_whitelist"`
IPBlacklist []string `json:"ip_blacklist"`
LastUsedAt *time.Time `json:"last_used_at"`
LastUsedIP *string `json:"last_used_ip"`
Quota float64 `json:"quota"` // Quota limit in USD (0 = unlimited)
QuotaUsed float64 `json:"quota_used"` // Used quota amount in USD
ExpiresAt *time.Time `json:"expires_at"` // Expiration time (nil = never expires)
@@ -106,6 +107,8 @@ type Group struct {
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
BatchImageDiscountMultiplier float64 `json:"batch_image_discount_multiplier"`
BatchImageHoldMultiplier float64 `json:"batch_image_hold_multiplier"`
VideoRateIndependent bool `json:"video_rate_independent"`
VideoRateMultiplier float64 `json:"video_rate_multiplier"`
// 高峰时段倍率配置
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
@@ -114,6 +117,9 @@ type Group struct {
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
// Claude Code 客户端限制
ClaudeCodeOnly bool `json:"claude_code_only"`
+7 -3
View File
@@ -20,7 +20,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
pkgerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
@@ -138,7 +137,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
defer h.maybeLogCompatibilityFallbackMetrics(reqLog)
// 读取请求体
body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg)
if err != nil {
if maxErr, ok := extractMaxBytesError(err); ok {
h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
@@ -1629,6 +1628,11 @@ func (h *GatewayHandler) mapUpstreamError(statusCode int) (int, string, string)
// handleStreamingAwareError handles errors that may occur after streaming has started
func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) {
if streamStarted {
// 响应状态码已固化为 200(ping/部分数据已 flush),错误只能就地以 SSE 帧回传。
// 标记本次流内错误,供 ops_error_logger 补记——否则该中间件按 status>=400 采集,
// 这类挂在 200 流上的失败(如并发限流回退)不会进错误看板。
service.MarkOpsStreamError(c, errType, message, status)
// /v1/responses 的严格 SDK(Codex CLI)要求终止事件必须属于
// response.completed/failed/incomplete/cancelled 集合。
// Anthropic-backed Responses 路径同样会因为通用 error 帧被拒。
@@ -1777,7 +1781,7 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
defer h.maybeLogCompatibilityFallbackMetrics(reqLog)
// 读取请求体
body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg)
if err != nil {
if maxErr, ok := extractMaxBytesError(err); ok {
h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
@@ -7,7 +7,6 @@ import (
"strconv"
"time"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -45,7 +44,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
)
// Read request body
body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg)
if err != nil {
if maxErr, ok := extractMaxBytesError(err); ok {
h.chatCompletionsErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
@@ -7,7 +7,6 @@ import (
"strconv"
"time"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -45,7 +44,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
)
// Read request body
body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg)
if err != nil {
if maxErr, ok := extractMaxBytesError(err); ok {
h.responsesErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
@@ -7,7 +7,6 @@ import (
"strconv"
"time"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
@@ -49,7 +48,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
return
}
body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg)
if err != nil {
if maxErr, ok := extractMaxBytesError(err); ok {
h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
@@ -0,0 +1,53 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
)
// CodexModels serves the Codex models manifest for Codex clients.
//
// Codex CLI and the Codex desktop app refresh their model picker from
// GET {base_url}/models?client_version=... (custom provider mode) or
// GET /backend-api/codex/models (chatgpt_base_url mode). Both routes land
// here. The manifest is proxied verbatim from the ChatGPT backend with a
// schedulable OAuth account's credentials, so clients pointed at the gateway
// see the account's real, always-current model entitlements instead of a
// frozen local cache.
func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
if !ok || apiKey.Group == nil {
h.errorResponse(c, http.StatusUnauthorized, "invalid_request_error", "API key group is required")
return
}
if apiKey.Group.Platform != service.PlatformOpenAI {
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex models manifest is only available for OpenAI groups")
return
}
account, err := h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, "", "")
if err != nil {
h.errorResponse(c, http.StatusServiceUnavailable, "upstream_error", "No available OpenAI accounts")
return
}
manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), c.GetHeader("If-None-Match"))
if err != nil {
h.errorResponse(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err))
return
}
if manifest.ETag != "" {
c.Header("ETag", manifest.ETag)
}
if manifest.NotModified {
c.Status(http.StatusNotModified)
return
}
c.Data(http.StatusOK, "application/json", manifest.Body)
}
@@ -6,7 +6,6 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -47,7 +46,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
return
}
body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg)
if err != nil {
if maxErr, ok := extractMaxBytesError(err); ok {
h.anthropicErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
@@ -13,7 +13,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
@@ -185,7 +184,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
}
// Read request body
body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg)
if err != nil {
if maxErr, ok := extractMaxBytesError(err); ok {
h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
@@ -715,7 +714,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
return
}
body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg)
if err != nil {
if maxErr, ok := extractMaxBytesError(err); ok {
h.anthropicErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
@@ -440,7 +440,7 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) {
Platform: service.PlatformGrok,
},
}
require.Equal(t, "grok-4.3", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5"))
require.Equal(t, "grok-4.5", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5"))
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(apiKey, "grok"))
})
@@ -590,6 +590,10 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
}
}
if !hasUpstreamContext {
// 没有上游错误上下文,但网关可能在已固化的 200 流上就地补发了 SSE 错误帧
// (如 ping 等待后并发超限、Wait 后二次计费校验失败)。这类失败若不在此补记,
// 会因 wire 状态码为 200 而在错误看板里彻底隐形。
logOpsStreamError(c, ops, status)
return
}
@@ -999,6 +1003,138 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
}
}
// logOpsStreamError 记录一次挂在已固化 HTTP 200 SSE 流上的就地错误。
// 由于 wire 状态码停留在 200,常规的 status>=400 捕获路径永远不会触发;
// handleStreamingAwareError 通过 service.MarkOpsStreamError 标记这类错误,
// 此函数据此补记一条错误日志,让并发限流/流内失败在错误看板里可见。
//
// 仅在 status<400 且不存在上游错误上下文时调用:上游透传错误已由中间件的
// upstream-context 分支落库,无需在此重复记录。
func logOpsStreamError(c *gin.Context, ops *service.OpsService, wireStatus int) {
streamErr, ok := service.GetOpsStreamError(c)
if !ok {
return
}
// 命中 skip_monitoring=true 透传规则的请求跳过落库,与其它分支一致。
if v, ok := c.Get(service.OpsSkipPassthroughKey); ok {
if skip, _ := v.(bool); skip {
return
}
}
// 复用与 status>=400 分支相同的设置过滤(context canceled / 无可用账号等)。
if shouldSkipOpsErrorLog(c.Request.Context(), ops, streamErr.Message, streamErr.Message, c.Request.URL.Path) {
return
}
// 分级用「本应返回的状态码」(如并发限流 429),wire 状态码缺省时回退。
classifyStatus := streamErr.IntendedStatus
if classifyStatus <= 0 {
classifyStatus = wireStatus
}
normalizedType := normalizeOpsErrorType(streamErr.ErrType, "")
phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, normalizedType, streamErr.Message, "", classifyStatus)
apiKey := getOpsAPIKey(c)
clientRequestID, _ := c.Request.Context().Value(ctxkey.ClientRequestID).(string)
model, _ := c.Get(opsModelKey)
var modelName string
if s, ok := model.(string); ok {
modelName = s
}
accountIDV, _ := c.Get(opsAccountIDKey)
var accountID *int64
if v, ok := accountIDV.(int64); ok && v > 0 {
accountID = &v
}
fallbackPlatform := guessPlatformFromPath(c.Request.URL.Path)
platform := resolveOpsPlatform(apiKey, fallbackPlatform)
requestID := c.Writer.Header().Get("X-Request-Id")
if requestID == "" {
requestID = c.Writer.Header().Get("x-request-id")
}
entry := &service.OpsInsertErrorLogInput{
RequestID: requestID,
ClientRequestID: clientRequestID,
AccountID: accountID,
Platform: platform,
Model: modelName,
RequestPath: func() string {
if c.Request != nil && c.Request.URL != nil {
return c.Request.URL.Path
}
return ""
}(),
// 就地 SSE 错误只出现在流式请求上。
Stream: true,
InboundEndpoint: GetInboundEndpoint(c),
UpstreamEndpoint: GetUpstreamEndpoint(c, platform),
RequestedModel: modelName,
UpstreamModel: func() string {
if v, ok := c.Get(opsUpstreamModelKey); ok {
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
}
return ""
}(),
RequestType: func() *int16 {
if v, ok := c.Get(opsRequestTypeKey); ok {
switch t := v.(type) {
case int16:
return &t
case int:
v16 := int16(t)
return &v16
}
}
return nil
}(),
UserAgent: c.GetHeader("User-Agent"),
ErrorPhase: phase,
ErrorType: normalizedType,
Severity: classifyOpsSeverity(normalizedType, classifyStatus),
StatusCode: wireStatus,
IsBusinessLimited: isBusinessLimited,
IsCountTokens: isCountTokensRequest(c),
ErrorMessage: streamErr.Message,
ErrorBody: "",
ErrorSource: errorSource,
ErrorOwner: errorOwner,
CreatedAt: time.Now(),
}
applyOpsLatencyFieldsFromContext(c, entry)
if apiKey != nil {
entry.APIKeyID = &apiKey.ID
entry.APIKeyPrefix = keyPrefix(apiKey.Key, 8)
if apiKey.User != nil {
entry.UserID = &apiKey.User.ID
}
if apiKey.GroupID != nil {
entry.GroupID = apiKey.GroupID
}
if apiKey.Group != nil && apiKey.Group.Platform != "" {
entry.Platform = apiKey.Group.Platform
}
}
if clientIP := strings.TrimSpace(ip.GetClientIP(c)); clientIP != "" {
entry.ClientIP = &clientIP
}
enqueueOpsErrorLog(ops, entry)
}
// isCountTokensRequest checks if the request is a count_tokens request
func isCountTokensRequest(c *gin.Context) bool {
if c == nil || c.Request == nil || c.Request.URL == nil {
@@ -139,6 +139,97 @@ func TestOpsErrorLoggerMiddleware_DoesNotBreakOuterMiddlewares(t *testing.T) {
require.Equal(t, http.StatusNoContent, rec.Code)
}
// setupOpsErrorLogTestQueue 阻止 enqueueOpsErrorLog 启动真实 worker,改用可检查的测试队列。
func setupOpsErrorLogTestQueue(t *testing.T, size int) {
t.Helper()
resetOpsErrorLoggerStateForTest(t)
opsErrorLogOnce.Do(func() {})
opsErrorLogMu.Lock()
opsErrorLogQueue = make(chan opsErrorLogJob, size)
opsErrorLogMu.Unlock()
}
// 就地(in-band) SSE 错误挂在已固化的 HTTP 200 流上:wire 状态码为 200,
// 常规 status>=400 采集路径不会触发。logOpsStreamError 必须据 MarkOpsStreamError
// 补记一条错误日志,且用 IntendedStatus(429) 分级、StatusCode 仍记 wire 的 200。
func TestLogOpsStreamError_RecordsInBandConcurrencyLimit(t *testing.T) {
setupOpsErrorLogTestQueue(t, 4)
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
c.Set(opsModelKey, "test-model")
service.MarkOpsStreamError(c, "rate_limit_error",
"Concurrency limit exceeded for account, please retry later", http.StatusTooManyRequests)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
logOpsStreamError(c, ops, http.StatusOK)
require.Equal(t, int64(1), OpsErrorLogEnqueuedTotal())
require.Equal(t, int64(1), OpsErrorLogQueueLength())
job := <-opsErrorLogQueue
require.NotNil(t, job.entry)
require.Equal(t, "rate_limit_error", job.entry.ErrorType)
require.Equal(t, "request", job.entry.ErrorPhase)
require.True(t, job.entry.IsBusinessLimited)
require.True(t, job.entry.Stream)
require.Equal(t, http.StatusOK, job.entry.StatusCode) // wire 状态码保持 200
require.Equal(t, "P1", job.entry.Severity) // 用 IntendedStatus 429 分级
require.Equal(t, "test-model", job.entry.Model)
require.Equal(t, "Concurrency limit exceeded for account, please retry later", job.entry.ErrorMessage)
}
// 未标记流内错误时 logOpsStreamError 必须是 no-op(不误记正常的 200 流)。
func TestLogOpsStreamError_NoopWhenNotMarked(t *testing.T) {
setupOpsErrorLogTestQueue(t, 4)
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
logOpsStreamError(c, ops, http.StatusOK)
require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal())
}
// 命中 skip_monitoring=true 透传规则时不落库,与其它采集分支一致。
func TestLogOpsStreamError_SkipWhenPassthroughSkipMonitoring(t *testing.T) {
setupOpsErrorLogTestQueue(t, 4)
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
service.MarkOpsStreamError(c, "upstream_error", "Upstream request failed", http.StatusBadGateway)
c.Set(service.OpsSkipPassthroughKey, true)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
logOpsStreamError(c, ops, http.StatusOK)
require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal())
}
// MarkOpsStreamError 采用「首个标记生效」:后续的通用兜底帧不得覆盖根因错误。
func TestMarkOpsStreamError_FirstWins(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
service.MarkOpsStreamError(c, "rate_limit_error", "Concurrency limit exceeded for account", http.StatusTooManyRequests)
service.MarkOpsStreamError(c, "upstream_error", "Upstream request failed", http.StatusBadGateway)
se, ok := service.GetOpsStreamError(c)
require.True(t, ok)
require.Equal(t, "rate_limit_error", se.ErrType)
require.Equal(t, "Concurrency limit exceeded for account", se.Message)
require.Equal(t, http.StatusTooManyRequests, se.IntendedStatus)
}
func TestIsKnownOpsErrorType(t *testing.T) {
known := []string{
"invalid_request_error",
@@ -4,6 +4,9 @@ import (
"errors"
"fmt"
"net/http"
"github.com/Wei-Shaw/sub2api/internal/config"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
)
func extractMaxBytesError(err error) (*http.MaxBytesError, bool) {
@@ -25,3 +28,14 @@ func formatBodyLimit(limit int64) string {
func buildBodyTooLargeMessage(limit int64) string {
return fmt.Sprintf("Request body too large, limit is %s", formatBodyLimit(limit))
}
func readLenientJSONRequestBodyWithPrealloc(req *http.Request, cfg *config.Config) ([]byte, error) {
return pkghttputil.ReadLenientJSONRequestBodyWithPrealloc(req, gatewayMaxBodySize(cfg))
}
func gatewayMaxBodySize(cfg *config.Config) int64 {
if cfg == nil {
return 0
}
return cfg.Gateway.MaxBodySize
}
+1 -1
View File
@@ -138,7 +138,7 @@ func (h *UsageHandler) parseUserUsageFilters(c *gin.Context, requireRange bool)
}
billingMode := strings.TrimSpace(c.Query("billing_mode"))
if billingMode != "" && !service.BillingMode(billingMode).IsValid() {
if billingMode != "" && !service.BillingMode(billingMode).IsValidUsageFilter() {
response.BadRequest(c, "Invalid billing_mode")
return nil, false
}
@@ -162,6 +162,18 @@ func TestUserUsageListInvalidBillingMode(t *testing.T) {
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestUserUsageListAllowsVideoBillingMode(t *testing.T) {
repo := &userUsageRepoCapture{}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage?billing_mode=video", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "video", repo.listFilters.BillingMode)
}
func TestUserUsageListKeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) {
ipAddress := "203.0.113.10"
upstreamModel := "upstream-private-model"
@@ -38,6 +38,9 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR
if len(req.ToolChoice) > 0 {
out.ToolChoice = responsesToolChoiceToChatToolChoice(req.ToolChoice)
}
if req.Text != nil {
out.ResponseFormat = responsesTextFormatToChatResponseFormat(req.Text.Format)
}
return out, nil
}
@@ -73,6 +73,60 @@ func TestResponsesToChatCompletionsRequest_InstructionsAndInputDeveloperRole(t *
assert.JSONEq(t, `"Hello"`, string(out.Messages[2].Content))
}
func TestResponsesToChatCompletionsRequest_TextFormatJsonObject(t *testing.T) {
req := &ResponsesRequest{
Model: "gpt-4o",
Input: json.RawMessage(`[
{"role":"user","content":"Return JSON"}
]`),
Text: &ResponsesText{
Format: json.RawMessage(`{"type":"json_object"}`),
},
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
assert.JSONEq(t, `{"type":"json_object"}`, string(out.ResponseFormat))
}
func TestResponsesToChatCompletionsRequest_TextFormatJsonSchema(t *testing.T) {
req := &ResponsesRequest{
Model: "gpt-4o",
Input: json.RawMessage(`[
{"role":"user","content":"Return structured JSON"}
]`),
Text: &ResponsesText{
Format: json.RawMessage(`{
"type":"json_schema",
"name":"answer",
"schema":{
"type":"object",
"properties":{"ok":{"type":"boolean"}},
"required":["ok"],
"additionalProperties":false
},
"strict":true
}`),
},
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
assert.JSONEq(t, `{
"type":"json_schema",
"json_schema":{
"name":"answer",
"schema":{
"type":"object",
"properties":{"ok":{"type":"boolean"}},
"required":["ok"],
"additionalProperties":false
},
"strict":true
}
}`, string(out.ResponseFormat))
}
func chatMessageRoles(messages []ChatMessage) []string {
roles := make([]string, 0, len(messages))
for _, message := range messages {
@@ -242,6 +242,62 @@ func TestChatCompletionsToResponses_ReasoningEffort(t *testing.T) {
assert.Equal(t, "auto", resp.Reasoning.Summary)
}
func TestChatCompletionsToResponses_ResponseFormatJsonObject(t *testing.T) {
req := &ChatCompletionsRequest{
Model: "gpt-4o",
Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Return JSON"`)}},
ResponseFormat: json.RawMessage(`{"type":"json_object"}`),
}
resp, err := ChatCompletionsToResponses(req)
require.NoError(t, err)
require.NotNil(t, resp.Text)
assert.JSONEq(t, `{"type":"json_object"}`, string(resp.Text.Format))
payload, err := json.Marshal(resp)
require.NoError(t, err)
var serialized struct {
Text ResponsesText `json:"text"`
}
require.NoError(t, json.Unmarshal(payload, &serialized))
assert.JSONEq(t, `{"type":"json_object"}`, string(serialized.Text.Format))
}
func TestChatCompletionsToResponses_ResponseFormatJsonSchema(t *testing.T) {
req := &ChatCompletionsRequest{
Model: "gpt-4o",
Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Return structured JSON"`)}},
ResponseFormat: json.RawMessage(`{
"type":"json_schema",
"json_schema":{
"name":"answer",
"schema":{
"type":"object",
"properties":{"ok":{"type":"boolean"}},
"required":["ok"],
"additionalProperties":false
},
"strict":true
}
}`),
}
resp, err := ChatCompletionsToResponses(req)
require.NoError(t, err)
require.NotNil(t, resp.Text)
assert.JSONEq(t, `{
"type":"json_schema",
"name":"answer",
"schema":{
"type":"object",
"properties":{"ok":{"type":"boolean"}},
"required":["ok"],
"additionalProperties":false
},
"strict":true
}`, string(resp.Text.Format))
}
func TestChatCompletionsToResponses_ImageURL(t *testing.T) {
content := `[{"type":"text","text":"Describe this"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc123"}}]`
req := &ChatCompletionsRequest{
@@ -69,6 +69,13 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest,
}
}
if format := chatResponseFormatToResponsesTextFormat(req.ResponseFormat); len(format) > 0 {
if out.Text == nil {
out.Text = &ResponsesText{}
}
out.Text.Format = format
}
// tools[] and legacy functions[] → ResponsesTool[]
if len(req.Tools) > 0 || len(req.Functions) > 0 {
out.Tools = convertChatToolsToResponses(req.Tools, req.Functions)
@@ -0,0 +1,92 @@
package apicompat
import "encoding/json"
func chatResponseFormatToResponsesTextFormat(raw json.RawMessage) json.RawMessage {
raw = normalizedRawJSON(raw)
if len(raw) == 0 {
return nil
}
obj, ok := rawJSONObject(raw)
if !ok || rawString(obj["type"]) != "json_schema" {
return raw
}
schemaRaw := normalizedRawJSON(obj["json_schema"])
if len(schemaRaw) == 0 {
return raw
}
var schema map[string]json.RawMessage
if err := json.Unmarshal(schemaRaw, &schema); err != nil {
return raw
}
schema["type"] = rawJSONString("json_schema")
out, err := json.Marshal(schema)
if err != nil {
return raw
}
return out
}
func responsesTextFormatToChatResponseFormat(raw json.RawMessage) json.RawMessage {
raw = normalizedRawJSON(raw)
if len(raw) == 0 {
return nil
}
obj, ok := rawJSONObject(raw)
if !ok || rawString(obj["type"]) != "json_schema" {
return raw
}
if _, alreadyChatShape := obj["json_schema"]; alreadyChatShape {
return raw
}
schema := make(map[string]json.RawMessage, len(obj))
for key, value := range obj {
if key == "type" {
continue
}
schema[key] = value
}
if len(schema) == 0 {
return raw
}
schemaRaw, err := json.Marshal(schema)
if err != nil {
return raw
}
out, err := json.Marshal(map[string]json.RawMessage{
"type": rawJSONString("json_schema"),
"json_schema": schemaRaw,
})
if err != nil {
return raw
}
return out
}
func normalizedRawJSON(raw json.RawMessage) json.RawMessage {
raw = bytesTrimSpace(raw)
if len(raw) == 0 || string(raw) == "null" {
return nil
}
return append(json.RawMessage(nil), raw...)
}
func rawJSONObject(raw json.RawMessage) (map[string]json.RawMessage, bool) {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return nil, false
}
return obj, true
}
func rawJSONString(value string) json.RawMessage {
data, _ := json.Marshal(value)
return data
}
@@ -0,0 +1,126 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponsesToAnthropicRequest_Instructions(t *testing.T) {
t.Run("instructions_becomes_system", func(t *testing.T) {
req := &ResponsesRequest{
Model: "claude-sonnet-4-20250514",
Instructions: "You are a helpful assistant.",
Input: json.RawMessage(`[{"role":"user","content":"hello"}]`),
}
result, err := ResponsesToAnthropicRequest(req)
require.NoError(t, err)
var system string
require.NoError(t, json.Unmarshal(result.System, &system))
assert.Equal(t, "You are a helpful assistant.", system)
assert.NotEmpty(t, result.Messages)
})
t.Run("empty_instructions_no_system", func(t *testing.T) {
req := &ResponsesRequest{
Model: "claude-sonnet-4-20250514",
Input: json.RawMessage(`[{"role":"user","content":"hello"}]`),
}
result, err := ResponsesToAnthropicRequest(req)
require.NoError(t, err)
assert.Nil(t, result.System)
})
t.Run("instructions_and_system_item_concatenated", func(t *testing.T) {
req := &ResponsesRequest{
Model: "claude-sonnet-4-20250514",
Instructions: "Top-level instruction.",
Input: json.RawMessage(`[
{"role":"system","content":"Input-level system prompt."},
{"role":"user","content":"hello"}
]`),
}
result, err := ResponsesToAnthropicRequest(req)
require.NoError(t, err)
var system string
require.NoError(t, json.Unmarshal(result.System, &system))
assert.Contains(t, system, "Top-level instruction.")
assert.Contains(t, system, "Input-level system prompt.")
})
t.Run("instructions_with_string_input", func(t *testing.T) {
req := &ResponsesRequest{
Model: "claude-sonnet-4-20250514",
Instructions: "Be concise.",
Input: json.RawMessage(`"What is Go?"`),
}
result, err := ResponsesToAnthropicRequest(req)
require.NoError(t, err)
var system string
require.NoError(t, json.Unmarshal(result.System, &system))
assert.Equal(t, "Be concise.", system)
require.Len(t, result.Messages, 1)
assert.Equal(t, "user", result.Messages[0].Role)
})
}
func TestConvertResponsesInputToAnthropic_DeveloperRole(t *testing.T) {
t.Run("developer_becomes_system", func(t *testing.T) {
input := `[
{"role":"developer","content":[{"type":"input_text","text":"You are a code reviewer."}]},
{"role":"user","content":"review this code"}
]`
system, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input))
require.NoError(t, err)
var systemText string
require.NoError(t, json.Unmarshal(system, &systemText))
assert.Equal(t, "You are a code reviewer.", systemText)
require.Len(t, messages, 1)
assert.Equal(t, "user", messages[0].Role)
})
t.Run("developer_does_not_become_user", func(t *testing.T) {
input := `[
{"role":"developer","content":[{"type":"input_text","text":"System prompt."}]},
{"role":"user","content":"hi"}
]`
_, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input))
require.NoError(t, err)
for _, m := range messages {
if m.Role == "user" {
var s string
if json.Unmarshal(m.Content, &s) == nil {
assert.NotContains(t, s, "System prompt.")
}
}
}
})
t.Run("instructions_and_developer_concatenated_in_order", func(t *testing.T) {
input := `[
{"role":"developer","content":"Extra context."},
{"role":"user","content":"hello"}
]`
system, _, err := convertResponsesInputToAnthropic("Main instruction.", json.RawMessage(input))
require.NoError(t, err)
var systemText string
require.NoError(t, json.Unmarshal(system, &systemText))
assert.Equal(t, "Main instruction.\n\nExtra context.", systemText)
})
}
@@ -11,7 +11,7 @@ import (
// enables Anthropic platform groups to accept OpenAI Responses API requests
// by converting them to the native /v1/messages format before forwarding upstream.
func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, error) {
system, messages, err := convertResponsesInputToAnthropic(req.Input)
system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input)
if err != nil {
return nil, err
}
@@ -98,14 +98,23 @@ func mapResponsesEffortToAnthropic(effort string) string {
}
// convertResponsesInputToAnthropic extracts system prompt and messages from
// a Responses API input array. Returns the system as raw JSON (for Anthropic's
// polymorphic system field) and a list of Anthropic messages.
func convertResponsesInputToAnthropic(inputRaw json.RawMessage) (json.RawMessage, []AnthropicMessage, error) {
// a Responses API instructions + input array. Returns the system as raw JSON
// (for Anthropic's polymorphic system field) and a list of Anthropic messages.
func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMessage) (json.RawMessage, []AnthropicMessage, error) {
var systemParts []string
if strings.TrimSpace(instructions) != "" {
systemParts = append(systemParts, strings.TrimSpace(instructions))
}
// Try as plain string input.
var inputStr string
if err := json.Unmarshal(inputRaw, &inputStr); err == nil {
content, _ := json.Marshal(inputStr)
return nil, []AnthropicMessage{{Role: "user", Content: content}}, nil
var system json.RawMessage
if len(systemParts) > 0 {
system, _ = json.Marshal(strings.Join(systemParts, "\n\n"))
}
return system, []AnthropicMessage{{Role: "user", Content: content}}, nil
}
var items []ResponsesInputItem
@@ -113,16 +122,14 @@ func convertResponsesInputToAnthropic(inputRaw json.RawMessage) (json.RawMessage
return nil, nil, fmt.Errorf("parse responses input: %w", err)
}
var system json.RawMessage
var messages []AnthropicMessage
for _, item := range items {
switch {
case item.Role == "system":
// System prompt → Anthropic system field
case item.Role == "system" || item.Role == "developer":
text := extractTextFromContent(item.Content)
if text != "" {
system, _ = json.Marshal(text)
systemParts = append(systemParts, text)
}
case item.Type == "function_call":
@@ -201,6 +208,11 @@ func convertResponsesInputToAnthropic(inputRaw json.RawMessage) (json.RawMessage
messages = normalizeAnthropicToolPairing(messages)
messages = mergeConsecutiveMessages(messages)
var system json.RawMessage
if len(systemParts) > 0 {
system, _ = json.Marshal(strings.Join(systemParts, "\n\n"))
}
return system, messages, nil
}
@@ -58,7 +58,7 @@ func hasToolResult(blocks []AnthropicContentBlock, toolUseID string) bool {
func convertAnthropic(t *testing.T, input string) []AnthropicMessage {
t.Helper()
_, messages, err := convertResponsesInputToAnthropic(json.RawMessage(input))
_, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input))
require.NoError(t, err)
assertAnthropicPairing(t, messages)
return messages
+3 -1
View File
@@ -216,7 +216,8 @@ type ResponsesReasoning struct {
// ResponsesText configures text output options in the Responses API.
type ResponsesText struct {
Verbosity string `json:"verbosity,omitempty"` // "low" | "medium" | "high"
Format json.RawMessage `json:"format,omitempty"`
Verbosity string `json:"verbosity,omitempty"` // "low" | "medium" | "high"
}
// ResponsesInputItem is one item in the Responses API input array.
@@ -438,6 +439,7 @@ type ChatCompletionsRequest struct {
ReasoningEffort string `json:"reasoning_effort,omitempty"` // "low" | "medium" | "high" | "xhigh"
ServiceTier string `json:"service_tier,omitempty"`
Stop json.RawMessage `json:"stop,omitempty"` // string or []string
ResponseFormat json.RawMessage `json:"response_format,omitempty"`
// Legacy function calling (deprecated but still supported)
Functions []ChatFunction `json:"functions,omitempty"`
+85
View File
@@ -16,6 +16,7 @@ import (
const (
requestBodyReadInitCap = 512
requestBodyReadMaxInitCap = 1 << 20
jsonUTF8BOMLen = 3
// maxDecompressedBodySize limits the decompressed request body to 64 MB
// to prevent decompression bomb attacks.
maxDecompressedBodySize = 64 << 20
@@ -64,6 +65,16 @@ func ReadRequestBodyWithPrealloc(req *http.Request) ([]byte, error) {
return decoded, nil
}
// ReadLenientJSONRequestBodyWithPrealloc reads a request body and normalizes
// JSON string control bytes before strict validation.
func ReadLenientJSONRequestBodyWithPrealloc(req *http.Request, maxNormalizedBytes int64) ([]byte, error) {
body, err := ReadRequestBodyWithPrealloc(req)
if err != nil {
return nil, err
}
return NormalizeLenientJSONRequestBody(body, maxNormalizedBytes)
}
func decompressRequestBody(encoding string, raw []byte) ([]byte, error) {
switch encoding {
case "zstd":
@@ -91,3 +102,77 @@ func decompressRequestBody(encoding string, raw []byte) ([]byte, error) {
return nil, errors.New("unsupported Content-Encoding")
}
}
// NormalizeLenientJSONRequestBody escapes raw control bytes that broken
// OpenAI-compatible clients sometimes place inside JSON strings.
func NormalizeLenientJSONRequestBody(body []byte, maxNormalizedBytes int64) ([]byte, error) {
if maxNormalizedBytes <= 0 {
maxNormalizedBytes = maxDecompressedBodySize
}
body = trimUTF8BOM(body)
if len(body) == 0 {
return body, nil
}
if int64(len(body)) > maxNormalizedBytes {
return nil, &http.MaxBytesError{Limit: maxNormalizedBytes}
}
var out []byte
inString := false
escaped := false
for i, b := range body {
if inString && isJSONControlByte(b) {
if out == nil {
capHint := len(body) + 6
if int64(capHint) > maxNormalizedBytes {
capHint = int(maxNormalizedBytes)
}
out = make([]byte, 0, capHint)
out = append(out, body[:i]...)
}
if int64(len(out)+6) > maxNormalizedBytes {
return nil, &http.MaxBytesError{Limit: maxNormalizedBytes}
}
out = appendJSONUnicodeEscape(out, b)
escaped = false
continue
}
switch {
case escaped:
escaped = false
case inString && b == '\\':
escaped = true
case b == '"':
inString = !inString
}
if out != nil {
if int64(len(out)+1) > maxNormalizedBytes {
return nil, &http.MaxBytesError{Limit: maxNormalizedBytes}
}
out = append(out, b)
}
}
if out != nil {
return out, nil
}
return body, nil
}
func trimUTF8BOM(body []byte) []byte {
if len(body) >= jsonUTF8BOMLen && body[0] == 0xef && body[1] == 0xbb && body[2] == 0xbf {
return body[jsonUTF8BOMLen:]
}
return body
}
func isJSONControlByte(b byte) bool {
return b < 0x20 || b == 0x7f
}
func appendJSONUnicodeEscape(dst []byte, b byte) []byte {
const hex = "0123456789abcdef"
return append(dst, '\\', 'u', '0', '0', hex[b>>4], hex[b&0x0f])
}
@@ -0,0 +1,184 @@
package httputil
import (
"bytes"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/tidwall/gjson"
)
func TestNormalizeLenientJSONRequestBody_accepts_client_control_chars_in_strings(t *testing.T) {
tests := []struct {
name string
body []byte
path string
want string
wantRaw string
}{
{
name: "null byte in message content",
body: []byte("{\"messages\":[{\"content\":\"hello\x00world\"}]}"),
path: "messages.0.content",
want: "hello\x00world",
wantRaw: `"hello\u0000world"`,
},
{
name: "ansi escape in message content",
body: []byte("{\"messages\":[{\"content\":\"hello\x1b[31mred\x1b[0m\"}]}"),
path: "messages.0.content",
want: "hello\x1b[31mred\x1b[0m",
wantRaw: `"hello\u001b[31mred\u001b[0m"`,
},
{
name: "leading UTF-8 BOM",
body: []byte("\xef\xbb\xbf{\"input\":\"hello\"}"),
path: "input",
want: "hello",
wantRaw: `"hello"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Given
if gjson.ValidBytes(tt.body) {
t.Fatalf("test payload should reproduce strict JSON rejection: %q", tt.body)
}
// When
got, err := NormalizeLenientJSONRequestBody(tt.body, 1024)
if err != nil {
t.Fatalf("NormalizeLenientJSONRequestBody: %v", err)
}
// Then
if !gjson.ValidBytes(got) {
t.Fatalf("normalized body should be valid JSON: %q", got)
}
result := gjson.GetBytes(got, tt.path)
if result.String() != tt.want {
t.Fatalf("value mismatch: got %q want %q", result.String(), tt.want)
}
if result.Raw != tt.wantRaw {
t.Fatalf("raw value mismatch: got %q want %q", result.Raw, tt.wantRaw)
}
})
}
}
func TestNormalizeLenientJSONRequestBody_keeps_invalid_structure_invalid(t *testing.T) {
tests := []struct {
name string
body []byte
}{
{
name: "truncated JSON",
body: []byte("{\"messages\":[{\"content\":\"hello\"}]"),
},
{
name: "control character outside string",
body: []byte("{\"input\":\"hello\"}\x00"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// When
got, err := NormalizeLenientJSONRequestBody(tt.body, 1024)
if err != nil {
t.Fatalf("NormalizeLenientJSONRequestBody: %v", err)
}
// Then
if gjson.ValidBytes(got) {
t.Fatalf("normalization must not repair invalid JSON structure: %q", got)
}
})
}
}
func TestNormalizeLenientJSONRequestBody_allows_http_requests_with_client_control_chars(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Given
body, err := ReadLenientJSONRequestBodyWithPrealloc(r, 1024)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// When
if !gjson.ValidBytes(body) {
http.Error(w, "Failed to parse request body", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusAccepted)
}))
defer server.Close()
tests := []struct {
name string
body []byte
want int
}{
{
name: "null byte in JSON string",
body: []byte("{\"model\":\"gpt-5.5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\x00world\"}]}"),
want: http.StatusAccepted,
},
{
name: "ANSI escape in JSON string",
body: []byte("{\"model\":\"gpt-5.5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\x1b[31mred\x1b[0m\"}]}"),
want: http.StatusAccepted,
},
{
name: "leading UTF-8 BOM",
body: []byte("\xef\xbb\xbf{\"model\":\"gpt-5.5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]}"),
want: http.StatusAccepted,
},
{
name: "truncated JSON",
body: []byte("{\"model\":\"gpt-5.5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]"),
want: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, server.URL+"/v1/chat/completions", bytes.NewReader(tt.body))
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := server.Client().Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != tt.want {
t.Fatalf("status mismatch: got %d want %d", resp.StatusCode, tt.want)
}
})
}
}
func TestNormalizeLenientJSONRequestBody_rejects_expansion_past_limit(t *testing.T) {
// Given
body := []byte("{\"input\":\"\x00\x00\"}")
// When
_, err := NormalizeLenientJSONRequestBody(body, int64(len(body)+5))
// Then
var maxErr *http.MaxBytesError
if !errors.As(err, &maxErr) {
t.Fatalf("expected MaxBytesError, got %T %v", err, err)
}
if maxErr.Limit != int64(len(body)+5) {
t.Fatalf("limit mismatch: got %d want %d", maxErr.Limit, len(body)+5)
}
}
+6 -3
View File
@@ -10,6 +10,7 @@ type Model struct {
}
var defaultModels = []Model{
{ID: "grok-4.5", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.5"},
{ID: "grok-4.3", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.3"},
{ID: "grok-build-0.1", Object: "model", OwnedBy: "xai", DisplayName: "Grok Build 0.1"},
{ID: "grok-composer-2.5-fast", Object: "model", OwnedBy: "xai", DisplayName: "Grok Composer 2.5 Fast"},
@@ -40,13 +41,15 @@ func DefaultModelIDs() []string {
}
func DefaultModelMapping() map[string]string {
mapping := make(map[string]string, len(defaultModels)+3)
mapping := make(map[string]string, len(defaultModels)+5)
for _, model := range defaultModels {
mapping[model.ID] = model.ID
}
mapping["grok"] = "grok-4.3"
mapping["grok-latest"] = "grok-4.3"
mapping["grok"] = "grok-4.5"
mapping["grok-latest"] = "grok-4.5"
mapping["grok-4.5-latest"] = "grok-4.5"
mapping["grok-build"] = "grok-build-0.1"
mapping["grok-build-latest"] = "grok-4.5"
mapping["grok-composer"] = "grok-composer-2.5-fast"
mapping["grok-4.20-reasoning"] = "grok-4.20-0309-reasoning"
mapping["grok-4.20-non-reasoning"] = "grok-4.20-0309-non-reasoning"
+5 -2
View File
@@ -207,9 +207,12 @@ func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) {
t.Parallel()
mapping := DefaultModelMapping()
require.Equal(t, "grok-4.3", mapping["grok"])
require.Equal(t, "grok-4.3", mapping["grok-latest"])
require.Equal(t, "grok-4.5", mapping["grok"])
require.Equal(t, "grok-4.5", mapping["grok-latest"])
require.Equal(t, "grok-4.5", mapping["grok-4.5"])
require.Equal(t, "grok-4.5", mapping["grok-4.5-latest"])
require.Equal(t, "grok-build-0.1", mapping["grok-build"])
require.Equal(t, "grok-4.5", mapping["grok-build-latest"])
require.Equal(t, "grok-composer-2.5-fast", mapping["grok-composer"])
require.Equal(t, "grok-4.20-0309-reasoning", mapping["grok-4.20-reasoning"])
require.Equal(t, "grok-4.20-0309-non-reasoning", mapping["grok-4.20-non-reasoning"])
+105
View File
@@ -14,9 +14,11 @@ import (
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/lib/pq"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
)
@@ -183,6 +185,11 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
group.FieldImagePrice1k,
group.FieldImagePrice2k,
group.FieldImagePrice4k,
group.FieldVideoRateIndependent,
group.FieldVideoRateMultiplier,
group.FieldVideoPrice480p,
group.FieldVideoPrice720p,
group.FieldVideoPrice1080p,
group.FieldClaudeCodeOnly,
group.FieldFallbackGroupID,
group.FieldFallbackGroupIDOnInvalidRequest,
@@ -436,6 +443,9 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param
for i := range keys {
outKeys = append(outKeys, *apiKeyEntityToService(keys[i]))
}
if err := r.attachLastUsedIPs(ctx, outKeys); err != nil {
return nil, nil, err
}
return outKeys, paginationResultFromTotal(int64(total), params), nil
}
@@ -453,9 +463,99 @@ func (r *apiKeyRepository) ListAllByUserID(ctx context.Context, userID int64, fi
for i := range keys {
outKeys = append(outKeys, *apiKeyEntityToService(keys[i]))
}
if err := r.attachLastUsedIPs(ctx, outKeys); err != nil {
return nil, err
}
return outKeys, nil
}
func (r *apiKeyRepository) attachLastUsedIPs(ctx context.Context, keys []service.APIKey) error {
if len(keys) == 0 || r.sql == nil {
return nil
}
apiKeyIDs := make([]int64, 0, len(keys))
for i := range keys {
apiKeyIDs = append(apiKeyIDs, keys[i].ID)
}
lastUsedIPs, err := r.latestUsageLogIPs(ctx, apiKeyIDs)
if err != nil {
return err
}
for i := range keys {
if ip, ok := lastUsedIPs[keys[i].ID]; ok {
keys[i].LastUsedIP = &ip
}
}
return nil
}
func (r *apiKeyRepository) latestUsageLogIPs(ctx context.Context, apiKeyIDs []int64) (result map[int64]string, err error) {
if len(apiKeyIDs) == 0 || r.sql == nil {
return map[int64]string{}, nil
}
query, args := latestUsageLogIPsQuery(apiKeyIDs, r.client.Driver().Dialect())
rows, err := r.sql.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer func() {
if closeErr := rows.Close(); closeErr != nil && err == nil {
err = closeErr
}
}()
out := make(map[int64]string, len(apiKeyIDs))
for rows.Next() {
var apiKeyID int64
var ipAddress string
if err := rows.Scan(&apiKeyID, &ipAddress); err != nil {
return nil, err
}
out[apiKeyID] = ipAddress
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func latestUsageLogIPsQuery(apiKeyIDs []int64, dialectName string) (string, []any) {
if dialectName == dialect.Postgres {
return `
SELECT api_key_id, ip_address
FROM (
SELECT api_key_id, ip_address,
ROW_NUMBER() OVER (PARTITION BY api_key_id ORDER BY created_at DESC, id DESC) AS rn
FROM usage_logs
WHERE api_key_id = ANY($1::bigint[])
AND ip_address IS NOT NULL
AND ip_address <> ''
) ranked
WHERE rn = 1`, []any{pq.Array(apiKeyIDs)}
}
placeholders := make([]string, len(apiKeyIDs))
args := make([]any, len(apiKeyIDs))
for i, id := range apiKeyIDs {
placeholders[i] = "?"
args[i] = id
}
return fmt.Sprintf(`
SELECT api_key_id, ip_address
FROM (
SELECT api_key_id, ip_address,
ROW_NUMBER() OVER (PARTITION BY api_key_id ORDER BY created_at DESC, id DESC) AS rn
FROM usage_logs
WHERE api_key_id IN (%s)
AND ip_address IS NOT NULL
AND ip_address <> ''
) ranked
WHERE rn = 1`, strings.Join(placeholders, ", ")), args
}
func (r *apiKeyRepository) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
if len(apiKeyIDs) == 0 {
return []int64{}, nil
@@ -838,6 +938,11 @@ func groupEntityToService(g *dbent.Group) *service.Group {
ImagePrice4K: g.ImagePrice4k,
BatchImageDiscountMultiplier: g.BatchImageDiscountMultiplier,
BatchImageHoldMultiplier: g.BatchImageHoldMultiplier,
VideoRateIndependent: g.VideoRateIndependent,
VideoRateMultiplier: g.VideoRateMultiplier,
VideoPrice480P: g.VideoPrice480p,
VideoPrice720P: g.VideoPrice720p,
VideoPrice1080P: g.VideoPrice1080p,
DefaultValidityDays: g.DefaultValidityDays,
ClaudeCodeOnly: g.ClaudeCodeOnly,
FallbackGroupID: g.FallbackGroupID,
@@ -8,6 +8,7 @@ import (
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/enttest"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
@@ -30,7 +31,7 @@ func newAPIKeyRepoSQLite(t *testing.T) (*apiKeyRepository, *dbent.Client) {
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
t.Cleanup(func() { _ = client.Close() })
return &apiKeyRepository{client: client}, client
return &apiKeyRepository{client: client, sql: db}, client
}
func mustCreateAPIKeyRepoUser(t *testing.T, ctx context.Context, client *dbent.Client, email string) *service.User {
@@ -45,6 +46,85 @@ func mustCreateAPIKeyRepoUser(t *testing.T, ctx context.Context, client *dbent.C
return userEntityToService(u)
}
func mustCreateAPIKeyRepoAccount(t *testing.T, ctx context.Context, client *dbent.Client, name string) int64 {
t.Helper()
a, err := client.Account.Create().
SetName(name).
SetPlatform(service.PlatformOpenAI).
SetType(service.AccountTypeAPIKey).
SetStatus(service.StatusActive).
SetCredentials(map[string]any{"api_key": "sk-test"}).
Save(ctx)
require.NoError(t, err)
return a.ID
}
func mustCreateAPIKeyRepoUsageLog(t *testing.T, ctx context.Context, client *dbent.Client, userID, apiKeyID, accountID int64, requestID string, createdAt time.Time, ipAddress *string) {
t.Helper()
builder := client.UsageLog.Create().
SetUserID(userID).
SetAPIKeyID(apiKeyID).
SetAccountID(accountID).
SetRequestID(requestID).
SetModel("gpt-5").
SetCreatedAt(createdAt)
if ipAddress != nil {
builder.SetIPAddress(*ipAddress)
}
_, err := builder.Save(ctx)
require.NoError(t, err)
}
func TestAPIKeyRepositoryListByUserIDAttachesLastUsedIP(t *testing.T) {
repo, client := newAPIKeyRepoSQLite(t)
ctx := context.Background()
user := mustCreateAPIKeyRepoUser(t, ctx, client, "list-last-used-ip@test.com")
accountID := mustCreateAPIKeyRepoAccount(t, ctx, client, "acc-list-last-used-ip")
withLogs := &service.APIKey{
UserID: user.ID,
Key: "sk-list-last-used-ip-logs",
Name: "With Logs",
Status: service.StatusActive,
}
emptyOnly := &service.APIKey{
UserID: user.ID,
Key: "sk-list-last-used-ip-empty",
Name: "Empty Only",
Status: service.StatusActive,
}
noLogs := &service.APIKey{
UserID: user.ID,
Key: "sk-list-last-used-ip-none",
Name: "No Logs",
Status: service.StatusActive,
}
require.NoError(t, repo.Create(ctx, withLogs))
require.NoError(t, repo.Create(ctx, emptyOnly))
require.NoError(t, repo.Create(ctx, noLogs))
olderIP := "198.51.100.10"
newerEmptyIP := ""
newestIP := "203.0.113.20"
base := time.Now().UTC().Add(-3 * time.Hour).Truncate(time.Second)
mustCreateAPIKeyRepoUsageLog(t, ctx, client, user.ID, withLogs.ID, accountID, "req-last-ip-older", base, &olderIP)
mustCreateAPIKeyRepoUsageLog(t, ctx, client, user.ID, withLogs.ID, accountID, "req-last-ip-empty", base.Add(time.Hour), &newerEmptyIP)
mustCreateAPIKeyRepoUsageLog(t, ctx, client, user.ID, withLogs.ID, accountID, "req-last-ip-newest", base.Add(2*time.Hour), &newestIP)
mustCreateAPIKeyRepoUsageLog(t, ctx, client, user.ID, emptyOnly.ID, accountID, "req-empty-ip", base.Add(3*time.Hour), &newerEmptyIP)
keys, _, err := repo.ListByUserID(ctx, user.ID, pagination.PaginationParams{Page: 1, PageSize: 10}, service.APIKeyListFilters{})
require.NoError(t, err)
byID := make(map[int64]service.APIKey, len(keys))
for _, key := range keys {
byID[key.ID] = key
}
require.NotNil(t, byID[withLogs.ID].LastUsedIP)
require.Equal(t, newestIP, *byID[withLogs.ID].LastUsedIP)
require.Nil(t, byID[emptyOnly.ID].LastUsedIP)
require.Nil(t, byID[noLogs.ID].LastUsedIP)
}
func TestAPIKeyRepository_CreateWithLastUsedAt(t *testing.T) {
repo, client := newAPIKeyRepoSQLite(t)
ctx := context.Background()
+25
View File
@@ -58,6 +58,11 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
SetNillableImagePrice4k(groupIn.ImagePrice4K).
SetBatchImageDiscountMultiplier(groupIn.BatchImageDiscountMultiplier).
SetBatchImageHoldMultiplier(groupIn.BatchImageHoldMultiplier).
SetVideoRateIndependent(groupIn.VideoRateIndependent).
SetVideoRateMultiplier(groupIn.VideoRateMultiplier).
SetNillableVideoPrice480p(groupIn.VideoPrice480P).
SetNillableVideoPrice720p(groupIn.VideoPrice720P).
SetNillableVideoPrice1080p(groupIn.VideoPrice1080P).
SetDefaultValidityDays(groupIn.DefaultValidityDays).
SetClaudeCodeOnly(groupIn.ClaudeCodeOnly).
SetNillableFallbackGroupID(groupIn.FallbackGroupID).
@@ -143,6 +148,11 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
SetNillableImagePrice4k(groupIn.ImagePrice4K).
SetBatchImageDiscountMultiplier(groupIn.BatchImageDiscountMultiplier).
SetBatchImageHoldMultiplier(groupIn.BatchImageHoldMultiplier).
SetVideoRateIndependent(groupIn.VideoRateIndependent).
SetVideoRateMultiplier(groupIn.VideoRateMultiplier).
SetNillableVideoPrice480p(groupIn.VideoPrice480P).
SetNillableVideoPrice720p(groupIn.VideoPrice720P).
SetNillableVideoPrice1080p(groupIn.VideoPrice1080P).
SetDefaultValidityDays(groupIn.DefaultValidityDays).
SetClaudeCodeOnly(groupIn.ClaudeCodeOnly).
SetModelRoutingEnabled(groupIn.ModelRoutingEnabled).
@@ -190,6 +200,21 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
} else {
builder = builder.ClearImagePrice4k()
}
if groupIn.VideoPrice480P != nil {
builder = builder.SetVideoPrice480p(*groupIn.VideoPrice480P)
} else {
builder = builder.ClearVideoPrice480p()
}
if groupIn.VideoPrice720P != nil {
builder = builder.SetVideoPrice720p(*groupIn.VideoPrice720P)
} else {
builder = builder.ClearVideoPrice720p()
}
if groupIn.VideoPrice1080P != nil {
builder = builder.SetVideoPrice1080p(*groupIn.VideoPrice1080P)
} else {
builder = builder.ClearVideoPrice1080p()
}
// 处理 FallbackGroupID:nil 时清除,否则设置
if groupIn.FallbackGroupID != nil {
@@ -49,6 +49,9 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
requireColumn(t, tx, "usage_logs", "image_output_size", "character varying", 32, true)
requireColumn(t, tx, "usage_logs", "image_size_source", "character varying", 16, true)
requireColumn(t, tx, "usage_logs", "image_size_breakdown", "jsonb", 0, true)
requireColumn(t, tx, "usage_logs", "video_count", "integer", 0, false)
requireColumn(t, tx, "usage_logs", "video_resolution", "character varying", 10, true)
requireColumn(t, tx, "usage_logs", "video_duration_seconds", "integer", 0, true)
requireConstraintDefinitionContains(
t,
tx,
@@ -66,6 +69,9 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
"usage_logs",
"usage_logs_image_billing_size_check",
"image_count",
"billing_mode",
"'video'",
"video_count",
"image_size IS NOT NULL",
"'1K'",
"'2K'",
@@ -80,7 +80,9 @@ func appendUsageLogBillingModeWhereConditionWithAlias(conditions []string, args
placeholder := fmt.Sprintf("$%d", len(args)+1)
switch service.BillingMode(mode) {
case service.BillingModeImage:
conditions = append(conditions, fmt.Sprintf("(%s = %s OR COALESCE(%s, 0) > 0)", column("billing_mode"), placeholder, column("image_count")))
conditions = append(conditions, fmt.Sprintf("(%s = %s OR ((%s IS NULL OR %s = '') AND COALESCE(%s, 0) > 0))", column("billing_mode"), placeholder, column("billing_mode"), column("billing_mode"), column("image_count")))
case service.BillingModeVideo:
conditions = append(conditions, fmt.Sprintf("%s = %s", column("billing_mode"), placeholder))
case service.BillingModeToken:
conditions = append(conditions, fmt.Sprintf("(%s = %s OR ((%s IS NULL OR %s = '') AND COALESCE(%s, 0) <= 0))", column("billing_mode"), placeholder, column("billing_mode"), column("billing_mode"), column("image_count")))
default:
@@ -63,6 +63,9 @@ var usageLogInsertArgTypes = [...]string{
"text", // image_output_size
"text", // image_size_source
"jsonb", // image_size_breakdown
"integer", // video_count
"text", // video_resolution
"integer", // video_duration_seconds
"text", // service_tier
"text", // reasoning_effort
"text", // inbound_endpoint
@@ -252,6 +255,9 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
image_output_size,
image_size_source,
image_size_breakdown,
video_count,
video_resolution,
video_duration_seconds,
service_tier,
reasoning_effort,
inbound_endpoint,
@@ -269,7 +275,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
$10, $11, $12, $13,
$14, $15, $16, $17,
$18, $19, $20, $21, $22, $23,
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53
)
ON CONFLICT (request_id, api_key_id) DO NOTHING
RETURNING id, created_at
@@ -700,6 +706,9 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
image_output_size,
image_size_source,
image_size_breakdown,
video_count,
video_resolution,
video_duration_seconds,
service_tier,
reasoning_effort,
inbound_endpoint,
@@ -713,7 +722,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
created_at
) AS (VALUES `)
args := make([]any, 0, len(keys)*50)
args := make([]any, 0, len(keys)*53)
argPos := 1
for idx, key := range keys {
if idx > 0 {
@@ -781,6 +790,9 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
image_output_size,
image_size_source,
image_size_breakdown,
video_count,
video_resolution,
video_duration_seconds,
service_tier,
reasoning_effort,
inbound_endpoint,
@@ -833,6 +845,9 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
image_output_size,
image_size_source,
image_size_breakdown,
video_count,
video_resolution,
video_duration_seconds,
service_tier,
reasoning_effort,
inbound_endpoint,
@@ -925,6 +940,9 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
image_output_size,
image_size_source,
image_size_breakdown,
video_count,
video_resolution,
video_duration_seconds,
service_tier,
reasoning_effort,
inbound_endpoint,
@@ -938,7 +956,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
created_at
) AS (VALUES `)
args := make([]any, 0, len(preparedList)*50)
args := make([]any, 0, len(preparedList)*53)
argPos := 1
for idx, prepared := range preparedList {
if idx > 0 {
@@ -1003,6 +1021,9 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
image_output_size,
image_size_source,
image_size_breakdown,
video_count,
video_resolution,
video_duration_seconds,
service_tier,
reasoning_effort,
inbound_endpoint,
@@ -1055,6 +1076,9 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
image_output_size,
image_size_source,
image_size_breakdown,
video_count,
video_resolution,
video_duration_seconds,
service_tier,
reasoning_effort,
inbound_endpoint,
@@ -1115,6 +1139,9 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
image_output_size,
image_size_source,
image_size_breakdown,
video_count,
video_resolution,
video_duration_seconds,
service_tier,
reasoning_effort,
inbound_endpoint,
@@ -1132,7 +1159,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
$10, $11, $12, $13,
$14, $15, $16, $17,
$18, $19, $20, $21, $22, $23,
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53
)
ON CONFLICT (request_id, api_key_id) DO NOTHING
`, prepared.args...)
@@ -1163,6 +1190,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
imageOutputSize := nullString(log.ImageOutputSize)
imageSizeSource := nullString(log.ImageSizeSource)
imageSizeBreakdown := nullStringIntMapJSON(log.ImageSizeBreakdown)
videoResolution := nullString(log.VideoResolution)
videoDurationSeconds := nullInt(log.VideoDurationSeconds)
serviceTier := nullString(log.ServiceTier)
reasoningEffort := nullString(log.ReasoningEffort)
inboundEndpoint := nullString(log.InboundEndpoint)
@@ -1227,6 +1256,9 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
imageOutputSize,
imageSizeSource,
imageSizeBreakdown,
log.VideoCount,
videoResolution,
videoDurationSeconds,
serviceTier,
reasoningEffort,
inboundEndpoint,
@@ -19,7 +19,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service"
)
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at"
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at"
func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) {
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1"
@@ -465,6 +465,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
imageOutputSize sql.NullString
imageSizeSource sql.NullString
imageSizeBreakdown sql.NullString
videoCount int
videoResolution sql.NullString
videoDurationSeconds sql.NullInt64
serviceTier sql.NullString
reasoningEffort sql.NullString
inboundEndpoint sql.NullString
@@ -519,6 +522,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
&imageOutputSize,
&imageSizeSource,
&imageSizeBreakdown,
&videoCount,
&videoResolution,
&videoDurationSeconds,
&serviceTier,
&reasoningEffort,
&inboundEndpoint,
@@ -560,6 +566,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
BillingType: int8(billingType),
RequestType: service.RequestTypeFromInt16(requestTypeRaw),
ImageCount: imageCount,
VideoCount: videoCount,
CacheTTLOverridden: cacheTTLOverridden,
CreatedAt: createdAt,
}
@@ -607,6 +614,13 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
log.ImageSizeSource = &imageSizeSource.String
}
log.ImageSizeBreakdown = stringIntMapFromNullJSON(imageSizeBreakdown)
if videoResolution.Valid {
log.VideoResolution = &videoResolution.String
}
if videoDurationSeconds.Valid {
value := int(videoDurationSeconds.Int64)
log.VideoDurationSeconds = &value
}
if serviceTier.Valid {
log.ServiceTier = &serviceTier.String
}
@@ -80,6 +80,9 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) {
sqlmock.AnyArg(), // image_output_size
sqlmock.AnyArg(), // image_size_source
sqlmock.AnyArg(), // image_size_breakdown
sqlmock.AnyArg(), // video_count
sqlmock.AnyArg(), // video_resolution
sqlmock.AnyArg(), // video_duration_seconds
sqlmock.AnyArg(), // service_tier
sqlmock.AnyArg(), // reasoning_effort
sqlmock.AnyArg(), // inbound_endpoint
@@ -163,6 +166,9 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) {
sqlmock.AnyArg(), // image_output_size
sqlmock.AnyArg(), // image_size_source
sqlmock.AnyArg(), // image_size_breakdown
sqlmock.AnyArg(), // video_count
sqlmock.AnyArg(), // video_resolution
sqlmock.AnyArg(), // video_duration_seconds
serviceTier,
sqlmock.AnyArg(),
sqlmock.AnyArg(),
@@ -281,9 +287,14 @@ func TestAppendUsageLogBillingModeWhereCondition(t *testing.T) {
wantCondition string
}{
{
name: "image includes legacy image rows",
name: "image includes explicit image and legacy image rows",
billingMode: string(service.BillingModeImage),
wantCondition: "(billing_mode = $1 OR COALESCE(image_count, 0) > 0)",
wantCondition: "(billing_mode = $1 OR ((billing_mode IS NULL OR billing_mode = '') AND COALESCE(image_count, 0) > 0))",
},
{
name: "video remains exact",
billingMode: string(service.BillingModeVideo),
wantCondition: "billing_mode = $1",
},
{
name: "token includes legacy non-image rows",
@@ -309,7 +320,7 @@ func TestAppendUsageLogBillingModeWhereCondition(t *testing.T) {
func TestAppendUsageLogBillingModeWhereConditionWithAlias(t *testing.T) {
conditions, args := appendUsageLogBillingModeWhereConditionWithAlias(nil, nil, string(service.BillingModeImage), "ul")
require.Equal(t, []string{"(ul.billing_mode = $1 OR COALESCE(ul.image_count, 0) > 0)"}, conditions)
require.Equal(t, []string{"(ul.billing_mode = $1 OR ((ul.billing_mode IS NULL OR ul.billing_mode = '') AND COALESCE(ul.image_count, 0) > 0))"}, conditions)
require.Equal(t, []any{string(service.BillingModeImage)}, args)
}
@@ -794,6 +805,9 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
sql.NullString{Valid: true, String: "3840x2160"},
sql.NullString{Valid: true, String: "output"},
sql.NullString{Valid: true, String: `{"4K":2}`},
0, // video_count
sql.NullString{}, // video_resolution
sql.NullInt64{}, // video_duration_seconds
sql.NullString{},
sql.NullString{},
sql.NullString{},
@@ -862,6 +876,9 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
sql.NullString{}, // image_output_size
sql.NullString{}, // image_size_source
sql.NullString{}, // image_size_breakdown
0, // video_count
sql.NullString{}, // video_resolution
sql.NullInt64{}, // video_duration_seconds
sql.NullString{Valid: true, String: "priority"},
sql.NullString{},
sql.NullString{},
@@ -914,6 +931,9 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
sql.NullString{}, // image_output_size
sql.NullString{}, // image_size_source
sql.NullString{}, // image_size_breakdown
0, // video_count
sql.NullString{}, // video_resolution
sql.NullInt64{}, // video_duration_seconds
sql.NullString{Valid: true, String: "flex"},
sql.NullString{},
sql.NullString{},
@@ -966,6 +986,9 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
sql.NullString{}, // image_output_size
sql.NullString{}, // image_size_source
sql.NullString{}, // image_size_breakdown
0, // video_count
sql.NullString{}, // video_resolution
sql.NullInt64{}, // video_duration_seconds
sql.NullString{Valid: true, String: "priority"},
sql.NullString{},
sql.NullString{},
@@ -234,6 +234,7 @@ func TestAPIContracts(t *testing.T) {
"ip_whitelist": null,
"ip_blacklist": null,
"last_used_at": null,
"last_used_ip": null,
"current_concurrency": 0,
"quota": 0,
"quota_used": 0,
@@ -284,6 +285,7 @@ func TestAPIContracts(t *testing.T) {
"ip_whitelist": null,
"ip_blacklist": null,
"last_used_at": null,
"last_used_ip": null,
"current_concurrency": 0,
"quota": 0,
"quota_used": 0,
@@ -361,12 +363,17 @@ func TestAPIContracts(t *testing.T) {
"image_price_1k": null,
"image_price_2k": null,
"image_price_4k": null,
"video_price_480p": null,
"video_price_720p": null,
"video_price_1080p": null,
"allow_image_generation": false,
"allow_batch_image_generation": false,
"batch_image_discount_multiplier": 0,
"batch_image_hold_multiplier": 0,
"image_rate_independent": false,
"image_rate_multiplier": 0,
"video_rate_independent": false,
"video_rate_multiplier": 0,
"claude_code_only": false,
"allow_messages_dispatch": false,
"fallback_group_id": null,
@@ -2,10 +2,12 @@ package middleware
import (
"errors"
"fmt"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -46,10 +48,32 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
// user/group/platform。
SetOpsFallbackAPIKey(c, apiKey)
if !apiKey.IsActive() {
// disabled / 未知状态 → 无条件拦截(expired 和 quota_exhausted 留给计费阶段,
// 与主中间件 api_key_auth.go 保持一致)。
if !apiKey.IsActive() &&
apiKey.Status != service.StatusAPIKeyExpired &&
apiKey.Status != service.StatusAPIKeyQuotaExhausted {
abortWithGoogleError(c, 401, "API key is disabled")
return
}
// 检查 IP 限制(白名单/黑名单)。与主中间件保持一致,避免 Gemini 端点绕过 Key 的 IP ACL。
if len(apiKey.IPWhitelist) > 0 || len(apiKey.IPBlacklist) > 0 {
clientIP := ip.GetTrustedClientIP(c)
if cfg.TrustForwardedIPForAPIKeyACL() {
clientIP = ip.GetClientIP(c)
}
allowed, _ := ip.CheckIPRestrictionWithCompiledRules(clientIP, apiKey.CompiledIPWhitelist, apiKey.CompiledIPBlacklist)
if !allowed {
if clientIP == "" {
clientIP = "unknown"
}
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonIPRestriction)
abortWithGoogleError(c, 403, fmt.Sprintf("Access denied. Your IP is %s", clientIP))
return
}
}
if apiKey.User == nil {
abortWithGoogleError(c, 401, "User associated with API key not found")
return
@@ -63,6 +87,12 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
abortWithGoogleError(c, 403, message)
return
}
// 专属分组授权校验:用户对该专属分组的授权被撤销后应拒绝(与主中间件一致,防止越权)。
if !validateAPIKeyGroupAllowed(apiKey) {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable)
abortWithGoogleError(c, 403, "API Key 所属专属分组不再允许当前用户使用")
return
}
// 简易模式:跳过余额和订阅检查
if cfg.RunMode == config.RunModeSimple {
@@ -78,6 +108,26 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
return
}
// Key 状态检查(状态字段可能因后台异步刷新而滞后,故显式拦截)。
switch apiKey.Status {
case service.StatusAPIKeyQuotaExhausted:
abortWithGoogleError(c, 429, "API key 额度已用完")
return
case service.StatusAPIKeyExpired:
abortWithGoogleError(c, 403, "API key 已过期")
return
}
// 运行时过期/配额检查(即使状态是 active,也要检查时间和用量,与主中间件一致)。
if apiKey.IsExpired() {
abortWithGoogleError(c, 403, "API key 已过期")
return
}
if apiKey.IsQuotaExhausted() {
abortWithGoogleError(c, 429, "API key 额度已用完")
return
}
isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
if isSubscriptionType && subscriptionService != nil {
subscription, err := subscriptionService.GetActiveSubscription(
+11 -1
View File
@@ -121,7 +121,16 @@ func RegisterGatewayRoutes(
}
h.Gateway.CountTokens(c)
})
gateway.GET("/models", h.Gateway.Models)
// Codex CLI / Codex app refresh their model picker from the provider's
// /models endpoint with a client_version query and expect the ChatGPT
// Codex manifest format; other clients keep the OpenAI-style list.
gateway.GET("/models", func(c *gin.Context) {
if isOpenAIGatewayPlatform(c) && c.Query("client_version") != "" {
h.OpenAIGateway.CodexModels(c)
return
}
h.Gateway.Models(c)
})
gateway.GET("/usage", h.Gateway.Usage)
// OpenAI Responses API: auto-route based on group platform
gateway.POST("/responses", func(c *gin.Context) {
@@ -214,6 +223,7 @@ func RegisterGatewayRoutes(
codexDirect.GET("/responses", func(c *gin.Context) {
h.OpenAIGateway.ResponsesWebSocket(c)
})
codexDirect.GET("/models", h.OpenAIGateway.CodexModels)
}
// OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform
r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) {
@@ -0,0 +1,22 @@
package routes
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
func TestGatewayRoutesCodexModelsManifestPathIsRegistered(t *testing.T) {
router := newGatewayRoutesTestRouter()
registered := make(map[string]bool)
for _, route := range router.Routes() {
if route.Method == http.MethodGet {
registered[route.Path] = true
}
}
require.True(t, registered["/backend-api/codex/models"], "GET /backend-api/codex/models should be registered")
require.True(t, registered["/v1/models"], "GET /v1/models should be registered")
}
+10 -1
View File
@@ -750,10 +750,19 @@ func resolveRequestedModelInMapping(mapping map[string]string, requestedModel st
}
// IsModelSupported 检查模型是否在 model_mapping 中(支持通配符)
// 如果未配置 mapping,返回 true(允许所有模型)
// 如果未配置 mapping,返回 true(允许所有模型)。
//
// 例外:OpenAI OAuth 账号(Codex 上游)的空映射会排除明确属于其他厂商
// 家族的模型(deepseek-*/glm-* 等)——转发阶段 normalizeOpenAIModelForUpstream
// 会把未知模型原样透传,Codex 上游对这类模型必然返回不可重试的 400,导致
// 请求卡死在该账号上、无法 failover 到真正支持该模型的 API Key 账号(#3662)。
// 未知/自定义别名仍保持允许(兼容渠道级映射),见 isOpenAIOAuthServableModel。
func (a *Account) IsModelSupported(requestedModel string) bool {
mapping := a.GetModelMapping()
if len(mapping) == 0 {
if a.IsOpenAIOAuth() && !a.IsOpenAIPassthroughEnabled() {
return isOpenAIOAuthServableModel(requestedModel)
}
return true // 无映射 = 允许所有
}
if mappingSupportsRequestedModel(mapping, requestedModel) {
+33
View File
@@ -153,6 +153,9 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
imagePrice1K := normalizePrice(input.ImagePrice1K)
imagePrice2K := normalizePrice(input.ImagePrice2K)
imagePrice4K := normalizePrice(input.ImagePrice4K)
videoPrice480P := normalizePrice(input.VideoPrice480P)
videoPrice720P := normalizePrice(input.VideoPrice720P)
videoPrice1080P := normalizePrice(input.VideoPrice1080P)
imageRateMultiplier := 1.0
if input.ImageRateMultiplier != nil {
if *input.ImageRateMultiplier < 0 {
@@ -179,6 +182,13 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
if batchImageHoldMultiplier < batchImageDiscountMultiplier {
return nil, errors.New("batch_image_hold_multiplier must be >= batch_image_discount_multiplier")
}
videoRateMultiplier := 1.0
if input.VideoRateMultiplier != nil {
if *input.VideoRateMultiplier < 0 {
return nil, errors.New("video_rate_multiplier must be >= 0")
}
videoRateMultiplier = *input.VideoRateMultiplier
}
peakRateMultiplier := 1.0
if input.PeakRateMultiplier != nil {
@@ -265,6 +275,8 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
ImageRateMultiplier: imageRateMultiplier,
BatchImageDiscountMultiplier: batchImageDiscountMultiplier,
BatchImageHoldMultiplier: batchImageHoldMultiplier,
VideoRateIndependent: input.VideoRateIndependent,
VideoRateMultiplier: videoRateMultiplier,
PeakRateEnabled: peakRateEnabled,
PeakStart: peakStart,
PeakEnd: peakEnd,
@@ -272,6 +284,9 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
ImagePrice1K: imagePrice1K,
ImagePrice2K: imagePrice2K,
ImagePrice4K: imagePrice4K,
VideoPrice480P: videoPrice480P,
VideoPrice720P: videoPrice720P,
VideoPrice1080P: videoPrice1080P,
ClaudeCodeOnly: input.ClaudeCodeOnly,
FallbackGroupID: input.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: fallbackOnInvalidRequest,
@@ -482,6 +497,15 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
group.BatchImageHoldMultiplier < group.BatchImageDiscountMultiplier {
return nil, errors.New("batch_image_hold_multiplier must be >= batch_image_discount_multiplier")
}
if input.VideoRateIndependent != nil {
group.VideoRateIndependent = *input.VideoRateIndependent
}
if input.VideoRateMultiplier != nil {
if *input.VideoRateMultiplier < 0 {
return nil, errors.New("video_rate_multiplier must be >= 0")
}
group.VideoRateMultiplier = *input.VideoRateMultiplier
}
if input.PeakRateEnabled != nil {
group.PeakRateEnabled = *input.PeakRateEnabled
}
@@ -510,6 +534,15 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
if input.ImagePrice4K != nil {
group.ImagePrice4K = normalizePrice(input.ImagePrice4K)
}
if input.VideoPrice480P != nil {
group.VideoPrice480P = normalizePrice(input.VideoPrice480P)
}
if input.VideoPrice720P != nil {
group.VideoPrice720P = normalizePrice(input.VideoPrice720P)
}
if input.VideoPrice1080P != nil {
group.VideoPrice1080P = normalizePrice(input.VideoPrice1080P)
}
// Claude Code 客户端限制
if input.ClaudeCodeOnly != nil {
+10
View File
@@ -201,6 +201,8 @@ type CreateGroupInput struct {
ImageRateMultiplier *float64
BatchImageDiscountMultiplier *float64
BatchImageHoldMultiplier *float64
VideoRateIndependent bool
VideoRateMultiplier *float64
// 高峰时段倍率配置(PeakRateMultiplier 为 nil 时按 1.0 处理)
PeakRateEnabled bool
PeakStart string
@@ -209,6 +211,9 @@ type CreateGroupInput struct {
ImagePrice1K *float64
ImagePrice2K *float64
ImagePrice4K *float64
VideoPrice480P *float64
VideoPrice720P *float64
VideoPrice1080P *float64
ClaudeCodeOnly bool // 仅允许 Claude Code 客户端
FallbackGroupID *int64 // 降级分组 ID
// 无效请求兜底分组 ID(仅 anthropic 平台使用)
@@ -250,6 +255,8 @@ type UpdateGroupInput struct {
ImageRateMultiplier *float64
BatchImageDiscountMultiplier *float64
BatchImageHoldMultiplier *float64
VideoRateIndependent *bool
VideoRateMultiplier *float64
// 高峰时段倍率配置(nil 表示不修改)
PeakRateEnabled *bool
PeakStart *string
@@ -258,6 +265,9 @@ type UpdateGroupInput struct {
ImagePrice1K *float64
ImagePrice2K *float64
ImagePrice4K *float64
VideoPrice480P *float64
VideoPrice720P *float64
VideoPrice1080P *float64
ClaudeCodeOnly *bool // 仅允许 Claude Code 客户端
FallbackGroupID *int64 // 降级分组 ID
// 无效请求兜底分组 ID(仅 anthropic 平台使用)
@@ -174,6 +174,42 @@ func TestAdminService_CreateGroup_WithImagePricing(t *testing.T) {
require.InDelta(t, 0.30, *repo.created.ImagePrice4K, 0.0001)
}
func TestAdminService_CreateGroup_WithVideoPricing(t *testing.T) {
repo := &groupRepoStubForAdmin{}
svc := &adminServiceImpl{groupRepo: repo}
price480P := 0.08
price720P := 0.12
price1080P := 0.18
videoMultiplier := 0.75
input := &CreateGroupInput{
Name: "grok-video",
Description: "Grok video group",
Platform: PlatformGrok,
RateMultiplier: 1.0,
VideoRateIndependent: true,
VideoRateMultiplier: &videoMultiplier,
VideoPrice480P: &price480P,
VideoPrice720P: &price720P,
VideoPrice1080P: &price1080P,
}
group, err := svc.CreateGroup(context.Background(), input)
require.NoError(t, err)
require.NotNil(t, group)
require.NotNil(t, repo.created)
require.True(t, repo.created.VideoRateIndependent)
require.InDelta(t, 0.75, repo.created.VideoRateMultiplier, 1e-12)
require.NotNil(t, repo.created.VideoPrice480P)
require.NotNil(t, repo.created.VideoPrice720P)
require.NotNil(t, repo.created.VideoPrice1080P)
require.InDelta(t, 0.08, *repo.created.VideoPrice480P, 0.0001)
require.InDelta(t, 0.12, *repo.created.VideoPrice720P, 0.0001)
require.InDelta(t, 0.18, *repo.created.VideoPrice1080P, 0.0001)
}
// TestAdminService_CreateGroup_NilImagePricing 测试 ImagePrice 为 nil 时正常创建
func TestAdminService_CreateGroup_NilImagePricing(t *testing.T) {
repo := &groupRepoStubForAdmin{}
@@ -307,6 +343,42 @@ func TestAdminService_UpdateGroup_WithImagePricing(t *testing.T) {
require.InDelta(t, 0.36, *repo.updated.ImagePrice4K, 0.0001)
}
func TestAdminService_UpdateGroup_WithVideoPricing(t *testing.T) {
existingGroup := &Group{
ID: 1,
Name: "existing-grok",
Platform: PlatformGrok,
Status: StatusActive,
}
repo := &groupRepoStubForAdmin{getByID: existingGroup}
svc := &adminServiceImpl{groupRepo: repo}
price480P := 0.09
price720P := 0.13
price1080P := 0.19
videoMultiplier := 0.6
independent := true
input := &UpdateGroupInput{
VideoRateIndependent: &independent,
VideoRateMultiplier: &videoMultiplier,
VideoPrice480P: &price480P,
VideoPrice720P: &price720P,
VideoPrice1080P: &price1080P,
}
group, err := svc.UpdateGroup(context.Background(), 1, input)
require.NoError(t, err)
require.NotNil(t, group)
require.NotNil(t, repo.updated)
require.True(t, repo.updated.VideoRateIndependent)
require.InDelta(t, 0.6, repo.updated.VideoRateMultiplier, 1e-12)
require.InDelta(t, 0.09, *repo.updated.VideoPrice480P, 0.0001)
require.InDelta(t, 0.13, *repo.updated.VideoPrice720P, 0.0001)
require.InDelta(t, 0.19, *repo.updated.VideoPrice1080P, 0.0001)
}
// TestAdminService_UpdateGroup_PartialImagePricing 测试仅更新部分 ImagePrice 字段
func TestAdminService_UpdateGroup_PartialImagePricing(t *testing.T) {
oldPrice2K := 0.15
@@ -542,6 +614,25 @@ func TestAdminService_GroupBatchImagePricingValidation(t *testing.T) {
}
}
func TestAdminService_UpdateGroup_RejectsNegativeVideoRateMultiplier(t *testing.T) {
existingGroup := &Group{
ID: 1,
Name: "existing-group",
Platform: PlatformGrok,
Status: StatusActive,
VideoRateMultiplier: 1,
}
repo := &groupRepoStubForAdmin{getByID: existingGroup}
svc := &adminServiceImpl{groupRepo: repo}
negative := -0.1
_, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{
VideoRateMultiplier: &negative,
})
require.Error(t, err)
require.Nil(t, repo.updated)
}
func TestAdminService_UpdateGroup_InvalidatesAuthCacheOnRPMLimitChange(t *testing.T) {
existingGroup := &Group{
ID: 1,
+1
View File
@@ -40,6 +40,7 @@ type APIKey struct {
CompiledIPWhitelist *ip.CompiledIPRules `json:"-"`
CompiledIPBlacklist *ip.CompiledIPRules `json:"-"`
LastUsedAt *time.Time
LastUsedIP *string
CreatedAt time.Time
UpdatedAt time.Time
User *User
@@ -73,6 +73,11 @@ type APIKeyAuthGroupSnapshot struct {
ImagePrice1K *float64 `json:"image_price_1k,omitempty"`
ImagePrice2K *float64 `json:"image_price_2k,omitempty"`
ImagePrice4K *float64 `json:"image_price_4k,omitempty"`
VideoRateIndependent bool `json:"video_rate_independent"`
VideoRateMultiplier float64 `json:"video_rate_multiplier"`
VideoPrice480P *float64 `json:"video_price_480p,omitempty"`
VideoPrice720P *float64 `json:"video_price_720p,omitempty"`
VideoPrice1080P *float64 `json:"video_price_1080p,omitempty"`
ClaudeCodeOnly bool `json:"claude_code_only"`
FallbackGroupID *int64 `json:"fallback_group_id,omitempty"`
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request,omitempty"`
@@ -14,7 +14,7 @@ import (
"github.com/dgraph-io/ristretto"
)
const apiKeyAuthSnapshotVersion = 13 // v13: include group peak rate fields
const apiKeyAuthSnapshotVersion = 14 // v14: include group video pricing fields
type apiKeyAuthCacheConfig struct {
l1Size int
@@ -265,6 +265,11 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey)
ImagePrice1K: apiKey.Group.ImagePrice1K,
ImagePrice2K: apiKey.Group.ImagePrice2K,
ImagePrice4K: apiKey.Group.ImagePrice4K,
VideoRateIndependent: apiKey.Group.VideoRateIndependent,
VideoRateMultiplier: apiKey.Group.VideoRateMultiplier,
VideoPrice480P: apiKey.Group.VideoPrice480P,
VideoPrice720P: apiKey.Group.VideoPrice720P,
VideoPrice1080P: apiKey.Group.VideoPrice1080P,
ClaudeCodeOnly: apiKey.Group.ClaudeCodeOnly,
FallbackGroupID: apiKey.Group.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: apiKey.Group.FallbackGroupIDOnInvalidRequest,
@@ -343,6 +348,11 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
ImagePrice1K: snapshot.Group.ImagePrice1K,
ImagePrice2K: snapshot.Group.ImagePrice2K,
ImagePrice4K: snapshot.Group.ImagePrice4K,
VideoRateIndependent: snapshot.Group.VideoRateIndependent,
VideoRateMultiplier: snapshot.Group.VideoRateMultiplier,
VideoPrice480P: snapshot.Group.VideoPrice480P,
VideoPrice720P: snapshot.Group.VideoPrice720P,
VideoPrice1080P: snapshot.Group.VideoPrice1080P,
ClaudeCodeOnly: snapshot.Group.ClaudeCodeOnly,
FallbackGroupID: snapshot.Group.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: snapshot.Group.FallbackGroupIDOnInvalidRequest,
+163 -2
View File
@@ -512,6 +512,14 @@ func (s *BillingService) initFallbackPricing() {
SupportsCacheBreakdown: false,
}
// xAI Grok 4.5 (official docs: $2 input / $0.50 cached input / $6 output per MTok)
s.fallbackPrices["grok-4.5"] = &ModelPricing{
InputPricePerToken: 2e-6,
OutputPricePerToken: 6e-6,
CacheReadPricePerToken: 0.5e-6,
SupportsCacheBreakdown: false,
}
// xAI Grok 4.3 (official docs: $1.25 input / $2.50 output per MTok)
s.fallbackPrices["grok-4.3"] = &ModelPricing{
InputPricePerToken: 1.25e-6,
@@ -696,7 +704,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
}
switch modelLower {
case "grok", "grok-latest", "grok-4.3":
case "grok", "grok-latest", "grok-4.5", "grok-4.5-latest", "grok-build-latest":
return s.fallbackPrices["grok-4.5"]
case "grok-4.3":
return s.fallbackPrices["grok-4.3"]
case "grok-build", "grok-build-0.1":
return s.fallbackPrices["grok-build-0.1"]
@@ -771,6 +781,9 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing
if channelPricing == nil {
return pricing, nil
}
// 防止修改 fallbackPrices 中的共享指针
cloned := *pricing
pricing = &cloned
if channelPricing.InputPrice != nil {
pricing.InputPricePerToken = *channelPricing.InputPrice
pricing.InputPricePerTokenPriority = *channelPricing.InputPrice
@@ -1228,6 +1241,29 @@ type ImagePriceConfig struct {
Price4K *float64 // 4K 尺寸价格(nil 表示使用默认值)
}
// VideoPriceConfig 视频生成计费配置。所有价格均为**每秒**单价(USD/s),与 xAI 官方计费口径一致。
type VideoPriceConfig struct {
Price480P *float64 // 480p 每秒价格(nil 表示使用默认值)
Price720P *float64 // 720p 每秒价格(nil 表示使用默认值)
Price1080P *float64 // 1080p 每秒价格(nil 表示使用默认值)
}
const (
defaultImageGenerationPrice = 0.134
defaultGrokImagineImagePrice1K = 0.02
defaultGrokImagineImagePrice2K = 0.02
defaultGrokImagineImageQualityPrice1K = 0.05
defaultGrokImagineImageQualityPrice2K = 0.07
// 视频默认价为 xAI 官方**每秒**输出价格(USD/s),总价 = 每秒价 × 时长(秒)。
defaultGrokImagineVideoPrice480P = 0.05
defaultGrokImagineVideoPrice720P = 0.07
defaultGrokImagineVideo15Price480P = 0.08
defaultGrokImagineVideo15Price720P = 0.14
defaultGrokImagineVideo15Price1080P = 0.25
)
// CalculateImageCost 计算图片生成费用
// model: 请求的模型名称(用于获取 LiteLLM 默认价格)
// imageSize: 图片尺寸 "1K", "2K", "4K"
@@ -1259,6 +1295,35 @@ func (s *BillingService) CalculateImageCost(model string, imageSize string, imag
}
}
// CalculateVideoCost 计算视频生成费用(按秒计费,与 xAI 口径一致)。
// model: 请求的模型名称(用于获取默认价格)
// resolution: 视频分辨率 "480p", "720p", "1080p"
// videoCount: 生成的视频数量
// durationSeconds: 单个视频时长(秒),<=0 时按上游默认时长计
// groupConfig: 分组配置的每秒价格(可能为 nil,表示使用默认值)
// rateMultiplier: 费率倍数
func (s *BillingService) CalculateVideoCost(model string, resolution string, videoCount int, durationSeconds int, groupConfig *VideoPriceConfig, rateMultiplier float64) *CostBreakdown {
if videoCount <= 0 {
return &CostBreakdown{}
}
resolution = NormalizeVideoBillingResolutionOrDefault(resolution)
durationSeconds = NormalizeVideoBillingDurationSecondsOrDefault(durationSeconds)
perSecondPrice := s.getVideoUnitPrice(model, resolution, groupConfig)
totalCost := perSecondPrice * float64(durationSeconds) * float64(videoCount)
if rateMultiplier < 0 {
rateMultiplier = 0
}
actualCost := totalCost * rateMultiplier
return &CostBreakdown{
TotalCost: totalCost,
ActualCost: actualCost,
BillingMode: string(BillingModeVideo),
}
}
// getImageUnitPrice 获取图片单价
func (s *BillingService) getImageUnitPrice(model string, imageSize string, groupConfig *ImagePriceConfig) float64 {
// 优先使用分组配置的价格
@@ -1283,8 +1348,33 @@ func (s *BillingService) getImageUnitPrice(model string, imageSize string, group
return s.getDefaultImagePrice(model, imageSize)
}
func (s *BillingService) getVideoUnitPrice(model string, resolution string, groupConfig *VideoPriceConfig) float64 {
if groupConfig != nil {
switch resolution {
case VideoBillingResolution480P:
if groupConfig.Price480P != nil {
return *groupConfig.Price480P
}
case VideoBillingResolution720P:
if groupConfig.Price720P != nil {
return *groupConfig.Price720P
}
case VideoBillingResolution1080P:
if groupConfig.Price1080P != nil {
return *groupConfig.Price1080P
}
}
}
return s.getDefaultVideoPrice(model, resolution)
}
// getDefaultImagePrice 获取 LiteLLM 默认图片价格
func (s *BillingService) getDefaultImagePrice(model string, imageSize string) float64 {
if price, ok := getDefaultGrokImagineImagePrice(model, imageSize); ok {
return price
}
basePrice := 0.0
// 从 PricingService 获取 output_cost_per_image
@@ -1297,7 +1387,7 @@ func (s *BillingService) getDefaultImagePrice(model string, imageSize string) fl
// 如果没有找到价格,使用硬编码默认值($0.134,来自 gemini-3-pro-image-preview)
if basePrice <= 0 {
basePrice = 0.134
basePrice = defaultImageGenerationPrice
}
// 2K 尺寸 1.5 倍,4K 尺寸翻倍
@@ -1310,3 +1400,74 @@ func (s *BillingService) getDefaultImagePrice(model string, imageSize string) fl
return basePrice
}
func (s *BillingService) getDefaultVideoPrice(model string, resolution string) float64 {
if price, ok := getDefaultGrokImagineVideoPrice(model, resolution); ok {
return price
}
// The bundled LiteLLM schema does not expose an output video generation price.
// Keep the historical model default as the fallback (interpreted as a per-second
// rate; today only Grok models reach video billing, so this path is a safety net),
// while letting group-level video prices override it independently from image prices.
return s.getDefaultImagePrice(model, ImageBillingSize2K)
}
func getDefaultGrokImagineImagePrice(model string, imageSize string) (float64, bool) {
model = strings.ToLower(strings.TrimSpace(model))
switch model {
case "grok-imagine-image-quality":
return getGrokImagineImageTierPrice(
imageSize,
defaultGrokImagineImageQualityPrice1K,
defaultGrokImagineImageQualityPrice2K,
), true
case "grok-imagine", "grok-imagine-image", "grok-imagine-edit":
return getGrokImagineImageTierPrice(
imageSize,
defaultGrokImagineImagePrice1K,
defaultGrokImagineImagePrice2K,
), true
default:
return 0, false
}
}
func getGrokImagineImageTierPrice(imageSize string, price1K float64, price2K float64) float64 {
switch NormalizeImageBillingTierOrDefault(imageSize) {
case ImageBillingSize1K:
return price1K
case ImageBillingSize2K, ImageBillingSize4K:
return price2K
default:
return price2K
}
}
func getDefaultGrokImagineVideoPrice(model string, resolution string) (float64, bool) {
model = strings.ToLower(strings.TrimSpace(model))
switch {
case strings.HasPrefix(model, "grok-imagine-video-1.5"):
switch NormalizeVideoBillingResolutionOrDefault(resolution) {
case VideoBillingResolution480P:
return defaultGrokImagineVideo15Price480P, true
case VideoBillingResolution720P:
return defaultGrokImagineVideo15Price720P, true
case VideoBillingResolution1080P:
return defaultGrokImagineVideo15Price1080P, true
default:
return defaultGrokImagineVideo15Price480P, true
}
case strings.HasPrefix(model, "grok-imagine-video"):
switch NormalizeVideoBillingResolutionOrDefault(resolution) {
case VideoBillingResolution480P:
return defaultGrokImagineVideoPrice480P, true
case VideoBillingResolution720P, VideoBillingResolution1080P:
return defaultGrokImagineVideoPrice720P, true
default:
return defaultGrokImagineVideoPrice480P, true
}
default:
return 0, false
}
}
@@ -872,6 +872,66 @@ func TestCalculateImageCost(t *testing.T) {
require.InDelta(t, 0.134*3, cost.ActualCost, 1e-10)
}
func TestCalculateVideoCostUsesSeparateConfig(t *testing.T) {
svc := newTestBillingService()
imagePrice := 0.4
videoPrice := 0.08
imageCost := svc.CalculateImageCost("grok-imagine-video", "2K", 1, &ImagePriceConfig{Price2K: &imagePrice}, 1.0)
videoCost := svc.CalculateVideoCost("grok-imagine-video", "480p", 1, 10, &VideoPriceConfig{Price480P: &videoPrice}, 0.5)
require.InDelta(t, 0.4, imageCost.TotalCost, 1e-10)
require.InDelta(t, 0.8, videoCost.TotalCost, 1e-10)
require.InDelta(t, 0.4, videoCost.ActualCost, 1e-10)
require.Equal(t, string(BillingModeVideo), videoCost.BillingMode)
}
func TestCalculateVideoCostBillsPerSecond(t *testing.T) {
svc := newTestBillingService()
oneSecond := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 1, nil, 1.0)
fifteenSeconds := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 15, nil, 1.0)
// duration <=0 时按上游默认 8 秒计费,超出上限按 15 秒收敛。
defaultDuration := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 0, nil, 1.0)
clampedDuration := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 999, nil, 1.0)
require.InDelta(t, 0.07, oneSecond.TotalCost, 1e-10)
require.InDelta(t, 0.07*15, fifteenSeconds.TotalCost, 1e-10)
require.InDelta(t, 0.07*8, defaultDuration.TotalCost, 1e-10)
require.InDelta(t, 0.07*15, clampedDuration.TotalCost, 1e-10)
}
func TestCalculateGrokImagineImageCostUsesDefaultRateCard(t *testing.T) {
svc := newTestBillingService()
standard1K := svc.CalculateImageCost("grok-imagine-image", "1K", 1, nil, 1.0)
standard2K := svc.CalculateImageCost("grok-imagine-image", "2K", 1, nil, 1.0)
quality1K := svc.CalculateImageCost("grok-imagine-image-quality", "1K", 1, nil, 1.0)
quality2K := svc.CalculateImageCost("grok-imagine-image-quality", "2K", 1, nil, 1.0)
require.InDelta(t, 0.02, standard1K.TotalCost, 1e-10)
require.InDelta(t, 0.02, standard2K.TotalCost, 1e-10)
require.InDelta(t, 0.05, quality1K.TotalCost, 1e-10)
require.InDelta(t, 0.07, quality2K.TotalCost, 1e-10)
}
func TestCalculateGrokImagineVideoCostUsesDefaultRateCard(t *testing.T) {
svc := newTestBillingService()
// 默认价目为 xAI 官方每秒价格,按 1 秒时长验证每秒单价。
standard480P := svc.CalculateVideoCost("grok-imagine-video", "480p", 1, 1, nil, 1.0)
standard720P := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 1, nil, 1.0)
video15_480P := svc.CalculateVideoCost("grok-imagine-video-1.5", "480p", 1, 1, nil, 1.0)
video15_720P := svc.CalculateVideoCost("grok-imagine-video-1.5", "720p", 1, 1, nil, 1.0)
video15_1080P := svc.CalculateVideoCost("grok-imagine-video-1.5", "1080p", 1, 1, nil, 1.0)
require.InDelta(t, 0.05, standard480P.TotalCost, 1e-10)
require.InDelta(t, 0.07, standard720P.TotalCost, 1e-10)
require.InDelta(t, 0.08, video15_480P.TotalCost, 1e-10)
require.InDelta(t, 0.14, video15_720P.TotalCost, 1e-10)
require.InDelta(t, 0.25, video15_1080P.TotalCost, 1e-10)
}
func TestIsModelSupported(t *testing.T) {
svc := newTestBillingService()
@@ -963,6 +1023,22 @@ func TestCalculateCostWithLongContext_PropagatesError(t *testing.T) {
require.Contains(t, err.Error(), "pricing not found")
}
func TestGetModelPricing_Grok45OfficialFallback(t *testing.T) {
svc := newTestBillingService()
for _, model := range []string{"grok", "grok-latest", "grok-4.5", "grok-4.5-latest", "grok-build-latest"} {
model := model
t.Run(model, func(t *testing.T) {
pricing, err := svc.GetModelPricing(model)
require.NoError(t, err)
require.InDelta(t, 2e-6, pricing.InputPricePerToken, 1e-12)
require.InDelta(t, 6e-6, pricing.OutputPricePerToken, 1e-12)
require.InDelta(t, 0.5e-6, pricing.CacheReadPricePerToken, 1e-12)
require.False(t, pricing.SupportsCacheBreakdown)
})
}
}
func TestCalculateCost_SupportsCacheBreakdown(t *testing.T) {
svc := &BillingService{
cfg: &config.Config{},
+10
View File
@@ -14,6 +14,7 @@ const (
BillingModeToken BillingMode = "token" // 按 token 区间计费
BillingModePerRequest BillingMode = "per_request" // 按次计费(支持上下文窗口分层)
BillingModeImage BillingMode = "image" // 图片计费(当前按次,预留 token 计费)
BillingModeVideo BillingMode = "video" // 视频生成计费(按视频生成次数)
)
// IsValid 检查 BillingMode 是否为合法值
@@ -25,6 +26,15 @@ func (m BillingMode) IsValid() bool {
return false
}
// IsValidUsageFilter 检查 BillingMode 是否可用于使用记录筛选。
func (m BillingMode) IsValidUsageFilter() bool {
switch m {
case BillingModeToken, BillingModePerRequest, BillingModeImage, BillingModeVideo, "":
return true
}
return false
}
const (
BillingModelSourceRequested = "requested"
BillingModelSourceUpstream = "upstream"
@@ -0,0 +1,67 @@
//go:build unit
package service
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestBuildVerifyCodeEmailBody_EscapesSiteName(t *testing.T) {
svc := &EmailService{}
t.Run("escapes_script_injection", func(t *testing.T) {
body := svc.buildVerifyCodeEmailBody("123456", `</h1><script>alert(1)</script><h1>`)
assert.NotContains(t, body, "<script>")
assert.Contains(t, body, "&lt;script&gt;")
})
t.Run("escapes_html_entities", func(t *testing.T) {
body := svc.buildVerifyCodeEmailBody("123456", `A&B<C>"D`)
assert.Contains(t, body, "A&amp;B&lt;C&gt;&#34;D")
})
t.Run("normal_site_name_unchanged", func(t *testing.T) {
body := svc.buildVerifyCodeEmailBody("654321", "My Site")
assert.Contains(t, body, "<h1>My Site</h1>")
assert.Contains(t, body, "654321")
})
}
func TestBuildPasswordResetEmailBody_EscapesSiteName(t *testing.T) {
svc := &EmailService{}
t.Run("escapes_html_tags_in_site_name", func(t *testing.T) {
body := svc.buildPasswordResetEmailBody("https://example.com/reset?token=abc", `</h1><img src=x onerror=alert(1)>`)
assert.NotContains(t, body, "<img src=x")
assert.True(t, strings.Contains(body, "&lt;img"))
})
t.Run("escapes_html_entities", func(t *testing.T) {
body := svc.buildPasswordResetEmailBody("https://example.com/reset", `A&B<C>`)
assert.Contains(t, body, "A&amp;B&lt;C&gt;")
})
t.Run("normal_site_name_and_url_unchanged", func(t *testing.T) {
resetURL := "https://example.com/reset?token=xyz"
body := svc.buildPasswordResetEmailBody(resetURL, "Sub2API")
assert.Contains(t, body, "<h1>Sub2API</h1>")
assert.Contains(t, body, resetURL)
})
t.Run("escapes_ampersand_in_reset_url", func(t *testing.T) {
resetURL := "https://example.com/reset?a=1&b=2"
body := svc.buildPasswordResetEmailBody(resetURL, "Site")
assert.NotContains(t, body, `href="https://example.com/reset?a=1&b=2"`)
assert.Contains(t, body, `href="https://example.com/reset?a=1&amp;b=2"`)
})
}
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"crypto/tls"
"encoding/hex"
"fmt"
"html"
"log/slog"
"math/big"
"net"
@@ -454,7 +455,7 @@ func (s *EmailService) buildVerifyCodeEmailBody(code, siteName string) string {
</div>
</body>
</html>
`, siteName, code)
`, html.EscapeString(siteName), code)
}
// TestSMTPConnectionWithConfig 使用指定配置测试SMTP连接
@@ -673,5 +674,5 @@ func (s *EmailService) buildPasswordResetEmailBody(resetURL, siteName string) st
</div>
</body>
</html>
`, siteName, resetURL, resetURL)
`, html.EscapeString(siteName), html.EscapeString(resetURL), html.EscapeString(resetURL))
}
+9 -3
View File
@@ -165,7 +165,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
// 最低缓存门槛,导致系统级缓存失效)。
//
// 对于非 Claude Code 的第三方客户端(opencode 等),仍然走完整 mimicry。
isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID)
var clientUserAgent string
if c != nil {
clientUserAgent = c.GetHeader("User-Agent")
}
isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(clientUserAgent, parsed.MetadataUserID)
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
if shouldMimicClaudeCode {
@@ -190,7 +194,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
// 未重写时(haiku / 注入开关关闭)剥离客户端 cache_control,与原有行为一致。
// 两种情况下 enforceCacheControlLimit 都会兜底处理上限。
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten}
if s.identityService != nil {
if s.identityService != nil && c != nil {
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
if err == nil && fp != nil {
// metadata 透传开启时跳过 metadata 注入
@@ -220,7 +224,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
if err := replaceBody(applyToolNameRewriteToBody(body, rw)); err != nil {
return nil, err
}
c.Set(toolNameRewriteKey, rw)
if c != nil {
c.Set(toolNameRewriteKey, rw)
}
} else {
if err := replaceBody(applyToolsLastCacheBreakpoint(body)); err != nil {
return nil, err
@@ -360,15 +360,15 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
stickyCacheMissReason = "session_limit"
// 会话限制已满,继续到负载感知选择
} else {
return &AccountSelectionResult{
Account: stickyAccount,
WaitPlan: &AccountWaitPlan{
AccountID: stickyAccountID,
MaxConcurrency: stickyAccount.Concurrency,
Timeout: cfg.StickySessionWaitTimeout,
MaxWaiting: cfg.StickySessionMaxWaiting,
},
}, nil
// 必须走 newSelectionResult 以 hydrate 账号凭证:
// 调度快照中的账号是精简版(OAuth token 等被剥离),
// 直接返回会导致后续转发缺少凭证而鉴权失败。
return s.newSelectionResult(ctx, stickyAccount, false, nil, &AccountWaitPlan{
AccountID: stickyAccountID,
MaxConcurrency: stickyAccount.Concurrency,
Timeout: cfg.StickySessionWaitTimeout,
MaxWaiting: cfg.StickySessionMaxWaiting,
})
}
} else {
stickyCacheMissReason = "wait_queue_full"
@@ -356,7 +356,13 @@ func (s *GatewayService) readUpstreamErrorBody(resp *http.Response) ([]byte, err
}
func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, requestedModel ...string) (*ForwardResult, error) {
body, _ := s.readUpstreamErrorBody(resp)
body, readErr := s.readUpstreamErrorBody(resp)
if readErr != nil {
// 读取失败时 body 可能被截断,错误分类会基于不完整数据;记录日志以便排查,
// 避免静默吞掉导致误判。
logger.LegacyPrintf("service.gateway", "[Forward] Failed to fully read upstream error body: Account=%d(%s) Status=%d err=%v",
account.ID, account.Name, resp.StatusCode, readErr)
}
// 调试日志:打印上游错误响应
logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (non-retryable): Account=%d(%s) Status=%d RequestID=%s Body=%s",
@@ -1023,11 +1029,14 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
if _, werr := fmt.Fprint(w, string(restored)); werr != nil {
clientDisconnected = true
logger.LegacyPrintf("service.gateway", "Client disconnected during streaming, continuing to drain upstream for billing")
break
// 不 break:客户端断开后仍需继续合并本事件及后续事件的 usage,
// 否则会漏计当前事件携带的 usage 导致少计费。后续写入由
// clientDisconnected 守卫跳过。
} else {
flusher.Flush()
lastDataAt = time.Now()
resetKeepaliveTimer()
}
flusher.Flush()
lastDataAt = time.Now()
resetKeepaliveTimer()
}
if data != "" {
if firstTokenMs == nil && data != "[DONE]" {
@@ -796,6 +796,10 @@ func (s *GatewayService) calculateImageCost(
multiplier float64,
) *CostBreakdown {
sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize)
groupConfig := imagePriceConfigFromAPIKey(apiKey)
if apiKeyHasConfiguredImagePrice(apiKey, sizeTier) {
return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
}
if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil {
tokens := UsageTokens{
InputTokens: result.Usage.InputTokens,
@@ -821,14 +825,6 @@ func (s *GatewayService) calculateImageCost(
return cost
}
var groupConfig *ImagePriceConfig
if apiKey.Group != nil {
groupConfig = &ImagePriceConfig{
Price1K: apiKey.Group.ImagePrice1K,
Price2K: apiKey.Group.ImagePrice2K,
Price4K: apiKey.Group.ImagePrice4K,
}
}
return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
}
+51 -30
View File
@@ -43,15 +43,17 @@ func (e GrokMediaEndpoint) IsGenerationRequest() bool {
}
type GrokMediaRequestInfo struct {
Model string
Prompt string
N int
Size string
SizeTier string
InputImageURLs []string
MaskImageURL string
Uploads []OpenAIImagesUpload
MaskUpload *OpenAIImagesUpload
Model string
Prompt string
N int
Size string
SizeTier string
Resolution string
DurationSeconds int
InputImageURLs []string
MaskImageURL string
Uploads []OpenAIImagesUpload
MaskUpload *OpenAIImagesUpload
}
func (r GrokMediaRequestInfo) ModerationBody() []byte {
@@ -114,6 +116,8 @@ func ParseGrokMediaRequest(contentType string, body []byte) GrokMediaRequestInfo
info.Prompt = strings.TrimSpace(info.Prompt)
info.Size = strings.TrimSpace(info.Size)
info.SizeTier = NormalizeImageBillingTierOrDefault(info.Size)
info.Resolution = NormalizeVideoBillingResolutionOrDefault(info.Resolution)
info.DurationSeconds = NormalizeVideoBillingDurationSecondsOrDefault(info.DurationSeconds)
if info.N <= 0 {
info.N = 1
}
@@ -127,6 +131,10 @@ func parseGrokMediaJSONRequest(body []byte, info *GrokMediaRequestInfo) {
info.Model = strings.TrimSpace(gjson.GetBytes(body, "model").String())
info.Prompt = strings.TrimSpace(gjson.GetBytes(body, "prompt").String())
info.Size = strings.TrimSpace(gjson.GetBytes(body, "size").String())
info.Resolution = strings.TrimSpace(gjson.GetBytes(body, "resolution").String())
if duration := gjson.GetBytes(body, "duration"); duration.Exists() && duration.Type == gjson.Number {
info.DurationSeconds = int(duration.Int())
}
if n := gjson.GetBytes(body, "n"); n.Exists() && n.Type == gjson.Number {
info.N = int(n.Int())
}
@@ -226,6 +234,12 @@ func parseGrokMediaMultipartRequest(contentType string, body []byte, info *GrokM
info.Prompt = value
case "size":
info.Size = value
case "resolution":
info.Resolution = value
case "duration":
if duration, err := strconv.Atoi(value); err == nil {
info.DurationSeconds = duration
}
case "n":
if n, err := strconv.Atoi(value); err == nil {
info.N = n
@@ -351,18 +365,21 @@ func (s *OpenAIGatewayService) ForwardGrokMedia(
writeGrokMediaResponse(c, resp, respBody, s.responseHeaderFilter)
usage := grokMediaUsageFromResponse(endpoint, requestInfo, respBody)
return &OpenAIForwardResult{
RequestID: requestIDHeader,
ResponseID: usage.ResponseID,
Usage: usage.Usage,
Model: requestModel,
BillingModel: requestModel,
UpstreamModel: requestModel,
ResponseHeaders: resp.Header.Clone(),
Duration: time.Since(startTime),
ImageCount: usage.ImageCount,
ImageSize: usage.ImageSize,
ImageInputSize: usage.ImageInputSize,
ImageOutputSizes: usage.ImageOutputSizes,
RequestID: requestIDHeader,
ResponseID: usage.ResponseID,
Usage: usage.Usage,
Model: requestModel,
BillingModel: requestModel,
UpstreamModel: requestModel,
ResponseHeaders: resp.Header.Clone(),
Duration: time.Since(startTime),
ImageCount: usage.ImageCount,
ImageSize: usage.ImageSize,
ImageInputSize: usage.ImageInputSize,
ImageOutputSizes: usage.ImageOutputSizes,
VideoCount: usage.VideoCount,
VideoResolution: usage.VideoResolution,
VideoDurationSeconds: usage.VideoDurationSeconds,
}, nil
}
@@ -465,12 +482,15 @@ func normalizeGrokMediaModelForEndpoint(endpoint GrokMediaEndpoint, model string
}
type grokMediaUsageMetadata struct {
ResponseID string
Usage OpenAIUsage
ImageCount int
ImageSize string
ImageInputSize string
ImageOutputSizes []string
ResponseID string
Usage OpenAIUsage
ImageCount int
ImageSize string
ImageInputSize string
ImageOutputSizes []string
VideoCount int
VideoResolution string
VideoDurationSeconds int
}
func grokMediaUsageFromResponse(endpoint GrokMediaEndpoint, requestInfo GrokMediaRequestInfo, responseBody []byte) grokMediaUsageMetadata {
@@ -491,10 +511,11 @@ func grokMediaUsageFromResponse(endpoint GrokMediaEndpoint, requestInfo GrokMedi
meta.ImageOutputSizes = collectOpenAIResponseImageOutputSizesFromJSONBytes(responseBody)
case GrokMediaEndpointVideosGenerations:
meta.ResponseID = extractGrokMediaVideoRequestID(responseBody)
// Video generation is one billable media unit; the legacy usage schema stores it in ImageCount.
meta.VideoCount = 1
meta.VideoResolution = requestInfo.Resolution
meta.VideoDurationSeconds = requestInfo.DurationSeconds
// Keep the legacy media-unit counter populated for existing usage displays.
meta.ImageCount = 1
meta.ImageSize = requestInfo.SizeTier
meta.ImageInputSize = requestInfo.Size
}
return meta
}
+20
View File
@@ -45,6 +45,11 @@ type Group struct {
ImagePrice4K *float64
BatchImageDiscountMultiplier float64
BatchImageHoldMultiplier float64
VideoRateIndependent bool
VideoRateMultiplier float64
VideoPrice480P *float64
VideoPrice720P *float64
VideoPrice1080P *float64
// Claude Code 客户端限制
ClaudeCodeOnly bool
@@ -125,6 +130,21 @@ func (g *Group) GetImagePrice(imageSize string) *float64 {
}
}
// GetVideoPrice 根据 resolution 返回对应的视频生成价格。
// 如果分组未配置价格,返回 nil(调用方应使用默认值)。
func (g *Group) GetVideoPrice(resolution string) *float64 {
switch NormalizeVideoBillingResolutionOrDefault(resolution) {
case VideoBillingResolution480P:
return g.VideoPrice480P
case VideoBillingResolution720P:
return g.VideoPrice720P
case VideoBillingResolution1080P:
return g.VideoPrice1080P
default:
return g.VideoPrice480P
}
}
// IsGroupContextValid reports whether a group from context has the fields required for routing decisions.
func IsGroupContextValid(group *Group) bool {
if group == nil {
@@ -9,3 +9,13 @@ func resolveImageRateMultiplier(apiKey *APIKey, effectiveGroupMultiplier float64
}
return effectiveGroupMultiplier
}
func resolveVideoRateMultiplier(apiKey *APIKey, effectiveGroupMultiplier float64) float64 {
if apiKey != nil && apiKey.Group != nil && apiKey.Group.VideoRateIndependent {
if apiKey.Group.VideoRateMultiplier < 0 {
return 0
}
return apiKey.Group.VideoRateMultiplier
}
return effectiveGroupMultiplier
}
@@ -0,0 +1,31 @@
package service
func imagePriceConfigFromAPIKey(apiKey *APIKey) *ImagePriceConfig {
if apiKey == nil || apiKey.Group == nil {
return nil
}
return &ImagePriceConfig{
Price1K: apiKey.Group.ImagePrice1K,
Price2K: apiKey.Group.ImagePrice2K,
Price4K: apiKey.Group.ImagePrice4K,
}
}
func apiKeyHasConfiguredImagePrice(apiKey *APIKey, imageSize string) bool {
return apiKey != nil && apiKey.Group != nil && apiKey.Group.GetImagePrice(imageSize) != nil
}
func videoPriceConfigFromAPIKey(apiKey *APIKey) *VideoPriceConfig {
if apiKey == nil || apiKey.Group == nil {
return nil
}
return &VideoPriceConfig{
Price480P: apiKey.Group.VideoPrice480P,
Price720P: apiKey.Group.VideoPrice720P,
Price1080P: apiKey.Group.VideoPrice1080P,
}
}
func apiKeyHasConfiguredVideoPrice(apiKey *APIKey, resolution string) bool {
return apiKey != nil && apiKey.Group != nil && apiKey.Group.GetVideoPrice(resolution) != nil
}
@@ -150,6 +150,10 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin
// 区间不匹配时回退到 BasePricing,也需要覆盖图片价格
if resolved.BasePricing == nil {
resolved.BasePricing = &ModelPricing{}
} else {
// 防止修改 fallbackPrices 中的共享指针
cloned := *resolved.BasePricing
resolved.BasePricing = &cloned
}
if chPricing.ImageOutputPrice != nil {
resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
@@ -163,6 +167,10 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin
// 否则用 flat 字段覆盖 BasePricing
if resolved.BasePricing == nil {
resolved.BasePricing = &ModelPricing{}
} else {
// 防止修改 fallbackPrices 中的共享指针
cloned := *resolved.BasePricing
resolved.BasePricing = &cloned
}
if chPricing.InputPrice != nil {
@@ -727,3 +727,63 @@ func TestApplyTokenOverrides_IntervalSetsImageOutputPriceExplicit(t *testing.T)
require.True(t, pricing.ImageOutputPriceExplicit)
require.Equal(t, 0.0, pricing.ImageOutputPricePerToken)
}
// ===========================================================================
// 10. Regression: channel overrides must not pollute fallbackPrices
// ===========================================================================
// TestApplyTokenOverrides_FlatDoesNotPolluteFallbackPrices verifies that the
// flat-override path in applyTokenOverrides clones the BasePricing struct
// before mutation, so the shared fallbackPrices map entry is not written through.
func TestApplyTokenOverrides_FlatDoesNotPolluteFallbackPrices(t *testing.T) {
r := newResolverWithChannel(t, []ChannelModelPricing{{
Platform: "anthropic",
Models: []string{"claude-sonnet-4"},
BillingMode: BillingModeToken,
InputPrice: testPtrFloat64(10e-6), // base is 3e-6
OutputPrice: testPtrFloat64(50e-6), // base is 15e-6
}})
resolved := r.Resolve(context.Background(), PricingInput{
Model: "claude-sonnet-4",
GroupID: groupIDPtr(),
})
// Resolved pricing should reflect the channel override
require.NotNil(t, resolved)
require.InDelta(t, 10e-6, resolved.BasePricing.InputPricePerToken, 1e-12)
require.InDelta(t, 50e-6, resolved.BasePricing.OutputPricePerToken, 1e-12)
// Global fallbackPrices must NOT be polluted
fp := r.billingService.fallbackPrices["claude-sonnet-4"]
require.InDelta(t, 3e-6, fp.InputPricePerToken, 1e-12, "fallback InputPricePerToken polluted")
require.InDelta(t, 15e-6, fp.OutputPricePerToken, 1e-12, "fallback OutputPricePerToken polluted")
require.False(t, fp.ImageOutputPriceExplicit, "fallback ImageOutputPriceExplicit polluted")
}
// TestApplyTokenOverrides_IntervalDoesNotPolluteFallbackPrices verifies that
// the interval-override path also clones before mutation.
func TestApplyTokenOverrides_IntervalDoesNotPolluteFallbackPrices(t *testing.T) {
r := newResolverWithChannel(t, []ChannelModelPricing{{
Platform: "anthropic",
Models: []string{"claude-sonnet-4"},
BillingMode: BillingModeToken,
Intervals: []PricingInterval{
{MinTokens: 0, MaxTokens: testPtrInt(100000), InputPrice: testPtrFloat64(2e-6), OutputPrice: testPtrFloat64(8e-6)},
},
}})
resolved := r.Resolve(context.Background(), PricingInput{
Model: "claude-sonnet-4",
GroupID: groupIDPtr(),
})
require.NotNil(t, resolved)
require.True(t, resolved.BasePricing.ImageOutputPriceExplicit)
// Global fallbackPrices must NOT be polluted
fp := r.billingService.fallbackPrices["claude-sonnet-4"]
require.InDelta(t, 3e-6, fp.InputPricePerToken, 1e-12, "fallback InputPricePerToken polluted")
require.InDelta(t, 15e-6, fp.OutputPricePerToken, 1e-12, "fallback OutputPricePerToken polluted")
require.False(t, fp.ImageOutputPriceExplicit, "fallback ImageOutputPriceExplicit polluted")
}
@@ -0,0 +1,107 @@
package service
import (
"context"
"io"
"net/http"
"net/url"
"strings"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
)
// chatgptCodexModelsURL is the ChatGPT Codex models manifest endpoint.
// Package-level variable so tests can point it at a stub server.
var chatgptCodexModelsURL = "https://chatgpt.com/backend-api/codex/models"
const codexModelsManifestBodyLimit int64 = 8 << 20
// CodexModelsManifest carries the raw upstream manifest payload plus caching
// metadata so handlers can pass both through to the client untouched.
type CodexModelsManifest struct {
Body []byte
ETag string
NotModified bool
}
// FetchCodexModelsManifest fetches the live Codex models manifest from the
// ChatGPT backend using the account's OAuth credentials.
//
// The response body is passed through verbatim: the manifest schema evolves
// with Codex client releases, and interpreting it here would force the gateway
// to chase upstream changes. Passing it through keeps the gateway
// schema-agnostic and always reflects the account's real entitlements.
func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, account *Account, clientVersion, ifNoneMatch string) (*CodexModelsManifest, error) {
if account == nil {
return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_ACCOUNT_REQUIRED", "account is required")
}
credAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account)
if err != nil {
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_CREDENTIALS_FAILED", "resolve credential account: %v", err)
}
accessToken := credAccount.GetOpenAIAccessToken()
if accessToken == "" {
return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_TOKEN_MISSING", "account has no Codex backend access token")
}
clientVersion = strings.TrimSpace(clientVersion)
if clientVersion == "" {
clientVersion = openAICodexProbeVersion
}
requestURL := chatgptCodexModelsURL + "?client_version=" + url.QueryEscape(clientVersion)
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, requestURL, nil)
if err != nil {
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_REQUEST_FAILED", "create codex models request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
req.Header.Set("Originator", "codex_cli_rs")
req.Header.Set("Version", clientVersion)
req.Header.Set("User-Agent", codexCLIUserAgent)
if ifNoneMatch = strings.TrimSpace(ifNoneMatch); ifNoneMatch != "" {
req.Header.Set("If-None-Match", ifNoneMatch)
}
setOpenAIChatGPTAccountHeaders(req.Header, credAccount)
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
client, err := httpclient.GetClient(httpclient.Options{
ProxyURL: proxyURL,
Timeout: 15 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
})
if err != nil {
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_PROXY_INVALID", "invalid proxy configuration: %v", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest request failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotModified {
return &CodexModelsManifest{ETag: resp.Header.Get("ETag"), NotModified: true}, nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
message := strings.TrimSpace(string(body))
if message == "" {
message = resp.Status
}
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest upstream error %d: %s", resp.StatusCode, message)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, codexModelsManifestBodyLimit))
if err != nil {
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "read codex models manifest response: %v", err)
}
return &CodexModelsManifest{Body: body, ETag: resp.Header.Get("ETag")}, nil
}
@@ -0,0 +1,138 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func newCodexModelsTestAccount() *Account {
return &Account{
ID: 1,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Credentials: map[string]any{
"access_token": "test-access-token",
"chatgpt_account_id": "acc-123",
},
}
}
func TestFetchCodexModelsManifestPassthrough(t *testing.T) {
manifestBody := `{"models":[{"slug":"gpt-5.5","display_name":"GPT-5.5"}]}`
var gotAuth, gotAccountID, gotOriginator, gotClientVersion string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotAccountID = r.Header.Get("chatgpt-account-id")
gotOriginator = r.Header.Get("Originator")
gotClientVersion = r.URL.Query().Get("client_version")
w.Header().Set("ETag", `W/"abc123"`)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(manifestBody))
}))
defer server.Close()
original := chatgptCodexModelsURL
chatgptCodexModelsURL = server.URL
defer func() { chatgptCodexModelsURL = original }()
s := &OpenAIGatewayService{}
manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", "")
if err != nil {
t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
}
if string(manifest.Body) != manifestBody {
t.Errorf("body not passed through verbatim: got %q", manifest.Body)
}
if manifest.ETag != `W/"abc123"` {
t.Errorf("etag not passed through: got %q", manifest.ETag)
}
if gotAuth != "Bearer test-access-token" {
t.Errorf("authorization header: got %q", gotAuth)
}
if gotAccountID != "acc-123" {
t.Errorf("chatgpt-account-id header: got %q", gotAccountID)
}
if gotOriginator != "codex_cli_rs" {
t.Errorf("originator header: got %q", gotOriginator)
}
if gotClientVersion != "0.137.0" {
t.Errorf("client_version query: got %q", gotClientVersion)
}
}
func TestFetchCodexModelsManifestDefaultClientVersion(t *testing.T) {
var gotClientVersion string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotClientVersion = r.URL.Query().Get("client_version")
_, _ = w.Write([]byte(`{"models":[]}`))
}))
defer server.Close()
original := chatgptCodexModelsURL
chatgptCodexModelsURL = server.URL
defer func() { chatgptCodexModelsURL = original }()
s := &OpenAIGatewayService{}
if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "", ""); err != nil {
t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
}
if gotClientVersion != openAICodexProbeVersion {
t.Errorf("default client_version: got %q, want %q", gotClientVersion, openAICodexProbeVersion)
}
}
func TestFetchCodexModelsManifestNotModified(t *testing.T) {
var gotIfNoneMatch string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotIfNoneMatch = r.Header.Get("If-None-Match")
w.Header().Set("ETag", `W/"abc123"`)
w.WriteHeader(http.StatusNotModified)
}))
defer server.Close()
original := chatgptCodexModelsURL
chatgptCodexModelsURL = server.URL
defer func() { chatgptCodexModelsURL = original }()
s := &OpenAIGatewayService{}
manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", `W/"abc123"`)
if err != nil {
t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
}
if !manifest.NotModified {
t.Error("expected NotModified to be true")
}
if gotIfNoneMatch != `W/"abc123"` {
t.Errorf("if-none-match header: got %q", gotIfNoneMatch)
}
}
func TestFetchCodexModelsManifestUpstreamError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"detail":"boom"}`, http.StatusInternalServerError)
}))
defer server.Close()
original := chatgptCodexModelsURL
chatgptCodexModelsURL = server.URL
defer func() { chatgptCodexModelsURL = original }()
s := &OpenAIGatewayService{}
if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", ""); err == nil {
t.Fatal("expected error for upstream 500, got nil")
}
}
func TestFetchCodexModelsManifestMissingToken(t *testing.T) {
account := newCodexModelsTestAccount()
delete(account.Credentials, "access_token")
s := &OpenAIGatewayService{}
if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", ""); err == nil {
t.Fatal("expected error for missing access token, got nil")
}
}
@@ -153,6 +153,16 @@ func patchGrokResponsesBody(body []byte, upstreamModel string) ([]byte, error) {
}
}
}
if strings.EqualFold(upstreamModel, "grok-4.5") {
for _, unsupportedField := range []string{"presence_penalty", "presencePenalty", "frequency_penalty", "frequencyPenalty", "stop"} {
if gjson.GetBytes(out, unsupportedField).Exists() {
out, err = sjson.DeleteBytes(out, unsupportedField)
if err != nil {
return nil, err
}
}
}
}
out, err = sanitizeGrokResponsesUnsupportedFields(out)
if err != nil {
return nil, err
@@ -41,6 +41,50 @@ func TestPatchGrokResponsesBodySetsMappedModelAndDropsUnsupportedFields(t *testi
require.Equal(t, "high", gjson.GetBytes(patched, "reasoning.effort").String())
}
func TestPatchGrokResponsesBodyDropsGrok45ReasoningUnsupportedFields(t *testing.T) {
t.Parallel()
body := []byte(`{
"model": "grok-latest",
"input": "hello",
"presence_penalty": 0.1,
"presencePenalty": 0.2,
"frequency_penalty": 0.3,
"frequencyPenalty": 0.4,
"stop": ["done"]
}`)
patched, err := patchGrokResponsesBody(body, "grok-4.5")
require.NoError(t, err)
require.True(t, json.Valid(patched))
require.Equal(t, "grok-4.5", gjson.GetBytes(patched, "model").String())
require.False(t, gjson.GetBytes(patched, "presence_penalty").Exists())
require.False(t, gjson.GetBytes(patched, "presencePenalty").Exists())
require.False(t, gjson.GetBytes(patched, "frequency_penalty").Exists())
require.False(t, gjson.GetBytes(patched, "frequencyPenalty").Exists())
require.False(t, gjson.GetBytes(patched, "stop").Exists())
}
func TestPatchGrokResponsesBodyKeepsPenaltyAndStopFieldsForNon45Models(t *testing.T) {
t.Parallel()
body := []byte(`{
"model": "grok-4.3",
"input": "hello",
"presence_penalty": 0.1,
"frequency_penalty": 0.2,
"stop": ["done"]
}`)
patched, err := patchGrokResponsesBody(body, "grok-4.3")
require.NoError(t, err)
require.True(t, json.Valid(patched))
require.Equal(t, "grok-4.3", gjson.GetBytes(patched, "model").String())
require.Equal(t, 0.1, gjson.GetBytes(patched, "presence_penalty").Float())
require.Equal(t, 0.2, gjson.GetBytes(patched, "frequency_penalty").Float())
require.Len(t, gjson.GetBytes(patched, "stop").Array(), 1)
}
func TestPatchGrokResponsesBodyDropsNestedUnsupportedFields(t *testing.T) {
t.Parallel()
@@ -201,6 +245,13 @@ func TestParseGrokMediaRequestBuildsMultipartModerationBody(t *testing.T) {
require.True(t, strings.HasPrefix(gjson.GetBytes(moderationBody, "images.0.image_url").String(), "data:image/"))
}
func TestParseGrokMediaVideoRequestResolution(t *testing.T) {
info := ParseGrokMediaRequest("application/json", []byte(`{"model":"grok-imagine-video","prompt":"waves","resolution":"720p"}`))
require.Equal(t, "grok-imagine-video", info.Model)
require.Equal(t, "720p", info.Resolution)
}
func TestNormalizeGrokMediaModelForEndpoint(t *testing.T) {
tests := []struct {
name string
@@ -330,7 +381,7 @@ func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
body := []byte(`{"model":"grok-imagine-video-1.5","prompt":"waves"}`)
body := []byte(`{"model":"grok-imagine-video-1.5","prompt":"waves","resolution":"720p","duration":10}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos/generations", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -358,12 +409,16 @@ func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T)
result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointVideosGenerations, "", body, "application/json")
require.NoError(t, err)
require.Equal(t, "https://xai.test/v1/videos/generations", upstream.lastReq.URL.String())
require.JSONEq(t, `{"model":"grok-imagine-video","prompt":"waves"}`, string(upstream.lastBody))
require.JSONEq(t, `{"model":"grok-imagine-video","prompt":"waves","resolution":"720p","duration":10}`, string(upstream.lastBody))
require.Equal(t, "video-request-123", result.ResponseID)
require.Equal(t, "grok-imagine-video", result.BillingModel)
require.Equal(t, 3, result.Usage.InputTokens)
require.Equal(t, 4, result.Usage.OutputTokens)
require.Equal(t, 1, result.ImageCount)
require.Empty(t, result.ImageSize)
require.Equal(t, 1, result.VideoCount)
require.Equal(t, VideoBillingResolution720P, result.VideoResolution)
require.Equal(t, 10, result.VideoDurationSeconds)
}
func TestForwardGrokMediaVideoGenerationPreservesImageToVideoModel(t *testing.T) {
@@ -402,6 +457,8 @@ func TestForwardGrokMediaVideoGenerationPreservesImageToVideoModel(t *testing.T)
require.JSONEq(t, `{"model":"grok-imagine-video-1.5","prompt":"animate","image":{"image_url":"data:image/png;base64,aW1n"}}`, string(upstream.lastBody))
require.Equal(t, "video-request-456", result.ResponseID)
require.Equal(t, "grok-imagine-video-1.5", result.BillingModel)
// 未指定 duration 时按上游默认 8 秒计费。
require.Equal(t, VideoBillingDefaultDurationSeconds, result.VideoDurationSeconds)
}
func TestForwardGrokMediaVideoStatusUsesGETWithoutBody(t *testing.T) {
@@ -548,9 +605,9 @@ func TestForwardAsChatCompletionsForGrokUsesXAIChatCompletionsAndSnapshots(t *te
require.NoError(t, err)
require.Equal(t, xai.DefaultCLIBaseURL+"/chat/completions", upstream.lastReq.URL.String())
require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization"))
require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "grok", result.Model)
require.Equal(t, "grok-4.3", result.UpstreamModel)
require.Equal(t, "grok-4.5", result.UpstreamModel)
require.Equal(t, 1, result.Usage.InputTokens)
require.Equal(t, 2, result.Usage.OutputTokens)
require.NotNil(t, repo.updates[51][grokQuotaSnapshotExtraKey])
@@ -613,7 +670,7 @@ func TestForwardGrokResponsesStreamingUsesXAIResponsesAndSnapshots(t *testing.T)
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization"))
require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta"))
require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String())
require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
require.True(t, result.Stream)
require.Equal(t, "resp_grok", result.ResponseID)
@@ -683,7 +740,7 @@ func TestForwardAsChatCompletionsForGrokStreamingUsesRawXAIChatCompletions(t *te
require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization"))
require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept"))
require.Equal(t, "sub2api-grok/1.0", upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String())
require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool())
require.True(t, result.Stream)
require.Equal(t, 6, result.Usage.InputTokens)
@@ -800,11 +857,11 @@ func TestForwardAsAnthropicForGrokUsesXAIResponses(t *testing.T) {
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization"))
require.Equal(t, "sub2api-grok/1.0", upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String())
require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
require.NotContains(t, string(upstream.lastBody), "chatgpt.com")
require.Equal(t, "grok", result.Model)
require.Equal(t, "grok-4.3", result.UpstreamModel)
require.Equal(t, "grok-4.5", result.UpstreamModel)
require.Equal(t, 5, result.Usage.InputTokens)
require.Equal(t, 2, result.Usage.OutputTokens)
require.Contains(t, recorder.Body.String(), `"type":"message"`)
@@ -302,18 +302,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
}
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
if err != nil {
safeErr := sanitizeUpstreamErrorMessage(err.Error())
setOpsUpstreamError(c, 0, safeErr, "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: 0,
Kind: "request_error",
Message: safeErr,
})
writeAnthropicError(c, http.StatusBadGateway, "api_error", "Upstream request failed")
return nil, fmt.Errorf("upstream request failed: %s", safeErr)
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
}
defer func() { _ = resp.Body.Close() }()
@@ -359,7 +348,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
result, handleErr = s.handleAnthropicStreamingResponse(resp, c, account, originalModel, billingModel, upstreamModel, startTime)
} else {
// Client wants JSON: buffer the streaming response and assemble a JSON reply.
result, handleErr = s.handleAnthropicBufferedStreamingResponse(resp, c, originalModel, billingModel, upstreamModel, startTime)
result, handleErr = s.handleAnthropicBufferedStreamingResponse(resp, c, account, originalModel, billingModel, upstreamModel, startTime)
}
// cyber_policy:标记已设、error 已按 Anthropic 格式发给客户端。丢弃 result、返回哨兵,
@@ -435,6 +424,7 @@ func (s *OpenAIGatewayService) handleAnthropicErrorResponse(
func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
resp *http.Response,
c *gin.Context,
account *Account,
originalModel string,
billingModel string,
upstreamModel string,
@@ -452,8 +442,6 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
return nil, fmt.Errorf("upstream stream ended without terminal event")
}
// cyber_policy:上游硬阻断(response.failed)。anthropic buffered 原对 failed 无特殊分支,
// 此处仅为 cyber 增加:以 Anthropic 错误格式回写,标记供 handler 事后写风控/邮件/tokens=0 用量行。
if strings.TrimSpace(finalResponse.Status) == "failed" {
payload, _ := json.Marshal(gin.H{"type": "response.failed", "response": finalResponse})
if hit, code, msg := detectOpenAICyberPolicy(payload); hit {
@@ -472,6 +460,13 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", clientMsg)
return nil, fmt.Errorf("openai cyber_policy: %s", msg)
}
message := openAICompatFailedResponseMessage(finalResponse)
if openAIStreamFailedEventShouldFailover(payload, message) {
return nil, s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message)
}
message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payload, message)
writeAnthropicError(c, http.StatusBadGateway, "api_error", message)
return nil, fmt.Errorf("upstream response failed: %s", message)
}
// When the terminal event has an empty output array, reconstruct from
@@ -712,6 +707,8 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
firstChunk := true
clientDisconnected := false
clientOutputStarted := false
var streamFailoverErr error
var streamNonFailoverErr error
scanner := s.newUpstreamSSEScanner(resp.Body)
@@ -778,7 +775,8 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
// cyber_policy 致命不可重试:标记供 handler 事后记录;以 Anthropic SSE error 事件
// 回写让客户端感知并停止重试(F4),丢弃后续转换输出。
if strings.TrimSpace(event.Type) == "response.failed" {
if hit, code, msg := detectOpenAICyberPolicy([]byte(payload)); hit {
payloadBytes := []byte(payload)
if hit, code, msg := detectOpenAICyberPolicy(payloadBytes); hit {
MarkOpsCyberPolicy(c, CyberPolicyMark{
Code: code,
Message: msg,
@@ -800,6 +798,25 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
}
return true
}
message := extractOpenAISSEErrorMessage(payloadBytes)
if openAIStreamFailedEventShouldFailover(payloadBytes, message) {
streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message)
return true
}
message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payloadBytes, message)
if !clientDisconnected {
if !clientOutputStarted {
writeAnthropicError(c, http.StatusBadGateway, "api_error", message)
clientOutputStarted = true
} else {
writeStreamHeaders()
if _, err := fmt.Fprint(c.Writer, buildAnthropicStreamErrorSSE("api_error", message)); err == nil {
c.Writer.Flush()
}
}
}
streamNonFailoverErr = fmt.Errorf("upstream response failed: %s", message)
return true
}
}
@@ -834,6 +851,12 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
// finalizeStream sends any remaining Anthropic events and returns the result.
finalizeStream := func() (*OpenAIForwardResult, error) {
if streamFailoverErr != nil {
return resultWithUsage(), streamFailoverErr
}
if streamNonFailoverErr != nil {
return resultWithUsage(), streamNonFailoverErr
}
if finalEvents := apicompat.FinalizeResponsesAnthropicStream(state); len(finalEvents) > 0 && !clientDisconnected {
for _, evt := range finalEvents {
sse, err := apicompat.ResponsesAnthropicEventToSSE(evt)
@@ -0,0 +1,107 @@
//go:build unit
package service
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func buildResponsesFailedSSEStream(errType, errorMessage string) string {
failed := fmt.Sprintf(`{"type":"response.failed","response":{"id":"resp_err","object":"response","status":"failed","error":{"type":"%s","message":"%s"},"output":[],"usage":{"input_tokens":10,"output_tokens":0,"total_tokens":10}}}`, errType, errorMessage)
return fmt.Sprintf("data: %s\n\n", failed)
}
func TestForwardAsAnthropic_BufferedResponseFailed_ReturnsError(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
ssePayload := buildResponsesFailedSSEStream("invalid_request_error", "Content policy violation")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(ssePayload)),
}}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
_, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
require.Error(t, err, "non-cyber response.failed must return an error, not swallow as 200")
require.Contains(t, err.Error(), "upstream response failed")
require.Equal(t, http.StatusBadGateway, rec.Code, "should write 502 for non-failover failed response")
}
func TestForwardAsAnthropic_StreamingResponseFailed_ReturnsError(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
ssePayload := buildResponsesFailedSSEStream("invalid_request_error", "Content policy violation")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(ssePayload)),
}}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
_, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
require.Error(t, err, "streaming response.failed must return an error")
require.Contains(t, err.Error(), "upstream response failed")
}
func TestForwardAsAnthropic_BufferedResponseFailed_Failover(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
ssePayload := buildResponsesFailedSSEStream("rate_limit_error", "Rate limit reached")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(ssePayload)),
}}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
_, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
require.Error(t, err)
var failoverErr *UpstreamFailoverError
require.True(t, errors.As(err, &failoverErr), "rate_limit_error should trigger UpstreamFailoverError for failover, got: %T: %v", err, err)
}
@@ -0,0 +1,92 @@
//go:build unit
package service
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestForwardAsAnthropic_TransportError_ReturnsFailoverError(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstream := &httpUpstreamRecorder{
err: errors.New(`dial tcp 1.2.3.4:443: connect: connection refused`),
}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
_, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
require.Error(t, err)
var failoverErr *UpstreamFailoverError
require.True(t, errors.As(err, &failoverErr), "transport error should return UpstreamFailoverError for handler failover, got: %T", err)
require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
}
func TestForwardAsAnthropic_TransportError_DoesNotWriteResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstream := &httpUpstreamRecorder{
err: errors.New(`read tcp: connection reset by peer`),
}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
_, _ = svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
require.Equal(t, http.StatusOK, rec.Code, "transport error must not write HTTP response — handler owns the response for failover")
require.Empty(t, rec.Body.String(), "response body must be empty so handler can write the correct error or failover")
}
func TestForwardAsAnthropic_TransportError_ClientCanceled_NoFailover(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
rec := httptest.NewRecorder()
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)).WithContext(cancelCtx)
c.Request.Header.Set("Content-Type", "application/json")
upstream := &httpUpstreamRecorder{
err: context.Canceled,
}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
_, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
require.Error(t, err)
var failoverErr *UpstreamFailoverError
require.False(t, errors.As(err, &failoverErr), "client-canceled transport error should NOT trigger failover")
}
@@ -1803,8 +1803,9 @@ func TestOpenAIGatewayServiceRecordUsage_ImageIndependentMultiplierUsesImageRate
require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode)
}
func TestGrokVideoMediaBillingUsesImageRateMultiplier(t *testing.T) {
mediaPrice2K := 0.4
func TestGrokVideoBillingUsesSeparateVideoRateMultiplier(t *testing.T) {
imagePrice2K := 0.4
videoPrice480P := 0.08
groupID := int64(126)
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
@@ -1812,14 +1813,15 @@ func TestGrokVideoMediaBillingUsesImageRateMultiplier(t *testing.T) {
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "video-request-123",
ResponseID: "video-request-123",
Model: "grok-imagine-video-1.5",
BillingModel: "grok-imagine-video-1.5",
// The usage schema has no separate video count; video generation is billed as one media unit.
ImageCount: 1,
ImageSize: ImageBillingSize2K,
Duration: time.Second,
RequestID: "video-request-123",
ResponseID: "video-request-123",
Model: "grok-imagine-video-1.5",
BillingModel: "grok-imagine-video-1.5",
ImageCount: 1,
VideoCount: 1,
VideoResolution: VideoBillingResolution480P,
VideoDurationSeconds: 1,
Duration: time.Second,
},
APIKey: &APIKey{
ID: 10126,
@@ -1830,7 +1832,10 @@ func TestGrokVideoMediaBillingUsesImageRateMultiplier(t *testing.T) {
RateMultiplier: 0.15,
ImageRateIndependent: true,
ImageRateMultiplier: 0.5,
ImagePrice2K: &mediaPrice2K,
ImagePrice2K: &imagePrice2K,
VideoRateIndependent: true,
VideoRateMultiplier: 0.25,
VideoPrice480P: &videoPrice480P,
},
},
User: &User{ID: 20126},
@@ -1841,14 +1846,294 @@ func TestGrokVideoMediaBillingUsesImageRateMultiplier(t *testing.T) {
require.NotNil(t, usageRepo.lastLog)
require.Equal(t, "grok-imagine-video-1.5", usageRepo.lastLog.Model)
require.Equal(t, 1, usageRepo.lastLog.ImageCount)
require.Nil(t, usageRepo.lastLog.ImageSize)
require.InDelta(t, 0.08, usageRepo.lastLog.TotalCost, 1e-12)
require.InDelta(t, 0.02, usageRepo.lastLog.ActualCost, 1e-12)
require.InDelta(t, 0.25, usageRepo.lastLog.RateMultiplier, 1e-12)
require.NotNil(t, usageRepo.lastLog.BillingMode)
require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode)
require.Equal(t, 1, usageRepo.lastLog.VideoCount)
require.NotNil(t, usageRepo.lastLog.VideoResolution)
require.Equal(t, VideoBillingResolution480P, *usageRepo.lastLog.VideoResolution)
require.NotNil(t, usageRepo.lastLog.VideoDurationSeconds)
require.Equal(t, 1, *usageRepo.lastLog.VideoDurationSeconds)
}
func TestOpenAIGatewayServiceRecordUsage_GrokVideoUsesDefaultRateCard(t *testing.T) {
groupID := int64(1261)
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "video-default-rate-card",
ResponseID: "video-default-rate-card",
Model: "grok-imagine-video-1.5",
BillingModel: "grok-imagine-video-1.5",
ImageCount: 1,
VideoCount: 1,
VideoResolution: VideoBillingResolution720P,
Duration: time.Second,
},
APIKey: &APIKey{
ID: 101261,
GroupID: i64p(groupID),
Group: &Group{
ID: groupID,
Platform: PlatformGrok,
RateMultiplier: 1,
},
},
User: &User{ID: 201261},
Account: &Account{ID: 301261, Platform: PlatformGrok},
})
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
require.Nil(t, usageRepo.lastLog.ImageSize)
// 结果未携带 duration 时按上游默认 8 秒计费:0.14 USD/s × 8s。
require.InDelta(t, 0.14*8, usageRepo.lastLog.TotalCost, 1e-12)
require.InDelta(t, 0.14*8, usageRepo.lastLog.ActualCost, 1e-12)
require.Equal(t, 1, usageRepo.lastLog.ImageCount)
require.NotNil(t, usageRepo.lastLog.BillingMode)
require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode)
require.Equal(t, 1, usageRepo.lastLog.VideoCount)
require.NotNil(t, usageRepo.lastLog.VideoDurationSeconds)
require.Equal(t, VideoBillingDefaultDurationSeconds, *usageRepo.lastLog.VideoDurationSeconds)
}
func TestOpenAIGatewayServiceRecordUsage_GroupImagePriceOverridesChannelImagePrice(t *testing.T) {
groupID := int64(127)
channelPrice := 0.201
groupImagePrice2K := 0.021
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
svc.resolver = newOpenAIImageChannelPricingResolverForTest(t, groupID, "grok-imagine-image-quality", channelPrice)
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "resp_grok_image_group_price",
Model: "grok-imagine-image-quality",
BillingModel: "grok-imagine-image-quality",
ImageCount: 1,
ImageSize: ImageBillingSize2K,
Duration: time.Second,
},
APIKey: &APIKey{
ID: 10127,
GroupID: i64p(groupID),
Group: &Group{
ID: groupID,
Platform: PlatformGrok,
RateMultiplier: 1,
ImageRateIndependent: true,
ImageRateMultiplier: 1,
ImagePrice2K: &groupImagePrice2K,
},
},
User: &User{ID: 20127},
Account: &Account{ID: 30127, Platform: PlatformGrok},
})
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
require.Equal(t, 1, usageRepo.lastLog.ImageCount)
require.Equal(t, ImageBillingSize2K, *usageRepo.lastLog.ImageSize)
require.InDelta(t, 0.4, usageRepo.lastLog.TotalCost, 1e-12)
require.InDelta(t, 0.2, usageRepo.lastLog.ActualCost, 1e-12)
require.InDelta(t, 0.5, usageRepo.lastLog.RateMultiplier, 1e-12)
require.InDelta(t, 0.021, usageRepo.lastLog.TotalCost, 1e-12)
require.InDelta(t, 0.021, usageRepo.lastLog.ActualCost, 1e-12)
require.NotNil(t, usageRepo.lastLog.BillingMode)
require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode)
}
func TestOpenAIGatewayServiceRecordUsage_GroupVideoPriceOverridesChannelImagePrice(t *testing.T) {
groupID := int64(128)
channelPrice := 0.201
groupVideoPrice720P := 0.037
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
svc.resolver = newOpenAIImageChannelPricingResolverForTest(t, groupID, "grok-imagine-video", channelPrice)
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "resp_grok_video_group_price",
Model: "grok-imagine-video",
BillingModel: "grok-imagine-video",
ImageCount: 1,
VideoCount: 1,
VideoResolution: VideoBillingResolution720P,
VideoDurationSeconds: 1,
Duration: time.Second,
},
APIKey: &APIKey{
ID: 10128,
GroupID: i64p(groupID),
Group: &Group{
ID: groupID,
Platform: PlatformGrok,
RateMultiplier: 1,
VideoRateIndependent: true,
VideoRateMultiplier: 1,
VideoPrice720P: &groupVideoPrice720P,
},
},
User: &User{ID: 20128},
Account: &Account{ID: 30128, Platform: PlatformGrok},
})
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
require.Equal(t, 1, usageRepo.lastLog.ImageCount)
require.Nil(t, usageRepo.lastLog.ImageSize)
require.InDelta(t, 0.037, usageRepo.lastLog.TotalCost, 1e-12)
require.InDelta(t, 0.037, usageRepo.lastLog.ActualCost, 1e-12)
require.NotNil(t, usageRepo.lastLog.BillingMode)
require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode)
}
func TestOpenAIGatewayServiceRecordUsage_HydratesGroupImagePriceWhenAuthSnapshotOmitsIt(t *testing.T) {
groupID := int64(130)
groupImagePrice2K := 0.021
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
channelService := &ChannelService{groupRepo: &openAIMediaPriceGroupRepoStub{group: &Group{
ID: groupID,
Platform: PlatformGrok,
RateMultiplier: 1,
ImagePrice2K: &groupImagePrice2K,
}}}
channelCache := newEmptyChannelCache()
channelCache.loadedAt = time.Now()
channelService.cache.Store(channelCache)
svc.channelService = channelService
refreshed := svc.apiKeyWithFreshGroupMediaPricing(context.Background(), &APIKey{GroupID: i64p(groupID), Group: &Group{ID: groupID}})
require.NotNil(t, refreshed.Group.ImagePrice2K)
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "resp_grok_image_hydrated_price",
Model: "grok-imagine-image-quality",
BillingModel: "grok-imagine-image-quality",
ImageCount: 1,
ImageSize: ImageBillingSize2K,
Duration: time.Second,
},
APIKey: &APIKey{
ID: 10130,
GroupID: i64p(groupID),
Group: &Group{
ID: groupID,
Platform: PlatformGrok,
RateMultiplier: 1,
},
},
User: &User{ID: 20130},
Account: &Account{ID: 30130, Platform: PlatformGrok},
})
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
require.InDelta(t, 0.021, usageRepo.lastLog.TotalCost, 1e-12)
require.InDelta(t, 0.021, usageRepo.lastLog.ActualCost, 1e-12)
require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode)
}
func TestOpenAIGatewayServiceRecordUsage_HydratesGroupVideoPriceWhenAuthSnapshotOmitsIt(t *testing.T) {
groupID := int64(131)
groupVideoPrice720P := 0.037
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
channelService := &ChannelService{groupRepo: &openAIMediaPriceGroupRepoStub{group: &Group{
ID: groupID,
Platform: PlatformGrok,
RateMultiplier: 1,
VideoPrice720P: &groupVideoPrice720P,
}}}
channelCache := newEmptyChannelCache()
channelCache.loadedAt = time.Now()
channelService.cache.Store(channelCache)
svc.channelService = channelService
refreshed := svc.apiKeyWithFreshGroupMediaPricing(context.Background(), &APIKey{GroupID: i64p(groupID), Group: &Group{ID: groupID}})
require.NotNil(t, refreshed.Group.VideoPrice720P)
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "resp_grok_video_hydrated_price",
Model: "grok-imagine-video",
BillingModel: "grok-imagine-video",
ImageCount: 1,
VideoCount: 1,
VideoResolution: VideoBillingResolution720P,
VideoDurationSeconds: 1,
Duration: time.Second,
},
APIKey: &APIKey{
ID: 10131,
GroupID: i64p(groupID),
Group: &Group{
ID: groupID,
Platform: PlatformGrok,
RateMultiplier: 1,
},
},
User: &User{ID: 20131},
Account: &Account{ID: 30131, Platform: PlatformGrok},
})
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
require.Nil(t, usageRepo.lastLog.ImageSize)
require.InDelta(t, 0.037, usageRepo.lastLog.TotalCost, 1e-12)
require.InDelta(t, 0.037, usageRepo.lastLog.ActualCost, 1e-12)
require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode)
}
// 视频请求命中渠道 token 计费时走 token 路径;此时行是 billing_mode='token'、image_count=1、
// image_size=NULL,必须携带 video_count>0 才能通过 usage_logs 的 image_size check 约束
// (迁移 172),否则整个计费事务会因约束违反而丢失。
func TestOpenAIGatewayServiceRecordUsage_GrokVideoWithTokenChannelPricingKeepsVideoMetadata(t *testing.T) {
groupID := int64(132)
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
svc.resolver = newOpenAITokenImageChannelPricingResolverForTest(t, groupID, "grok-imagine-video")
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "resp_grok_video_token_channel",
Model: "grok-imagine-video",
BillingModel: "grok-imagine-video",
ImageCount: 1,
VideoCount: 1,
VideoResolution: VideoBillingResolution720P,
VideoDurationSeconds: 5,
Usage: OpenAIUsage{InputTokens: 100, OutputTokens: 200},
Duration: time.Second,
},
APIKey: &APIKey{
ID: 10132,
GroupID: i64p(groupID),
Group: &Group{
ID: groupID,
Platform: PlatformGrok,
RateMultiplier: 1,
},
},
User: &User{ID: 20132},
Account: &Account{ID: 30132, Platform: PlatformGrok},
})
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
require.NotNil(t, usageRepo.lastLog.BillingMode)
require.Equal(t, string(BillingModeToken), *usageRepo.lastLog.BillingMode)
require.Nil(t, usageRepo.lastLog.ImageSize)
require.Equal(t, 1, usageRepo.lastLog.ImageCount)
require.Equal(t, 1, usageRepo.lastLog.VideoCount)
require.NotNil(t, usageRepo.lastLog.VideoResolution)
require.Equal(t, VideoBillingResolution720P, *usageRepo.lastLog.VideoResolution)
require.NotNil(t, usageRepo.lastLog.VideoDurationSeconds)
require.Equal(t, 5, *usageRepo.lastLog.VideoDurationSeconds)
}
func TestOpenAIGatewayServiceRecordUsage_ChannelImageBillingUsesImageCountAndSharedMultiplier(t *testing.T) {
groupID := int64(123)
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
@@ -1960,6 +2245,19 @@ func newOpenAITokenImageChannelPricingResolverForTest(t *testing.T, groupID int6
return NewModelPricingResolver(cs, NewBillingService(&config.Config{}, nil))
}
type openAIMediaPriceGroupRepoStub struct {
GroupRepository
group *Group
err error
}
func (s *openAIMediaPriceGroupRepoStub) GetByIDLite(context.Context, int64) (*Group, error) {
if s.err != nil {
return nil, s.err
}
return s.group, nil
}
func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesImageCount(t *testing.T) {
groupID := int64(126)
billingService := NewBillingService(&config.Config{}, nil)
@@ -2023,6 +2321,38 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesSizeTier(
require.InDelta(t, 0.80, cost.ActualCost, 1e-12)
}
func TestGatewayServiceCalculateRecordUsageCost_GroupImagePriceOverridesChannelImagePrice(t *testing.T) {
groupID := int64(129)
channelPrice := 0.25
groupImagePrice2K := 0.021
svc := &GatewayService{
billingService: NewBillingService(&config.Config{}, nil),
resolver: newOpenAIImageChannelPricingResolverForTest(t, groupID, "gemini-image", channelPrice),
}
cost := svc.calculateRecordUsageCost(
context.Background(),
&ForwardResult{Model: "gemini-image", ImageCount: 2, ImageSize: ImageBillingSize2K},
&APIKey{
GroupID: i64p(groupID),
Group: &Group{
ID: groupID,
ImagePrice2K: &groupImagePrice2K,
},
},
"gemini-image",
1.0,
1.0,
nil,
)
require.NotNil(t, cost)
require.Equal(t, string(BillingModeImage), cost.BillingMode)
require.InDelta(t, 0.042, cost.TotalCost, 1e-12)
require.InDelta(t, 0.042, cost.ActualCost, 1e-12)
}
func TestRecordUsageMarksCyberRequestType(t *testing.T) {
logStub := &openAIRecordUsageLogRepoStub{inserted: true}
userStub := &openAIRecordUsageUserRepoStub{}
@@ -242,6 +242,10 @@ type OpenAIForwardResult struct {
ImageOutputSizes []string
ImageSizeSource string
ImageSizeBreakdown map[string]int
VideoCount int
VideoResolution string
// VideoDurationSeconds 是提交时请求的生成时长(xAI 按输出秒数计费),已归一化到 1-15 秒。
VideoDurationSeconds int
wsReplayInput []json.RawMessage
wsReplayInputExists bool
+139 -11
View File
@@ -115,7 +115,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
user := input.User
account := input.Account
subscription := input.Subscription
ApplyOpenAIImageBillingResolution(result)
if !isGrokVideoUsageResult(result, nil) {
ApplyOpenAIImageBillingResolution(result)
}
// 计算实际的新输入token(减去缓存读取的token)
// 因为 input_tokens 包含了 cache_read_tokens,而缓存读取的token不应按输入价格计费
@@ -148,7 +150,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
}
// token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算,
// 不并入上面的 Resolve,以免污染 user:group 倍率缓存。
multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, multiplier, timezone.Now())
baseMultiplier := multiplier
multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, timezone.Now())
videoMultiplier := resolveVideoRateMultiplier(apiKey, baseMultiplier)
var cost *CostBreakdown
var err error
@@ -174,7 +178,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
if result.ServiceTier != nil {
serviceTier = strings.TrimSpace(*result.ServiceTier)
}
cost, err = s.calculateOpenAIRecordUsageCost(ctx, result, apiKey, billingModels, multiplier, imageMultiplier, tokens, serviceTier)
cost, err = s.calculateOpenAIRecordUsageCost(ctx, result, apiKey, billingModels, multiplier, imageMultiplier, videoMultiplier, tokens, serviceTier)
if err != nil {
if !isUsagePricingUnavailableError(err) {
return err
@@ -238,6 +242,13 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource),
ImageSizeBreakdown: result.ImageSizeBreakdown,
}
isVideoUsage := isGrokVideoUsageResult(result, billingModels)
if isVideoUsage {
usageLog.VideoCount = result.VideoCount
usageLog.VideoResolution = optionalTrimmedStringPtr(NormalizeVideoBillingResolutionOrDefault(result.VideoResolution))
videoDurationSeconds := NormalizeVideoBillingDurationSecondsOrDefault(result.VideoDurationSeconds)
usageLog.VideoDurationSeconds = &videoDurationSeconds
}
if cost != nil {
usageLog.InputCost = cost.InputCost
usageLog.OutputCost = cost.OutputCost
@@ -247,7 +258,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
usageLog.TotalCost = cost.TotalCost
usageLog.ActualCost = cost.ActualCost
}
if result.ImageCount > 0 && (cost == nil || cost.BillingMode != string(BillingModeToken)) {
if isVideoUsage && (cost == nil || cost.BillingMode != string(BillingModeToken)) {
usageLog.RateMultiplier = videoMultiplier
} else if result.ImageCount > 0 && (cost == nil || cost.BillingMode != string(BillingModeToken)) {
usageLog.RateMultiplier = imageMultiplier
} else {
usageLog.RateMultiplier = multiplier
@@ -269,6 +282,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
if cost != nil && cost.BillingMode != "" {
billingMode := cost.BillingMode
usageLog.BillingMode = &billingMode
} else if isVideoUsage {
billingMode := string(BillingModeVideo)
usageLog.BillingMode = &billingMode
} else if result.ImageCount > 0 {
billingMode := string(BillingModeImage)
usageLog.BillingMode = &billingMode
@@ -346,10 +362,16 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
billingModels []string,
multiplier float64,
imageMultiplier float64,
videoMultiplier float64,
tokens UsageTokens,
serviceTier string,
) (*CostBreakdown, error) {
billingModel := firstUsageBillingModel(billingModels)
if isGrokVideoUsageResult(result, billingModels) {
if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved == nil || resolved.Mode != BillingModeToken {
return s.calculateOpenAIVideoCost(ctx, billingModel, apiKey, result, videoMultiplier), nil
}
}
if result != nil && result.ImageCount > 0 {
// 渠道定价为 token 计费时走 token 路径,否则走图片计费
if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved == nil || resolved.Mode != BillingModeToken {
@@ -377,6 +399,24 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr)
}
func isGrokVideoBillingModel(model string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "grok-imagine-video")
}
func isGrokVideoUsageResult(result *OpenAIForwardResult, billingModels []string) bool {
if result == nil || result.VideoCount <= 0 {
return false
}
candidates := append([]string{}, billingModels...)
candidates = append(candidates, result.BillingModel, result.Model, result.UpstreamModel)
for _, candidate := range candidates {
if isGrokVideoBillingModel(candidate) {
return true
}
}
return false
}
func isUsagePricingUnavailableError(err error) bool {
if err == nil {
return false
@@ -420,6 +460,17 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost(
multiplier float64,
) *CostBreakdown {
sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize)
groupConfig := imagePriceConfigFromAPIKey(apiKey)
if apiKeyHasConfiguredImagePrice(apiKey, sizeTier) {
return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
}
if refreshed := s.apiKeyWithFreshGroupMediaPricing(ctx, apiKey); refreshed != apiKey {
apiKey = refreshed
groupConfig = imagePriceConfigFromAPIKey(apiKey)
if apiKeyHasConfiguredImagePrice(apiKey, sizeTier) {
return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
}
}
if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil &&
(resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) {
gid := apiKey.Group.ID
@@ -439,15 +490,92 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost(
logger.LegacyPrintf("service.openai_gateway", "Calculate image channel cost failed: %v", err)
}
var groupConfig *ImagePriceConfig
if apiKey != nil && apiKey.Group != nil {
groupConfig = &ImagePriceConfig{
Price1K: apiKey.Group.ImagePrice1K,
Price2K: apiKey.Group.ImagePrice2K,
Price4K: apiKey.Group.ImagePrice4K,
return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
}
func (s *OpenAIGatewayService) calculateOpenAIVideoCost(
ctx context.Context,
billingModel string,
apiKey *APIKey,
result *OpenAIForwardResult,
multiplier float64,
) *CostBreakdown {
videoCount := result.VideoCount
if videoCount <= 0 {
videoCount = 1
}
resolution := NormalizeVideoBillingResolutionOrDefault(result.VideoResolution)
durationSeconds := NormalizeVideoBillingDurationSecondsOrDefault(result.VideoDurationSeconds)
groupConfig := videoPriceConfigFromAPIKey(apiKey)
if apiKeyHasConfiguredVideoPrice(apiKey, resolution) {
return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier)
}
if refreshed := s.apiKeyWithFreshGroupMediaPricing(ctx, apiKey); refreshed != apiKey {
apiKey = refreshed
groupConfig = videoPriceConfigFromAPIKey(apiKey)
if apiKeyHasConfiguredVideoPrice(apiKey, resolution) {
return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier)
}
}
return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil &&
(resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) {
// 渠道 per_request/image 定价保持"按请求次数"口径(价格由管理员按次配置),不乘视频时长。
gid := apiKey.Group.ID
cost, err := s.billingService.CalculateCostUnified(CostInput{
Ctx: ctx,
Model: billingModel,
GroupID: &gid,
RequestCount: videoCount,
SizeTier: resolution,
RateMultiplier: multiplier,
Resolver: s.resolver,
Resolved: resolved,
})
if err == nil {
cost.BillingMode = string(BillingModeVideo)
return cost
}
logger.LegacyPrintf("service.openai_gateway", "Calculate video channel cost failed: %v", err)
}
return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier)
}
func (s *OpenAIGatewayService) apiKeyWithFreshGroupMediaPricing(ctx context.Context, apiKey *APIKey) *APIKey {
if apiKey == nil || apiKey.GroupID == nil || *apiKey.GroupID <= 0 {
return apiKey
}
if !groupMediaPricingLooksIncomplete(apiKey.Group) {
return apiKey
}
if s == nil || s.channelService == nil || s.channelService.groupRepo == nil {
return apiKey
}
group, err := s.channelService.groupRepo.GetByIDLite(ctx, *apiKey.GroupID)
if err != nil || group == nil {
return apiKey
}
clone := *apiKey
clone.Group = group
return &clone
}
// groupMediaPricingLooksIncomplete 判断分组对象是否可能缺失媒体计费字段(例如由不含
// 这些字段的旧快照或手工构造的上下文对象生成)。image/video 独立倍率在数据库中的
// 默认值均为 1.0,正常加载的分组不可能两个倍率同时为 0 且未开启独立倍率、全部媒体
// 价为 nil——只有这种情况才回源查库,避免对未配置覆盖价的分组每条媒体用量都多打一次 DB 查询。
func groupMediaPricingLooksIncomplete(group *Group) bool {
if group == nil {
return true
}
if group.ImageRateIndependent || group.VideoRateIndependent {
return false
}
if group.ImageRateMultiplier != 0 || group.VideoRateMultiplier != 0 {
return false
}
return group.ImagePrice1K == nil && group.ImagePrice2K == nil && group.ImagePrice4K == nil &&
group.VideoPrice480P == nil && group.VideoPrice720P == nil && group.VideoPrice1080P == nil
}
func (s *OpenAIGatewayService) resolveOpenAIChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing {
@@ -31,9 +31,9 @@ func TestGroupResolveMessagesDispatchModel_GrokMapsClaudeFamilyToGrok(t *testing
group := &Group{Platform: PlatformGrok}
require.Equal(t, "grok-4.3", group.ResolveMessagesDispatchModel("claude-sonnet-4-5"))
require.Equal(t, "grok-4.3", group.ResolveMessagesDispatchModel("claude-opus-4-6"))
require.Equal(t, "grok-4.3", group.ResolveMessagesDispatchModel("claude-haiku-4-5"))
require.Equal(t, "grok-4.5", group.ResolveMessagesDispatchModel("claude-sonnet-4-5"))
require.Equal(t, "grok-4.5", group.ResolveMessagesDispatchModel("claude-opus-4-6"))
require.Equal(t, "grok-4.5", group.ResolveMessagesDispatchModel("claude-haiku-4-5"))
require.Empty(t, group.ResolveMessagesDispatchModel("grok"))
require.Empty(t, group.ResolveMessagesDispatchModel("gpt-5.3-codex"))
}
@@ -20,6 +20,61 @@ func resolveOpenAIForwardModel(account *Account, requestedModel, defaultMappedMo
return mappedModel
}
// openAIOAuthForeignModelPrefixes 列出明确属于其他厂商家族的模型名前缀。
// Codex 上游不可能服务这些模型:转发阶段 normalizeOpenAIModelForUpstream
// 对未知模型原样透传,上游必然返回不可重试的 400。
//
// 采用保守黑名单而非 Codex 模型白名单:未知/自定义别名保持「允许」,
// 以兼容渠道级模型映射等「账号选定之后才改写模型名」的部署方式
// (调度过滤看到的是改写前的原始模型名)。前缀分类的先例见
// ResolveThinkingProtocol(thinking_protocol.go)。
var openAIOAuthForeignModelPrefixes = []string{
"deepseek-",
"glm-",
"kimi-",
"moonshot-",
"qwen-",
"qwen2-",
"qwen3-",
"qwen4-",
"qwq-",
"minimax-",
"gemini-",
"gemma-",
"grok-",
"doubao-",
"hunyuan-",
"llama-",
"llama2-",
"llama3-",
"meta-llama",
"mistral-",
"mixtral-",
"baichuan-",
"ernie-",
"step-",
"seed-",
"yi-",
}
// isOpenAIOAuthServableModel 判断「空 model_mapping 的 OpenAI OAuth 账号」能否
// 服务请求模型。空映射默认仍是「允许」,仅排除明确属于其他厂商家族的模型
// (deepseek-*/glm-* 等)——这类请求原样透传必然被 Codex 上游以不可重试的
// 400 拒绝,且不触发 failover,应在调度阶段就跳过该账号,把请求让给
// 显式声明支持该模型的账号(#3662)。
func isOpenAIOAuthServableModel(requestedModel string) bool {
model := strings.ToLower(lastOpenAIModelSegment(requestedModel))
if model == "" {
return true // 空模型交由上层必填校验处理
}
for _, prefix := range openAIOAuthForeignModelPrefixes {
if strings.HasPrefix(model, prefix) {
return false
}
}
return true
}
// resolveOpenAICompactForwardModel determines the compact-only upstream model
// for /responses/compact requests. It never affects normal /responses traffic.
// When no compact-specific mapping matches, the input model is returned as-is.
@@ -0,0 +1,110 @@
//go:build unit
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func newOpenAIOAuthAccountForModelTest() *Account {
return &Account{
ID: 1,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
}
}
func TestIsModelSupported_OpenAIOAuthEmptyMapping_ServableModels(t *testing.T) {
account := newOpenAIOAuthAccountForModelTest()
servable := []string{
"", // 空模型交由上层必填校验
"gpt-5.4",
"gpt-5.4-high", // 推理后缀变体
"gpt-5.3-codex",
"gpt-5.1-codex-mini",
"gpt-5",
"codex-mini-latest",
"gpt5.3codexspark", // 别名拼写
"gpt-image-1", // 图像生成模型
"claude-sonnet-4-6", // /v1/messages 调度默认映射兜底
"claude-3-opus-20240229",
"gpt-4o", // 保守 fail-open:非黑名单模型保持允许
"my-custom-alias", // 自定义别名可能由渠道级映射在转发前改写,保持允许
}
for _, model := range servable {
require.True(t, account.IsModelSupported(model), "expected %q to be servable by empty-mapping OpenAI OAuth account", model)
}
}
func TestIsModelSupported_OpenAIOAuthEmptyMapping_RejectsForeignModels(t *testing.T) {
account := newOpenAIOAuthAccountForModelTest()
// Codex 上游必然以不可重试的 400 拒绝这些厂商家族;调度阶段就应跳过
// 该账号,让显式声明支持的 API Key 账号接手(#3662)。
foreign := []string{
"deepseek-v4",
"deepseek-chat",
"glm-4.7",
"kimi-k2",
"moonshot-v1-128k",
"gemini-3.0-pro",
"grok-4",
"qwen3-max",
"minimax-m2.5",
"llama-3.3-70b",
"provider/deepseek-v4", // vendor/model 形式取最后一段判定
}
for _, model := range foreign {
require.False(t, account.IsModelSupported(model), "expected %q to be rejected by empty-mapping OpenAI OAuth account", model)
}
}
func TestIsModelSupported_OpenAIOAuthExplicitMappingUnchanged(t *testing.T) {
account := newOpenAIOAuthAccountForModelTest()
account.Credentials = map[string]any{
"model_mapping": map[string]any{"deepseek-v4": "gpt-5.4"},
}
// 显式映射沿用原有语义:命中映射即支持,未命中即不支持。
require.True(t, account.IsModelSupported("deepseek-v4"))
require.False(t, account.IsModelSupported("glm-4.7"))
}
func TestIsModelSupported_OpenAIOAuthPassthroughAllowsAll(t *testing.T) {
account := newOpenAIOAuthAccountForModelTest()
account.Extra = map[string]any{"openai_passthrough": true}
// 透传模式仅替换认证,模型语义由上游决定,保持"允许所有"。
require.True(t, account.IsModelSupported("deepseek-v4"))
}
func TestIsModelSupported_OpenAIAPIKeyEmptyMappingAllowsAll(t *testing.T) {
account := &Account{
ID: 2,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
}
// API Key 账号(第三方 OpenAI 兼容上游)可服务任意别名,语义不变。
require.True(t, account.IsModelSupported("deepseek-v4"))
require.True(t, account.IsModelSupported("gpt-5.4"))
}
func TestIsModelSupported_NonOpenAIPlatformsUnchanged(t *testing.T) {
anthropic := &Account{ID: 3, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
require.True(t, anthropic.IsModelSupported("claude-sonnet-4-6"))
require.True(t, anthropic.IsModelSupported("deepseek-v4"))
}
func TestIsOpenAIOAuthServableModel(t *testing.T) {
require.True(t, isOpenAIOAuthServableModel("gpt-5.4-high"))
require.True(t, isOpenAIOAuthServableModel(" gpt-5.3-codex "))
require.True(t, isOpenAIOAuthServableModel("claude-3-5-haiku-20241022"))
require.True(t, isOpenAIOAuthServableModel("DeepThink-x")) // 非黑名单前缀,保持允许
require.False(t, isOpenAIOAuthServableModel("DeepSeek-V4")) // 大小写不敏感
require.False(t, isOpenAIOAuthServableModel("qwen3-235b-thinking"))
require.True(t, isOpenAIOAuthServableModel("deepseekcoder")) // 无连字符 → 非黑名单前缀,保持允许
}
@@ -283,7 +283,7 @@ func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T)
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization"))
require.Equal(t, "sub2api-grok/1.0", upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "type").Exists())
require.False(t, gjson.GetBytes(upstream.lastBody, "generate").Exists())
require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_retention").Exists())

Some files were not shown because too many files have changed in this diff Show More