Files
new-api-custom/model/relay_attempt.go
T
cneicy ceddfd3d6b feat: sync deployed v1.0.0-rc.38-custom.42
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
2026-09-26 04:49:11 +08:00

288 lines
11 KiB
Go

package model
import (
"errors"
"fmt"
"time"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RelayAttempt is the durable financial fact and log outbox for one upstream
// generation. It contains numeric usage and pricing, never a prompt or key.
type RelayAttempt struct {
ID int64 `json:"id"`
AttemptKey string `json:"attempt_key" gorm:"type:varchar(96);uniqueIndex"`
RequestID string `json:"request_id" gorm:"type:varchar(64);index"`
Number int `json:"number"`
UserID int `json:"-" gorm:"index"`
TokenID int `json:"-"`
ChannelID int `json:"channel_id"`
GroupName string `json:"group"`
RequestedModel string `json:"requested_model"`
ReturnedModel string `json:"returned_model"`
GuardResult string `json:"guard_result"`
Funding string `json:"billing_source"`
SubscriptionID int `json:"-"`
SubscriptionResetTime int64 `json:"-"`
Reserved int `json:"-"`
Quota int `json:"quota"`
ObservedQuota int `json:"-"`
EstimateQuota int `json:"-"`
Billable bool `json:"-"`
UsageSource string `json:"usage_source"`
State string `json:"state" gorm:"type:varchar(24);index"`
UsageJSON string `json:"-" gorm:"type:text"`
PriceJSON string `json:"-" gorm:"type:text"`
LogJSON string `json:"-" gorm:"type:text"`
LogDelivered bool `json:"-" gorm:"index"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime:false"`
UpdatedAt int64 `json:"-" gorm:"autoUpdateTime:false"`
Deadline int64 `json:"-" gorm:"index"`
}
// SQLite cannot upgrade a read transaction while another attempt is writing.
// Retry the entire rolled-back transaction, never an individual debit statement.
func relayAttemptTransaction(fn func(*gorm.DB) error) error {
for retry := 0; ; retry++ {
err := DB.Transaction(fn)
var coded interface{ Code() int }
busy := errors.As(err, &coded) && (coded.Code()&255 == 5 || coded.Code()&255 == 6)
if !busy || retry >= 24 {
return err
}
delay := time.Duration(retry+1) * 5 * time.Millisecond
if delay > 100*time.Millisecond {
delay = 100 * time.Millisecond
}
time.Sleep(delay)
}
}
var ErrAttemptQuota = errors.New("insufficient quota for another upstream attempt")
func ReserveRelayAttempt(attempt *RelayAttempt, preference string) error {
if attempt.Reserved < 0 || attempt.Reserved > common.MaxWalletQuota {
return ErrAttemptQuota
}
attempt.CreatedAt = time.Now().Unix()
attempt.UpdatedAt = attempt.CreatedAt
attempt.State = "reserved"
var tokenKey string
original := *attempt
err := relayAttemptTransaction(func(tx *gorm.DB) error {
*attempt = original
var existing RelayAttempt
if err := tx.Where("attempt_key = ?", attempt.AttemptKey).First(&existing).Error; err == nil {
*attempt = existing
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var user User
if err := lockForUpdate(tx).First(&user, attempt.UserID).Error; err != nil {
return err
}
var token Token
if err := lockForUpdate(tx).First(&token, attempt.TokenID).Error; err != nil {
return err
}
tokenKey = token.Key
if token.UserId != attempt.UserID || token.Status != common.TokenStatusEnabled || (!token.UnlimitedQuota && token.RemainQuota < attempt.Reserved) {
return ErrAttemptQuota
}
if err := invalidateTokenCacheForMutation(token.Key); err != nil {
return err
}
amount := int64(attempt.Reserved)
wallet := func() bool { return user.Quota >= attempt.Reserved }
trySub := func() (bool, bool, error) {
var subs []UserSubscription
if err := lockForUpdate(tx).Where("user_id = ? AND status = ? AND end_time > ?", attempt.UserID, "active", attempt.CreatedAt).Order("end_time asc, id asc").Find(&subs).Error; err != nil {
return false, false, err
}
allow := len(subs) == 0
for _, sub := range subs {
allow = allow || sub.AllowWalletOverflow
plan, err := getSubscriptionPlanByIdTx(tx, sub.PlanId)
if err != nil {
return false, allow, err
}
if err := maybeResetUserSubscriptionWithPlanTx(tx, &sub, plan, attempt.CreatedAt); err != nil {
return false, allow, err
}
if sub.AmountTotal > 0 && sub.AmountTotal-sub.AmountUsed < amount {
continue
}
if err := tx.Model(&UserSubscription{}).Where("id = ?", sub.Id).Update("amount_used", gorm.Expr("amount_used + ?", amount)).Error; err != nil {
return false, allow, err
}
attempt.Funding = "subscription"
attempt.SubscriptionID = sub.Id
attempt.SubscriptionResetTime = sub.LastResetTime
return true, allow, nil
}
return false, allow, nil
}
useWallet := false
switch common.NormalizeBillingPreference(preference) {
case "wallet_only":
useWallet = wallet()
case "wallet_first":
useWallet = wallet()
if !useWallet {
ok, _, err := trySub()
if err != nil {
return err
}
if !ok {
return ErrAttemptQuota
}
}
case "subscription_only":
ok, _, err := trySub()
if err != nil {
return err
}
if !ok {
return ErrAttemptQuota
}
default:
ok, allow, err := trySub()
if err != nil {
return err
}
if !ok {
useWallet = allow && wallet()
}
}
if useWallet {
attempt.Funding = "wallet"
if err := tx.Model(&User{}).Where("id = ?", user.Id).Update("quota", gorm.Expr("quota - ?", attempt.Reserved)).Error; err != nil {
return err
}
} else if attempt.Funding != "subscription" {
return ErrAttemptQuota
}
if err := tx.Model(&Token{}).Where("id = ?", token.Id).Updates(map[string]any{"remain_quota": gorm.Expr("remain_quota - ?", attempt.Reserved), "used_quota": gorm.Expr("used_quota + ?", attempt.Reserved), "accessed_time": attempt.CreatedAt}).Error; err != nil {
return err
}
return tx.Create(attempt).Error
})
if err == nil {
_ = invalidateUserCache(attempt.UserID)
_ = invalidateTokenCacheForMutation(tokenKey)
}
return err
}
func CheckpointRelayAttempt(id int64, quota int, usage, logJSON, returned, guard, source string, billable bool, deadline int64) error {
return DB.Model(&RelayAttempt{}).Where("id = ? AND state <> ?", id, "settled").Updates(map[string]any{"observed_quota": quota, "usage_json": usage, "log_json": logJSON, "returned_model": returned, "guard_result": guard, "usage_source": source, "billable": billable, "state": "collecting", "updated_at": time.Now().Unix(), "deadline": deadline}).Error
}
// Settlement and all balances/statistics commit together. Retrying after any
// process crash cannot repeat a debit, including the delta-zero case.
func SettleRelayAttempt(id int64, quota int, log *Log, details ...RelayAttempt) error {
if quota < 0 || quota > common.MaxWalletQuota {
return fmt.Errorf("invalid attempt charge")
}
var attempt RelayAttempt
var tokenKey string
err := relayAttemptTransaction(func(tx *gorm.DB) error {
attempt = RelayAttempt{}
if err := lockForUpdate(tx).First(&attempt, id).Error; err != nil {
return err
}
if attempt.State == "settled" {
return nil
}
delta := quota - attempt.Reserved
var user User
if err := lockForUpdate(tx).Unscoped().First(&user, attempt.UserID).Error; err != nil {
return err
}
var token Token
if err := lockForUpdate(tx).Unscoped().First(&token, attempt.TokenID).Error; err != nil {
return err
}
tokenKey = token.Key
if err := invalidateTokenCacheForMutation(token.Key); err != nil {
return err
}
if attempt.Funding == "subscription" {
if err := tx.Model(&UserSubscription{}).Where("id = ? AND last_reset_time = ?", attempt.SubscriptionID, attempt.SubscriptionResetTime).Update("amount_used", gorm.Expr("CASE WHEN amount_used + ? < 0 THEN 0 ELSE amount_used + ? END", delta, delta)).Error; err != nil {
return err
}
} else if err := tx.Unscoped().Model(&User{}).Where("id = ?", attempt.UserID).Update("quota", gorm.Expr("quota - ?", delta)).Error; err != nil {
return err
}
if err := tx.Unscoped().Model(&Token{}).Where("id = ?", attempt.TokenID).Updates(map[string]any{"remain_quota": gorm.Expr("remain_quota - ?", delta), "used_quota": gorm.Expr("used_quota + ?", delta)}).Error; err != nil {
return err
}
if quota > 0 || attempt.Billable || (len(details) > 0 && details[0].Billable) {
if err := tx.Unscoped().Model(&User{}).Where("id = ?", attempt.UserID).Updates(map[string]any{"used_quota": gorm.Expr("used_quota + ?", quota), "request_count": gorm.Expr("request_count + 1")}).Error; err != nil {
return err
}
if err := tx.Model(&Channel{}).Where("id = ?", attempt.ChannelID).Update("used_quota", gorm.Expr("used_quota + ?", quota)).Error; err != nil {
return err
}
}
if len(details) > 0 {
d := details[0]
attempt.UsageJSON = d.UsageJSON
attempt.ReturnedModel = d.ReturnedModel
attempt.GuardResult = d.GuardResult
attempt.UsageSource = d.UsageSource
attempt.Billable = d.Billable
attempt.ObservedQuota = quota
}
attempt.Quota = quota
attempt.State = "settled"
attempt.UpdatedAt = time.Now().Unix()
if log != nil {
log.Quota = quota
log.AttemptKey = &attempt.AttemptKey
if common.DataExportEnabled {
if err := tx.Create(&QuotaData{AttemptKey: &attempt.AttemptKey, UserID: attempt.UserID, Username: log.Username, ModelName: log.ModelName, CreatedAt: log.CreatedAt - (log.CreatedAt % 3600), UseGroup: attempt.GroupName, TokenID: attempt.TokenID, ChannelID: attempt.ChannelID, NodeName: common.NodeName, Count: 1, Quota: quota, TokenUsed: log.PromptTokens + log.CompletionTokens}).Error; err != nil {
return err
}
}
body, err := common.Marshal(log)
if err != nil {
return err
}
attempt.LogJSON = string(body)
}
return tx.Save(&attempt).Error
})
if err == nil {
_ = invalidateUserCache(attempt.UserID)
_ = invalidateTokenCacheForMutation(tokenKey)
}
return err
}
func DeliverRelayAttemptLogs() error {
var attempts []RelayAttempt
if err := DB.Where("state = ? AND log_delivered = ? AND log_json <> ?", "settled", false, "").Limit(100).Find(&attempts).Error; err != nil {
return err
}
for _, a := range attempts {
var log Log
if err := common.UnmarshalJsonStr(a.LogJSON, &log); err != nil {
return err
}
log.Id = 0
log.AttemptKey = &a.AttemptKey
if err := LOG_DB.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "attempt_key"}}, DoNothing: true}).Create(&log).Error; err != nil {
return err
}
if err := DB.Model(&RelayAttempt{}).Where("id = ?", a.ID).Update("log_delivered", true).Error; err != nil {
return err
}
}
return nil
}