@@ -750,7 +750,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
|
||||
}
|
||||
defer releaseChannel()
|
||||
if c.Request.Method == http.MethodPost {
|
||||
order, err := svc.ReserveProxyBilling(user.ID, channel.ID, strings.TrimPrefix(modelName, "models/"), capability, c.GetHeader("X-Canvas-Scene"), c.GetHeader("X-Idempotency-Key"), proxyRequestVideoSeconds(c.GetHeader("Content-Type"), body))
|
||||
order, err := svc.ReserveProxyBillingWithBody(user.ID, channel.ID, strings.TrimPrefix(modelName, "models/"), capability, c.GetHeader("X-Canvas-Scene"), c.GetHeader("X-Idempotency-Key"), proxyRequestVideoSeconds(c.GetHeader("Content-Type"), body), body)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
@@ -816,8 +816,11 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
|
||||
fail(c, http.StatusBadGateway, fmt.Errorf("系统渠道响应超过 %dMB 限制", policy.Request.SystemRelayResponseMB))
|
||||
return
|
||||
}
|
||||
logErr := logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, capability, protocol, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), status, statusCode, time.Since(startedAt), errorText, concurrencyLimit), responseBody)
|
||||
if status == model.ApiCallStatusSucceeded {
|
||||
if err := svc.SettleBilling(billingOrderID, ""); err != nil {
|
||||
if logErr != nil {
|
||||
_ = svc.MarkBillingUncertain(billingOrderID, "上游成功但调用日志写入失败,费用状态待核对")
|
||||
} else if err := svc.SettleBilling(billingOrderID, ""); err != nil {
|
||||
_ = svc.MarkBillingUncertain(billingOrderID, "上游成功但积分结算失败:"+err.Error())
|
||||
}
|
||||
} else if statusCode == 524 {
|
||||
@@ -825,7 +828,6 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
|
||||
} else {
|
||||
_ = svc.RefundBilling(billingOrderID, "上游明确返回失败")
|
||||
}
|
||||
logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, capability, protocol, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), status, statusCode, time.Since(startedAt), errorText, concurrencyLimit), responseBody)
|
||||
for _, key := range []string{"Content-Type", "Cache-Control", "Content-Disposition"} {
|
||||
if value := resp.Header.Get(key); value != "" {
|
||||
c.Header(key, value)
|
||||
@@ -870,10 +872,10 @@ func apiCallLog(user *model.User, channel *model.ModelChannel, billingOrderID st
|
||||
}
|
||||
}
|
||||
|
||||
func logSystemProxyCall(svc *service.Service, log model.ApiCallLog, responseBody []byte) {
|
||||
func logSystemProxyCall(svc *service.Service, log model.ApiCallLog, responseBody []byte) error {
|
||||
log.ResponseBody = service.SanitizeAPICallPayload(responseBody, "")
|
||||
svc.EnrichAPICallLog(&log, responseBody)
|
||||
_ = svc.LogAPICall(log)
|
||||
return svc.LogAPICall(log)
|
||||
}
|
||||
|
||||
func readPayloadModel(body []byte) string {
|
||||
|
||||
@@ -217,20 +217,23 @@ type ModelChannel struct {
|
||||
}
|
||||
|
||||
type ChannelModel struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
ChannelID string `json:"channelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_key_active,priority:1,where:deleted_at IS NULL"`
|
||||
ModelKey string `json:"modelKey" gorm:"size:120;uniqueIndex:idx_channel_model_key_active,priority:2,where:deleted_at IS NULL"`
|
||||
DisplayName string `json:"displayName" gorm:"size:160"`
|
||||
Capability string `json:"capability" gorm:"size:32;index"`
|
||||
Protocol ChannelInterfaceType `json:"protocol" gorm:"size:32;index"`
|
||||
BillingMode string `json:"billingMode" gorm:"size:32"`
|
||||
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
|
||||
PriceConfigured bool `json:"priceConfigured" gorm:"index"`
|
||||
Enabled bool `json:"enabled" gorm:"index"`
|
||||
PriceVersion int64 `json:"priceVersion"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
ChannelID string `json:"channelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_key_active,priority:1,where:deleted_at IS NULL"`
|
||||
ModelKey string `json:"modelKey" gorm:"size:120;uniqueIndex:idx_channel_model_key_active,priority:2,where:deleted_at IS NULL"`
|
||||
DisplayName string `json:"displayName" gorm:"size:160"`
|
||||
Capability string `json:"capability" gorm:"size:32;index"`
|
||||
Protocol ChannelInterfaceType `json:"protocol" gorm:"size:32;index"`
|
||||
BillingMode string `json:"billingMode" gorm:"size:32"`
|
||||
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
|
||||
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
|
||||
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
|
||||
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
|
||||
PriceConfigured bool `json:"priceConfigured" gorm:"index"`
|
||||
Enabled bool `json:"enabled" gorm:"index"`
|
||||
PriceVersion int64 `json:"priceVersion"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
}
|
||||
|
||||
type ApiCallLog struct {
|
||||
@@ -310,31 +313,41 @@ type CreditLedgerEntry struct {
|
||||
}
|
||||
|
||||
type BillingOrder struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_billing_user_idempotency,priority:1"`
|
||||
IdempotencyKey string `json:"idempotencyKey" gorm:"size:160;uniqueIndex:idx_billing_user_idempotency,priority:2"`
|
||||
TaskID string `json:"taskId,omitempty" gorm:"index;size:36"`
|
||||
ChannelID string `json:"channelId" gorm:"index;size:36"`
|
||||
ChannelModelID string `json:"channelModelId" gorm:"index;size:36"`
|
||||
Model string `json:"model" gorm:"index;size:120"`
|
||||
Capability string `json:"capability" gorm:"index;size:32"`
|
||||
Scene string `json:"scene" gorm:"index;size:80"`
|
||||
BillingMode string `json:"billingMode" gorm:"size:32"`
|
||||
PriceVersion int64 `json:"priceVersion"`
|
||||
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
|
||||
MultiplierBasisPoints int64 `json:"multiplierBasisPoints"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
AmountMicrocredits int64 `json:"amountMicrocredits"`
|
||||
Status BillingStatus `json:"status" gorm:"index;size:24"`
|
||||
ProviderRequestID string `json:"providerRequestId,omitempty" gorm:"index;size:160"`
|
||||
Error string `json:"error,omitempty" gorm:"size:1000"`
|
||||
ResolvedBy string `json:"resolvedBy,omitempty" gorm:"index;size:36"`
|
||||
ResolutionNote string `json:"resolutionNote,omitempty" gorm:"size:500"`
|
||||
StartedAt *time.Time `json:"startedAt"`
|
||||
SettledAt *time.Time `json:"settledAt"`
|
||||
RefundedAt *time.Time `json:"refundedAt"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"index"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_billing_user_idempotency,priority:1"`
|
||||
IdempotencyKey string `json:"idempotencyKey" gorm:"size:160;uniqueIndex:idx_billing_user_idempotency,priority:2"`
|
||||
TaskID string `json:"taskId,omitempty" gorm:"index;size:36"`
|
||||
ChannelID string `json:"channelId" gorm:"index;size:36"`
|
||||
ChannelModelID string `json:"channelModelId" gorm:"index;size:36"`
|
||||
Model string `json:"model" gorm:"index;size:120"`
|
||||
Capability string `json:"capability" gorm:"index;size:32"`
|
||||
Scene string `json:"scene" gorm:"index;size:80"`
|
||||
BillingMode string `json:"billingMode" gorm:"size:32"`
|
||||
PriceVersion int64 `json:"priceVersion"`
|
||||
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
|
||||
MultiplierBasisPoints int64 `json:"multiplierBasisPoints"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
AmountMicrocredits int64 `json:"amountMicrocredits"`
|
||||
ReservedAmountMicrocredits int64 `json:"reservedAmountMicrocredits"`
|
||||
ActualAmountMicrocredits int64 `json:"actualAmountMicrocredits"`
|
||||
RefundedAmountMicrocredits int64 `json:"refundedAmountMicrocredits"`
|
||||
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
|
||||
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
|
||||
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
|
||||
InputTokens int64 `json:"inputTokens"`
|
||||
OutputTokens int64 `json:"outputTokens"`
|
||||
CachedTokens int64 `json:"cachedTokens"`
|
||||
UsageAvailable bool `json:"usageAvailable"`
|
||||
Status BillingStatus `json:"status" gorm:"index;size:24"`
|
||||
ProviderRequestID string `json:"providerRequestId,omitempty" gorm:"index;size:160"`
|
||||
Error string `json:"error,omitempty" gorm:"size:1000"`
|
||||
ResolvedBy string `json:"resolvedBy,omitempty" gorm:"index;size:36"`
|
||||
ResolutionNote string `json:"resolutionNote,omitempty" gorm:"size:500"`
|
||||
StartedAt *time.Time `json:"startedAt"`
|
||||
SettledAt *time.Time `json:"settledAt"`
|
||||
RefundedAt *time.Time `json:"refundedAt"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"index"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type RedeemBatch struct {
|
||||
|
||||
@@ -14,11 +14,13 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInsufficientCredits = errors.New("insufficient credits")
|
||||
ErrRedeemCodeInvalid = errors.New("redeem code invalid")
|
||||
ErrActiveTaskLimit = errors.New("active task limit reached")
|
||||
ErrTaskNotRetryable = errors.New("task is not retryable")
|
||||
ErrBillingStateConflict = errors.New("billing state conflict")
|
||||
ErrInsufficientCredits = errors.New("insufficient credits")
|
||||
ErrRedeemCodeInvalid = errors.New("redeem code invalid")
|
||||
ErrActiveTaskLimit = errors.New("active task limit reached")
|
||||
ErrTaskNotRetryable = errors.New("task is not retryable")
|
||||
ErrBillingStateConflict = errors.New("billing state conflict")
|
||||
ErrBillingUsageUnavailable = errors.New("billing usage unavailable")
|
||||
ErrBillingUnderreserved = errors.New("billing amount exceeds reservation")
|
||||
)
|
||||
|
||||
// 先抢占唯一业务键再更新账户,确保注册和签到奖励在多实例并发下只入账一次。
|
||||
@@ -265,6 +267,9 @@ func (r *Repository) ReserveBillingOrder(order *model.BillingOrder) error {
|
||||
}
|
||||
|
||||
func reserveBillingOrder(tx *gorm.DB, order *model.BillingOrder) error {
|
||||
if order.ReservedAmountMicrocredits <= 0 {
|
||||
order.ReservedAmountMicrocredits = order.AmountMicrocredits
|
||||
}
|
||||
account := model.CreditAccount{UserID: order.UserID}
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error; err != nil {
|
||||
return err
|
||||
@@ -366,6 +371,29 @@ func (r *Repository) TaskHasSuccessfulBillableCall(taskID string) (bool, error)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
type BillingUsage struct {
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
CachedTokens int64
|
||||
}
|
||||
|
||||
func (r *Repository) BillingUsage(orderID string) (*BillingUsage, error) {
|
||||
return billingUsage(r.db, orderID)
|
||||
}
|
||||
|
||||
func billingUsage(db *gorm.DB, orderID string) (*BillingUsage, error) {
|
||||
var log model.ApiCallLog
|
||||
err := db.Where("billing_order_id = ? AND billable = ? AND status = ? AND usage_available = ?", orderID, true, model.ApiCallStatusSucceeded, true).
|
||||
Order("created_at desc").First(&log).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrBillingUsageUnavailable
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &BillingUsage{InputTokens: log.InputTokens, OutputTokens: log.OutputTokens, CachedTokens: log.CachedTokens}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) RecordBillingResolution(id string, actorUserID string, note string) error {
|
||||
return r.db.Model(&model.BillingOrder{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"resolved_by": actorUserID, "resolution_note": note, "updated_at": time.Now(),
|
||||
@@ -423,6 +451,67 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err
|
||||
if order.Status == model.BillingStatusRefunded {
|
||||
return errors.New("billing order already refunded")
|
||||
}
|
||||
if order.BillingMode == "token" {
|
||||
usage, err := billingUsage(tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reserved := order.ReservedAmountMicrocredits
|
||||
if reserved <= 0 {
|
||||
reserved = order.AmountMicrocredits
|
||||
}
|
||||
actual, err := tokenUsageAmount(order, usage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if actual > reserved {
|
||||
return ErrBillingUnderreserved
|
||||
}
|
||||
refund := reserved - actual
|
||||
updated := tx.Model(&model.CreditAccount{}).
|
||||
Where("user_id = ? AND reserved_microcredits >= ?", order.UserID, reserved).
|
||||
Updates(map[string]any{
|
||||
"available_microcredits": gorm.Expr("available_microcredits + ?", refund),
|
||||
"reserved_microcredits": gorm.Expr("reserved_microcredits - ?", reserved),
|
||||
"version": gorm.Expr("version + 1"), "updated_at": time.Now(),
|
||||
})
|
||||
if updated.Error != nil {
|
||||
return updated.Error
|
||||
}
|
||||
if updated.RowsAffected != 1 {
|
||||
return errors.New("reserved credit balance is inconsistent")
|
||||
}
|
||||
var account model.CreditAccount
|
||||
if err := tx.First(&account, "user_id = ?", order.UserID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
updates := map[string]any{"status": model.BillingStatusSettled, "settled_at": &now, "updated_at": now,
|
||||
"actual_amount_microcredits": actual, "refunded_amount_microcredits": refund,
|
||||
"input_tokens": usage.InputTokens, "output_tokens": usage.OutputTokens, "cached_tokens": usage.CachedTokens,
|
||||
"usage_available": true}
|
||||
if providerRequestID != "" {
|
||||
updates["provider_request_id"] = providerRequestID
|
||||
}
|
||||
if err := tx.Model(&order).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&model.CreditLedgerEntry{ID: newRepositoryID(), UserID: order.UserID, Type: model.CreditLedgerConsume,
|
||||
AmountMicrocredits: -actual, ReservedDeltaMicrocredits: -reserved,
|
||||
AvailableAfterMicrocredits: account.AvailableMicrocredits, ReservedAfterMicrocredits: account.ReservedMicrocredits,
|
||||
BillingOrderID: order.ID, Model: order.Model, ChannelID: order.ChannelID, Scene: order.Scene}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if refund > 0 {
|
||||
if err := tx.Create(&model.CreditLedgerEntry{ID: newRepositoryID(), UserID: order.UserID, Type: model.CreditLedgerRefund,
|
||||
AmountMicrocredits: refund, AvailableDeltaMicrocredits: refund,
|
||||
AvailableAfterMicrocredits: account.AvailableMicrocredits, ReservedAfterMicrocredits: account.ReservedMicrocredits,
|
||||
BillingOrderID: order.ID, Model: order.Model, ChannelID: order.ChannelID, Scene: order.Scene, Note: "Token 预授权差额退回"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
updated := tx.Model(&model.CreditAccount{}).
|
||||
Where("user_id = ? AND reserved_microcredits >= ?", order.UserID, order.AmountMicrocredits).
|
||||
Updates(map[string]any{
|
||||
@@ -441,7 +530,7 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
orderUpdates := map[string]any{"status": model.BillingStatusSettled, "settled_at": &now, "updated_at": now}
|
||||
orderUpdates := map[string]any{"status": model.BillingStatusSettled, "actual_amount_microcredits": order.AmountMicrocredits, "settled_at": &now, "updated_at": now}
|
||||
if providerRequestID != "" {
|
||||
orderUpdates["provider_request_id"] = providerRequestID
|
||||
}
|
||||
@@ -495,7 +584,8 @@ func (r *Repository) RefundBillingOrder(id string, errorText string) error {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
if err := tx.Model(&order).Updates(map[string]any{"status": model.BillingStatusRefunded, "error": errorText, "refunded_at": &now, "updated_at": now}).Error; err != nil {
|
||||
updates := map[string]any{"status": model.BillingStatusRefunded, "error": errorText, "refunded_amount_microcredits": order.AmountMicrocredits, "refunded_at": &now, "updated_at": now}
|
||||
if err := tx.Model(&order).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&model.CreditLedgerEntry{
|
||||
@@ -516,6 +606,41 @@ func (r *Repository) RefundBillingOrder(id string, errorText string) error {
|
||||
})
|
||||
}
|
||||
|
||||
func tokenUsageAmount(order model.BillingOrder, usage *BillingUsage) (int64, error) {
|
||||
if usage == nil {
|
||||
return 0, ErrBillingUsageUnavailable
|
||||
}
|
||||
input := usage.InputTokens - usage.CachedTokens
|
||||
if input < 0 {
|
||||
input = 0
|
||||
}
|
||||
inputAmount, ok := safeTokenUsageProduct(input, order.InputTokenPriceMicrocredits)
|
||||
if !ok {
|
||||
return 0, errors.New("invalid token usage amount")
|
||||
}
|
||||
outputAmount, ok := safeTokenUsageProduct(usage.OutputTokens, order.OutputTokenPriceMicrocredits)
|
||||
if !ok || inputAmount > 1<<63-1-outputAmount {
|
||||
return 0, errors.New("invalid token usage amount")
|
||||
}
|
||||
cachedAmount, ok := safeTokenUsageProduct(usage.CachedTokens, order.CachedTokenPriceMicrocredits)
|
||||
base := inputAmount + outputAmount
|
||||
if !ok || base > 1<<63-1-cachedAmount {
|
||||
return 0, errors.New("invalid token usage amount")
|
||||
}
|
||||
base += cachedAmount
|
||||
if order.MultiplierBasisPoints <= 0 || base > (1<<63-1-9_999_999_999)/order.MultiplierBasisPoints {
|
||||
return 0, errors.New("invalid token usage amount")
|
||||
}
|
||||
return (base*order.MultiplierBasisPoints + 9_999_999_999) / 10_000_000_000, nil
|
||||
}
|
||||
|
||||
func safeTokenUsageProduct(tokens int64, price int64) (int64, bool) {
|
||||
if tokens < 0 || price < 0 || (tokens > 0 && price > (1<<63-1)/tokens) {
|
||||
return 0, false
|
||||
}
|
||||
return tokens * price, true
|
||||
}
|
||||
|
||||
func (r *Repository) AdjustCredits(userID string, actorUserID string, amount int64, note string) (*model.CreditAccount, error) {
|
||||
var account model.CreditAccount
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
@@ -114,12 +114,15 @@ type PublicModelChannel struct {
|
||||
}
|
||||
|
||||
type PublicChannelModelPrice struct {
|
||||
Model string `json:"model"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Capability string `json:"capability"`
|
||||
Protocol model.ChannelInterfaceType `json:"protocol"`
|
||||
BillingMode string `json:"billingMode"`
|
||||
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
|
||||
Model string `json:"model"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Capability string `json:"capability"`
|
||||
Protocol model.ChannelInterfaceType `json:"protocol"`
|
||||
BillingMode string `json:"billingMode"`
|
||||
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
|
||||
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
|
||||
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
|
||||
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
|
||||
}
|
||||
|
||||
func (s *Service) RequireAdmin(user *model.User) error {
|
||||
@@ -260,7 +263,7 @@ func (s *Service) CreateAdminUser(actor *model.User, req CreateAdminUserRequest)
|
||||
return nil, err
|
||||
}
|
||||
return &AdminUser{
|
||||
User: *user,
|
||||
User: *user,
|
||||
AvailableMicrocredits: account.AvailableMicrocredits,
|
||||
ReservedMicrocredits: account.ReservedMicrocredits,
|
||||
}, nil
|
||||
@@ -746,7 +749,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
|
||||
}
|
||||
models = append(models, item.ModelKey)
|
||||
if item.Enabled && item.PriceConfigured {
|
||||
modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits})
|
||||
modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits})
|
||||
}
|
||||
}
|
||||
if len(models) == 0 {
|
||||
|
||||
@@ -14,14 +14,17 @@ import (
|
||||
)
|
||||
|
||||
type ChannelModelRequest struct {
|
||||
ModelKey string `json:"modelKey"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Capability string `json:"capability"`
|
||||
Protocol string `json:"protocol"`
|
||||
BillingMode string `json:"billingMode"`
|
||||
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
|
||||
PriceConfigured bool `json:"priceConfigured"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ModelKey string `json:"modelKey"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Capability string `json:"capability"`
|
||||
Protocol string `json:"protocol"`
|
||||
BillingMode string `json:"billingMode"`
|
||||
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
|
||||
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
|
||||
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
|
||||
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
|
||||
PriceConfigured bool `json:"priceConfigured"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// AdminChannelModelFetchResult 是管理员从上游拉目录后的汇总:models 为去重后的标识,added 为本次新建条数。
|
||||
@@ -124,15 +127,25 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
|
||||
if billingMode == "" {
|
||||
billingMode = "fixed_request"
|
||||
}
|
||||
if billingMode != "fixed_request" && billingMode != "per_second" {
|
||||
return nil, BadAuthRequest("模型计费方式仅支持按次或按秒")
|
||||
if billingMode != "fixed_request" && billingMode != "per_second" && billingMode != "token" {
|
||||
return nil, BadAuthRequest("模型计费方式仅支持按次、按秒或 Token")
|
||||
}
|
||||
if billingMode == "per_second" && capability != "video" {
|
||||
return nil, BadAuthRequest("只有视频模型可以按秒计费")
|
||||
}
|
||||
if req.UnitPriceMicrocredits < 0 {
|
||||
if billingMode == "token" && capability != "text" {
|
||||
return nil, BadAuthRequest("只有文本模型可以按 Token 计费")
|
||||
}
|
||||
if req.UnitPriceMicrocredits < 0 || req.InputTokenPriceMicrocredits < 0 || req.OutputTokenPriceMicrocredits < 0 || req.CachedTokenPriceMicrocredits < 0 {
|
||||
return nil, BadAuthRequest("模型积分价格不能小于 0")
|
||||
}
|
||||
if billingMode == "token" && req.InputTokenPriceMicrocredits == 0 && req.OutputTokenPriceMicrocredits == 0 && req.CachedTokenPriceMicrocredits == 0 {
|
||||
return nil, BadAuthRequest("Token 计费至少需要配置一项价格")
|
||||
}
|
||||
const maxTokenPriceMicrocredits = int64(1_000_000) * CreditScale
|
||||
if req.InputTokenPriceMicrocredits > maxTokenPriceMicrocredits || req.OutputTokenPriceMicrocredits > maxTokenPriceMicrocredits || req.CachedTokenPriceMicrocredits > maxTokenPriceMicrocredits {
|
||||
return nil, BadAuthRequest("Token 每百万用量价格不能超过 1,000,000 积分")
|
||||
}
|
||||
item := &model.ChannelModel{ID: newID(), ChannelID: channelID, Enabled: true, PriceVersion: 1}
|
||||
if id != "" {
|
||||
item, err = s.repo.ChannelModelByID(channelID, id)
|
||||
@@ -157,6 +170,9 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
|
||||
item.Protocol = protocol
|
||||
item.BillingMode = billingMode
|
||||
item.UnitPriceMicrocredits = req.UnitPriceMicrocredits
|
||||
item.InputTokenPriceMicrocredits = req.InputTokenPriceMicrocredits
|
||||
item.OutputTokenPriceMicrocredits = req.OutputTokenPriceMicrocredits
|
||||
item.CachedTokenPriceMicrocredits = req.CachedTokenPriceMicrocredits
|
||||
item.PriceConfigured = req.PriceConfigured
|
||||
if req.Enabled != nil {
|
||||
item.Enabled = *req.Enabled
|
||||
|
||||
@@ -99,10 +99,15 @@ type ResolveBillingBatchFailure struct {
|
||||
}
|
||||
|
||||
type ResolveBillingBatchResult struct {
|
||||
ResolvedCount int `json:"resolvedCount"`
|
||||
ResolvedCount int `json:"resolvedCount"`
|
||||
Failed []ResolveBillingBatchFailure `json:"failed"`
|
||||
}
|
||||
|
||||
type tokenBillingEstimate struct {
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
}
|
||||
|
||||
func (s *Service) Wallet(user *model.User, entryType string, page int, limit int) (*WalletSummary, error) {
|
||||
if user == nil {
|
||||
return nil, Unauthorized("请先登录")
|
||||
@@ -444,10 +449,14 @@ func (s *Service) taskBillingOrder(userID string, task *model.Task, input map[st
|
||||
capability = capabilityFromTaskType(task.Type)
|
||||
}
|
||||
scene := firstNonEmpty(strings.TrimSpace(task.Operation), task.Type)
|
||||
return s.newBillingOrder(userID, task.ID, "task:"+task.ID+":"+newID(), channelID, modelKey, capability, scene, billingQuantity(capability, config["videoSeconds"]))
|
||||
return s.newBillingOrder(userID, task.ID, "task:"+task.ID+":"+newID(), channelID, modelKey, capability, scene, billingQuantity(capability, config["videoSeconds"]), estimateTaskTokens(input))
|
||||
}
|
||||
|
||||
func (s *Service) ReserveProxyBilling(userID string, channelID string, modelKey string, capability string, scene string, idempotencyKey string, quantity int64) (*model.BillingOrder, error) {
|
||||
return s.ReserveProxyBillingWithBody(userID, channelID, modelKey, capability, scene, idempotencyKey, quantity, nil)
|
||||
}
|
||||
|
||||
func (s *Service) ReserveProxyBillingWithBody(userID string, channelID string, modelKey string, capability string, scene string, idempotencyKey string, quantity int64, requestBody []byte) (*model.BillingOrder, error) {
|
||||
enabled, err := s.FeatureEnabled(FeatureCredits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -458,7 +467,7 @@ func (s *Service) ReserveProxyBilling(userID string, channelID string, modelKey
|
||||
if strings.TrimSpace(idempotencyKey) == "" {
|
||||
idempotencyKey = newID()
|
||||
}
|
||||
order, err := s.newBillingOrder(userID, "", "proxy:"+idempotencyKey, channelID, modelKey, capability, firstNonEmpty(strings.TrimSpace(scene), "system_proxy"), quantity)
|
||||
order, err := s.newBillingOrder(userID, "", "proxy:"+idempotencyKey, channelID, modelKey, capability, firstNonEmpty(strings.TrimSpace(scene), "system_proxy"), quantity, estimateProxyTokens(requestBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -471,7 +480,7 @@ func (s *Service) ReserveProxyBilling(userID string, channelID string, modelKey
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey string, channelID string, modelKey string, capability string, scene string, requestedQuantity int64) (*model.BillingOrder, error) {
|
||||
func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey string, channelID string, modelKey string, capability string, scene string, requestedQuantity int64, tokenEstimate tokenBillingEstimate) (*model.BillingOrder, error) {
|
||||
item, err := s.repo.ChannelModelByKey(channelID, modelKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, BadAuthRequest("当前系统渠道模型未配置或已停用")
|
||||
@@ -486,6 +495,7 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s
|
||||
return nil, BadAuthRequest("即梦视频仅支持 5 秒或 10 秒,请调整视频时长")
|
||||
}
|
||||
quantity := int64(1)
|
||||
amount := int64(0)
|
||||
switch item.BillingMode {
|
||||
case "fixed_request":
|
||||
case "per_second":
|
||||
@@ -496,6 +506,14 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s
|
||||
return nil, BadAuthRequest("视频生成时长无效,无法按秒计费")
|
||||
}
|
||||
quantity = requestedQuantity
|
||||
case "token":
|
||||
if item.Capability != "text" || capability != "text" {
|
||||
return nil, BadAuthRequest("Token 计费仅适用于文本生成")
|
||||
}
|
||||
if tokenEstimate.InputTokens <= 0 || tokenEstimate.OutputTokens <= 0 {
|
||||
return nil, BadAuthRequest("无法估算文本 Token 用量")
|
||||
}
|
||||
quantity = tokenEstimate.InputTokens + tokenEstimate.OutputTokens
|
||||
default:
|
||||
return nil, BadAuthRequest("当前模型计费方式暂不支持")
|
||||
}
|
||||
@@ -507,7 +525,11 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s
|
||||
if configured := policy.ModelMultiplierBPS[modelKey]; configured > 0 {
|
||||
multiplierBPS = configured
|
||||
}
|
||||
amount, err := creditAmount(item.UnitPriceMicrocredits, quantity, multiplierBPS)
|
||||
if item.BillingMode == "token" {
|
||||
amount, err = tokenEstimateAmount(item, tokenEstimate, multiplierBPS)
|
||||
} else {
|
||||
amount, err = creditAmount(item.UnitPriceMicrocredits, quantity, multiplierBPS)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -516,10 +538,78 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s
|
||||
ChannelID: channelID, ChannelModelID: item.ID, Model: modelKey, Capability: capability,
|
||||
Scene: truncateRunes(scene, 80), BillingMode: item.BillingMode, PriceVersion: item.PriceVersion,
|
||||
UnitPriceMicrocredits: item.UnitPriceMicrocredits, MultiplierBasisPoints: multiplierBPS, Quantity: quantity, AmountMicrocredits: amount,
|
||||
ReservedAmountMicrocredits: amount, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits,
|
||||
OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits,
|
||||
Status: model.BillingStatusReserved,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func estimateTaskTokens(input map[string]any) tokenBillingEstimate {
|
||||
encoded, _ := json.Marshal(input)
|
||||
return tokenBillingEstimate{InputTokens: estimatedTokens(encoded), OutputTokens: maxOutputTokens(input)}
|
||||
}
|
||||
|
||||
func estimateProxyTokens(body []byte) tokenBillingEstimate {
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
return tokenBillingEstimate{InputTokens: estimatedTokens(body), OutputTokens: maxOutputTokens(payload)}
|
||||
}
|
||||
|
||||
func estimatedTokens(value []byte) int64 {
|
||||
count := int64((len([]rune(string(value))) + 3) / 4)
|
||||
if count < 1 {
|
||||
return 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func maxOutputTokens(payload map[string]any) int64 {
|
||||
for _, key := range []string{"max_output_tokens", "max_tokens", "maxOutputTokens"} {
|
||||
value, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(payload[key])), 10, 64)
|
||||
if err == nil && value > 0 {
|
||||
if value > 131072 {
|
||||
return 131072
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
if config, ok := payload["config"].(map[string]any); ok {
|
||||
return maxOutputTokens(config)
|
||||
}
|
||||
return 4096
|
||||
}
|
||||
|
||||
// Token 单价按每百万 Token 配置;预授权使用输入价估算缓存 Token,真实结算再按 usage 拆分。
|
||||
func tokenEstimateAmount(item *model.ChannelModel, estimate tokenBillingEstimate, multiplierBPS int64) (int64, error) {
|
||||
if item == nil || estimate.InputTokens <= 0 || estimate.OutputTokens <= 0 || multiplierBPS <= 0 {
|
||||
return 0, errors.New("Token 计费参数无效")
|
||||
}
|
||||
inputAmount, ok := safeTokenProduct(estimate.InputTokens, item.InputTokenPriceMicrocredits)
|
||||
if !ok {
|
||||
return 0, errors.New("Token 计费金额溢出")
|
||||
}
|
||||
outputAmount, ok := safeTokenProduct(estimate.OutputTokens, item.OutputTokenPriceMicrocredits)
|
||||
if !ok || inputAmount > 1<<63-1-outputAmount {
|
||||
return 0, errors.New("Token 计费金额溢出")
|
||||
}
|
||||
base := inputAmount + outputAmount
|
||||
if base > (1<<63-1-9_999_999_999)/multiplierBPS {
|
||||
return 0, errors.New("Token 计费金额溢出")
|
||||
}
|
||||
amount := (base*multiplierBPS + 9_999_999_999) / 10_000_000_000
|
||||
if amount <= 0 {
|
||||
return 0, errors.New("Token 计费金额必须大于 0")
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func safeTokenProduct(tokens int64, price int64) (int64, bool) {
|
||||
if tokens < 0 || price < 0 || (tokens > 0 && price > (1<<63-1)/tokens) {
|
||||
return 0, false
|
||||
}
|
||||
return tokens * price, true
|
||||
}
|
||||
|
||||
func billingQuantity(capability string, value any) int64 {
|
||||
if capability != "video" {
|
||||
return 1
|
||||
|
||||
@@ -11,7 +11,7 @@ export function CreditSymbol({ className, ...props }: ComponentProps<"span">) {
|
||||
|
||||
export type ModelCreditCost = {
|
||||
model: string;
|
||||
billingMode: "fixed_request" | "per_second";
|
||||
billingMode: "fixed_request" | "per_second" | "token";
|
||||
unitPriceMicrocredits: number;
|
||||
};
|
||||
|
||||
@@ -27,6 +27,8 @@ export function requestCreditCost(options: { channelMode: string; modelCosts?: M
|
||||
if (options.channelMode !== "remote") return null;
|
||||
const cost = modelCreditCost(options.modelCosts, options.model);
|
||||
if (!cost) return null;
|
||||
// Token 订单由服务端按请求体预授权并在 usage 返回后结算,前端不展示无依据的固定价格。
|
||||
if (cost.billingMode === "token") return null;
|
||||
const quantity = cost.billingMode === "per_second"
|
||||
? Math.max(1, Math.floor(Math.abs(Number(options.seconds)) || 1))
|
||||
: Math.max(1, Math.floor(Math.abs(Number(options.count)) || 1));
|
||||
|
||||
@@ -20,6 +20,9 @@ type FormValues = {
|
||||
protocol: ModelProtocol;
|
||||
billingMode: ChannelModel["billingMode"];
|
||||
unitPrice: number;
|
||||
inputTokenPrice: number;
|
||||
outputTokenPrice: number;
|
||||
cachedTokenPrice: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
@@ -83,13 +86,13 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
|
||||
const startCreate = () => {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({ modelKey: "", displayName: "", capability: "text", protocol: "chat-completion", billingMode: "fixed_request", unitPrice: 0, enabled: true });
|
||||
form.setFieldsValue({ modelKey: "", displayName: "", capability: "text", protocol: "chat-completion", billingMode: "fixed_request", unitPrice: 0, inputTokenPrice: 0, outputTokenPrice: 0, cachedTokenPrice: 0, enabled: true });
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const startEdit = (item: ChannelModel) => {
|
||||
setEditing(item);
|
||||
form.setFieldsValue({ modelKey: item.modelKey, displayName: item.displayName, capability: item.capability || undefined, protocol: item.protocol, billingMode: item.billingMode, unitPrice: item.unitPriceMicrocredits / 1_000_000, enabled: item.enabled });
|
||||
form.setFieldsValue({ modelKey: item.modelKey, displayName: item.displayName, capability: item.capability || undefined, protocol: item.protocol, billingMode: item.billingMode, unitPrice: item.unitPriceMicrocredits / 1_000_000, inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000, outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000, enabled: item.enabled });
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
@@ -104,6 +107,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
protocol: values.protocol,
|
||||
billingMode: values.billingMode,
|
||||
unitPriceMicrocredits: Math.round(values.unitPrice * 1_000_000),
|
||||
inputTokenPriceMicrocredits: Math.round((values.inputTokenPrice || 0) * 1_000_000),
|
||||
outputTokenPriceMicrocredits: Math.round((values.outputTokenPrice || 0) * 1_000_000),
|
||||
cachedTokenPriceMicrocredits: Math.round((values.cachedTokenPrice || 0) * 1_000_000),
|
||||
priceConfigured: true,
|
||||
enabled: values.enabled !== false,
|
||||
};
|
||||
@@ -151,7 +157,10 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
|
||||
const handleFormValuesChange = (changed: Partial<FormValues>) => {
|
||||
if (!changed.capability) return;
|
||||
if (changed.capability !== "video") form.setFieldValue("billingMode", "fixed_request");
|
||||
const currentBillingMode = form.getFieldValue("billingMode") as ChannelModel["billingMode"] | undefined;
|
||||
if ((currentBillingMode === "per_second" && changed.capability !== "video") || (currentBillingMode === "token" && changed.capability !== "text")) {
|
||||
form.setFieldValue("billingMode", "fixed_request");
|
||||
}
|
||||
const current = form.getFieldValue("protocol") as ModelProtocol | undefined;
|
||||
if (modelProtocolCapability(current) !== changed.capability) {
|
||||
form.setFieldValue("protocol", MODEL_PROTOCOLS.find((item) => item.capability === changed.capability)?.value);
|
||||
@@ -173,7 +182,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
},
|
||||
{ title: "能力", dataIndex: "capability", width: 90, render: capabilityLabel },
|
||||
{ title: "请求协议", dataIndex: "protocol", width: 230, render: (value: ModelProtocol) => value ? <div><div className="text-xs font-medium">{modelProtocolLabel(value)}</div><div className="truncate text-[var(--fs-tiny)] text-foreground/45">{modelProtocolDefinition(value)?.create}</div></div> : <Tag color="orange">待配置</Tag> },
|
||||
{ title: "计费", width: 165, render: (_, item) => (item.priceConfigured ? `${formatCredits(item.unitPriceMicrocredits)} 积分 / ${item.billingMode === "per_second" ? "秒" : "次"}` : <Tag color="orange">未配置价格</Tag>) },
|
||||
{ title: "计费", width: 220, render: (_, item) => (item.priceConfigured ? billingSummary(item) : <Tag color="orange">未配置价格</Tag>) },
|
||||
{ title: "版本", dataIndex: "priceVersion", width: 75, render: (value) => `v${value}` },
|
||||
{ title: "状态", dataIndex: "enabled", width: 85, render: (enabled) => (enabled ? <Tag color="green">启用</Tag> : <Tag>停用</Tag>) },
|
||||
{
|
||||
@@ -238,11 +247,25 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
<ProtocolCardPicker capability={modelCapability} />
|
||||
</Form.Item>
|
||||
<Form.Item name="billingMode" label="计费方式" rules={[{ required: true }]}>
|
||||
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="unitPrice" label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
|
||||
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }, { label: "Token 计费", value: "token", disabled: modelCapability !== "text" }]} />
|
||||
</Form.Item>
|
||||
{billingMode === "token" ? (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<Form.Item name="inputTokenPrice" label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入价格" }]}>
|
||||
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="outputTokenPrice" label="输出 / 百万 Token" rules={[{ required: true, message: "请输入输出价格" }]}>
|
||||
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="cachedTokenPrice" label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存价格" }]}>
|
||||
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
) : (
|
||||
<Form.Item name="unitPrice" label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
|
||||
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
@@ -375,6 +398,19 @@ function capabilityLabel(value: ChannelModel["capability"]) {
|
||||
return { text: "文本", image: "图片", video: "视频", audio: "音频", "": "待配置" }[value];
|
||||
}
|
||||
|
||||
function billingSummary(item: ChannelModel) {
|
||||
if (item.billingMode !== "token") {
|
||||
return `${formatCredits(item.unitPriceMicrocredits)} 积分 / ${item.billingMode === "per_second" ? "秒" : "次"}`;
|
||||
}
|
||||
return (
|
||||
<div className="text-xs leading-5">
|
||||
<div>输入 {formatCredits(item.inputTokenPriceMicrocredits)} / 百万</div>
|
||||
<div>输出 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万</div>
|
||||
<div>缓存 {formatCredits(item.cachedTokenPriceMicrocredits)} / 百万</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCredits(value: number) {
|
||||
return (value / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||||
}
|
||||
|
||||
@@ -195,7 +195,17 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: "冻结积分", dataIndex: "amountMicrocredits", width: 120, align: "right", render: (value) => <span className="font-medium tabular-nums">{formatCredits(value)}</span> },
|
||||
{ title: "预授权积分", dataIndex: "amountMicrocredits", width: 120, align: "right", render: (value) => <span className="font-medium tabular-nums">{formatCredits(value)}</span> },
|
||||
{
|
||||
title: "实际结算 / 用量",
|
||||
width: 190,
|
||||
render: (_, order) => order.billingMode === "token" ? (
|
||||
<div className="text-xs leading-5">
|
||||
<div className="font-medium tabular-nums">{order.status === "settled" ? `${formatCredits(order.actualAmountMicrocredits)} 积分` : "待 usage 结算"}</div>
|
||||
<div className="text-foreground/50">输入 {order.inputTokens} · 输出 {order.outputTokens} · 缓存 {order.cachedTokens}</div>
|
||||
</div>
|
||||
) : <span className="tabular-nums">{order.status === "settled" ? formatCredits(order.actualAmountMicrocredits || order.amountMicrocredits) : "--"}</span>,
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
@@ -414,7 +424,7 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference
|
||||
setPageSize(nextPageSize);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 1390 }}
|
||||
/>
|
||||
</TableSurface>
|
||||
</section>
|
||||
|
||||
@@ -66,8 +66,11 @@ export type ChannelModel = {
|
||||
displayName: string;
|
||||
capability: "text" | "image" | "video" | "audio" | "";
|
||||
protocol?: import("@/lib/model-protocols").ModelProtocol;
|
||||
billingMode: "fixed_request" | "per_second";
|
||||
billingMode: "fixed_request" | "per_second" | "token";
|
||||
unitPriceMicrocredits: number;
|
||||
inputTokenPriceMicrocredits: number;
|
||||
outputTokenPriceMicrocredits: number;
|
||||
cachedTokenPriceMicrocredits: number;
|
||||
priceConfigured: boolean;
|
||||
enabled: boolean;
|
||||
priceVersion: number;
|
||||
@@ -161,11 +164,21 @@ export type BillingOrder = {
|
||||
model: string;
|
||||
capability: string;
|
||||
scene: string;
|
||||
billingMode: "fixed_request" | "per_second";
|
||||
billingMode: "fixed_request" | "per_second" | "token";
|
||||
unitPriceMicrocredits: number;
|
||||
multiplierBasisPoints: number;
|
||||
quantity: number;
|
||||
amountMicrocredits: number;
|
||||
reservedAmountMicrocredits: number;
|
||||
actualAmountMicrocredits: number;
|
||||
refundedAmountMicrocredits: number;
|
||||
inputTokenPriceMicrocredits: number;
|
||||
outputTokenPriceMicrocredits: number;
|
||||
cachedTokenPriceMicrocredits: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedTokens: number;
|
||||
usageAvailable: boolean;
|
||||
status: "reserved" | "running" | "settled" | "refunded" | "uncertain";
|
||||
providerRequestId?: string;
|
||||
error?: string;
|
||||
|
||||
@@ -31,8 +31,11 @@ export type ModelChannel = {
|
||||
displayName?: string;
|
||||
capability: ModelCapability;
|
||||
protocol?: ModelProtocol;
|
||||
billingMode: "fixed_request" | "per_second";
|
||||
billingMode: "fixed_request" | "per_second" | "token";
|
||||
unitPriceMicrocredits: number;
|
||||
inputTokenPriceMicrocredits?: number;
|
||||
outputTokenPriceMicrocredits?: number;
|
||||
cachedTokenPriceMicrocredits?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user