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 ( +