mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.26.4-alpine
|
||||
FROM golang:1.26.5-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+66
-2
@@ -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(", ")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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
@@ -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(", ")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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, "<script>")
|
||||
})
|
||||
|
||||
t.Run("escapes_html_entities", func(t *testing.T) {
|
||||
body := svc.buildVerifyCodeEmailBody("123456", `A&B<C>"D`)
|
||||
|
||||
assert.Contains(t, body, "A&B<C>"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, "<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&B<C>")
|
||||
})
|
||||
|
||||
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&b=2"`)
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user