Files
CaIon 7de10f2c60 feat(token): let an API key read its owner's quota
Add a per-token "Allow querying user quota" switch. When it is on,
GET /api/usage/token/ also returns user_quota with the account's
remaining and used quota in quota units. New tokens start with the
switch on: the console form, POST /api/token/ without the field, and the
default token created at registration. Tokens created before this change
stay off. PUT /api/token/ keeps the stored value when the field is
absent, and the token audit lists the field when it changes.

The key usage and key log queries now check the key's IP whitelist the
same way API requests do, and answer 403 from an address outside it.
Expired and exhausted keys can still use both queries.

New API channels now query their balance from the upstream's
/api/usage/token/ with the channel key. When the upstream also returns
the owner's quota, the smaller of the key's and the owner's remaining
quota is the balance, and an unlimited key uses the owner's quota alone.
Without it, a limited key reports its own remaining quota, and an
unlimited key asks for the switch to be turned on upstream. Quota
converts to USD with the upstream's quota_per_unit from /api/status.

Refs #3122
Refs #6828
2026-10-13 21:39:52 +08:00

576 lines
16 KiB
Go

package controller
import (
"net/http"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
)
type tokenAutoGroupsInput struct {
Set bool
Groups []string
}
func (input *tokenAutoGroupsInput) UnmarshalJSON(data []byte) error {
input.Set = true
if strings.TrimSpace(string(data)) == "null" {
input.Groups = nil
return nil
}
return common.Unmarshal(data, &input.Groups)
}
type tokenRequest struct {
model.Token
AutoGroups tokenAutoGroupsInput `json:"auto_groups"`
// 指针区分“未传”和显式 false:新建时未传即开启,更新时未传则保留原值。
AllowUserQuotaQuery *bool `json:"allow_user_quota_query"`
}
type tokenResponse struct {
*model.Token
AutoGroups []string `json:"auto_groups"`
}
func maxTokenQuota() int {
quota, err := common.WalletQuotaFromDecimalStrict(
decimal.NewFromInt(1_000_000_000).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if err != nil {
return common.MaxWalletQuota
}
return quota
}
func buildMaskedTokenResponse(token *model.Token) *tokenResponse {
if token == nil {
return nil
}
maskedToken := *token
maskedToken.Key = token.GetMaskedKey()
autoGroups, err := token.GetAutoGroups()
if err != nil {
common.SysError(common.LogText("failed to parse auto groups for token %d: %v", token.Id, err))
autoGroups = nil
}
if len(autoGroups) == 0 {
autoGroups = nil
}
return &tokenResponse{Token: &maskedToken, AutoGroups: autoGroups}
}
func buildMaskedTokenResponses(tokens []*model.Token) []*tokenResponse {
maskedTokens := make([]*tokenResponse, 0, len(tokens))
for _, token := range tokens {
maskedTokens = append(maskedTokens, buildMaskedTokenResponse(token))
}
return maskedTokens
}
func getTokenRequestUserGroup(c *gin.Context) (string, error) {
if userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup); userGroup != "" {
return userGroup, nil
}
if userGroup := c.GetString("group"); userGroup != "" {
return userGroup, nil
}
return model.GetUserGroup(c.GetInt("id"), false)
}
func setTokenAutoGroups(c *gin.Context, token *model.Token, groups []string) bool {
if len(groups) == 0 {
if err := token.SetAutoGroups(nil); err != nil {
common.ApiError(c, err)
return false
}
return true
}
maxCount := setting.GetMaxTokenAutoGroups()
if len(groups) > maxCount {
common.ApiErrorT(c, "A token can select at most {{max}} Auto groups", map[string]any{"max": maxCount})
return false
}
userGroup, err := getTokenRequestUserGroup(c)
if err != nil {
common.ApiError(c, err)
return false
}
seen := make(map[string]struct{}, len(groups))
for _, group := range groups {
if _, ok := seen[group]; ok {
common.ApiErrorT(c, "Auto group {{group}} is duplicated", map[string]any{"group": group})
return false
}
seen[group] = struct{}{}
if !service.IsUserSelectableGroup(userGroup, group) {
common.ApiErrorT(c, "Auto group {{group}} is unavailable or unauthorized", map[string]any{"group": group})
return false
}
}
if err := token.SetAutoGroups(groups); err != nil {
common.ApiError(c, err)
return false
}
return true
}
func GetAllTokens(c *gin.Context) {
userId := c.GetInt("id")
pageInfo := common.GetPageQuery(c)
tokens, err := model.GetAllUserTokens(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
total, _ := model.CountUserTokens(userId)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(buildMaskedTokenResponses(tokens))
common.ApiSuccess(c, pageInfo)
}
func SearchTokens(c *gin.Context) {
userId := c.GetInt("id")
keyword := c.Query("keyword")
token := c.Query("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
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(buildMaskedTokenResponses(tokens))
common.ApiSuccess(c, pageInfo)
}
func GetToken(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
userId := c.GetInt("id")
if err != nil {
common.ApiError(c, err)
return
}
token, err := model.GetTokenByIds(id, userId)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, buildMaskedTokenResponse(token))
}
func GetTokenAutoGroups(c *gin.Context) {
userGroup, err := getTokenRequestUserGroup(c)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{
"groups": service.GetUserAutoGroup(userGroup),
"max_count": setting.GetMaxTokenAutoGroups(),
})
}
func GetTokenKey(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
userId := c.GetInt("id")
if err != nil {
common.ApiError(c, err)
return
}
token, err := model.GetTokenByIds(id, userId)
if err != nil {
common.ApiError(c, err)
return
}
params := tokenAuditParams(c)
params["id"], params["name"] = token.Id, token.Name
common.SetContextKey(c, constant.ContextKeyTokenAuditSucceeded, true)
common.ApiSuccess(c, gin.H{
"key": token.GetFullKey(),
})
}
func GetTokenStatus(c *gin.Context) {
tokenId := c.GetInt("token_id")
userId := c.GetInt("id")
token, err := model.GetTokenByIds(tokenId, userId)
if err != nil {
common.ApiError(c, err)
return
}
expiredAt := token.ExpiredTime
if expiredAt == -1 {
expiredAt = 0
}
c.JSON(http.StatusOK, gin.H{
"object": "credit_summary",
"total_granted": token.RemainQuota,
"total_used": 0, // not supported currently
"total_available": token.RemainQuota,
"expires_at": expiredAt * 1000,
})
}
func GetTokenUsage(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "No Authorization header",
})
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "Invalid Bearer token",
})
return
}
tokenKey := parts[1]
token, err := model.GetTokenByKey(strings.TrimPrefix(tokenKey, "sk-"), false)
if err != nil {
common.SysError(common.LogText("failed to get token by key: %s", err.Error()))
common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed)
return
}
expiredAt := token.ExpiredTime
if expiredAt == -1 {
expiredAt = 0
}
data := gin.H{
"object": "token_usage",
"name": token.Name,
"total_granted": token.RemainQuota + token.UsedQuota,
"total_used": token.UsedQuota,
"total_available": token.RemainQuota,
"unlimited_quota": token.UnlimitedQuota,
"model_limits": token.GetModelLimitsMap(),
"model_limits_enabled": token.ModelLimitsEnabled,
"expires_at": expiredAt,
}
// 用户额度只在令牌允许时返回;IP 白名单已由 TokenAuthReadOnly 检查。
if token.AllowUserQuotaQuery {
userQuota, err := model.GetUserQuota(token.UserId, false)
var userUsedQuota int
if err == nil {
userUsedQuota, err = model.GetUserUsedQuota(token.UserId)
}
if err != nil {
common.SysError(common.LogText("failed to get user quota: %s", err.Error()))
common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed)
return
}
data["user_quota"] = gin.H{
"total_available": userQuota,
"total_used": userUsedQuota,
}
}
c.JSON(http.StatusOK, gin.H{
"code": true,
"message": "ok",
"data": data,
})
}
func AddToken(c *gin.Context) {
request := tokenRequest{}
err := c.ShouldBindJSON(&request)
if err != nil {
common.ApiError(c, err)
return
}
token := request.Token
if len(token.Name) > 50 {
common.ApiErrorT(c, "Token name is too long")
return
}
params := tokenAuditParams(c)
params["name"] = token.Name
// 非无限额度时,检查额度值是否超出有效范围
if !token.UnlimitedQuota {
if token.RemainQuota < 0 {
common.ApiErrorT(c, "Quota value cannot be negative")
return
}
maxQuotaValue := maxTokenQuota()
if token.RemainQuota > maxQuotaValue {
common.ApiErrorT(c, "Quota value exceeds valid range, maximum is {{max}}", map[string]any{"max": logger.FormatQuota(maxQuotaValue)})
return
}
}
// 检查用户令牌数量是否已达上限
maxTokens := operation_setting.GetMaxUserTokens()
count, err := model.CountUserTokens(c.GetInt("id"))
if err != nil {
common.ApiError(c, err)
return
}
if int(count) >= maxTokens {
common.ApiErrorT(c, "Maximum number of tokens reached ({{max}})", map[string]any{"max": maxTokens})
return
}
if token.Group == "auto" {
if !setTokenAutoGroups(c, &token, request.AutoGroups.Groups) {
return
}
} else {
token.CrossGroupRetry = false
_ = token.SetAutoGroups(nil)
}
key, err := common.GenerateKey()
if err != nil {
common.ApiErrorT(c, "Failed to generate token")
common.SysLog(common.LogText("failed to generate token key: %s", err.Error()))
return
}
cleanToken := model.Token{
UserId: c.GetInt("id"),
Name: token.Name,
Key: key,
CreatedTime: common.GetTimestamp(),
AccessedTime: common.GetTimestamp(),
ExpiredTime: token.ExpiredTime,
RemainQuota: token.RemainQuota,
UnlimitedQuota: token.UnlimitedQuota,
ModelLimitsEnabled: token.ModelLimitsEnabled,
ModelLimits: token.ModelLimits,
AllowIps: token.AllowIps,
Group: token.Group,
CrossGroupRetry: token.CrossGroupRetry,
AutoGroups: token.AutoGroups,
}
cleanToken.AllowUserQuotaQuery = request.AllowUserQuotaQuery == nil || *request.AllowUserQuotaQuery
err = cleanToken.Insert()
if err != nil {
common.ApiError(c, err)
return
}
params["id"] = cleanToken.Id
common.SetContextKey(c, constant.ContextKeyTokenAuditSucceeded, true)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}
func DeleteToken(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
userId := c.GetInt("id")
token, err := model.GetTokenByIds(id, userId)
if err != nil {
common.ApiError(c, err)
return
}
params := tokenAuditParams(c)
params["id"], params["name"] = token.Id, token.Name
err = token.Delete()
if err != nil {
common.ApiError(c, err)
return
}
common.SetContextKey(c, constant.ContextKeyTokenAuditSucceeded, true)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}
func UpdateToken(c *gin.Context) {
userId := c.GetInt("id")
statusOnly := c.Query("status_only")
request := tokenRequest{}
err := c.ShouldBindJSON(&request)
if err != nil {
common.ApiError(c, err)
return
}
token := request.Token
params := tokenAuditParams(c)
if token.Id > 0 {
params["id"] = token.Id
}
if len(token.Name) > 50 {
common.ApiErrorT(c, "Token name is too long")
return
}
if !token.UnlimitedQuota {
if token.RemainQuota < 0 {
common.ApiErrorT(c, "Quota value cannot be negative")
return
}
maxQuotaValue := maxTokenQuota()
if token.RemainQuota > maxQuotaValue {
common.ApiErrorT(c, "Quota value exceeds valid range, maximum is {{max}}", map[string]any{"max": logger.FormatQuota(maxQuotaValue)})
return
}
}
cleanToken, err := model.GetTokenByIds(token.Id, userId)
if err != nil {
common.ApiError(c, err)
return
}
params["name"] = cleanToken.Name
previous := *cleanToken
if token.Status == common.TokenStatusEnabled {
if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 {
common.ApiErrorT(c, "Token has expired and cannot be enabled. Please modify the expiration time or set it to never expire")
return
}
if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainQuota <= 0 && !cleanToken.UnlimitedQuota {
common.ApiErrorT(c, "Token quota is exhausted and cannot be enabled. Please modify the remaining quota or set it to unlimited")
return
}
}
if statusOnly != "" {
cleanToken.Status = token.Status
} else {
// If you add more fields, please also update token.Update()
cleanToken.Name = token.Name
cleanToken.ExpiredTime = token.ExpiredTime
cleanToken.RemainQuota = token.RemainQuota
cleanToken.UnlimitedQuota = token.UnlimitedQuota
cleanToken.ModelLimitsEnabled = token.ModelLimitsEnabled
cleanToken.ModelLimits = token.ModelLimits
cleanToken.AllowIps = token.AllowIps
if request.AllowUserQuotaQuery != nil {
cleanToken.AllowUserQuotaQuery = *request.AllowUserQuotaQuery
}
cleanToken.Group = token.Group
cleanToken.CrossGroupRetry = token.CrossGroupRetry
if token.Group != "auto" {
cleanToken.CrossGroupRetry = false
_ = cleanToken.SetAutoGroups(nil)
} else if request.AutoGroups.Set {
if !setTokenAutoGroups(c, cleanToken, request.AutoGroups.Groups) {
return
}
}
}
err = cleanToken.Update()
if err != nil {
common.ApiError(c, err)
return
}
params["name"] = cleanToken.Name
if statusOnly != "" {
params["from"], params["to"] = previous.Status, cleanToken.Status
} else {
changedFields := []string{}
for _, field := range []struct {
name string
changed bool
}{
{"name", previous.Name != cleanToken.Name},
{"expired_time", previous.ExpiredTime != cleanToken.ExpiredTime},
{"remain_quota", previous.RemainQuota != cleanToken.RemainQuota},
{"unlimited_quota", previous.UnlimitedQuota != cleanToken.UnlimitedQuota},
{"model_limits_enabled", previous.ModelLimitsEnabled != cleanToken.ModelLimitsEnabled},
{"model_limits", previous.ModelLimits != cleanToken.ModelLimits},
{"allow_ips", (previous.AllowIps == nil) != (cleanToken.AllowIps == nil) ||
(previous.AllowIps != nil && cleanToken.AllowIps != nil && *previous.AllowIps != *cleanToken.AllowIps)},
{"allow_user_quota_query", previous.AllowUserQuotaQuery != cleanToken.AllowUserQuotaQuery},
{"group", previous.Group != cleanToken.Group},
{"cross_group_retry", previous.CrossGroupRetry != cleanToken.CrossGroupRetry},
{"auto_groups", previous.AutoGroups != cleanToken.AutoGroups},
} {
if field.changed {
changedFields = append(changedFields, field.name)
}
}
params["changed_fields"] = changedFields
}
common.SetContextKey(c, constant.ContextKeyTokenAuditSucceeded, true)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": buildMaskedTokenResponse(cleanToken),
})
}
type TokenBatch struct {
Ids []int `json:"ids"`
}
func DeleteTokenBatch(c *gin.Context) {
tokenBatch := TokenBatch{}
if err := c.ShouldBindJSON(&tokenBatch); err != nil {
common.ApiErrorT(c, "Invalid parameters")
return
}
params := tokenBatchAuditParams(c, tokenBatch.Ids)
if len(tokenBatch.Ids) == 0 {
common.ApiErrorT(c, "Invalid parameters")
return
}
userId := c.GetInt("id")
count, err := model.BatchDeleteTokens(tokenBatch.Ids, userId)
if err != nil {
common.ApiError(c, err)
return
}
params["count"] = count
common.SetContextKey(c, constant.ContextKeyTokenAuditSucceeded, true)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": count,
})
}
func GetTokenKeysBatch(c *gin.Context) {
tokenBatch := TokenBatch{}
if err := c.ShouldBindJSON(&tokenBatch); err != nil {
common.ApiErrorT(c, "Invalid parameters")
return
}
params := tokenBatchAuditParams(c, tokenBatch.Ids)
if len(tokenBatch.Ids) == 0 {
common.ApiErrorT(c, "Invalid parameters")
return
}
if len(tokenBatch.Ids) > 100 {
common.ApiErrorT(c, "Too many items in batch request, maximum is {{max}}", map[string]any{"max": 100})
return
}
userId := c.GetInt("id")
tokens, err := model.GetTokenKeysByIds(tokenBatch.Ids, userId)
if err != nil {
common.ApiError(c, err)
return
}
keysMap := make(map[int]string)
returnedIDs := make([]int, 0, len(tokens))
for _, t := range tokens {
keysMap[t.Id] = t.GetFullKey()
returnedIDs = append(returnedIDs, t.Id)
}
params["count"] = len(tokens)
params["returned_ids"] = returnedIDs
common.SetContextKey(c, constant.ContextKeyTokenAuditSucceeded, true)
common.ApiSuccess(c, gin.H{"keys": keysMap})
}