diff --git a/common/constants.go b/common/constants.go
index 51b798dbc825..6823b2c813e6 100644
--- a/common/constants.go
+++ b/common/constants.go
@@ -39,7 +39,7 @@ var OptionMap map[string]string
var OptionMapRWMutex sync.RWMutex
var ItemsPerPage = 10
-var MaxRecentItems = 100
+var MaxRecentItems = 1000
var PasswordLoginEnabled = true
var PasswordRegisterEnabled = true
@@ -175,6 +175,10 @@ var (
DownloadRateLimitNum = 10
DownloadRateLimitDuration int64 = 60
+
+ // Per-user search rate limit (applies after authentication, keyed by user ID)
+ SearchRateLimitNum = 10
+ SearchRateLimitDuration int64 = 60
)
var RateLimitKeyExpirationDuration = 20 * time.Minute
diff --git a/common/utils.go b/common/utils.go
index b67fe1c5f634..3a8be45b31af 100644
--- a/common/utils.go
+++ b/common/utils.go
@@ -192,7 +192,7 @@ func Interface2String(inter interface{}) string {
case int:
return fmt.Sprintf("%d", inter.(int))
case float64:
- return fmt.Sprintf("%f", inter.(float64))
+ return strconv.FormatFloat(inter.(float64), 'f', -1, 64)
case bool:
if inter.(bool) {
return "true"
diff --git a/controller/custom_oauth.go b/controller/custom_oauth.go
index a4acfc38ad3f..e2245f880bbd 100644
--- a/controller/custom_oauth.go
+++ b/controller/custom_oauth.go
@@ -166,21 +166,21 @@ func CreateCustomOAuthProvider(c *gin.Context) {
// UpdateCustomOAuthProviderRequest is the request structure for updating a custom OAuth provider
type UpdateCustomOAuthProviderRequest struct {
- Name string `json:"name"`
- Slug string `json:"slug"`
- Enabled bool `json:"enabled"`
- ClientId string `json:"client_id"`
- ClientSecret string `json:"client_secret"` // Optional: if empty, keep existing
- AuthorizationEndpoint string `json:"authorization_endpoint"`
- TokenEndpoint string `json:"token_endpoint"`
- UserInfoEndpoint string `json:"user_info_endpoint"`
- Scopes string `json:"scopes"`
- UserIdField string `json:"user_id_field"`
- UsernameField string `json:"username_field"`
- DisplayNameField string `json:"display_name_field"`
- EmailField string `json:"email_field"`
- WellKnown string `json:"well_known"`
- AuthStyle int `json:"auth_style"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Enabled *bool `json:"enabled"` // Optional: if nil, keep existing
+ ClientId string `json:"client_id"`
+ ClientSecret string `json:"client_secret"` // Optional: if empty, keep existing
+ AuthorizationEndpoint string `json:"authorization_endpoint"`
+ TokenEndpoint string `json:"token_endpoint"`
+ UserInfoEndpoint string `json:"user_info_endpoint"`
+ Scopes string `json:"scopes"`
+ UserIdField string `json:"user_id_field"`
+ UsernameField string `json:"username_field"`
+ DisplayNameField string `json:"display_name_field"`
+ EmailField string `json:"email_field"`
+ WellKnown *string `json:"well_known"` // Optional: if nil, keep existing
+ AuthStyle *int `json:"auth_style"` // Optional: if nil, keep existing
}
// UpdateCustomOAuthProvider updates an existing custom OAuth provider
@@ -227,7 +227,9 @@ func UpdateCustomOAuthProvider(c *gin.Context) {
if req.Slug != "" {
provider.Slug = req.Slug
}
- provider.Enabled = req.Enabled
+ if req.Enabled != nil {
+ provider.Enabled = *req.Enabled
+ }
if req.ClientId != "" {
provider.ClientId = req.ClientId
}
@@ -258,8 +260,12 @@ func UpdateCustomOAuthProvider(c *gin.Context) {
if req.EmailField != "" {
provider.EmailField = req.EmailField
}
- provider.WellKnown = req.WellKnown
- provider.AuthStyle = req.AuthStyle
+ if req.WellKnown != nil {
+ provider.WellKnown = *req.WellKnown
+ }
+ if req.AuthStyle != nil {
+ provider.AuthStyle = *req.AuthStyle
+ }
if err := model.UpdateCustomOAuthProvider(provider); err != nil {
common.ApiError(c, err)
@@ -296,7 +302,12 @@ func DeleteCustomOAuthProvider(c *gin.Context) {
}
// Check if there are any user bindings
- count, _ := model.GetBindingCountByProviderId(id)
+ count, err := model.GetBindingCountByProviderId(id)
+ if err != nil {
+ common.SysError("Failed to get binding count for provider " + strconv.Itoa(id) + ": " + err.Error())
+ common.ApiErrorMsg(c, "检查用户绑定时发生错误,请稍后重试")
+ return
+ }
if count > 0 {
common.ApiErrorMsg(c, "该 OAuth 提供商还有用户绑定,无法删除。请先解除所有用户绑定。")
return
diff --git a/controller/log.go b/controller/log.go
index 1b2068b6cbdc..cf3825f16d5c 100644
--- a/controller/log.go
+++ b/controller/log.go
@@ -53,40 +53,32 @@ func GetUserLogs(c *gin.Context) {
return
}
+// Deprecated: SearchAllLogs 已废弃,前端未使用该接口。
func SearchAllLogs(c *gin.Context) {
- keyword := c.Query("keyword")
- logs, err := model.SearchAllLogs(keyword)
- if err != nil {
- common.ApiError(c, err)
- return
- }
c.JSON(http.StatusOK, gin.H{
- "success": true,
- "message": "",
- "data": logs,
+ "success": false,
+ "message": "该接口已废弃",
})
- return
}
+// Deprecated: SearchUserLogs 已废弃,前端未使用该接口。
func SearchUserLogs(c *gin.Context) {
- keyword := c.Query("keyword")
- userId := c.GetInt("id")
- logs, err := model.SearchUserLogs(userId, keyword)
- if err != nil {
- common.ApiError(c, err)
- return
- }
c.JSON(http.StatusOK, gin.H{
- "success": true,
- "message": "",
- "data": logs,
+ "success": false,
+ "message": "该接口已废弃",
})
- return
}
func GetLogByKey(c *gin.Context) {
- key := c.Query("key")
- logs, err := model.GetLogByKey(key)
+ tokenId := c.GetInt("token_id")
+ if tokenId == 0 {
+ c.JSON(200, gin.H{
+ "success": false,
+ "message": "无效的令牌",
+ })
+ return
+ }
+ logs, err := model.GetLogByTokenId(tokenId)
if err != nil {
c.JSON(200, gin.H{
"success": false,
@@ -110,7 +102,11 @@ func GetLogsStat(c *gin.Context) {
modelName := c.Query("model_name")
channel, _ := strconv.Atoi(c.Query("channel"))
group := c.Query("group")
- stat := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group)
+ stat, err := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
//tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -133,7 +129,11 @@ func GetLogsSelfStat(c *gin.Context) {
modelName := c.Query("model_name")
channel, _ := strconv.Atoi(c.Query("channel"))
group := c.Query("group")
- quotaNum := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group)
+ quotaNum, err := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
//tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, tokenName)
c.JSON(200, gin.H{
"success": true,
diff --git a/controller/oauth.go b/controller/oauth.go
index 58cb40d5c662..65e18f9daa17 100644
--- a/controller/oauth.go
+++ b/controller/oauth.go
@@ -11,6 +11,7 @@ import (
"github.com/QuantumNous/new-api/oauth"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
+ "gorm.io/gorm"
)
// providerParams returns map with Provider key for i18n templates
@@ -256,27 +257,62 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
}
- if err := user.Insert(inviterId); err != nil {
- return nil, err
- }
-
- // For custom providers, create the binding after user is created
+ // Use transaction to ensure user creation and OAuth binding are atomic
if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok {
- binding := &model.UserOAuthBinding{
- UserId: user.Id,
- ProviderId: genericProvider.GetProviderId(),
- ProviderUserId: oauthUser.ProviderUserID,
- }
- if err := model.CreateUserOAuthBinding(binding); err != nil {
- common.SysError(fmt.Sprintf("[OAuth] Failed to create binding for user %d: %s", user.Id, err.Error()))
- // Don't fail the registration, just log the error
+ // Custom provider: create user and binding in a transaction
+ err := model.DB.Transaction(func(tx *gorm.DB) error {
+ // Create user
+ if err := user.InsertWithTx(tx, inviterId); err != nil {
+ return err
+ }
+
+ // Create OAuth binding
+ binding := &model.UserOAuthBinding{
+ UserId: user.Id,
+ ProviderId: genericProvider.GetProviderId(),
+ ProviderUserId: oauthUser.ProviderUserID,
+ }
+ if err := model.CreateUserOAuthBindingWithTx(tx, binding); err != nil {
+ return err
+ }
+
+ return nil
+ })
+ if err != nil {
+ return nil, err
}
+
+ // Perform post-transaction tasks (logs, sidebar config, inviter rewards)
+ user.FinalizeOAuthUserCreation(inviterId)
} else {
- // Built-in provider: set the provider user ID on the user model
- provider.SetProviderUserID(user, oauthUser.ProviderUserID)
- if err := user.Update(false); err != nil {
- common.SysError(fmt.Sprintf("[OAuth] Failed to update provider ID for user %d: %s", user.Id, err.Error()))
+ // Built-in provider: create user and update provider ID in a transaction
+ err := model.DB.Transaction(func(tx *gorm.DB) error {
+ // Create user
+ if err := user.InsertWithTx(tx, inviterId); err != nil {
+ return err
+ }
+
+ // Set the provider user ID on the user model and update
+ provider.SetProviderUserID(user, oauthUser.ProviderUserID)
+ if err := tx.Model(user).Updates(map[string]interface{}{
+ "github_id": user.GitHubId,
+ "discord_id": user.DiscordId,
+ "oidc_id": user.OidcId,
+ "linux_do_id": user.LinuxDOId,
+ "wechat_id": user.WeChatId,
+ "telegram_id": user.TelegramId,
+ }).Error; err != nil {
+ return err
+ }
+
+ return nil
+ })
+ if err != nil {
+ return nil, err
}
+
+ // Perform post-transaction tasks
+ user.FinalizeOAuthUserCreation(inviterId)
}
return user, nil
diff --git a/controller/relay.go b/controller/relay.go
index 5310a9fbaf19..2d5ae7df642c 100644
--- a/controller/relay.go
+++ b/controller/relay.go
@@ -170,8 +170,8 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
// Only return quota if downstream failed and quota was actually pre-consumed
if newAPIError != nil {
newAPIError = service.NormalizeViolationFeeError(newAPIError)
- if relayInfo.FinalPreConsumedQuota != 0 {
- service.ReturnPreConsumedQuota(c, relayInfo)
+ if relayInfo.Billing != nil {
+ relayInfo.Billing.Refund(c)
}
service.ChargeViolationFeeIfNeeded(c, relayInfo, newAPIError)
}
diff --git a/controller/secure_verification.go b/controller/secure_verification.go
index f30c259e629a..ad1a615eacf9 100644
--- a/controller/secure_verification.go
+++ b/controller/secure_verification.go
@@ -133,94 +133,6 @@ func UniversalVerify(c *gin.Context) {
})
}
-// GetVerificationStatus 获取验证状态
-func GetVerificationStatus(c *gin.Context) {
- userId := c.GetInt("id")
- if userId == 0 {
- c.JSON(http.StatusUnauthorized, gin.H{
- "success": false,
- "message": "未登录",
- })
- return
- }
-
- session := sessions.Default(c)
- verifiedAtRaw := session.Get(SecureVerificationSessionKey)
-
- if verifiedAtRaw == nil {
- c.JSON(http.StatusOK, gin.H{
- "success": true,
- "message": "",
- "data": VerificationStatusResponse{
- Verified: false,
- },
- })
- return
- }
-
- verifiedAt, ok := verifiedAtRaw.(int64)
- if !ok {
- c.JSON(http.StatusOK, gin.H{
- "success": true,
- "message": "",
- "data": VerificationStatusResponse{
- Verified: false,
- },
- })
- return
- }
-
- elapsed := time.Now().Unix() - verifiedAt
- if elapsed >= SecureVerificationTimeout {
- // 验证已过期
- session.Delete(SecureVerificationSessionKey)
- _ = session.Save()
- c.JSON(http.StatusOK, gin.H{
- "success": true,
- "message": "",
- "data": VerificationStatusResponse{
- Verified: false,
- },
- })
- return
- }
-
- c.JSON(http.StatusOK, gin.H{
- "success": true,
- "message": "",
- "data": VerificationStatusResponse{
- Verified: true,
- ExpiresAt: verifiedAt + SecureVerificationTimeout,
- },
- })
-}
-
-// CheckSecureVerification 检查是否已通过安全验证
-// 返回 true 表示验证有效,false 表示需要重新验证
-func CheckSecureVerification(c *gin.Context) bool {
- session := sessions.Default(c)
- verifiedAtRaw := session.Get(SecureVerificationSessionKey)
-
- if verifiedAtRaw == nil {
- return false
- }
-
- verifiedAt, ok := verifiedAtRaw.(int64)
- if !ok {
- return false
- }
-
- elapsed := time.Now().Unix() - verifiedAt
- if elapsed >= SecureVerificationTimeout {
- // 验证已过期,清除 session
- session.Delete(SecureVerificationSessionKey)
- _ = session.Save()
- return false
- }
-
- return true
-}
-
// PasskeyVerifyAndSetSession Passkey 验证完成后设置 session
// 这是一个辅助函数,供 PasskeyVerifyFinish 调用
func PasskeyVerifyAndSetSession(c *gin.Context) {
diff --git a/controller/token.go b/controller/token.go
index d2d095a04912..50da7e339def 100644
--- a/controller/token.go
+++ b/controller/token.go
@@ -1,6 +1,7 @@
package controller
import (
+ "fmt"
"net/http"
"strconv"
"strings"
@@ -8,6 +9,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
@@ -31,16 +33,17 @@ func SearchTokens(c *gin.Context) {
userId := c.GetInt("id")
keyword := c.Query("keyword")
token := c.Query("token")
- tokens, err := model.SearchUserTokens(userId, keyword, token)
+
+ pageInfo := common.GetPageQuery(c)
+
+ tokens, total, err := model.SearchUserTokens(userId, keyword, token, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
- c.JSON(http.StatusOK, gin.H{
- "success": true,
- "message": "",
- "data": tokens,
- })
+ pageInfo.SetTotal(int(total))
+ pageInfo.SetItems(tokens)
+ common.ApiSuccess(c, pageInfo)
return
}
@@ -157,6 +160,20 @@ func AddToken(c *gin.Context) {
return
}
}
+ // 检查用户令牌数量是否已达上限
+ maxTokens := operation_setting.GetMaxUserTokens()
+ count, err := model.CountUserTokens(c.GetInt("id"))
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if int(count) >= maxTokens {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": fmt.Sprintf("已达到最大令牌数量限制 (%d)", maxTokens),
+ })
+ return
+ }
key, err := common.GenerateKey()
if err != nil {
common.ApiErrorI18n(c, i18n.MsgTokenGenerateFailed)
diff --git a/dto/channel_settings.go b/dto/channel_settings.go
index e88f2235e330..74bceb281a05 100644
--- a/dto/channel_settings.go
+++ b/dto/channel_settings.go
@@ -27,6 +27,7 @@ type ChannelOtherSettings struct {
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"`
+ ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
diff --git a/middleware/auth.go b/middleware/auth.go
index 0bb27ead057a..f5a8630ffa34 100644
--- a/middleware/auth.go
+++ b/middleware/auth.go
@@ -168,6 +168,63 @@ func WssAuth(c *gin.Context) {
}
+// TokenAuthReadOnly 宽松版本的令牌认证中间件,用于只读查询接口。
+// 只验证令牌 key 是否存在,不检查令牌状态、过期时间和额度。
+// 即使令牌已过期、已耗尽或已禁用,也允许访问。
+// 仍然检查用户是否被封禁。
+func TokenAuthReadOnly() func(c *gin.Context) {
+ return func(c *gin.Context) {
+ key := c.Request.Header.Get("Authorization")
+ if key == "" {
+ c.JSON(http.StatusUnauthorized, gin.H{
+ "success": false,
+ "message": "未提供 Authorization 请求头",
+ })
+ c.Abort()
+ return
+ }
+ if strings.HasPrefix(key, "Bearer ") || strings.HasPrefix(key, "bearer ") {
+ key = strings.TrimSpace(key[7:])
+ }
+ key = strings.TrimPrefix(key, "sk-")
+ parts := strings.Split(key, "-")
+ key = parts[0]
+
+ token, err := model.GetTokenByKey(key, false)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{
+ "success": false,
+ "message": "无效的令牌",
+ })
+ c.Abort()
+ return
+ }
+
+ userCache, err := model.GetUserCache(token.UserId)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ c.Abort()
+ return
+ }
+ if userCache.Status != common.UserStatusEnabled {
+ c.JSON(http.StatusForbidden, gin.H{
+ "success": false,
+ "message": "用户已被封禁",
+ })
+ c.Abort()
+ return
+ }
+
+ c.Set("id", token.UserId)
+ c.Set("token_id", token.Id)
+ c.Set("token_key", token.Key)
+ c.Next()
+ }
+}
+
func TokenAuth() func(c *gin.Context) {
return func(c *gin.Context) {
// 先检测是否为ws
diff --git a/middleware/rate-limit.go b/middleware/rate-limit.go
index 866542e17e13..10d7d8217d0c 100644
--- a/middleware/rate-limit.go
+++ b/middleware/rate-limit.go
@@ -115,3 +115,88 @@ func DownloadRateLimit() func(c *gin.Context) {
func UploadRateLimit() func(c *gin.Context) {
return rateLimitFactory(common.UploadRateLimitNum, common.UploadRateLimitDuration, "UP")
}
+
+// userRateLimitFactory creates a rate limiter keyed by authenticated user ID
+// instead of client IP, making it resistant to proxy rotation attacks.
+// Must be used AFTER authentication middleware (UserAuth).
+func userRateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gin.Context) {
+ if common.RedisEnabled {
+ return func(c *gin.Context) {
+ userId := c.GetInt("id")
+ if userId == 0 {
+ c.Status(http.StatusUnauthorized)
+ c.Abort()
+ return
+ }
+ key := fmt.Sprintf("rateLimit:%s:user:%d", mark, userId)
+ userRedisRateLimiter(c, maxRequestNum, duration, key)
+ }
+ }
+ // It's safe to call multi times.
+ inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration)
+ return func(c *gin.Context) {
+ userId := c.GetInt("id")
+ if userId == 0 {
+ c.Status(http.StatusUnauthorized)
+ c.Abort()
+ return
+ }
+ key := fmt.Sprintf("%s:user:%d", mark, userId)
+ if !inMemoryRateLimiter.Request(key, maxRequestNum, duration) {
+ c.Status(http.StatusTooManyRequests)
+ c.Abort()
+ return
+ }
+ }
+}
+
+// userRedisRateLimiter is like redisRateLimiter but accepts a pre-built key
+// (to support user-ID-based keys).
+func userRedisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, key string) {
+ ctx := context.Background()
+ rdb := common.RDB
+ listLength, err := rdb.LLen(ctx, key).Result()
+ if err != nil {
+ fmt.Println(err.Error())
+ c.Status(http.StatusInternalServerError)
+ c.Abort()
+ return
+ }
+ if listLength < int64(maxRequestNum) {
+ rdb.LPush(ctx, key, time.Now().Format(timeFormat))
+ rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
+ } else {
+ oldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result()
+ oldTime, err := time.Parse(timeFormat, oldTimeStr)
+ if err != nil {
+ fmt.Println(err)
+ c.Status(http.StatusInternalServerError)
+ c.Abort()
+ return
+ }
+ nowTimeStr := time.Now().Format(timeFormat)
+ nowTime, err := time.Parse(timeFormat, nowTimeStr)
+ if err != nil {
+ fmt.Println(err)
+ c.Status(http.StatusInternalServerError)
+ c.Abort()
+ return
+ }
+ if int64(nowTime.Sub(oldTime).Seconds()) < duration {
+ rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
+ c.Status(http.StatusTooManyRequests)
+ c.Abort()
+ return
+ } else {
+ rdb.LPush(ctx, key, time.Now().Format(timeFormat))
+ rdb.LTrim(ctx, key, 0, int64(maxRequestNum-1))
+ rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
+ }
+ }
+}
+
+// SearchRateLimit returns a per-user rate limiter for search endpoints.
+// 10 requests per 60 seconds per user (by user ID, not IP).
+func SearchRateLimit() func(c *gin.Context) {
+ return userRateLimitFactory(common.SearchRateLimitNum, common.SearchRateLimitDuration, "SR")
+}
diff --git a/model/custom_oauth_provider.go b/model/custom_oauth_provider.go
index 884e87b06ee6..43c69833a982 100644
--- a/model/custom_oauth_provider.go
+++ b/model/custom_oauth_provider.go
@@ -97,13 +97,18 @@ func DeleteCustomOAuthProvider(id int) error {
}
// IsSlugTaken checks if a slug is already taken by another provider
+// Returns true on DB errors (fail-closed) to prevent slug conflicts
func IsSlugTaken(slug string, excludeId int) bool {
var count int64
query := DB.Model(&CustomOAuthProvider{}).Where("slug = ?", slug)
if excludeId > 0 {
query = query.Where("id != ?", excludeId)
}
- query.Count(&count)
+ res := query.Count(&count)
+ if res.Error != nil {
+ // Fail-closed: treat DB errors as slug being taken to prevent conflicts
+ return true
+ }
return count > 0
}
diff --git a/model/log.go b/model/log.go
index de6628e7f1fe..d7cd97a4252c 100644
--- a/model/log.go
+++ b/model/log.go
@@ -2,9 +2,8 @@ package model
import (
"context"
+ "errors"
"fmt"
- "os"
- "strings"
"time"
"github.com/QuantumNous/new-api/common"
@@ -18,8 +17,8 @@ import (
)
type Log struct {
- Id int `json:"id" gorm:"index:idx_created_at_id,priority:1"`
- UserId int `json:"user_id" gorm:"index"`
+ Id int `json:"id" gorm:"index:idx_created_at_id,priority:1;index:idx_user_id_id,priority:2"`
+ UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"`
Type int `json:"type" gorm:"index:idx_created_at_type"`
Content string `json:"content"`
@@ -51,7 +50,7 @@ const (
LogTypeRefund = 6
)
-func formatUserLogs(logs []*Log) {
+func formatUserLogs(logs []*Log, startIdx int) {
for i := range logs {
logs[i].ChannelName = ""
var otherMap map[string]interface{}
@@ -62,21 +61,13 @@ func formatUserLogs(logs []*Log) {
delete(otherMap, "reject_reason")
}
logs[i].Other = common.MapToJsonStr(otherMap)
- logs[i].Id = logs[i].Id % 1024
+ logs[i].Id = startIdx + i + 1
}
}
-func GetLogByKey(key string) (logs []*Log, err error) {
- if os.Getenv("LOG_SQL_DSN") != "" {
- var tk Token
- if err = DB.Model(&Token{}).Where(logKeyCol+"=?", strings.TrimPrefix(key, "sk-")).First(&tk).Error; err != nil {
- return nil, err
- }
- err = LOG_DB.Model(&Log{}).Where("token_id=?", tk.Id).Find(&logs).Error
- } else {
- err = LOG_DB.Joins("left join tokens on tokens.id = logs.token_id").Where("tokens.key = ?", strings.TrimPrefix(key, "sk-")).Find(&logs).Error
- }
- formatUserLogs(logs)
+func GetLogByTokenId(tokenId int) (logs []*Log, err error) {
+ err = LOG_DB.Model(&Log{}).Where("token_id = ?", tokenId).Order("id desc").Limit(common.MaxRecentItems).Find(&logs).Error
+ formatUserLogs(logs, 0)
return logs, err
}
@@ -276,6 +267,8 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
return logs, total, err
}
+const logSearchCountLimit = 10000
+
func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string) (logs []*Log, total int64, err error) {
var tx *gorm.DB
if logType == LogTypeUnknown {
@@ -285,7 +278,11 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
}
if modelName != "" {
- tx = tx.Where("logs.model_name like ?", modelName)
+ modelNamePattern, err := sanitizeLikePattern(modelName)
+ if err != nil {
+ return nil, 0, err
+ }
+ tx = tx.Where("logs.model_name LIKE ? ESCAPE '!'", modelNamePattern)
}
if tokenName != "" {
tx = tx.Where("logs.token_name = ?", tokenName)
@@ -302,37 +299,28 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
if group != "" {
tx = tx.Where("logs."+logGroupCol+" = ?", group)
}
- err = tx.Model(&Log{}).Count(&total).Error
+ err = tx.Model(&Log{}).Limit(logSearchCountLimit).Count(&total).Error
if err != nil {
- return nil, 0, err
+ common.SysError("failed to count user logs: " + err.Error())
+ return nil, 0, errors.New("查询日志失败")
}
err = tx.Order("logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error
if err != nil {
- return nil, 0, err
+ common.SysError("failed to search user logs: " + err.Error())
+ return nil, 0, errors.New("查询日志失败")
}
- formatUserLogs(logs)
+ formatUserLogs(logs, startIdx)
return logs, total, err
}
-func SearchAllLogs(keyword string) (logs []*Log, err error) {
- err = LOG_DB.Where("type = ? or content LIKE ?", keyword, keyword+"%").Order("id desc").Limit(common.MaxRecentItems).Find(&logs).Error
- return logs, err
-}
-
-func SearchUserLogs(userId int, keyword string) (logs []*Log, err error) {
- err = LOG_DB.Where("user_id = ? and type = ?", userId, keyword).Order("id desc").Limit(common.MaxRecentItems).Find(&logs).Error
- formatUserLogs(logs)
- return logs, err
-}
-
type Stat struct {
Quota int `json:"quota"`
Rpm int `json:"rpm"`
Tpm int `json:"tpm"`
}
-func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int, group string) (stat Stat) {
+func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int, group string) (stat Stat, err error) {
tx := LOG_DB.Table("logs").Select("sum(quota) quota")
// 为rpm和tpm创建单独的查询
@@ -353,8 +341,12 @@ func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelNa
tx = tx.Where("created_at <= ?", endTimestamp)
}
if modelName != "" {
- tx = tx.Where("model_name like ?", modelName)
- rpmTpmQuery = rpmTpmQuery.Where("model_name like ?", modelName)
+ modelNamePattern, err := sanitizeLikePattern(modelName)
+ if err != nil {
+ return stat, err
+ }
+ tx = tx.Where("model_name LIKE ? ESCAPE '!'", modelNamePattern)
+ rpmTpmQuery = rpmTpmQuery.Where("model_name LIKE ? ESCAPE '!'", modelNamePattern)
}
if channel != 0 {
tx = tx.Where("channel_id = ?", channel)
@@ -372,10 +364,16 @@ func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelNa
rpmTpmQuery = rpmTpmQuery.Where("created_at >= ?", time.Now().Add(-60*time.Second).Unix())
// 执行查询
- tx.Scan(&stat)
- rpmTpmQuery.Scan(&stat)
+ if err := tx.Scan(&stat).Error; err != nil {
+ common.SysError("failed to query log stat: " + err.Error())
+ return stat, errors.New("查询统计数据失败")
+ }
+ if err := rpmTpmQuery.Scan(&stat).Error; err != nil {
+ common.SysError("failed to query rpm/tpm stat: " + err.Error())
+ return stat, errors.New("查询统计数据失败")
+ }
- return stat
+ return stat, nil
}
func SumUsedToken(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string) (token int) {
diff --git a/model/subscription.go b/model/subscription.go
index 6d8d2130601b..2d23a8b5bf2c 100644
--- a/model/subscription.go
+++ b/model/subscription.go
@@ -666,6 +666,22 @@ func GetAllActiveUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
return buildSubscriptionSummaries(subs), nil
}
+// HasActiveUserSubscription returns whether the user has any active subscription.
+// This is a lightweight existence check to avoid heavy pre-consume transactions.
+func HasActiveUserSubscription(userId int) (bool, error) {
+ if userId <= 0 {
+ return false, errors.New("invalid userId")
+ }
+ now := common.GetTimestamp()
+ var count int64
+ if err := DB.Model(&UserSubscription{}).
+ Where("user_id = ? AND status = ? AND end_time > ?", userId, "active", now).
+ Count(&count).Error; err != nil {
+ return false, err
+ }
+ return count > 0, nil
+}
+
// GetAllUserSubscriptions returns all subscriptions (active and expired) for a user.
func GetAllUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
if userId <= 0 {
diff --git a/model/token.go b/model/token.go
index b68fc0cfba43..04be1c55c0b9 100644
--- a/model/token.go
+++ b/model/token.go
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/bytedance/gopkg/util/gopool"
"gorm.io/gorm"
)
@@ -63,12 +64,104 @@ func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) {
return tokens, err
}
-func SearchUserTokens(userId int, keyword string, token string) (tokens []*Token, err error) {
+// sanitizeLikePattern 校验并清洗用户输入的 LIKE 搜索模式。
+// 规则:
+// 1. 转义 ! 和 _(使用 ! 作为 ESCAPE 字符,兼容 MySQL/PostgreSQL/SQLite)
+// 2. 连续的 % 合并为单个 %
+// 3. 最多允许 2 个 %
+// 4. 含 % 时(模糊搜索),去掉 % 后关键词长度必须 >= 2
+// 5. 不含 % 时按精确匹配
+func sanitizeLikePattern(input string) (string, error) {
+ // 1. 先转义 ESCAPE 字符 ! 自身,再转义 _
+ // 使用 ! 而非 \ 作为 ESCAPE 字符,避免 MySQL 中反斜杠的字符串转义问题
+ input = strings.ReplaceAll(input, "!", "!!")
+ input = strings.ReplaceAll(input, `_`, `!_`)
+
+ // 2. 连续的 % 直接拒绝
+ if strings.Contains(input, "%%") {
+ return "", errors.New("搜索模式中不允许包含连续的 % 通配符")
+ }
+
+ // 3. 统计 % 数量,不得超过 2
+ count := strings.Count(input, "%")
+ if count > 2 {
+ return "", errors.New("搜索模式中最多允许包含 2 个 % 通配符")
+ }
+
+ // 4. 含 % 时,去掉 % 后关键词长度必须 >= 2
+ if count > 0 {
+ stripped := strings.ReplaceAll(input, "%", "")
+ if len(stripped) < 2 {
+ return "", errors.New("使用模糊搜索时,关键词长度至少为 2 个字符")
+ }
+ return input, nil
+ }
+
+ // 5. 无 % 时,精确全匹配
+ return input, nil
+}
+
+const searchHardLimit = 100
+
+func SearchUserTokens(userId int, keyword string, token string, offset int, limit int) (tokens []*Token, total int64, err error) {
+ // model 层强制截断
+ if limit <= 0 || limit > searchHardLimit {
+ limit = searchHardLimit
+ }
+ if offset < 0 {
+ offset = 0
+ }
+
if token != "" {
token = strings.Trim(token, "sk-")
}
- err = DB.Where("user_id = ?", userId).Where("name LIKE ?", "%"+keyword+"%").Where(commonKeyCol+" LIKE ?", "%"+token+"%").Find(&tokens).Error
- return tokens, err
+
+ // 超量用户(令牌数超过上限)只允许精确搜索,禁止模糊搜索
+ maxTokens := operation_setting.GetMaxUserTokens()
+ hasFuzzy := strings.Contains(keyword, "%") || strings.Contains(token, "%")
+ if hasFuzzy {
+ count, err := CountUserTokens(userId)
+ if err != nil {
+ common.SysLog("failed to count user tokens: " + err.Error())
+ return nil, 0, errors.New("获取令牌数量失败")
+ }
+ if int(count) > maxTokens {
+ return nil, 0, errors.New("令牌数量超过上限,仅允许精确搜索,请勿使用 % 通配符")
+ }
+ }
+
+ baseQuery := DB.Model(&Token{}).Where("user_id = ?", userId)
+
+ // 非空才加 LIKE 条件,空则跳过(不过滤该字段)
+ if keyword != "" {
+ keywordPattern, err := sanitizeLikePattern(keyword)
+ if err != nil {
+ return nil, 0, err
+ }
+ baseQuery = baseQuery.Where("name LIKE ? ESCAPE '!'", keywordPattern)
+ }
+ if token != "" {
+ tokenPattern, err := sanitizeLikePattern(token)
+ if err != nil {
+ return nil, 0, err
+ }
+ baseQuery = baseQuery.Where(commonKeyCol+" LIKE ? ESCAPE '!'", tokenPattern)
+ }
+
+ // 先查匹配总数(用于分页,受 maxTokens 上限保护,避免全表 COUNT)
+ err = baseQuery.Limit(maxTokens).Count(&total).Error
+ if err != nil {
+ common.SysError("failed to count search tokens: " + err.Error())
+ return nil, 0, errors.New("搜索令牌失败")
+ }
+
+ // 再分页查数据
+ err = baseQuery.Order("id desc").Offset(offset).Limit(limit).Find(&tokens).Error
+ if err != nil {
+ common.SysError("failed to search tokens: " + err.Error())
+ return nil, 0, errors.New("搜索令牌失败")
+ }
+ return tokens, total, nil
}
func ValidateUserToken(key string) (token *Token, err error) {
diff --git a/model/user.go b/model/user.go
index 47508a0bb821..e0c9c686fdbb 100644
--- a/model/user.go
+++ b/model/user.go
@@ -429,6 +429,65 @@ func (user *User) Insert(inviterId int) error {
return nil
}
+// InsertWithTx inserts a new user within an existing transaction.
+// This is used for OAuth registration where user creation and binding need to be atomic.
+// Post-creation tasks (sidebar config, logs, inviter rewards) are handled after the transaction commits.
+func (user *User) InsertWithTx(tx *gorm.DB, inviterId int) error {
+ var err error
+ if user.Password != "" {
+ user.Password, err = common.Password2Hash(user.Password)
+ if err != nil {
+ return err
+ }
+ }
+ user.Quota = common.QuotaForNewUser
+ user.AffCode = common.GetRandomString(4)
+
+ // 初始化用户设置
+ if user.Setting == "" {
+ defaultSetting := dto.UserSetting{}
+ user.SetSetting(defaultSetting)
+ }
+
+ result := tx.Create(user)
+ if result.Error != nil {
+ return result.Error
+ }
+
+ return nil
+}
+
+// FinalizeOAuthUserCreation performs post-transaction tasks for OAuth user creation.
+// This should be called after the transaction commits successfully.
+func (user *User) FinalizeOAuthUserCreation(inviterId int) {
+ // 用户创建成功后,根据角色初始化边栏配置
+ var createdUser User
+ if err := DB.Where("id = ?", user.Id).First(&createdUser).Error; err == nil {
+ defaultSidebarConfig := generateDefaultSidebarConfigForRole(createdUser.Role)
+ if defaultSidebarConfig != "" {
+ currentSetting := createdUser.GetSetting()
+ currentSetting.SidebarModules = defaultSidebarConfig
+ createdUser.SetSetting(currentSetting)
+ createdUser.Update(false)
+ common.SysLog(fmt.Sprintf("为新用户 %s (角色: %d) 初始化边栏配置", createdUser.Username, createdUser.Role))
+ }
+ }
+
+ if common.QuotaForNewUser > 0 {
+ RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser)))
+ }
+ if inviterId != 0 {
+ if common.QuotaForInvitee > 0 {
+ _ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true)
+ RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee)))
+ }
+ if common.QuotaForInviter > 0 {
+ RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", logger.LogQuota(common.QuotaForInviter)))
+ _ = inviteUser(inviterId)
+ }
+ }
+}
+
func (user *User) Update(updatePassword bool) error {
var err error
if updatePassword {
diff --git a/model/user_oauth_binding.go b/model/user_oauth_binding.go
index 7b2acd474907..492166251e86 100644
--- a/model/user_oauth_binding.go
+++ b/model/user_oauth_binding.go
@@ -3,18 +3,17 @@ package model
import (
"errors"
"time"
+
+ "gorm.io/gorm"
)
// UserOAuthBinding stores the binding relationship between users and custom OAuth providers
type UserOAuthBinding struct {
Id int `json:"id" gorm:"primaryKey"`
- UserId int `json:"user_id" gorm:"index;not null"` // User ID
- ProviderId int `json:"provider_id" gorm:"index;not null"` // Custom OAuth provider ID
- ProviderUserId string `json:"provider_user_id" gorm:"type:varchar(256);not null"` // User ID from OAuth provider
+ UserId int `json:"user_id" gorm:"not null;uniqueIndex:ux_user_provider"` // User ID - one binding per user per provider
+ ProviderId int `json:"provider_id" gorm:"not null;uniqueIndex:ux_user_provider;uniqueIndex:ux_provider_userid"` // Custom OAuth provider ID
+ ProviderUserId string `json:"provider_user_id" gorm:"type:varchar(256);not null;uniqueIndex:ux_provider_userid"` // User ID from OAuth provider - one OAuth account per provider
CreatedAt time.Time `json:"created_at"`
-
- // Composite unique index to prevent duplicate bindings
- // One OAuth account can only be bound to one user
}
func (UserOAuthBinding) TableName() string {
@@ -82,6 +81,29 @@ func CreateUserOAuthBinding(binding *UserOAuthBinding) error {
return DB.Create(binding).Error
}
+// CreateUserOAuthBindingWithTx creates a new OAuth binding within a transaction
+func CreateUserOAuthBindingWithTx(tx *gorm.DB, binding *UserOAuthBinding) error {
+ if binding.UserId == 0 {
+ return errors.New("user ID is required")
+ }
+ if binding.ProviderId == 0 {
+ return errors.New("provider ID is required")
+ }
+ if binding.ProviderUserId == "" {
+ return errors.New("provider user ID is required")
+ }
+
+ // Check if this provider user ID is already taken (use tx to check within the same transaction)
+ var count int64
+ tx.Model(&UserOAuthBinding{}).Where("provider_id = ? AND provider_user_id = ?", binding.ProviderId, binding.ProviderUserId).Count(&count)
+ if count > 0 {
+ return errors.New("this OAuth account is already bound to another user")
+ }
+
+ binding.CreatedAt = time.Now()
+ return tx.Create(binding).Error
+}
+
// UpdateUserOAuthBinding updates an existing OAuth binding (e.g., rebind to different OAuth account)
func UpdateUserOAuthBinding(userId, providerId int, newProviderUserId string) error {
// Check if the new provider user ID is already taken by another user
diff --git a/oauth/github.go b/oauth/github.go
index e38f8a784de0..314118a3765c 100644
--- a/oauth/github.go
+++ b/oauth/github.go
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "io"
"net/http"
"strconv"
"time"
@@ -122,6 +123,17 @@ func (p *GitHubProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*O
logger.LogDebug(ctx, "[OAuth-GitHub] GetUserInfo response status: %d", res.StatusCode)
+ // Check for non-200 status codes before attempting to decode
+ if res.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(res.Body)
+ bodyStr := string(body)
+ if len(bodyStr) > 500 {
+ bodyStr = bodyStr[:500] + "..."
+ }
+ logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] GetUserInfo failed: status=%d, body=%s", res.StatusCode, bodyStr))
+ return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthGetUserErr, map[string]any{"Provider": "GitHub"}, fmt.Sprintf("status %d", res.StatusCode))
+ }
+
var githubUser gitHubUser
err = json.NewDecoder(res.Body).Decode(&githubUser)
if err != nil {
diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go
index 4b78b398531c..54ab32c8c88d 100644
--- a/relay/channel/aws/constants.go
+++ b/relay/channel/aws/constants.go
@@ -3,7 +3,7 @@ package aws
import "strings"
var awsModelIDMap = map[string]string{
- "claude-3-sonnet-20240229": "anthropic.claude-3-sonnet-20240229-v1:0",
+ "claude-3-sonnet-20240229": "anthropic.claude-3-sonnet-20240229-v1:0",
"claude-3-opus-20240229": "anthropic.claude-3-opus-20240229-v1:0",
"claude-3-haiku-20240307": "anthropic.claude-3-haiku-20240307-v1:0",
"claude-3-5-sonnet-20240620": "anthropic.claude-3-5-sonnet-20240620-v1:0",
@@ -16,6 +16,7 @@ var awsModelIDMap = map[string]string{
"claude-sonnet-4-5-20250929": "anthropic.claude-sonnet-4-5-20250929-v1:0",
"claude-haiku-4-5-20251001": "anthropic.claude-haiku-4-5-20251001-v1:0",
"claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0",
+ "claude-opus-4-6": "anthropic.claude-opus-4-6-v1",
// Nova models
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
@@ -79,6 +80,11 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
"ap": true,
"eu": true,
},
+ "anthropic.claude-opus-4-6-v1": {
+ "us": true,
+ "ap": true,
+ "eu": true,
+ },
"anthropic.claude-haiku-4-5-20251001-v1:0": {
"us": true,
"ap": true,
diff --git a/relay/channel/aws/dto.go b/relay/channel/aws/dto.go
index b060a593bf9d..4a942714d434 100644
--- a/relay/channel/aws/dto.go
+++ b/relay/channel/aws/dto.go
@@ -26,6 +26,7 @@ type AwsClaudeRequest struct {
Tools any `json:"tools,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
Thinking *dto.Thinking `json:"thinking,omitempty"`
+ OutputConfig json.RawMessage `json:"output_config,omitempty"`
}
func formatRequest(requestBody io.Reader, requestHeader http.Header) (*AwsClaudeRequest, error) {
diff --git a/relay/channel/claude/constants.go b/relay/channel/claude/constants.go
index b5dcc5af439c..2da61d0c7a97 100644
--- a/relay/channel/claude/constants.go
+++ b/relay/channel/claude/constants.go
@@ -20,6 +20,11 @@ var ModelList = []string{
"claude-sonnet-4-5-20250929-thinking",
"claude-opus-4-5-20251101",
"claude-opus-4-5-20251101-thinking",
+ "claude-opus-4-6",
+ "claude-opus-4-6-max",
+ "claude-opus-4-6-high",
+ "claude-opus-4-6-medium",
+ "claude-opus-4-6-low",
}
var ChannelName = "claude"
diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go
index 6d90daa08a0f..bdb376edd182 100644
--- a/relay/channel/claude/relay-claude.go
+++ b/relay/channel/claude/relay-claude.go
@@ -17,6 +17,7 @@ import (
"github.com/QuantumNous/new-api/relay/reasonmap"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
+ "github.com/QuantumNous/new-api/setting/reasoning"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
@@ -141,7 +142,16 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
claudeRequest.MaxTokens = uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(textRequest.Model))
}
- if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
+ if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" &&
+ strings.HasPrefix(textRequest.Model, "claude-opus-4-6") {
+ claudeRequest.Model = baseModel
+ claudeRequest.Thinking = &dto.Thinking{
+ Type: "adaptive",
+ }
+ claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
+ claudeRequest.TopP = 0
+ claudeRequest.Temperature = common.GetPointer[float64](1.0)
+ } else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
strings.HasSuffix(textRequest.Model, "-thinking") {
// 因为BudgetTokens 必须大于1024
diff --git a/relay/channel/codex/constants.go b/relay/channel/codex/constants.go
index 8cdb2c38a2c8..c4f66ae17f61 100644
--- a/relay/channel/codex/constants.go
+++ b/relay/channel/codex/constants.go
@@ -8,7 +8,7 @@ import (
var baseModelList = []string{
"gpt-5", "gpt-5-codex", "gpt-5-codex-mini",
"gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-max", "gpt-5.1-codex-mini",
- "gpt-5.2", "gpt-5.2-codex",
+ "gpt-5.2", "gpt-5.2-codex", "gpt-5.3-codex",
}
var ModelList = withCompactModelSuffix(baseModelList)
diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go
index f30a6876b500..b695442380b9 100644
--- a/relay/channel/openai/adaptor.go
+++ b/relay/channel/openai/adaptor.go
@@ -171,7 +171,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
url = strings.Replace(url, "{model}", info.UpstreamModelName, -1)
return url, nil
default:
- if info.RelayFormat == types.RelayFormatClaude || info.RelayFormat == types.RelayFormatGemini {
+ if (info.RelayFormat == types.RelayFormatClaude || info.RelayFormat == types.RelayFormatGemini) &&
+ info.RelayMode != relayconstant.RelayModeResponses &&
+ info.RelayMode != relayconstant.RelayModeResponsesCompact {
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
}
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil
diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go
index d00b53907653..1aa06473c377 100644
--- a/relay/channel/openai/chat_via_responses.go
+++ b/relay/channel/openai/chat_via_responses.go
@@ -71,12 +71,22 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
chatResp.Usage = *usage
}
- chatBody, err := common.Marshal(chatResp)
+ var responseBody []byte
+ switch info.RelayFormat {
+ case types.RelayFormatClaude:
+ claudeResp := service.ResponseOpenAI2Claude(chatResp, info)
+ responseBody, err = common.Marshal(claudeResp)
+ case types.RelayFormatGemini:
+ geminiResp := service.ResponseOpenAI2Gemini(chatResp, info)
+ responseBody, err = common.Marshal(geminiResp)
+ default:
+ responseBody, err = common.Marshal(chatResp)
+ }
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
}
- service.IOCopyBytesGracefully(c, resp, chatBody)
+ service.IOCopyBytesGracefully(c, resp, responseBody)
return usage, nil
}
@@ -106,14 +116,43 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
toolCallArgsByID := make(map[string]string)
toolCallNameSent := make(map[string]bool)
toolCallCanonicalIDByItemID := make(map[string]string)
+ hasSentReasoningSummary := false
+ needsReasoningSummarySeparator := false
//reasoningSummaryTextByKey := make(map[string]string)
+ if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo == nil {
+ info.ClaudeConvertInfo = &relaycommon.ClaudeConvertInfo{LastMessagesType: relaycommon.LastMessageTypeNone}
+ }
+
+ sendChatChunk := func(chunk *dto.ChatCompletionsStreamResponse) bool {
+ if chunk == nil {
+ return true
+ }
+ if info.RelayFormat == types.RelayFormatOpenAI {
+ if err := helper.ObjectData(c, chunk); err != nil {
+ streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ return false
+ }
+ return true
+ }
+
+ chunkData, err := common.Marshal(chunk)
+ if err != nil {
+ streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
+ return false
+ }
+ if err := HandleStreamFormat(c, info, string(chunkData), false, false); err != nil {
+ streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ return false
+ }
+ return true
+ }
+
sendStartIfNeeded := func() bool {
if sentStart {
return true
}
- if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil {
- streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ if !sendChatChunk(helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)) {
return false
}
sentStart = true
@@ -154,6 +193,17 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
if delta == "" {
return true
}
+ if needsReasoningSummarySeparator {
+ if strings.HasPrefix(delta, "\n\n") {
+ needsReasoningSummarySeparator = false
+ } else if strings.HasPrefix(delta, "\n") {
+ delta = "\n" + delta
+ needsReasoningSummarySeparator = false
+ } else {
+ delta = "\n\n" + delta
+ needsReasoningSummarySeparator = false
+ }
+ }
if !sendStartIfNeeded() {
return false
}
@@ -173,10 +223,10 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
},
},
}
- if err := helper.ObjectData(c, chunk); err != nil {
- streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ if !sendChatChunk(chunk) {
return false
}
+ hasSentReasoningSummary = true
return true
}
@@ -231,8 +281,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
},
},
}
- if err := helper.ObjectData(c, chunk); err != nil {
- streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ if !sendChatChunk(chunk) {
return false
}
sawToolCall = true
@@ -282,6 +331,9 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
}
case "response.reasoning_summary_text.done":
+ if hasSentReasoningSummary {
+ needsReasoningSummarySeparator = true
+ }
//case "response.reasoning_summary_part.added", "response.reasoning_summary_part.done":
// key := responsesStreamIndexKey(strings.TrimSpace(streamResp.ItemID), streamResp.SummaryIndex)
@@ -323,8 +375,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
},
},
}
- if err := helper.ObjectData(c, chunk); err != nil {
- streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ if !sendChatChunk(chunk) {
return false
}
}
@@ -419,13 +470,15 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return false
}
if !sentStop {
+ if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
+ info.ClaudeConvertInfo.Usage = usage
+ }
finishReason := "stop"
if sawToolCall && outputText.Len() == 0 {
finishReason = "tool_calls"
}
stop := helper.GenerateStopResponse(responseId, createAt, model, finishReason)
- if err := helper.ObjectData(c, stop); err != nil {
- streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ if !sendChatChunk(stop) {
return false
}
sentStop = true
@@ -456,26 +509,31 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
}
if !sentStart {
- if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil {
- return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ if !sendChatChunk(helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)) {
+ return nil, streamErr
}
}
if !sentStop {
+ if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
+ info.ClaudeConvertInfo.Usage = usage
+ }
finishReason := "stop"
if sawToolCall && outputText.Len() == 0 {
finishReason = "tool_calls"
}
stop := helper.GenerateStopResponse(responseId, createAt, model, finishReason)
- if err := helper.ObjectData(c, stop); err != nil {
- return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ if !sendChatChunk(stop) {
+ return nil, streamErr
}
}
- if info.ShouldIncludeUsage && usage != nil {
+ if info.RelayFormat == types.RelayFormatOpenAI && info.ShouldIncludeUsage && usage != nil {
if err := helper.ObjectData(c, helper.GenerateFinalUsageResponse(responseId, createAt, model, *usage)); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
}
- helper.Done(c)
+ if info.RelayFormat == types.RelayFormatOpenAI {
+ helper.Done(c)
+ }
return usage, nil
}
diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go
index b9fbd19a4f99..7c48907ec7e8 100644
--- a/relay/channel/vertex/adaptor.go
+++ b/relay/channel/vertex/adaptor.go
@@ -42,6 +42,7 @@ var claudeModelMap = map[string]string{
"claude-sonnet-4-5-20250929": "claude-sonnet-4-5@20250929",
"claude-haiku-4-5-20251001": "claude-haiku-4-5@20251001",
"claude-opus-4-5-20251101": "claude-opus-4-5@20251101",
+ "claude-opus-4-6": "claude-opus-4-6",
}
const anthropicVersion = "vertex-2023-10-16"
diff --git a/relay/channel/vertex/dto.go b/relay/channel/vertex/dto.go
index 68044ff328b1..2ddafa31b3d7 100644
--- a/relay/channel/vertex/dto.go
+++ b/relay/channel/vertex/dto.go
@@ -1,6 +1,8 @@
package vertex
import (
+ "encoding/json"
+
"github.com/QuantumNous/new-api/dto"
)
@@ -17,6 +19,7 @@ type VertexAIClaudeRequest struct {
Tools any `json:"tools,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
Thinking *dto.Thinking `json:"thinking,omitempty"`
+ OutputConfig json.RawMessage `json:"output_config,omitempty"`
}
func copyRequest(req *dto.ClaudeRequest, version string) *VertexAIClaudeRequest {
@@ -33,5 +36,6 @@ func copyRequest(req *dto.ClaudeRequest, version string) *VertexAIClaudeRequest
Tools: req.Tools,
ToolChoice: req.ToolChoice,
Thinking: req.Thinking,
+ OutputConfig: req.OutputConfig,
}
}
diff --git a/relay/claude_handler.go b/relay/claude_handler.go
index 7e05116daf95..518ff3f87a0c 100644
--- a/relay/claude_handler.go
+++ b/relay/claude_handler.go
@@ -2,6 +2,7 @@ package relay
import (
"bytes"
+ "encoding/json"
"fmt"
"io"
"net/http"
@@ -14,6 +15,7 @@ import (
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
+ "github.com/QuantumNous/new-api/setting/reasoning"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
@@ -49,7 +51,17 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
request.MaxTokens = uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(request.Model))
}
- if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
+ if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(request.Model); ok && effortLevel != "" &&
+ strings.HasPrefix(request.Model, "claude-opus-4-6") {
+ request.Model = baseModel
+ request.Thinking = &dto.Thinking{
+ Type: "adaptive",
+ }
+ request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
+ request.TopP = 0
+ request.Temperature = common.GetPointer[float64](1.0)
+ info.UpstreamModelName = request.Model
+ } else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
strings.HasSuffix(request.Model, "-thinking") {
if request.Thinking == nil {
// 因为BudgetTokens 必须大于1024
@@ -98,6 +110,23 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
}
}
+ if !model_setting.GetGlobalSettings().PassThroughRequestEnabled &&
+ !info.ChannelSetting.PassThroughBodyEnabled &&
+ service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.ChannelType, info.OriginModelName) {
+ openAIRequest, convErr := service.ClaudeToOpenAIRequest(*request, info)
+ if convErr != nil {
+ return types.NewError(convErr, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+ }
+
+ usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, openAIRequest)
+ if newApiErr != nil {
+ return newApiErr
+ }
+
+ service.PostClaudeConsumeQuota(c, info, usage)
+ return nil
+ }
+
var requestBody io.Reader
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled {
body, err := common.GetRequestBody(c)
diff --git a/relay/common/billing.go b/relay/common/billing.go
new file mode 100644
index 000000000000..78f5cb195104
--- /dev/null
+++ b/relay/common/billing.go
@@ -0,0 +1,21 @@
+package common
+
+import "github.com/gin-gonic/gin"
+
+// BillingSettler 抽象计费会话的生命周期操作。
+// 由 service.BillingSession 实现,存储在 RelayInfo 上以避免循环引用。
+type BillingSettler interface {
+ // Settle 根据实际消耗额度进行结算,计算 delta = actualQuota - preConsumedQuota,
+ // 同时调整资金来源(钱包/订阅)和令牌额度。
+ Settle(actualQuota int) error
+
+ // Refund 退还所有预扣费额度(资金来源 + 令牌),幂等安全。
+ // 通过 gopool 异步执行。如果已经结算或退款则不做任何操作。
+ Refund(c *gin.Context)
+
+ // NeedsRefund 返回会话是否存在需要退还的预扣状态(未结算且未退款)。
+ NeedsRefund() bool
+
+ // GetPreConsumedQuota 返回实际预扣的额度值(信任用户可能为 0)。
+ GetPreConsumedQuota() int
+}
diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go
index f5c1d769e0fa..5b25ebf021db 100644
--- a/relay/common/relay_info.go
+++ b/relay/common/relay_info.go
@@ -37,6 +37,9 @@ type ClaudeConvertInfo struct {
Usage *dto.Usage
FinishReason string
Done bool
+
+ ToolCallBaseIndex int
+ ToolCallMaxIndexOffset int
}
type RerankerInfo struct {
@@ -115,6 +118,9 @@ type RelayInfo struct {
SendResponseCount int
ReceivedResponseCount int
FinalPreConsumedQuota int // 最终预消耗的配额
+ // Billing 是计费会话,封装了预扣费/结算/退款的统一生命周期。
+ // 免费模型和按次计费(MJ/Task)时为 nil。
+ Billing BillingSettler
// BillingSource indicates whether this request is billed from wallet quota or subscription.
// "" or "wallet" => wallet; "subscription" => subscription
BillingSource string
@@ -316,12 +322,15 @@ func GenRelayInfoClaude(c *gin.Context, request dto.Request) *RelayInfo {
info.ClaudeConvertInfo = &ClaudeConvertInfo{
LastMessagesType: LastMessageTypeNone,
}
- if c.Query("beta") == "true" {
- info.IsClaudeBetaQuery = true
- }
+ info.IsClaudeBetaQuery = c.Query("beta") == "true" || isClaudeBetaForced(c)
return info
}
+func isClaudeBetaForced(c *gin.Context) bool {
+ channelOtherSettings, ok := common.GetContextKeyType[dto.ChannelOtherSettings](c, constant.ContextKeyChannelOtherSetting)
+ return ok && channelOtherSettings.ClaudeBetaQuery
+}
+
func GenRelayInfoRerank(c *gin.Context, request *dto.RerankRequest) *RelayInfo {
info := genBaseRelayInfo(c, request)
info.RelayMode = relayconstant.RelayModeRerank
diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go
index 74abfe5b2334..21180d8de830 100644
--- a/relay/compatible_handler.go
+++ b/relay/compatible_handler.go
@@ -423,29 +423,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
}
- quotaDelta := quota - relayInfo.FinalPreConsumedQuota
-
- //logger.LogInfo(ctx, fmt.Sprintf("request quota delta: %s", logger.FormatQuota(quotaDelta)))
-
- if quotaDelta > 0 {
- logger.LogInfo(ctx, fmt.Sprintf("预扣费后补扣费:%s(实际消耗:%s,预扣费:%s)",
- logger.FormatQuota(quotaDelta),
- logger.FormatQuota(quota),
- logger.FormatQuota(relayInfo.FinalPreConsumedQuota),
- ))
- } else if quotaDelta < 0 {
- logger.LogInfo(ctx, fmt.Sprintf("预扣费后返还扣费:%s(实际消耗:%s,预扣费:%s)",
- logger.FormatQuota(-quotaDelta),
- logger.FormatQuota(quota),
- logger.FormatQuota(relayInfo.FinalPreConsumedQuota),
- ))
- }
-
- if quotaDelta != 0 {
- err := service.PostConsumeQuota(relayInfo, quotaDelta, relayInfo.FinalPreConsumedQuota, true)
- if err != nil {
- logger.LogError(ctx, "error consuming token remain quota: "+err.Error())
- }
+ if err := service.SettleBilling(ctx, relayInfo, quota); err != nil {
+ logger.LogError(ctx, "error settling billing: "+err.Error())
}
logModel := modelName
diff --git a/router/api-router.go b/router/api-router.go
index e26f9b70074b..e2ef2f531b9c 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -50,7 +50,6 @@ func SetApiRouter(router *gin.Engine) {
// Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
- apiRouter.GET("/verify/status", middleware.UserAuth(), controller.GetVerificationStatus)
userRoute := apiRouter.Group("/user")
{
@@ -238,7 +237,7 @@ func SetApiRouter(router *gin.Engine) {
tokenRoute.Use(middleware.UserAuth())
{
tokenRoute.GET("/", controller.GetAllTokens)
- tokenRoute.GET("/search", controller.SearchTokens)
+ tokenRoute.GET("/search", middleware.SearchRateLimit(), controller.SearchTokens)
tokenRoute.GET("/:id", controller.GetToken)
tokenRoute.POST("/", controller.AddToken)
tokenRoute.PUT("/", controller.UpdateToken)
@@ -247,10 +246,10 @@ func SetApiRouter(router *gin.Engine) {
}
usageRoute := apiRouter.Group("/usage")
- usageRoute.Use(middleware.CriticalRateLimit())
+ usageRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())
{
tokenUsageRoute := usageRoute.Group("/token")
- tokenUsageRoute.Use(middleware.TokenAuth())
+ tokenUsageRoute.Use(middleware.TokenAuthReadOnly())
{
tokenUsageRoute.GET("/", controller.GetTokenUsage)
}
@@ -275,15 +274,15 @@ func SetApiRouter(router *gin.Engine) {
logRoute.GET("/channel_affinity_usage_cache", middleware.AdminAuth(), controller.GetChannelAffinityUsageCacheStats)
logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs)
logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs)
- logRoute.GET("/self/search", middleware.UserAuth(), controller.SearchUserLogs)
+ logRoute.GET("/self/search", middleware.UserAuth(), middleware.SearchRateLimit(), controller.SearchUserLogs)
dataRoute := apiRouter.Group("/data")
dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates)
dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates)
- logRoute.Use(middleware.CORS())
+ logRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())
{
- logRoute.GET("/token", controller.GetLogByKey)
+ logRoute.GET("/token", middleware.TokenAuthReadOnly(), controller.GetLogByKey)
}
groupRoute := apiRouter.Group("/group")
groupRoute.Use(middleware.AdminAuth())
diff --git a/service/billing.go b/service/billing.go
index c7b3c6d8a526..81daeed82c29 100644
--- a/service/billing.go
+++ b/service/billing.go
@@ -2,12 +2,8 @@ package service
import (
"fmt"
- "net/http"
- "strings"
- "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
- "github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
@@ -18,89 +14,65 @@ const (
BillingSourceSubscription = "subscription"
)
-// PreConsumeBilling decides whether to pre-consume from subscription or wallet based on user preference.
-// It also always pre-consumes token quota in quota units (same as legacy flow).
+// PreConsumeBilling 根据用户计费偏好创建 BillingSession 并执行预扣费。
+// 会话存储在 relayInfo.Billing 上,供后续 Settle / Refund 使用。
func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
- if relayInfo == nil {
- return types.NewError(fmt.Errorf("relayInfo is nil"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
+ session, apiErr := NewBillingSession(c, relayInfo, preConsumedQuota)
+ if apiErr != nil {
+ return apiErr
}
+ relayInfo.Billing = session
+ return nil
+}
- pref := common.NormalizeBillingPreference(relayInfo.UserSetting.BillingPreference)
- trySubscription := func() *types.NewAPIError {
- quotaType := 0
- // For total quota: consume preConsumedQuota quota units.
- subConsume := int64(preConsumedQuota)
- if subConsume <= 0 {
- subConsume = 1
- }
-
- // Pre-consume token quota in quota units to keep token limits consistent.
- if preConsumedQuota > 0 {
- if err := PreConsumeTokenQuota(relayInfo, preConsumedQuota); err != nil {
- return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
- }
- }
+// ---------------------------------------------------------------------------
+// SettleBilling — 后结算辅助函数
+// ---------------------------------------------------------------------------
- res, err := model.PreConsumeUserSubscription(relayInfo.RequestId, relayInfo.UserId, relayInfo.OriginModelName, quotaType, subConsume)
- if err != nil {
- // revert token pre-consume when subscription fails
- if preConsumedQuota > 0 && !relayInfo.IsPlayground {
- _ = model.IncreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, preConsumedQuota)
- }
- errMsg := err.Error()
- if strings.Contains(errMsg, "no active subscription") || strings.Contains(errMsg, "subscription quota insufficient") {
- return types.NewErrorWithStatusCode(fmt.Errorf("订阅额度不足或未配置订阅: %s", errMsg), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
- }
- return types.NewErrorWithStatusCode(fmt.Errorf("订阅预扣失败: %s", errMsg), types.ErrorCodeQueryDataError, http.StatusInternalServerError)
- }
+// SettleBilling 执行计费结算。如果 RelayInfo 上有 BillingSession 则通过 session 结算,
+// 否则回退到旧的 PostConsumeQuota 路径(兼容按次计费等场景)。
+func SettleBilling(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, actualQuota int) error {
+ if relayInfo.Billing != nil {
+ preConsumed := relayInfo.Billing.GetPreConsumedQuota()
+ delta := actualQuota - preConsumed
- relayInfo.BillingSource = BillingSourceSubscription
- relayInfo.SubscriptionId = res.UserSubscriptionId
- relayInfo.SubscriptionPreConsumed = res.PreConsumed
- relayInfo.SubscriptionPostDelta = 0
- relayInfo.SubscriptionAmountTotal = res.AmountTotal
- relayInfo.SubscriptionAmountUsedAfterPreConsume = res.AmountUsedAfter
- if planInfo, err := model.GetSubscriptionPlanInfoByUserSubscriptionId(res.UserSubscriptionId); err == nil && planInfo != nil {
- relayInfo.SubscriptionPlanId = planInfo.PlanId
- relayInfo.SubscriptionPlanTitle = planInfo.PlanTitle
+ if delta > 0 {
+ logger.LogInfo(ctx, fmt.Sprintf("预扣费后补扣费:%s(实际消耗:%s,预扣费:%s)",
+ logger.FormatQuota(delta),
+ logger.FormatQuota(actualQuota),
+ logger.FormatQuota(preConsumed),
+ ))
+ } else if delta < 0 {
+ logger.LogInfo(ctx, fmt.Sprintf("预扣费后返还扣费:%s(实际消耗:%s,预扣费:%s)",
+ logger.FormatQuota(-delta),
+ logger.FormatQuota(actualQuota),
+ logger.FormatQuota(preConsumed),
+ ))
+ } else {
+ logger.LogInfo(ctx, fmt.Sprintf("预扣费与实际消耗一致,无需调整:%s(按次计费)",
+ logger.FormatQuota(actualQuota),
+ ))
}
- relayInfo.FinalPreConsumedQuota = preConsumedQuota
- logger.LogInfo(c, fmt.Sprintf("用户 %d 使用订阅计费预扣:订阅=%d,token_quota=%d", relayInfo.UserId, res.PreConsumed, preConsumedQuota))
- return nil
- }
-
- tryWallet := func() *types.NewAPIError {
- relayInfo.BillingSource = BillingSourceWallet
- relayInfo.SubscriptionId = 0
- relayInfo.SubscriptionPreConsumed = 0
- return PreConsumeQuota(c, preConsumedQuota, relayInfo)
- }
-
- switch pref {
- case "subscription_only":
- return trySubscription()
- case "wallet_only":
- return tryWallet()
- case "wallet_first":
- if err := tryWallet(); err != nil {
- // only fallback for insufficient wallet quota
- if err.GetErrorCode() == types.ErrorCodeInsufficientUserQuota {
- return trySubscription()
- }
+ if err := relayInfo.Billing.Settle(actualQuota); err != nil {
return err
}
- return nil
- case "subscription_first":
- fallthrough
- default:
- if err := trySubscription(); err != nil {
- // fallback only when subscription not available/insufficient
- if err.GetErrorCode() == types.ErrorCodeInsufficientUserQuota {
- return tryWallet()
+
+ // 发送额度通知(订阅计费使用订阅剩余额度)
+ if actualQuota != 0 {
+ if relayInfo.BillingSource == BillingSourceSubscription {
+ checkAndSendSubscriptionQuotaNotify(relayInfo)
+ } else {
+ checkAndSendQuotaNotify(relayInfo, actualQuota-preConsumed, preConsumed)
}
- return err
}
return nil
}
+
+ // 回退:无 BillingSession 时使用旧路径
+ quotaDelta := actualQuota - relayInfo.FinalPreConsumedQuota
+ if quotaDelta != 0 {
+ return PostConsumeQuota(relayInfo, quotaDelta, relayInfo.FinalPreConsumedQuota, true)
+ }
+ return nil
}
diff --git a/service/billing_session.go b/service/billing_session.go
new file mode 100644
index 000000000000..1a31316b5686
--- /dev/null
+++ b/service/billing_session.go
@@ -0,0 +1,342 @@
+package service
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/logger"
+ "github.com/QuantumNous/new-api/model"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/types"
+
+ "github.com/bytedance/gopkg/util/gopool"
+ "github.com/gin-gonic/gin"
+)
+
+// ---------------------------------------------------------------------------
+// BillingSession — 统一计费会话
+// ---------------------------------------------------------------------------
+
+// BillingSession 封装单次请求的预扣费/结算/退款生命周期。
+// 实现 relaycommon.BillingSettler 接口。
+type BillingSession struct {
+ relayInfo *relaycommon.RelayInfo
+ funding FundingSource
+ preConsumedQuota int // 实际预扣额度(信任用户可能为 0)
+ tokenConsumed int // 令牌额度实际扣减量
+ fundingSettled bool // funding.Settle 已成功,资金来源已提交
+ settled bool // Settle 全部完成(资金 + 令牌)
+ refunded bool // Refund 已调用
+ mu sync.Mutex
+}
+
+// Settle 根据实际消耗额度进行结算。
+// 资金来源和令牌额度分两步提交:若资金来源已提交但令牌调整失败,
+// 会标记 fundingSettled 防止 Refund 对已提交的资金来源执行退款。
+func (s *BillingSession) Settle(actualQuota int) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.settled {
+ return nil
+ }
+ delta := actualQuota - s.preConsumedQuota
+ if delta == 0 {
+ s.settled = true
+ return nil
+ }
+ // 1) 调整资金来源(仅在尚未提交时执行,防止重复调用)
+ if !s.fundingSettled {
+ if err := s.funding.Settle(delta); err != nil {
+ return err
+ }
+ s.fundingSettled = true
+ }
+ // 2) 调整令牌额度
+ var tokenErr error
+ if !s.relayInfo.IsPlayground {
+ if delta > 0 {
+ tokenErr = model.DecreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, delta)
+ } else {
+ tokenErr = model.IncreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, -delta)
+ }
+ if tokenErr != nil {
+ // 资金来源已提交,令牌调整失败只能记录日志;标记 settled 防止 Refund 误退资金
+ common.SysLog(fmt.Sprintf("error adjusting token quota after funding settled (userId=%d, tokenId=%d, delta=%d): %s",
+ s.relayInfo.UserId, s.relayInfo.TokenId, delta, tokenErr.Error()))
+ }
+ }
+ // 3) 更新 relayInfo 上的订阅 PostDelta(用于日志)
+ if s.funding.Source() == BillingSourceSubscription {
+ s.relayInfo.SubscriptionPostDelta += int64(delta)
+ }
+ s.settled = true
+ return tokenErr
+}
+
+// Refund 退还所有预扣费,幂等安全,异步执行。
+func (s *BillingSession) Refund(c *gin.Context) {
+ s.mu.Lock()
+ if s.settled || s.refunded || !s.needsRefundLocked() {
+ s.mu.Unlock()
+ return
+ }
+ s.refunded = true
+ s.mu.Unlock()
+
+ logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费(token_quota=%s, funding=%s)",
+ s.relayInfo.UserId,
+ logger.FormatQuota(s.tokenConsumed),
+ s.funding.Source(),
+ ))
+
+ // 复制需要的值到闭包中
+ tokenId := s.relayInfo.TokenId
+ tokenKey := s.relayInfo.TokenKey
+ isPlayground := s.relayInfo.IsPlayground
+ tokenConsumed := s.tokenConsumed
+ funding := s.funding
+
+ gopool.Go(func() {
+ // 1) 退还资金来源
+ if err := funding.Refund(); err != nil {
+ common.SysLog("error refunding billing source: " + err.Error())
+ }
+ // 2) 退还令牌额度
+ if tokenConsumed > 0 && !isPlayground {
+ if err := model.IncreaseTokenQuota(tokenId, tokenKey, tokenConsumed); err != nil {
+ common.SysLog("error refunding token quota: " + err.Error())
+ }
+ }
+ })
+}
+
+// NeedsRefund 返回是否存在需要退还的预扣状态。
+func (s *BillingSession) NeedsRefund() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.needsRefundLocked()
+}
+
+func (s *BillingSession) needsRefundLocked() bool {
+ if s.settled || s.refunded || s.fundingSettled {
+ // fundingSettled 时资金来源已提交结算,不能再退预扣费
+ return false
+ }
+ if s.tokenConsumed > 0 {
+ return true
+ }
+ // 订阅可能在 tokenConsumed=0 时仍预扣了额度
+ if sub, ok := s.funding.(*SubscriptionFunding); ok && sub.preConsumed > 0 {
+ return true
+ }
+ return false
+}
+
+// GetPreConsumedQuota 返回实际预扣的额度。
+func (s *BillingSession) GetPreConsumedQuota() int {
+ return s.preConsumedQuota
+}
+
+// ---------------------------------------------------------------------------
+// PreConsume — 统一预扣费入口(含信任额度旁路)
+// ---------------------------------------------------------------------------
+
+// preConsume 执行预扣费:信任检查 -> 令牌预扣 -> 资金来源预扣。
+// 任一步骤失败时原子回滚已完成的步骤。
+func (s *BillingSession) preConsume(c *gin.Context, quota int) *types.NewAPIError {
+ effectiveQuota := quota
+
+ // ---- 信任额度旁路 ----
+ if s.shouldTrust(c) {
+ effectiveQuota = 0
+ logger.LogInfo(c, fmt.Sprintf("用户 %d 额度充足, 信任且不需要预扣费 (funding=%s)", s.relayInfo.UserId, s.funding.Source()))
+ } else if effectiveQuota > 0 {
+ logger.LogInfo(c, fmt.Sprintf("用户 %d 需要预扣费 %s (funding=%s)", s.relayInfo.UserId, logger.FormatQuota(effectiveQuota), s.funding.Source()))
+ }
+
+ // ---- 1) 预扣令牌额度 ----
+ if effectiveQuota > 0 {
+ if err := PreConsumeTokenQuota(s.relayInfo, effectiveQuota); err != nil {
+ return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
+ }
+ s.tokenConsumed = effectiveQuota
+ }
+
+ // ---- 2) 预扣资金来源 ----
+ if err := s.funding.PreConsume(effectiveQuota); err != nil {
+ // 预扣费失败,回滚令牌额度
+ if s.tokenConsumed > 0 && !s.relayInfo.IsPlayground {
+ if rollbackErr := model.IncreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, s.tokenConsumed); rollbackErr != nil {
+ common.SysLog(fmt.Sprintf("error rolling back token quota (userId=%d, tokenId=%d, amount=%d, fundingErr=%s): %s",
+ s.relayInfo.UserId, s.relayInfo.TokenId, s.tokenConsumed, err.Error(), rollbackErr.Error()))
+ }
+ s.tokenConsumed = 0
+ }
+ // TODO: model 层应定义哨兵错误(如 ErrNoActiveSubscription),用 errors.Is 替代字符串匹配
+ errMsg := err.Error()
+ if strings.Contains(errMsg, "no active subscription") || strings.Contains(errMsg, "subscription quota insufficient") {
+ return types.NewErrorWithStatusCode(fmt.Errorf("订阅额度不足或未配置订阅: %s", errMsg), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
+ }
+ return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
+ }
+
+ s.preConsumedQuota = effectiveQuota
+
+ // ---- 同步 RelayInfo 兼容字段 ----
+ s.syncRelayInfo()
+
+ return nil
+}
+
+// shouldTrust 统一信任额度检查,适用于钱包和订阅。
+func (s *BillingSession) shouldTrust(c *gin.Context) bool {
+ trustQuota := common.GetTrustQuota()
+ if trustQuota <= 0 {
+ return false
+ }
+
+ // 检查令牌是否充足
+ tokenTrusted := s.relayInfo.TokenUnlimited
+ if !tokenTrusted {
+ tokenQuota := c.GetInt("token_quota")
+ tokenTrusted = tokenQuota > trustQuota
+ }
+ if !tokenTrusted {
+ return false
+ }
+
+ switch s.funding.Source() {
+ case BillingSourceWallet:
+ return s.relayInfo.UserQuota > trustQuota
+ case BillingSourceSubscription:
+ // 订阅不能启用信任旁路。原因:
+ // 1. PreConsumeUserSubscription 要求 amount>0 来创建预扣记录并锁定订阅
+ // 2. SubscriptionFunding.PreConsume 忽略参数,始终用 s.amount 预扣
+ // 3. 若信任旁路将 effectiveQuota 设为 0,会导致 preConsumedQuota 与实际订阅预扣不一致
+ return false
+ default:
+ return false
+ }
+}
+
+// syncRelayInfo 将 BillingSession 的状态同步到 RelayInfo 的兼容字段上。
+func (s *BillingSession) syncRelayInfo() {
+ info := s.relayInfo
+ info.FinalPreConsumedQuota = s.preConsumedQuota
+ info.BillingSource = s.funding.Source()
+
+ if sub, ok := s.funding.(*SubscriptionFunding); ok {
+ info.SubscriptionId = sub.subscriptionId
+ info.SubscriptionPreConsumed = sub.preConsumed
+ info.SubscriptionPostDelta = 0
+ info.SubscriptionAmountTotal = sub.AmountTotal
+ info.SubscriptionAmountUsedAfterPreConsume = sub.AmountUsedAfter
+ info.SubscriptionPlanId = sub.PlanId
+ info.SubscriptionPlanTitle = sub.PlanTitle
+ } else {
+ info.SubscriptionId = 0
+ info.SubscriptionPreConsumed = 0
+ }
+}
+
+// ---------------------------------------------------------------------------
+// NewBillingSession 工厂 — 根据计费偏好创建会话并处理回退
+// ---------------------------------------------------------------------------
+
+// NewBillingSession 根据用户计费偏好创建 BillingSession,处理 subscription_first / wallet_first 的回退。
+func NewBillingSession(c *gin.Context, relayInfo *relaycommon.RelayInfo, preConsumedQuota int) (*BillingSession, *types.NewAPIError) {
+ if relayInfo == nil {
+ return nil, types.NewError(fmt.Errorf("relayInfo is nil"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
+ }
+
+ pref := common.NormalizeBillingPreference(relayInfo.UserSetting.BillingPreference)
+
+ // 钱包路径需要先检查用户额度
+ tryWallet := func() (*BillingSession, *types.NewAPIError) {
+ userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
+ if err != nil {
+ return nil, types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
+ }
+ if userQuota <= 0 {
+ return nil, types.NewErrorWithStatusCode(
+ fmt.Errorf("用户额度不足, 剩余额度: %s", logger.FormatQuota(userQuota)),
+ types.ErrorCodeInsufficientUserQuota, http.StatusForbidden,
+ types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
+ }
+ if userQuota-preConsumedQuota < 0 {
+ return nil, types.NewErrorWithStatusCode(
+ fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)),
+ types.ErrorCodeInsufficientUserQuota, http.StatusForbidden,
+ types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
+ }
+ relayInfo.UserQuota = userQuota
+
+ session := &BillingSession{
+ relayInfo: relayInfo,
+ funding: &WalletFunding{userId: relayInfo.UserId},
+ }
+ if apiErr := session.preConsume(c, preConsumedQuota); apiErr != nil {
+ return nil, apiErr
+ }
+ return session, nil
+ }
+
+ trySubscription := func() (*BillingSession, *types.NewAPIError) {
+ subConsume := int64(preConsumedQuota)
+ if subConsume <= 0 {
+ subConsume = 1
+ }
+ session := &BillingSession{
+ relayInfo: relayInfo,
+ funding: &SubscriptionFunding{
+ requestId: relayInfo.RequestId,
+ userId: relayInfo.UserId,
+ modelName: relayInfo.OriginModelName,
+ amount: subConsume,
+ },
+ }
+ // 必须传 subConsume 而非 preConsumedQuota,保证 SubscriptionFunding.amount、
+ // preConsume 参数和 FinalPreConsumedQuota 三者一致,避免订阅多扣费。
+ if apiErr := session.preConsume(c, int(subConsume)); apiErr != nil {
+ return nil, apiErr
+ }
+ return session, nil
+ }
+
+ switch pref {
+ case "subscription_only":
+ return trySubscription()
+ case "wallet_only":
+ return tryWallet()
+ case "wallet_first":
+ session, err := tryWallet()
+ if err != nil {
+ if err.GetErrorCode() == types.ErrorCodeInsufficientUserQuota {
+ return trySubscription()
+ }
+ return nil, err
+ }
+ return session, nil
+ case "subscription_first":
+ fallthrough
+ default:
+ hasSub, subCheckErr := model.HasActiveUserSubscription(relayInfo.UserId)
+ if subCheckErr != nil {
+ return nil, types.NewError(subCheckErr, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
+ }
+ if !hasSub {
+ return tryWallet()
+ }
+ session, apiErr := trySubscription()
+ if apiErr != nil {
+ if apiErr.GetErrorCode() == types.ErrorCodeInsufficientUserQuota {
+ return tryWallet()
+ }
+ return nil, apiErr
+ }
+ return session, nil
+ }
+}
diff --git a/service/convert.go b/service/convert.go
index 26534e4095b2..52824374e094 100644
--- a/service/convert.go
+++ b/service/convert.go
@@ -207,6 +207,44 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
}
var claudeResponses []*dto.ClaudeResponse
+ // stopOpenBlocks emits the required content_block_stop event(s) for the currently open block(s)
+ // according to Anthropic's SSE streaming state machine:
+ // content_block_start -> content_block_delta* -> content_block_stop (per index).
+ //
+ // For text/thinking, there is at most one open block at info.ClaudeConvertInfo.Index.
+ // For tools, OpenAI tool_calls can stream multiple parallel tool_use blocks (indexed from 0),
+ // so we may have multiple open blocks and must stop each one explicitly.
+ stopOpenBlocks := func() {
+ switch info.ClaudeConvertInfo.LastMessagesType {
+ case relaycommon.LastMessageTypeText, relaycommon.LastMessageTypeThinking:
+ claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index))
+ case relaycommon.LastMessageTypeTools:
+ base := info.ClaudeConvertInfo.ToolCallBaseIndex
+ for offset := 0; offset <= info.ClaudeConvertInfo.ToolCallMaxIndexOffset; offset++ {
+ claudeResponses = append(claudeResponses, generateStopBlock(base+offset))
+ }
+ }
+ }
+ // stopOpenBlocksAndAdvance closes the currently open block(s) and advances the content block index
+ // to the next available slot for subsequent content_block_start events.
+ //
+ // This prevents invalid streams where a content_block_delta (e.g. thinking_delta) is emitted for an
+ // index whose active content_block type is different (the typical cause of "Mismatched content block type").
+ stopOpenBlocksAndAdvance := func() {
+ if info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeNone {
+ return
+ }
+ stopOpenBlocks()
+ switch info.ClaudeConvertInfo.LastMessagesType {
+ case relaycommon.LastMessageTypeTools:
+ info.ClaudeConvertInfo.Index = info.ClaudeConvertInfo.ToolCallBaseIndex + info.ClaudeConvertInfo.ToolCallMaxIndexOffset + 1
+ info.ClaudeConvertInfo.ToolCallBaseIndex = 0
+ info.ClaudeConvertInfo.ToolCallMaxIndexOffset = 0
+ default:
+ info.ClaudeConvertInfo.Index++
+ }
+ info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeNone
+ }
if info.SendResponseCount == 1 {
msg := &dto.ClaudeMediaMessage{
Id: openAIResponse.Id,
@@ -228,6 +266,8 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
//})
if openAIResponse.IsToolCall() {
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools
+ info.ClaudeConvertInfo.ToolCallBaseIndex = 0
+ info.ClaudeConvertInfo.ToolCallMaxIndexOffset = 0
var toolCall dto.ToolCallResponse
if len(openAIResponse.Choices) > 0 && len(openAIResponse.Choices[0].Delta.ToolCalls) > 0 {
toolCall = openAIResponse.Choices[0].Delta.ToolCalls[0]
@@ -252,8 +292,9 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
claudeResponses = append(claudeResponses, resp)
// 首块包含工具 delta,则追加 input_json_delta
if toolCall.Function.Arguments != "" {
+ idx := 0
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
- Index: &info.ClaudeConvertInfo.Index,
+ Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
@@ -270,16 +311,21 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
content := openAIResponse.Choices[0].Delta.GetContentString()
if reasoning != "" {
+ if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeThinking {
+ stopOpenBlocksAndAdvance()
+ }
+ idx := info.ClaudeConvertInfo.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
- Index: &info.ClaudeConvertInfo.Index,
+ Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "thinking",
Thinking: common.GetPointer[string](""),
},
})
+ idx2 := idx
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
- Index: &info.ClaudeConvertInfo.Index,
+ Index: &idx2,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "thinking_delta",
@@ -288,16 +334,21 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
})
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeThinking
} else if content != "" {
+ if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeText {
+ stopOpenBlocksAndAdvance()
+ }
+ idx := info.ClaudeConvertInfo.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
- Index: &info.ClaudeConvertInfo.Index,
+ Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](""),
},
})
+ idx2 := idx
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
- Index: &info.ClaudeConvertInfo.Index,
+ Index: &idx2,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "text_delta",
@@ -311,7 +362,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
// 如果首块就带 finish_reason,需要立即发送停止块
if len(openAIResponse.Choices) > 0 && openAIResponse.Choices[0].FinishReason != nil && *openAIResponse.Choices[0].FinishReason != "" {
info.FinishReason = *openAIResponse.Choices[0].FinishReason
- claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index))
+ stopOpenBlocks()
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = info.ClaudeConvertInfo.Usage
@@ -342,7 +393,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
// no choices
// 可能为非标准的 OpenAI 响应,判断是否已经完成
if info.ClaudeConvertInfo.Done {
- claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index))
+ stopOpenBlocks()
oaiUsage := info.ClaudeConvertInfo.Usage
if oaiUsage != nil {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
@@ -376,18 +427,25 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
if len(chosenChoice.Delta.ToolCalls) > 0 {
toolCalls := chosenChoice.Delta.ToolCalls
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeTools {
- claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index))
- info.ClaudeConvertInfo.Index++
+ stopOpenBlocksAndAdvance()
+ info.ClaudeConvertInfo.ToolCallBaseIndex = info.ClaudeConvertInfo.Index
+ info.ClaudeConvertInfo.ToolCallMaxIndexOffset = 0
}
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools
+ base := info.ClaudeConvertInfo.ToolCallBaseIndex
+ maxOffset := info.ClaudeConvertInfo.ToolCallMaxIndexOffset
for i, toolCall := range toolCalls {
- blockIndex := info.ClaudeConvertInfo.Index
+ offset := 0
if toolCall.Index != nil {
- blockIndex = *toolCall.Index
- } else if len(toolCalls) > 1 {
- blockIndex = info.ClaudeConvertInfo.Index + i
+ offset = *toolCall.Index
+ } else {
+ offset = i
}
+ if offset > maxOffset {
+ maxOffset = offset
+ }
+ blockIndex := base + offset
idx := blockIndex
if toolCall.Function.Name != "" {
@@ -413,17 +471,19 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
},
})
}
-
- info.ClaudeConvertInfo.Index = blockIndex
}
+ info.ClaudeConvertInfo.ToolCallMaxIndexOffset = maxOffset
+ info.ClaudeConvertInfo.Index = base + maxOffset
} else {
reasoning := chosenChoice.Delta.GetReasoningContent()
textContent := chosenChoice.Delta.GetContentString()
if reasoning != "" || textContent != "" {
if reasoning != "" {
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeThinking {
+ stopOpenBlocksAndAdvance()
+ idx := info.ClaudeConvertInfo.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
- Index: &info.ClaudeConvertInfo.Index,
+ Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "thinking",
@@ -438,12 +498,10 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
}
} else {
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeText {
- if info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeThinking || info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeTools {
- claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index))
- info.ClaudeConvertInfo.Index++
- }
+ stopOpenBlocksAndAdvance()
+ idx := info.ClaudeConvertInfo.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
- Index: &info.ClaudeConvertInfo.Index,
+ Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "text",
@@ -462,13 +520,13 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
}
}
- claudeResponse.Index = &info.ClaudeConvertInfo.Index
+ claudeResponse.Index = common.GetPointer[int](info.ClaudeConvertInfo.Index)
if !isEmpty && claudeResponse.Delta != nil {
claudeResponses = append(claudeResponses, &claudeResponse)
}
if doneChunk || info.ClaudeConvertInfo.Done {
- claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index))
+ stopOpenBlocks()
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = info.ClaudeConvertInfo.Usage
diff --git a/service/funding_source.go b/service/funding_source.go
new file mode 100644
index 000000000000..98f5e874d855
--- /dev/null
+++ b/service/funding_source.go
@@ -0,0 +1,139 @@
+package service
+
+import (
+ "time"
+
+ "github.com/QuantumNous/new-api/model"
+)
+
+// ---------------------------------------------------------------------------
+// FundingSource — 资金来源接口(钱包 or 订阅)
+// ---------------------------------------------------------------------------
+
+// FundingSource 抽象了预扣费的资金来源。
+type FundingSource interface {
+ // Source 返回资金来源标识:"wallet" 或 "subscription"
+ Source() string
+ // PreConsume 从该资金来源预扣 amount 额度
+ PreConsume(amount int) error
+ // Settle 根据差额调整资金来源(正数补扣,负数退还)
+ Settle(delta int) error
+ // Refund 退还所有预扣费
+ Refund() error
+}
+
+// ---------------------------------------------------------------------------
+// WalletFunding — 钱包资金来源实现
+// ---------------------------------------------------------------------------
+
+type WalletFunding struct {
+ userId int
+ consumed int // 实际预扣的用户额度
+}
+
+func (w *WalletFunding) Source() string { return BillingSourceWallet }
+
+func (w *WalletFunding) PreConsume(amount int) error {
+ if amount <= 0 {
+ return nil
+ }
+ if err := model.DecreaseUserQuota(w.userId, amount); err != nil {
+ return err
+ }
+ w.consumed = amount
+ return nil
+}
+
+func (w *WalletFunding) Settle(delta int) error {
+ if delta == 0 {
+ return nil
+ }
+ if delta > 0 {
+ return model.DecreaseUserQuota(w.userId, delta)
+ }
+ return model.IncreaseUserQuota(w.userId, -delta, false)
+}
+
+func (w *WalletFunding) Refund() error {
+ if w.consumed <= 0 {
+ return nil
+ }
+ // IncreaseUserQuota 是 quota += N 的非幂等操作,不能重试,否则会多退额度。
+ // 订阅的 RefundSubscriptionPreConsume 有 requestId 幂等保护所以可以重试。
+ return model.IncreaseUserQuota(w.userId, w.consumed, false)
+}
+
+// ---------------------------------------------------------------------------
+// SubscriptionFunding — 订阅资金来源实现
+// ---------------------------------------------------------------------------
+
+type SubscriptionFunding struct {
+ requestId string
+ userId int
+ modelName string
+ amount int64 // 预扣的订阅额度(subConsume)
+ subscriptionId int
+ preConsumed int64
+ // 以下字段在 PreConsume 成功后填充,供 RelayInfo 同步使用
+ AmountTotal int64
+ AmountUsedAfter int64
+ PlanId int
+ PlanTitle string
+}
+
+func (s *SubscriptionFunding) Source() string { return BillingSourceSubscription }
+
+func (s *SubscriptionFunding) PreConsume(_ int) error {
+ // amount 参数被忽略,使用内部 s.amount(已在构造时根据 preConsumedQuota 计算)
+ res, err := model.PreConsumeUserSubscription(s.requestId, s.userId, s.modelName, 0, s.amount)
+ if err != nil {
+ return err
+ }
+ s.subscriptionId = res.UserSubscriptionId
+ s.preConsumed = res.PreConsumed
+ s.AmountTotal = res.AmountTotal
+ s.AmountUsedAfter = res.AmountUsedAfter
+ // 获取订阅计划信息
+ if planInfo, err := model.GetSubscriptionPlanInfoByUserSubscriptionId(res.UserSubscriptionId); err == nil && planInfo != nil {
+ s.PlanId = planInfo.PlanId
+ s.PlanTitle = planInfo.PlanTitle
+ }
+ return nil
+}
+
+func (s *SubscriptionFunding) Settle(delta int) error {
+ if delta == 0 {
+ return nil
+ }
+ return model.PostConsumeUserSubscriptionDelta(s.subscriptionId, int64(delta))
+}
+
+func (s *SubscriptionFunding) Refund() error {
+ if s.preConsumed <= 0 {
+ return nil
+ }
+ return refundWithRetry(func() error {
+ return model.RefundSubscriptionPreConsume(s.requestId)
+ })
+}
+
+// refundWithRetry 尝试多次执行退款操作以提高成功率,只能用于基于事务的退款函数!!!!!!
+// try to refund with retries, only for refund functions based on transactions!!!
+func refundWithRetry(fn func() error) error {
+ if fn == nil {
+ return nil
+ }
+ const maxAttempts = 3
+ var lastErr error
+ for i := 0; i < maxAttempts; i++ {
+ if err := fn(); err == nil {
+ return nil
+ } else {
+ lastErr = err
+ }
+ if i < maxAttempts-1 {
+ time.Sleep(time.Duration(200*(i+1)) * time.Millisecond)
+ }
+ }
+ return lastErr
+}
diff --git a/service/openaicompat/chat_to_responses.go b/service/openaicompat/chat_to_responses.go
index 76aa6d25d373..c2b44c902904 100644
--- a/service/openaicompat/chat_to_responses.go
+++ b/service/openaicompat/chat_to_responses.go
@@ -34,6 +34,44 @@ func normalizeChatImageURLToString(v any) any {
}
}
+func convertChatResponseFormatToResponsesText(reqFormat *dto.ResponseFormat) json.RawMessage {
+ if reqFormat == nil || strings.TrimSpace(reqFormat.Type) == "" {
+ return nil
+ }
+
+ format := map[string]any{
+ "type": reqFormat.Type,
+ }
+
+ if reqFormat.Type == "json_schema" && len(reqFormat.JsonSchema) > 0 {
+ var chatSchema map[string]any
+ if err := common.Unmarshal(reqFormat.JsonSchema, &chatSchema); err == nil {
+ for key, value := range chatSchema {
+ if key == "type" {
+ continue
+ }
+ format[key] = value
+ }
+
+ if nested, ok := format["json_schema"].(map[string]any); ok {
+ for key, value := range nested {
+ if _, exists := format[key]; !exists {
+ format[key] = value
+ }
+ }
+ delete(format, "json_schema")
+ }
+ } else {
+ format["json_schema"] = reqFormat.JsonSchema
+ }
+ }
+
+ textRaw, _ := common.Marshal(map[string]any{
+ "format": format,
+ })
+ return textRaw
+}
+
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
if req == nil {
return nil, errors.New("request is nil")
@@ -312,17 +350,16 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
parallelToolCallsRaw, _ = common.Marshal(*req.ParallelTooCalls)
}
- var textRaw json.RawMessage
- if req.ResponseFormat != nil && req.ResponseFormat.Type != "" {
- textRaw, _ = common.Marshal(map[string]any{
- "format": req.ResponseFormat,
- })
- }
+ textRaw := convertChatResponseFormatToResponsesText(req.ResponseFormat)
maxOutputTokens := req.MaxTokens
if req.MaxCompletionTokens > maxOutputTokens {
maxOutputTokens = req.MaxCompletionTokens
}
+ // OpenAI Responses API rejects max_output_tokens < 16 when explicitly provided.
+ //if maxOutputTokens > 0 && maxOutputTokens < 16 {
+ // maxOutputTokens = 16
+ //}
var topP *float64
if req.TopP != 0 {
diff --git a/service/pre_consume_quota.go b/service/pre_consume_quota.go
deleted file mode 100644
index 3b049cb4fa66..000000000000
--- a/service/pre_consume_quota.go
+++ /dev/null
@@ -1,124 +0,0 @@
-package service
-
-import (
- "fmt"
- "net/http"
- "time"
-
- "github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/logger"
- "github.com/QuantumNous/new-api/model"
- relaycommon "github.com/QuantumNous/new-api/relay/common"
- "github.com/QuantumNous/new-api/types"
-
- "github.com/bytedance/gopkg/util/gopool"
- "github.com/gin-gonic/gin"
-)
-
-func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
- // Always refund subscription pre-consumed (can be non-zero even when FinalPreConsumedQuota is 0)
- needRefundSub := relayInfo.BillingSource == BillingSourceSubscription && relayInfo.SubscriptionId != 0 && relayInfo.SubscriptionPreConsumed > 0
- needRefundToken := relayInfo.FinalPreConsumedQuota != 0
- if !needRefundSub && !needRefundToken {
- return
- }
- logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费(token_quota=%s, subscription=%d)",
- relayInfo.UserId,
- logger.FormatQuota(relayInfo.FinalPreConsumedQuota),
- relayInfo.SubscriptionPreConsumed,
- ))
- gopool.Go(func() {
- relayInfoCopy := *relayInfo
- if relayInfoCopy.BillingSource == BillingSourceSubscription {
- if needRefundSub {
- if err := refundWithRetry(func() error {
- return model.RefundSubscriptionPreConsume(relayInfoCopy.RequestId)
- }); err != nil {
- common.SysLog("error refund subscription pre-consume: " + err.Error())
- }
- }
- // refund token quota only
- if needRefundToken && !relayInfoCopy.IsPlayground {
- _ = model.IncreaseTokenQuota(relayInfoCopy.TokenId, relayInfoCopy.TokenKey, relayInfoCopy.FinalPreConsumedQuota)
- }
- return
- }
-
- // wallet refund uses existing path (user quota + token quota)
- if needRefundToken {
- err := PostConsumeQuota(&relayInfoCopy, -relayInfoCopy.FinalPreConsumedQuota, 0, false)
- if err != nil {
- common.SysLog("error return pre-consumed quota: " + err.Error())
- }
- }
- })
-}
-
-func refundWithRetry(fn func() error) error {
- if fn == nil {
- return nil
- }
- const maxAttempts = 3
- var lastErr error
- for i := 0; i < maxAttempts; i++ {
- if err := fn(); err == nil {
- return nil
- } else {
- lastErr = err
- }
- if i < maxAttempts-1 {
- time.Sleep(time.Duration(200*(i+1)) * time.Millisecond)
- }
- }
- return lastErr
-}
-
-// PreConsumeQuota checks if the user has enough quota to pre-consume.
-// It returns the pre-consumed quota if successful, or an error if not.
-func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
- userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
- if err != nil {
- return types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
- }
- if userQuota <= 0 {
- return types.NewErrorWithStatusCode(fmt.Errorf("用户额度不足, 剩余额度: %s", logger.FormatQuota(userQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
- }
- if userQuota-preConsumedQuota < 0 {
- return types.NewErrorWithStatusCode(fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
- }
-
- trustQuota := common.GetTrustQuota()
-
- relayInfo.UserQuota = userQuota
- if userQuota > trustQuota {
- // 用户额度充足,判断令牌额度是否充足
- if !relayInfo.TokenUnlimited {
- // 非无限令牌,判断令牌额度是否充足
- tokenQuota := c.GetInt("token_quota")
- if tokenQuota > trustQuota {
- // 令牌额度充足,信任令牌
- preConsumedQuota = 0
- logger.LogInfo(c, fmt.Sprintf("用户 %d 剩余额度 %s 且令牌 %d 额度 %d 充足, 信任且不需要预扣费", relayInfo.UserId, logger.FormatQuota(userQuota), relayInfo.TokenId, tokenQuota))
- }
- } else {
- // in this case, we do not pre-consume quota
- // because the user has enough quota
- preConsumedQuota = 0
- logger.LogInfo(c, fmt.Sprintf("用户 %d 额度充足且为无限额度令牌, 信任且不需要预扣费", relayInfo.UserId))
- }
- }
-
- if preConsumedQuota > 0 {
- err := PreConsumeTokenQuota(relayInfo, preConsumedQuota)
- if err != nil {
- return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
- }
- err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota)
- if err != nil {
- return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
- }
- logger.LogInfo(c, fmt.Sprintf("用户 %d 预扣费 %s, 预扣费后剩余额度: %s", relayInfo.UserId, logger.FormatQuota(preConsumedQuota), logger.FormatQuota(userQuota-preConsumedQuota)))
- }
- relayInfo.FinalPreConsumedQuota = preConsumedQuota
- return nil
-}
diff --git a/service/quota.go b/service/quota.go
index 951eecec58ef..50421017e126 100644
--- a/service/quota.go
+++ b/service/quota.go
@@ -307,27 +307,8 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
}
- quotaDelta := quota - relayInfo.FinalPreConsumedQuota
-
- if quotaDelta > 0 {
- logger.LogInfo(ctx, fmt.Sprintf("预扣费后补扣费:%s(实际消耗:%s,预扣费:%s)",
- logger.FormatQuota(quotaDelta),
- logger.FormatQuota(quota),
- logger.FormatQuota(relayInfo.FinalPreConsumedQuota),
- ))
- } else if quotaDelta < 0 {
- logger.LogInfo(ctx, fmt.Sprintf("预扣费后返还扣费:%s(实际消耗:%s,预扣费:%s)",
- logger.FormatQuota(-quotaDelta),
- logger.FormatQuota(quota),
- logger.FormatQuota(relayInfo.FinalPreConsumedQuota),
- ))
- }
-
- if quotaDelta != 0 {
- err := PostConsumeQuota(relayInfo, quotaDelta, relayInfo.FinalPreConsumedQuota, true)
- if err != nil {
- logger.LogError(ctx, "error consuming token remain quota: "+err.Error())
- }
+ if err := SettleBilling(ctx, relayInfo, quota); err != nil {
+ logger.LogError(ctx, "error settling billing: "+err.Error())
}
other := GenerateClaudeOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio,
@@ -432,27 +413,8 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
}
- quotaDelta := quota - relayInfo.FinalPreConsumedQuota
-
- if quotaDelta > 0 {
- logger.LogInfo(ctx, fmt.Sprintf("预扣费后补扣费:%s(实际消耗:%s,预扣费:%s)",
- logger.FormatQuota(quotaDelta),
- logger.FormatQuota(quota),
- logger.FormatQuota(relayInfo.FinalPreConsumedQuota),
- ))
- } else if quotaDelta < 0 {
- logger.LogInfo(ctx, fmt.Sprintf("预扣费后返还扣费:%s(实际消耗:%s,预扣费:%s)",
- logger.FormatQuota(-quotaDelta),
- logger.FormatQuota(quota),
- logger.FormatQuota(relayInfo.FinalPreConsumedQuota),
- ))
- }
-
- if quotaDelta != 0 {
- err := PostConsumeQuota(relayInfo, quotaDelta, relayInfo.FinalPreConsumedQuota, true)
- if err != nil {
- logger.LogError(ctx, "error consuming token remain quota: "+err.Error())
- }
+ if err := SettleBilling(ctx, relayInfo, quota); err != nil {
+ logger.LogError(ctx, "error settling billing: "+err.Error())
}
logModel := relayInfo.OriginModelName
@@ -594,3 +556,51 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon
}
})
}
+
+func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) {
+ gopool.Go(func() {
+ if relayInfo == nil {
+ return
+ }
+ if relayInfo.SubscriptionId == 0 || relayInfo.SubscriptionAmountTotal <= 0 {
+ return
+ }
+
+ userSetting := relayInfo.UserSetting
+ threshold := common.QuotaRemindThreshold
+ if userSetting.QuotaWarningThreshold != 0 {
+ threshold = int(userSetting.QuotaWarningThreshold)
+ }
+
+ usedAfter := relayInfo.SubscriptionAmountUsedAfterPreConsume + relayInfo.SubscriptionPostDelta
+ remaining := relayInfo.SubscriptionAmountTotal - usedAfter
+ if remaining >= int64(threshold) {
+ return
+ }
+
+ prompt := "您的订阅额度即将用尽"
+ topUpLink := fmt.Sprintf("%s/console/topup", system_setting.ServerAddress)
+
+ var content string
+ var values []interface{}
+ notifyType := userSetting.NotifyType
+ if notifyType == "" {
+ notifyType = dto.NotifyTypeEmail
+ }
+
+ if notifyType == dto.NotifyTypeBark {
+ content = "{{value}},剩余额度:{{value}},请及时充值"
+ values = []interface{}{prompt, logger.FormatQuota(int(remaining))}
+ } else if notifyType == dto.NotifyTypeGotify {
+ content = "{{value}},当前剩余额度为 {{value}},请及时充值。"
+ values = []interface{}{prompt, logger.FormatQuota(int(remaining))}
+ } else {
+ content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。
充值链接:{{value}}"
+ values = []interface{}{prompt, logger.FormatQuota(int(remaining)), topUpLink, topUpLink}
+ }
+
+ if err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, values)); err != nil {
+ common.SysError(fmt.Sprintf("failed to send subscription quota notify to user %d: %s", relayInfo.UserId, err.Error()))
+ }
+ })
+}
diff --git a/setting/config/config.go b/setting/config/config.go
index 6c6abe9d4a9e..8b3d05139209 100644
--- a/setting/config/config.go
+++ b/setting/config/config.go
@@ -212,13 +212,23 @@ func updateConfigFromMap(config interface{}, configMap map[string]string) error
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
intValue, err := strconv.ParseInt(strValue, 10, 64)
if err != nil {
- continue
+ // 兼容 float 格式的字符串(如 "2.000000")
+ floatValue, fErr := strconv.ParseFloat(strValue, 64)
+ if fErr != nil {
+ continue
+ }
+ intValue = int64(floatValue)
}
field.SetInt(intValue)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
uintValue, err := strconv.ParseUint(strValue, 10, 64)
if err != nil {
- continue
+ // 兼容 float 格式的字符串
+ floatValue, fErr := strconv.ParseFloat(strValue, 64)
+ if fErr != nil || floatValue < 0 {
+ continue
+ }
+ uintValue = uint64(floatValue)
}
field.SetUint(uintValue)
case reflect.Float32, reflect.Float64:
diff --git a/setting/operation_setting/token_setting.go b/setting/operation_setting/token_setting.go
new file mode 100644
index 000000000000..0d4c4e2f244c
--- /dev/null
+++ b/setting/operation_setting/token_setting.go
@@ -0,0 +1,28 @@
+package operation_setting
+
+import "github.com/QuantumNous/new-api/setting/config"
+
+// TokenSetting 令牌相关配置
+type TokenSetting struct {
+ MaxUserTokens int `json:"max_user_tokens"` // 每用户最大令牌数量
+}
+
+// 默认配置
+var tokenSetting = TokenSetting{
+ MaxUserTokens: 1000, // 默认每用户最多 1000 个令牌
+}
+
+func init() {
+ // 注册到全局配置管理器
+ config.GlobalConfig.Register("token_setting", &tokenSetting)
+}
+
+// GetTokenSetting 获取令牌配置
+func GetTokenSetting() *TokenSetting {
+ return &tokenSetting
+}
+
+// GetMaxUserTokens 获取每用户最大令牌数量
+func GetMaxUserTokens() int {
+ return GetTokenSetting().MaxUserTokens
+}
diff --git a/setting/ratio_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go
index 665c2f5932fa..626267537da7 100644
--- a/setting/ratio_setting/cache_ratio.go
+++ b/setting/ratio_setting/cache_ratio.go
@@ -60,6 +60,12 @@ var defaultCacheRatio = map[string]float64{
"claude-sonnet-4-5-20250929-thinking": 0.1,
"claude-opus-4-5-20251101": 0.1,
"claude-opus-4-5-20251101-thinking": 0.1,
+ "claude-opus-4-6": 0.1,
+ "claude-opus-4-6-thinking": 0.1,
+ "claude-opus-4-6-max": 0.1,
+ "claude-opus-4-6-high": 0.1,
+ "claude-opus-4-6-medium": 0.1,
+ "claude-opus-4-6-low": 0.1,
}
var defaultCreateCacheRatio = map[string]float64{
@@ -82,6 +88,12 @@ var defaultCreateCacheRatio = map[string]float64{
"claude-sonnet-4-5-20250929-thinking": 1.25,
"claude-opus-4-5-20251101": 1.25,
"claude-opus-4-5-20251101-thinking": 1.25,
+ "claude-opus-4-6": 1.25,
+ "claude-opus-4-6-thinking": 1.25,
+ "claude-opus-4-6-max": 1.25,
+ "claude-opus-4-6-high": 1.25,
+ "claude-opus-4-6-medium": 1.25,
+ "claude-opus-4-6-low": 1.25,
}
//var defaultCreateCacheRatio = map[string]float64{}
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index 61f6d044fe71..6b7d70e770df 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -142,6 +142,11 @@ var defaultModelRatio = map[string]float64{
"claude-sonnet-4-20250514": 1.5,
"claude-sonnet-4-5-20250929": 1.5,
"claude-opus-4-5-20251101": 2.5,
+ "claude-opus-4-6": 2.5,
+ "claude-opus-4-6-max": 2.5,
+ "claude-opus-4-6-high": 2.5,
+ "claude-opus-4-6-medium": 2.5,
+ "claude-opus-4-6-low": 2.5,
"claude-3-opus-20240229": 7.5, // $15 / 1M tokens
"claude-opus-4-20250514": 7.5,
"claude-opus-4-1-20250805": 7.5,
diff --git a/setting/reasoning/suffix.go b/setting/reasoning/suffix.go
index da3bdc7d3efb..fb66c6019a5d 100644
--- a/setting/reasoning/suffix.go
+++ b/setting/reasoning/suffix.go
@@ -6,7 +6,7 @@ import (
"github.com/samber/lo"
)
-var EffortSuffixes = []string{"-high", "-medium", "-low", "-minimal"}
+var EffortSuffixes = []string{"-max", "-high", "-medium", "-low", "-minimal"}
// TrimEffortSuffix -> modelName level(low) exists
func TrimEffortSuffix(modelName string) (string, string, bool) {
diff --git a/web/src/components/common/markdown/MarkdownRenderer.jsx b/web/src/components/common/markdown/MarkdownRenderer.jsx
index 05419f8cc228..6a71c695f845 100644
--- a/web/src/components/common/markdown/MarkdownRenderer.jsx
+++ b/web/src/components/common/markdown/MarkdownRenderer.jsx
@@ -93,6 +93,49 @@ export function Mermaid(props) {
);
}
+function SandboxedHtmlPreview({ code }) {
+ const iframeRef = useRef(null);
+ const [iframeHeight, setIframeHeight] = useState(150);
+
+ useEffect(() => {
+ const iframe = iframeRef.current;
+ if (!iframe) return;
+
+ const handleLoad = () => {
+ try {
+ const doc = iframe.contentDocument || iframe.contentWindow?.document;
+ if (doc) {
+ const height =
+ doc.documentElement.scrollHeight || doc.body.scrollHeight;
+ setIframeHeight(Math.min(Math.max(height + 16, 60), 600));
+ }
+ } catch {
+ // sandbox restrictions may prevent access, that's fine
+ }
+ };
+
+ iframe.addEventListener('load', handleLoad);
+ return () => iframe.removeEventListener('load', handleLoad);
+ }, [code]);
+
+ return (
+
+ );
+}
+
export function PreCode(props) {
const ref = useRef(null);
const [mermaidCode, setMermaidCode] = useState('');
@@ -227,7 +270,7 @@ export function PreCode(props) {
>
HTML预览:
-