Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
288 lines
11 KiB
Go
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
|
|
}
|