From 7de10f2c602b50122017556e8a09cb7c488c273c Mon Sep 17 00:00:00 2001 From: CaIon Date: Tue, 13 Oct 2026 21:39:52 +0800 Subject: [PATCH] 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 --- common/log_text.zh-CN.json | 1 + controller/channel-billing.go | 62 +++++++++ controller/channel_billing_test.go | 49 +++++++ controller/token.go | 48 +++++-- controller/token_test.go | 123 ++++++++++++++++++ controller/user.go | 1 + middleware/auth.go | 20 ++- model/token.go | 5 +- model/token_auto_groups_cache_test.go | 13 ++ model/token_cache.go | 8 +- .../__tests__/api-keys-mutate-drawer.test.tsx | 21 +++ .../components/api-keys-mutate-drawer.tsx | 25 ++++ .../lib/__tests__/auto-group-form.test.ts | 29 +++++ web/src/features/keys/lib/api-key-form.ts | 4 + web/src/features/keys/types.ts | 2 + .../audit/__tests__/details.test.tsx | 1 + .../usage-logs/audit/lib/audit-details.ts | 2 + web/src/i18n/locales/en.json | 3 + web/src/i18n/locales/fr.json | 3 + web/src/i18n/locales/ja.json | 3 + web/src/i18n/locales/ru.json | 3 + web/src/i18n/locales/vi.json | 3 + web/src/i18n/locales/zh-TW.json | 3 + web/src/i18n/locales/zh.json | 3 + 24 files changed, 417 insertions(+), 18 deletions(-) diff --git a/common/log_text.zh-CN.json b/common/log_text.zh-CN.json index bb313c8ee..a4caed5e6 100644 --- a/common/log_text.zh-CN.json +++ b/common/log_text.zh-CN.json @@ -505,6 +505,7 @@ "failed to get paginated tags: %s": "分页查询标签失败: %s", "failed to get token by key: %s": "按密钥查询令牌失败: %s", "failed to get token key (tokenId=%d, task=%s): %s": "获取令牌 key 失败 (tokenId=%d, task=%s): %s", + "failed to get user quota: %s": "查询用户额度失败: %s", "failed to increase token quota: %s": "增加令牌额度失败: %s", "failed to increase user quota: %s": "增加用户额度失败: %s", "failed to init token cache: %s": "初始化令牌缓存失败: %s", diff --git a/controller/channel-billing.go b/controller/channel-billing.go index 26fec7ea9..3285dc0d6 100644 --- a/controller/channel-billing.go +++ b/controller/channel-billing.go @@ -468,6 +468,66 @@ func updateChannelSub2APIBalance(channel *model.Channel) (float64, error) { return *balance, nil } +// updateChannelNewAPIBalance reads the upstream key's remaining quota. When the +// upstream also returns its owner's quota, the smaller of the two is what the +// key can still spend. Quota converts to USD with the upstream's quota_per_unit. +func updateChannelNewAPIBalance(channel *model.Channel) (float64, error) { + baseURL := strings.TrimRight(channel.GetBaseURL(), "/") + if baseURL == "" { + return 0, errors.New("New API channel is missing base_url") + } + body, err := GetResponseBody("GET", baseURL+"/api/usage/token/", channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + var usage struct { + Code bool `json:"code"` + Message string `json:"message"` + Data struct { + TotalAvailable float64 `json:"total_available"` + UnlimitedQuota bool `json:"unlimited_quota"` + UserQuota *struct { + TotalAvailable float64 `json:"total_available"` + } `json:"user_quota"` + } `json:"data"` + } + if err := common.Unmarshal(body, &usage); err != nil { + return 0, err + } + if !usage.Code { + return 0, fmt.Errorf("New API usage query failed: %s", usage.Message) + } + remaining := usage.Data.TotalAvailable + if usage.Data.UnlimitedQuota { + remaining = math.Inf(1) + } + if userQuota := usage.Data.UserQuota; userQuota != nil { + remaining = min(remaining, userQuota.TotalAvailable) + } + if math.IsInf(remaining, 1) { + return 0, common.NewMessage(`The upstream key has unlimited quota. Turn on "Allow querying user quota" for this key on the upstream to see the real balance.`) + } + + body, err = GetResponseBody("GET", baseURL+"/api/status", channel, nil) + if err != nil { + return 0, err + } + var status struct { + Data struct { + QuotaPerUnit float64 `json:"quota_per_unit"` + } `json:"data"` + } + if err := common.Unmarshal(body, &status); err != nil { + return 0, err + } + if status.Data.QuotaPerUnit <= 0 { + return 0, errors.New("New API /api/status response is missing quota_per_unit") + } + balance := remaining / status.Data.QuotaPerUnit + channel.UpdateBalance(balance) + return balance, nil +} + func fetchAdvancedCustomBalance(channel *model.Channel) (channelBalanceResult, error) { key := strings.TrimSpace(channel.Key) info := &relaycommon.RelayInfo{ @@ -594,6 +654,8 @@ func updateStandardChannelBalance(channel *model.Channel) (float64, error) { return updateChannelMoonshotBalance(channel) case constant.ChannelTypeSub2API: return updateChannelSub2APIBalance(channel) + case constant.ChannelTypeNewAPI: + return updateChannelNewAPIBalance(channel) default: return 0, common.NewMessage("Not implemented yet") } diff --git a/controller/channel_billing_test.go b/controller/channel_billing_test.go index ab978fcba..5837db75e 100644 --- a/controller/channel_billing_test.go +++ b/controller/channel_billing_test.go @@ -2,9 +2,13 @@ package controller import ( "math" + "net/http" + "net/http/httptest" "testing" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -114,3 +118,48 @@ func TestGetDeepSeekBalanceUSD(t *testing.T) { }) } } + +func TestUpdateChannelNewAPIBalanceUsesTheSmallerRemainingQuota(t *testing.T) { + for _, tc := range []struct { + name string + usage string + want float64 + wantErr string + }{ + {name: "user quota below the key quota", usage: `{"code":true,"data":{"total_available":2000000,"unlimited_quota":false,"user_quota":{"total_available":500000}}}`, want: 0.5}, + {name: "key quota below the user quota", usage: `{"code":true,"data":{"total_available":500000,"unlimited_quota":false,"user_quota":{"total_available":2000000}}}`, want: 0.5}, + {name: "unlimited key uses the user quota", usage: `{"code":true,"data":{"total_available":0,"unlimited_quota":true,"user_quota":{"total_available":3000000}}}`, want: 3}, + {name: "key quota alone without user quota", usage: `{"code":true,"data":{"total_available":1500000,"unlimited_quota":false}}`, want: 1.5}, + {name: "unlimited key without user quota", usage: `{"code":true,"data":{"total_available":0,"unlimited_quota":true}}`, wantErr: "Allow querying user quota"}, + {name: "upstream rejects the key", usage: `{"success":false,"message":"invalid token"}`, wantErr: "invalid token"}, + } { + t.Run(tc.name, func(t *testing.T) { + setupSub2APIBalanceTest(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/usage/token/": + assert.Equal(t, "Bearer sk-upstream", r.Header.Get("Authorization")) + _, _ = w.Write([]byte(tc.usage)) + case "/api/status": + assert.Empty(t, r.Header.Get("Authorization"), "the key is not sent to the public status endpoint") + _, _ = w.Write([]byte(`{"success":true,"data":{"quota_per_unit":1000000}}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + baseURL := server.URL + "/" + channel := &model.Channel{Type: constant.ChannelTypeNewAPI, BaseURL: &baseURL, Key: "sk-upstream", Name: "new-api-upstream"} + require.NoError(t, model.DB.Create(channel).Error) + + result, err := updateChannelBalance(channel) + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + assert.InDelta(t, tc.want, result.Balance, 1e-9) + assert.InDelta(t, tc.want, channel.Balance, 1e-9) + }) + } +} diff --git a/controller/token.go b/controller/token.go index feae0948c..13233aab6 100644 --- a/controller/token.go +++ b/controller/token.go @@ -35,6 +35,8 @@ func (input *tokenAutoGroupsInput) UnmarshalJSON(data []byte) error { type tokenRequest struct { model.Token AutoGroups tokenAutoGroupsInput `json:"auto_groups"` + // 指针区分“未传”和显式 false:新建时未传即开启,更新时未传则保留原值。 + AllowUserQuotaQuery *bool `json:"allow_user_quota_query"` } type tokenResponse struct { @@ -258,20 +260,39 @@ func GetTokenUsage(c *gin.Context) { 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": 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, - }, + "data": data, }) } @@ -342,6 +363,7 @@ func AddToken(c *gin.Context) { CrossGroupRetry: token.CrossGroupRetry, AutoGroups: token.AutoGroups, } + cleanToken.AllowUserQuotaQuery = request.AllowUserQuotaQuery == nil || *request.AllowUserQuotaQuery err = cleanToken.Insert() if err != nil { common.ApiError(c, err) @@ -434,6 +456,9 @@ func UpdateToken(c *gin.Context) { 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" { @@ -467,6 +492,7 @@ func UpdateToken(c *gin.Context) { {"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}, diff --git a/controller/token_test.go b/controller/token_test.go index 28af3bb9c..72e05cf1b 100644 --- a/controller/token_test.go +++ b/controller/token_test.go @@ -6,9 +6,11 @@ import ( "encoding/json" "errors" "fmt" + "maps" "net/http" "net/http/httptest" "os" + "slices" "strconv" "strings" "testing" @@ -368,6 +370,8 @@ func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect strin if migratedToken.AutoGroups != "" { t.Fatalf("expected legacy token to inherit global Auto groups, got %q", migratedToken.AutoGroups) } + require.True(t, db.Migrator().HasColumn(&model.Token{}, "allow_user_quota_query")) + assert.False(t, migratedToken.AllowUserQuotaQuery, "tokens created before the upgrade keep user quota queries off") inserted := model.Token{ UserId: 8, @@ -585,6 +589,124 @@ func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) { } } +func TestTokenAllowUserQuotaQueryOnCreateAndUpdate(t *testing.T) { + for _, tc := range []struct { + name string + target string + stored *bool // nil creates the token through the API + field any // nil omits allow_user_quota_query from the request + want bool + }{ + {name: "create without the field turns it on", target: "/api/token/", want: true}, + {name: "create with false keeps it off", target: "/api/token/", field: false}, + {name: "update without the field keeps it on", target: "/api/token/", stored: common.GetPointer(true), want: true}, + {name: "update without the field keeps it off", target: "/api/token/", stored: common.GetPointer(false)}, + {name: "update turns it off", target: "/api/token/", stored: common.GetPointer(true), field: false}, + {name: "update turns it on", target: "/api/token/", stored: common.GetPointer(false), field: true, want: true}, + {name: "status update leaves it alone", target: "/api/token/?status_only=true", stored: common.GetPointer(true), field: false, want: true}, + } { + t.Run(tc.name, func(t *testing.T) { + db := setupTokenControllerTestDB(t) + body := map[string]any{"name": "quota-query", "expired_time": -1, "unlimited_quota": true, "status": common.TokenStatusEnabled} + if tc.field != nil { + body["allow_user_quota_query"] = tc.field + } + var recorder *httptest.ResponseRecorder + if tc.stored == nil { + var ctx *gin.Context + ctx, recorder = newAuthenticatedContext(t, http.MethodPost, tc.target, body, 1) + AddToken(ctx) + } else { + token := seedToken(t, db, 1, "quota-query", "quota-query-key") + require.NoError(t, db.Model(token).Update("allow_user_quota_query", *tc.stored).Error) + body["id"] = token.Id + var ctx *gin.Context + ctx, recorder = newAuthenticatedContext(t, http.MethodPut, tc.target, body, 1) + UpdateToken(ctx) + } + response := decodeAPIResponse(t, recorder) + require.True(t, response.Success, response.Message) + + var saved model.Token + require.NoError(t, db.Where("name = ?", "quota-query").First(&saved).Error) + assert.Equal(t, tc.want, saved.AllowUserQuotaQuery) + if tc.stored != nil { + var returned struct { + AllowUserQuotaQuery bool `json:"allow_user_quota_query"` + } + require.NoError(t, common.Unmarshal(response.Data, &returned)) + assert.Equal(t, tc.want, returned.AllowUserQuotaQuery) + } + }) + } +} + +func TestGetTokenUsageFollowsTheUserQuotaSwitchAndIPWhitelist(t *testing.T) { + for _, tc := range []struct { + name string + allow bool + expired bool + allowIps string + remoteAddr string + wantStatus int + wantUserQuota bool + }{ + {name: "switch off", remoteAddr: "203.0.113.7:4321", wantStatus: http.StatusOK}, + {name: "switch on without IP whitelist", allow: true, remoteAddr: "203.0.113.7:4321", wantStatus: http.StatusOK, wantUserQuota: true}, + {name: "switch on with matching IP whitelist", allow: true, allowIps: "198.51.100.1\n203.0.113.0/24", remoteAddr: "203.0.113.7:4321", wantStatus: http.StatusOK, wantUserQuota: true}, + {name: "expired key with the switch on", allow: true, expired: true, remoteAddr: "203.0.113.7:4321", wantStatus: http.StatusOK, wantUserQuota: true}, + {name: "switch on from an IP outside the whitelist", allow: true, allowIps: "198.51.100.1", remoteAddr: "203.0.113.7:4321", wantStatus: http.StatusForbidden}, + {name: "switch off from an IP outside the whitelist", allowIps: "198.51.100.1", remoteAddr: "203.0.113.7:4321", wantStatus: http.StatusForbidden}, + {name: "unresolved client IP with a whitelist", allow: true, allowIps: "203.0.113.0/24", remoteAddr: "unresolved", wantStatus: http.StatusForbidden}, + } { + t.Run(tc.name, func(t *testing.T) { + db := setupTokenControllerTestDB(t) + require.NoError(t, db.AutoMigrate(&model.User{})) + // Initialize production column quoting for the token key lookup. + t.Setenv("LOG_SQL_DSN", "") + require.NoError(t, model.InitLogDB()) + user := &model.User{Username: "quota-owner", Password: "placeholder", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", Quota: 12345, UsedQuota: 678, AuthVersion: 1, AffCode: "quota-owner"} + require.NoError(t, db.Create(user).Error) + token := seedToken(t, db, user.Id, "usage-token", "usagekey1234") + updates := map[string]any{"allow_user_quota_query": tc.allow, "allow_ips": tc.allowIps, "used_quota": 30} + if tc.expired { + updates["status"], updates["expired_time"] = common.TokenStatusExpired, 1 + } + require.NoError(t, db.Model(token).Updates(updates).Error) + + router := gin.New() + router.GET("/api/usage/token/", middleware.TokenAuthReadOnly(), GetTokenUsage) + request := httptest.NewRequest(http.MethodGet, "/api/usage/token/", nil) + request.Header.Set("Authorization", "Bearer sk-usagekey1234") + request.RemoteAddr = tc.remoteAddr + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + require.Equal(t, tc.wantStatus, recorder.Code, recorder.Body.String()) + if tc.wantStatus != http.StatusOK { + assert.NotContains(t, recorder.Body.String(), "total_available") + return + } + var response struct { + Code bool `json:"code"` + Data map[string]json.RawMessage `json:"data"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.True(t, response.Code, recorder.Body.String()) + fields := []string{"expires_at", "model_limits", "model_limits_enabled", "name", "object", "total_available", "total_granted", "total_used", "unlimited_quota"} + if tc.wantUserQuota { + fields = append(fields, "user_quota") + } + assert.Equal(t, fields, slices.Sorted(maps.Keys(response.Data))) + assert.JSONEq(t, `100`, string(response.Data["total_available"])) + assert.JSONEq(t, `30`, string(response.Data["total_used"])) + if tc.wantUserQuota { + assert.JSONEq(t, `{"total_available":12345,"total_used":678}`, string(response.Data["user_quota"])) + } + }) + } +} + func TestAPITokenAuditDatabaseMatrix(t *testing.T) { for _, database := range []struct { name, env string @@ -686,6 +808,7 @@ func verifyAPITokenAudit(t *testing.T) { {name: "normalized update", method: "PUT", path: "/", body: `{"id":$id,"name":"renamed","expired_time":-1,"remain_quota":200,"unlimited_quota":true,"group":"default","cross_group_retry":true,"allow_ips":""}`, action: "token.update", success: true, params: `{"id":$id,"name":"renamed","changed_fields":["name","remain_quota","group","cross_group_retry","auto_groups"]}`}, {name: "configuration values stay private", method: "PUT", path: "/", body: `{"id":$id,"name":"owned","expired_time":42,"remain_quota":100,"unlimited_quota":false,"model_limits_enabled":true,"model_limits":"private-model-configuration","allow_ips":"203.0.113.57","group":"auto","cross_group_retry":true}`, action: "token.update", success: true, params: `{"id":$id,"name":"owned","changed_fields":["expired_time","unlimited_quota","model_limits_enabled","model_limits","allow_ips"]}`}, {name: "unchanged update", method: "PUT", path: "/", body: `{"id":$id,"name":"owned","expired_time":-1,"remain_quota":100,"unlimited_quota":true,"group":"auto","cross_group_retry":true,"allow_ips":""}`, action: "token.update", success: true, params: `{"id":$id,"name":"owned","changed_fields":[]}`}, + {name: "user quota query update", method: "PUT", path: "/", body: `{"id":$id,"name":"owned","expired_time":-1,"remain_quota":100,"unlimited_quota":true,"group":"auto","cross_group_retry":true,"allow_ips":"","allow_user_quota_query":true}`, action: "token.update", success: true, params: `{"id":$id,"name":"owned","changed_fields":["allow_user_quota_query"]}`}, {name: "successful response exceeds audit buffer", method: "PUT", path: "/", body: `{"id":$id,"name":"owned","expired_time":-1,"remain_quota":100,"unlimited_quota":true,"group":"auto","cross_group_retry":true,"allow_ips":"","model_limits":"` + strings.Repeat("m", 64*1024-128) + `"}`, action: "token.update", success: true, params: `{"id":$id,"name":"owned","changed_fields":["model_limits"]}`}, {name: "update storage failure", method: "PUT", path: "/", body: `{"id":$id,"name":"failed-rename","unlimited_quota":true}`, action: "token.update", params: `{"id":$id,"name":"owned"}`, failWrite: true}, {name: "foreign update", method: "PUT", path: "/", body: `{"id":$other,"name":"forged-name","unlimited_quota":true}`, action: "token.update", params: `{"id":$other}`}, diff --git a/controller/user.go b/controller/user.go index a8e95192b..bc0cf7ba7 100644 --- a/controller/user.go +++ b/controller/user.go @@ -318,6 +318,7 @@ func Register(c *gin.Context) { UnlimitedQuota: true, ModelLimitsEnabled: false, } + token.AllowUserQuotaQuery = true if setting.DefaultUseAutoGroup { token.Group = "auto" } diff --git a/middleware/auth.go b/middleware/auth.go index f939c334d..20bac23ac 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -453,9 +453,9 @@ func TokenOrUserAuth() func(c *gin.Context) { } // TokenAuthReadOnly 宽松版本的令牌认证中间件,用于只读查询接口。 -// 只验证令牌 key 是否存在,不检查令牌状态、过期时间和额度。 -// 即使令牌已过期、已耗尽或已禁用,也允许访问。 -// 仍然检查用户是否被封禁。 +// 只验证令牌 key 是否存在,不检查过期时间和额度。 +// 即使令牌已过期或已耗尽,也允许访问;已禁用的令牌会被拒绝。 +// 仍然检查令牌 IP 白名单和用户是否被封禁。 func TokenAuthReadOnly() func(c *gin.Context) { return func(c *gin.Context) { key := c.Request.Header.Get("Authorization") @@ -491,6 +491,20 @@ func TokenAuthReadOnly() func(c *gin.Context) { return } + if allowIps := token.GetIpLimits(); len(allowIps) > 0 { + ip := net.ParseIP(c.ClientIP()) + if ip == nil { + common.ApiErrorStatus(c, http.StatusForbidden, errors.New(common.TranslateMessage(c, i18n.MsgAuthClientIPUnresolved))) + c.Abort() + return + } + if !common.IsIpInCIDRList(ip, allowIps) { + common.ApiErrorStatus(c, http.StatusForbidden, errors.New(common.TranslateMessage(c, i18n.MsgAuthTokenIPNotAllowed))) + c.Abort() + return + } + } + userCache, err := model.GetUserCache(token.UserId) if err != nil { common.SysLog(common.LogText("TokenAuthReadOnly GetUserCache error for user %d: %v", token.UserId, err)) diff --git a/model/token.go b/model/token.go index d1291d0e6..ecae99bcf 100644 --- a/model/token.go +++ b/model/token.go @@ -30,6 +30,8 @@ type Token struct { CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效 AutoGroups string `json:"-" gorm:"type:text"` DeletedAt gorm.DeletedAt `gorm:"index"` + // 允许用此令牌查询所属用户的额度;新建令牌默认开启,已有令牌升级后保持关闭 + AllowUserQuotaQuery bool `json:"allow_user_quota_query"` } func (token *Token) GetAutoGroups() ([]string, error) { @@ -313,7 +315,8 @@ func (token *Token) Update() (err error) { common.SysLog(common.LogText("failed to invalidate token cache before update: %s", cacheErr.Error())) } return DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota", - "model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry", "auto_groups").Updates(token).Error + "model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry", "auto_groups", + "allow_user_quota_query").Updates(token).Error } func (token *Token) SelectUpdate() (err error) { diff --git a/model/token_auto_groups_cache_test.go b/model/token_auto_groups_cache_test.go index d748c4f8c..b80cb8c23 100644 --- a/model/token_auto_groups_cache_test.go +++ b/model/token_auto_groups_cache_test.go @@ -28,6 +28,19 @@ func TestTokenAutoGroupsRoundTripThroughRedisHashCache(t *testing.T) { assert.Equal(t, []string{"vip", "default"}, groups) } +func TestTokenAllowUserQuotaQueryRoundTripsThroughRedisHashCache(t *testing.T) { + server := useUserCacheMiniRedis(t) + token := Token{Id: 43, UserId: 7, Key: "token-user-quota-cache-key", Name: "user-quota-cache", AllowUserQuotaQuery: true} + + initialized, err := cacheInitToken(token) + require.NoError(t, err) + require.Equal(t, 1, initialized) + cached, err := cacheGetTokenByKey(token.Key) + require.NoError(t, err) + assert.True(t, cached.AllowUserQuotaQuery) + assert.Positive(t, server.TTL(getTokenCacheKey(token.Key))) +} + func TestTokenUpdateSynchronouslyNarrowsPreheatedAutoGroupsCache(t *testing.T) { truncateTables(t) useUserCacheMiniRedis(t) diff --git a/model/token_cache.go b/model/token_cache.go index c1cdf53f3..bef43de3b 100644 --- a/model/token_cache.go +++ b/model/token_cache.go @@ -66,7 +66,7 @@ if redis.call('EXISTS', KEYS[2]) == 1 then return 0 end if redis.call('EXISTS', KEYS[1]) == 1 then - redis.call('EXPIRE', KEYS[1], ARGV[17]) + redis.call('EXPIRE', KEYS[1], ARGV[18]) return 2 end redis.call('HSET', KEYS[1], @@ -74,8 +74,9 @@ redis.call('HSET', KEYS[1], 'CreatedTime', ARGV[5], 'AccessedTime', ARGV[6], 'ExpiredTime', ARGV[7], 'UnlimitedQuota', ARGV[8], 'ModelLimitsEnabled', ARGV[9], 'ModelLimits', ARGV[10], 'AllowIps', ARGV[11], 'Group', ARGV[12], 'CrossGroupRetry', ARGV[13], - 'AutoGroups', ARGV[14], 'RemainQuota', ARGV[15], 'UsedQuota', ARGV[16]) -redis.call('EXPIRE', KEYS[1], ARGV[17]) + 'AutoGroups', ARGV[14], 'RemainQuota', ARGV[15], 'UsedQuota', ARGV[16], + 'AllowUserQuotaQuery', ARGV[17]) +redis.call('EXPIRE', KEYS[1], ARGV[18]) return 1` return common.RDB.Eval(context.Background(), script, []string{ @@ -86,6 +87,7 @@ return 1` strconv.FormatBool(token.UnlimitedQuota), strconv.FormatBool(token.ModelLimitsEnabled), token.ModelLimits, allowIps, token.Group, strconv.FormatBool(token.CrossGroupRetry), token.AutoGroups, token.RemainQuota, token.UsedQuota, + strconv.FormatBool(token.AllowUserQuotaQuery), tokenCacheTTLSeconds(), ).Int() } diff --git a/web/src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx b/web/src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx index e57fcbfa1..763bf1e7f 100644 --- a/web/src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx +++ b/web/src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx @@ -278,3 +278,24 @@ describe('API keys mutate drawer Auto group integration', () => { expect(createdPayloads[0]?.auto_groups).toEqual(['vip']) }) }) + +describe('API keys mutate drawer user quota query switch', () => { + test('sends false for a new key after the switch, which starts on, is turned off', async () => { + const createdPayloads: Array> = [] + installApiFixtures(createdPayloads) + await renderCreateDrawer() + + fireEvent.click(findButton('Advanced Settings', true)) + const quotaQuerySwitch = await screen.findByRole('switch', { + name: 'Allow querying user quota', + }) + expect(quotaQuerySwitch).toBeChecked() + fireEvent.click(quotaQuerySwitch) + expect(quotaQuerySwitch).not.toBeChecked() + + changeInput(getControlByLabel('Name'), 'no-user-quota') + fireEvent.click(findButton('Save changes', true)) + await waitFor(() => expect(createdPayloads).toHaveLength(1)) + expect(createdPayloads[0]?.allow_user_quota_query).toBe(false) + }) +}) diff --git a/web/src/features/keys/components/api-keys-mutate-drawer.tsx b/web/src/features/keys/components/api-keys-mutate-drawer.tsx index a75d66e64..1d2dbb0d3 100644 --- a/web/src/features/keys/components/api-keys-mutate-drawer.tsx +++ b/web/src/features/keys/components/api-keys-mutate-drawer.tsx @@ -752,6 +752,31 @@ export function ApiKeysMutateDrawer({ )} /> + + ( + +
+ + {t('Allow querying user quota')} + + + {t( + "When enabled, this API key can be used to check your account's remaining and used quota." + )} + +
+ + + +
+ )} + /> diff --git a/web/src/features/keys/lib/__tests__/auto-group-form.test.ts b/web/src/features/keys/lib/__tests__/auto-group-form.test.ts index 479ff5943..1f04a55d6 100644 --- a/web/src/features/keys/lib/__tests__/auto-group-form.test.ts +++ b/web/src/features/keys/lib/__tests__/auto-group-form.test.ts @@ -51,6 +51,7 @@ const baseApiKey: ApiKey = { model_limits_enabled: false, model_limits: '', allow_ips: '', + allow_user_quota_query: false, } describe('API key Auto group form mapping', () => { @@ -180,3 +181,31 @@ describe('API key Auto group form mapping', () => { ) }) }) + +describe('API key user quota query form mapping', () => { + test('lets a new key query the user quota by default', () => { + for (const defaultUseAutoGroup of [true, false]) { + const defaults = getApiKeyFormDefaultValues(defaultUseAutoGroup) + + expect(defaults.allow_user_quota_query).toBe(true) + expect(transformFormDataToPayload(defaults).allow_user_quota_query).toBe( + true + ) + } + }) + + test.each([true, false])( + 'keeps the stored value %s when an existing key is edited and saved', + (stored) => { + const defaults = transformApiKeyToFormDefaults({ + ...baseApiKey, + allow_user_quota_query: stored, + }) + + expect(defaults.allow_user_quota_query).toBe(stored) + expect(transformFormDataToPayload(defaults).allow_user_quota_query).toBe( + stored + ) + } + ) +}) diff --git a/web/src/features/keys/lib/api-key-form.ts b/web/src/features/keys/lib/api-key-form.ts index 78f82eded..1cf41258c 100644 --- a/web/src/features/keys/lib/api-key-form.ts +++ b/web/src/features/keys/lib/api-key-form.ts @@ -40,6 +40,7 @@ export function getApiKeyFormSchema(t: TFunction, maxAutoGroups = 5) { unlimited_quota: z.boolean(), model_limits: z.array(z.string()), allow_ips: z.string().optional(), + allow_user_quota_query: z.boolean(), group: z.string().optional(), auto_groups_mode: z.enum(['inherit', 'custom']), auto_groups: z.array(z.string()), @@ -110,6 +111,7 @@ export const API_KEY_FORM_DEFAULT_VALUES: ApiKeyFormValues = { unlimited_quota: true, model_limits: [], allow_ips: '', + allow_user_quota_query: true, group: DEFAULT_GROUP, auto_groups_mode: 'inherit', auto_groups: [], @@ -151,6 +153,7 @@ export function transformFormDataToPayload( model_limits_enabled: data.model_limits.length > 0, model_limits: data.model_limits.join(','), allow_ips: data.allow_ips || '', + allow_user_quota_query: data.allow_user_quota_query, group: data.group || '', auto_groups: data.group === 'auto' && data.auto_groups_mode === 'custom' @@ -189,6 +192,7 @@ export function transformApiKeyToFormDefaults( ? apiKey.model_limits.split(',').filter(Boolean) : [], allow_ips: apiKey.allow_ips || '', + allow_user_quota_query: !!apiKey.allow_user_quota_query, group: apiKey.group || DEFAULT_GROUP, auto_groups_mode: autoGroupsMode, auto_groups: autoGroups, diff --git a/web/src/features/keys/types.ts b/web/src/features/keys/types.ts index 78bc16d09..964b65cd3 100644 --- a/web/src/features/keys/types.ts +++ b/web/src/features/keys/types.ts @@ -46,6 +46,7 @@ export const apiKeySchema = z.object({ model_limits_enabled: z.boolean(), model_limits: z.string().nullish().default(''), allow_ips: z.string().nullish().default(''), + allow_user_quota_query: z.boolean().optional().default(false), }) export type ApiKey = z.infer @@ -91,6 +92,7 @@ export interface ApiKeyFormData { model_limits_enabled: boolean model_limits: string allow_ips: string + allow_user_quota_query: boolean group: string auto_groups: string[] cross_group_retry: boolean diff --git a/web/src/features/usage-logs/audit/__tests__/details.test.tsx b/web/src/features/usage-logs/audit/__tests__/details.test.tsx index 7914fad50..502c2664f 100644 --- a/web/src/features/usage-logs/audit/__tests__/details.test.tsx +++ b/web/src/features/usage-logs/audit/__tests__/details.test.tsx @@ -143,6 +143,7 @@ it.each([ ['remain_quota', 'auto_groups', 'cross_group_retry'], 'Remaining quota, Auto Group Chain, Cross-group retry', ], + [['allow_user_quota_query'], 'Allow querying user quota'], ])( 'describes token configuration changes %j', async (changedFields, expected) => { diff --git a/web/src/features/usage-logs/audit/lib/audit-details.ts b/web/src/features/usage-logs/audit/lib/audit-details.ts index 2c8ec1568..311b83f09 100644 --- a/web/src/features/usage-logs/audit/lib/audit-details.ts +++ b/web/src/features/usage-logs/audit/lib/audit-details.ts @@ -114,6 +114,8 @@ export function auditFieldLabel(key: string, t: TFunction): string { return t('Model Limits') case 'allow_ips': return t('IP Whitelist (supports CIDR)') + case 'allow_user_quota_query': + return t('Allow querying user quota') case 'auto_groups': return t('Auto Group Chain') case 'cross_group_retry': diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index dcd63b6cf..141a83aa5 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -490,6 +490,7 @@ "Allow metadata sync": "Allow metadata sync", "Allow new users to register": "Allow new users to register", "Allow Private IPs": "Allow Private IPs", + "Allow querying user quota": "Allow querying user quota", "Allow registration with password": "Allow registration with password", "Allow requests to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)": "Allow requests to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)", "Allow Retry": "Allow Retry", @@ -6212,6 +6213,7 @@ "The unique name for this vendor": "The unique name for this vendor", "The upstream address this plugin sends requests to. The plugin declares no default, so it must be filled in.": "The upstream address this plugin sends requests to. The plugin declares no default, so it must be filled in.", "The upstream channel that served the requests": "The upstream channel that served the requests", + "The upstream key has unlimited quota. Turn on \"Allow querying user quota\" for this key on the upstream to see the real balance.": "The upstream key has unlimited quota. Turn on \"Allow querying user quota\" for this key on the upstream to see the real balance.", "The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "The upstream natively supports all three protocols; every selected route is forwarded without conversion.", "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.", "The upstream returned a model name different from both the requested and upstream models. Aliases or dated versions may also cause this; this warning alone does not prove model substitution.": "The upstream returned a model name different from both the requested and upstream models. Aliases or dated versions may also cause this; this warning alone does not prove model substitution.", @@ -7179,6 +7181,7 @@ "When enabled, newly created tokens start in the first auto group.": "When enabled, newly created tokens start in the first auto group.", "When enabled, prompts are scanned before reaching upstream models.": "When enabled, prompts are scanned before reaching upstream models.", "When enabled, the store field will be blocked": "When enabled, the store field will be blocked", + "When enabled, this API key can be used to check your account's remaining and used quota.": "When enabled, this API key can be used to check your account's remaining and used quota.", "When enabled, users can pick this group when creating tokens.": "When enabled, users can pick this group when creating tokens.", "When enabled, violation requests will incur additional charges.": "When enabled, violation requests will incur additional charges.", "When enabled, zero-cost models also pre-consume quota before final settlement.": "When enabled, zero-cost models also pre-consume quota before final settlement.", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 875960dff..5dc5cbc39 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -490,6 +490,7 @@ "Allow metadata sync": "Autoriser la synchronisation des métadonnées", "Allow new users to register": "Autoriser les nouveaux utilisateurs à s'inscrire", "Allow Private IPs": "Autoriser les IP privées", + "Allow querying user quota": "Autoriser la consultation du quota utilisateur", "Allow registration with password": "Autoriser l'inscription avec mot de passe", "Allow requests to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)": "Autoriser les requêtes vers les plages d'adresses IP privées (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)", "Allow Retry": "Autoriser la relance", @@ -6212,6 +6213,7 @@ "The unique name for this vendor": "Le nom unique de ce fournisseur", "The upstream address this plugin sends requests to. The plugin declares no default, so it must be filled in.": "Adresse amont vers laquelle ce plugin envoie ses requêtes. Le plugin ne déclare aucune valeur par défaut, elle doit donc être renseignée.", "The upstream channel that served the requests": "Le canal en amont ayant servi les requêtes", + "The upstream key has unlimited quota. Turn on \"Allow querying user quota\" for this key on the upstream to see the real balance.": "La clé en amont a un quota illimité. Activez « Autoriser la consultation du quota utilisateur » pour cette clé en amont afin de voir le solde réel.", "The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "Le service amont prend nativement en charge les trois protocoles ; chaque route sélectionnée est transférée sans conversion.", "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "La réponse amont est un JSON valide, mais ne correspond pas au format OpenAI credit_summary. Le solde du canal n'a pas été mis à jour.", "The upstream returned a model name different from both the requested and upstream models. Aliases or dated versions may also cause this; this warning alone does not prove model substitution.": "Le modèle renvoyé diffère du modèle demandé et de celui transmis en amont. Un alias ou une version datée peut aussi expliquer cet écart ; cet avertissement seul ne prouve pas une substitution de modèle.", @@ -7179,6 +7181,7 @@ "When enabled, newly created tokens start in the first auto group.": "Lorsqu'elle est activée, les jetons nouvellement créés commencent dans le premier groupe automatique.", "When enabled, prompts are scanned before reaching upstream models.": "Lorsqu'elle est activée, les invites sont scannées avant d'atteindre les modèles en amont.", "When enabled, the store field will be blocked": "Lorsqu'il est activé, le champ de la boutique sera bloqué", + "When enabled, this API key can be used to check your account's remaining and used quota.": "Si l'option est activée, cette clé API permet de consulter le quota restant et le quota utilisé de votre compte.", "When enabled, users can pick this group when creating tokens.": "Une fois activé, les utilisateurs peuvent choisir ce groupe lors de la création de jetons.", "When enabled, violation requests will incur additional charges.": "Lorsqu'activé, les requêtes en violation entraîneront des frais supplémentaires.", "When enabled, zero-cost models also pre-consume quota before final settlement.": "Lorsqu'elle est activée, les modèles à coût zéro pré-consomment également du quota avant le règlement final.", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 6ac0bb700..1019dd057 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -490,6 +490,7 @@ "Allow metadata sync": "メタデータの同期を許可", "Allow new users to register": "新規ユーザーの登録を許可", "Allow Private IPs": "プライベートIPを許可", + "Allow querying user quota": "ユーザーのクォータ照会を許可", "Allow registration with password": "パスワードによる登録を許可", "Allow requests to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)": "プライベートIP範囲へのリクエストを許可 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)", "Allow Retry": "リトライ許可", @@ -6212,6 +6213,7 @@ "The unique name for this vendor": "このベンダーの一意の名前", "The upstream address this plugin sends requests to. The plugin declares no default, so it must be filled in.": "このプラグインがリクエストを送信する上流アドレスです。既定値が宣言されていないため、入力が必要です。", "The upstream channel that served the requests": "リクエストを処理した上流チャネル", + "The upstream key has unlimited quota. Turn on \"Allow querying user quota\" for this key on the upstream to see the real balance.": "上流のキーはクォータが無制限です。上流でこのキーの「ユーザーのクォータ照会を許可」をオンにすると、実際の残高を確認できます。", "The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "上流が3つのプロトコルをネイティブ対応し、選択したルートを変換せず転送します。", "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "上流レスポンスは有効な JSON ですが、OpenAI credit_summary 形式ではありません。チャネル残高は更新されていません。", "The upstream returned a model name different from both the requested and upstream models. Aliases or dated versions may also cause this; this warning alone does not prove model substitution.": "上流が返したモデル名は、リクエストモデルおよび上流に指定したモデルのいずれとも一致しません。別名や日付付きバージョンによる違いもあるため、この警告だけではモデルの置き換えを断定できません。", @@ -7179,6 +7181,7 @@ "When enabled, newly created tokens start in the first auto group.": "有効にすると、新しく作成されたトークンは最初の自動グループで開始されます。", "When enabled, prompts are scanned before reaching upstream models.": "有効にすると、プロンプトはアップストリームモデルに到達する前にスキャンされます。", "When enabled, the store field will be blocked": "有効にすると、ストアフィールドはブロックされます", + "When enabled, this API key can be used to check your account's remaining and used quota.": "有効にすると、このAPIキーでアカウントの残りクォータと使用済みクォータを確認できます。", "When enabled, users can pick this group when creating tokens.": "有効にすると、ユーザーはトークン作成時にこのグループを選択できます。", "When enabled, violation requests will incur additional charges.": "有効にすると、違反リクエストに追加料金が発生します。", "When enabled, zero-cost models also pre-consume quota before final settlement.": "有効にすると、ゼロコストモデルも最終決済前にクォータを事前消費します。", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 728234055..759944659 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -490,6 +490,7 @@ "Allow metadata sync": "Разрешить синхронизацию метаданных", "Allow new users to register": "Разрешить регистрацию новым пользователям", "Allow Private IPs": "Разрешить частные IP-адреса", + "Allow querying user quota": "Разрешить просмотр квоты пользователя", "Allow registration with password": "Разрешить регистрацию по паролю", "Allow requests to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)": "Разрешить запросы к частным диапазонам IP-адресов (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)", "Allow Retry": "Разрешить повтор", @@ -6212,6 +6213,7 @@ "The unique name for this vendor": "Уникальное имя для этого поставщика", "The upstream address this plugin sends requests to. The plugin declares no default, so it must be filled in.": "Адрес вышестоящего сервиса, на который плагин отправляет запросы. Плагин не объявляет значение по умолчанию, поэтому поле обязательно.", "The upstream channel that served the requests": "Вышестоящий канал, обслуживший запросы", + "The upstream key has unlimited quota. Turn on \"Allow querying user quota\" for this key on the upstream to see the real balance.": "У вышестоящего ключа неограниченная квота. Включите для него «Разрешить просмотр квоты пользователя» на вышестоящем сервере, чтобы увидеть реальный баланс.", "The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "Поставщик нативно поддерживает все три протокола; выбранные маршруты пересылаются без преобразования.", "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "Ответ поставщика содержит допустимый JSON, но не соответствует формату OpenAI credit_summary. Баланс канала не обновлён.", "The upstream returned a model name different from both the requested and upstream models. Aliases or dated versions may also cause this; this warning alone does not prove model substitution.": "Провайдер вернул имя модели, отличное и от запрошенного, и от переданного ему. Причиной также могут быть псевдонимы или версии с датой; само по себе это предупреждение не доказывает подмену модели.", @@ -7179,6 +7181,7 @@ "When enabled, newly created tokens start in the first auto group.": "При включении вновь созданные токены начинаются в первой автогруппе.", "When enabled, prompts are scanned before reaching upstream models.": "При включении запросы сканируются перед достижением вышестоящих моделей.", "When enabled, the store field will be blocked": "Если включено, поле магазина будет заблокировано", + "When enabled, this API key can be used to check your account's remaining and used quota.": "Если включено, через этот API-ключ можно узнать оставшуюся и использованную квоту вашего аккаунта.", "When enabled, users can pick this group when creating tokens.": "Если включено, пользователи могут выбрать эту группу при создании токенов.", "When enabled, violation requests will incur additional charges.": "При включении за нарушения будут начисляться дополнительные расходы.", "When enabled, zero-cost models also pre-consume quota before final settlement.": "При включении бесплатные модели также предварительно потребляют квоту до окончательного расчета.", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 0bece5ac6..ce4d01558 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -490,6 +490,7 @@ "Allow metadata sync": "Cho phép đồng bộ siêu dữ liệu", "Allow new users to register": "Cho phép người dùng mới đăng ký", "Allow Private IPs": "Cho phép IP riêng", + "Allow querying user quota": "Cho phép tra cứu hạn mức người dùng", "Allow registration with password": "Cho phép đăng ký bằng mật khẩu", "Allow requests to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)": "Cho phép các yêu cầu đến các dải IP riêng (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)", "Allow Retry": "Cho phép thử lại", @@ -6212,6 +6213,7 @@ "The unique name for this vendor": "Tên duy nhất cho nhà cung cấp này", "The upstream address this plugin sends requests to. The plugin declares no default, so it must be filled in.": "Địa chỉ upstream mà plugin gửi yêu cầu tới. Plugin không khai báo giá trị mặc định nên bắt buộc phải điền.", "The upstream channel that served the requests": "Kênh thượng nguồn đã phục vụ các yêu cầu", + "The upstream key has unlimited quota. Turn on \"Allow querying user quota\" for this key on the upstream to see the real balance.": "Khóa thượng nguồn có hạn mức không giới hạn. Hãy bật “Cho phép tra cứu hạn mức người dùng” cho khóa này ở thượng nguồn để xem số dư thực tế.", "The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "Thượng nguồn hỗ trợ nguyên bản cả ba giao thức; mọi route đã chọn được chuyển tiếp mà không chuyển đổi.", "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "Phản hồi thượng nguồn là JSON hợp lệ nhưng không khớp định dạng OpenAI credit_summary. Số dư kênh chưa được cập nhật.", "The upstream returned a model name different from both the requested and upstream models. Aliases or dated versions may also cause this; this warning alone does not prove model substitution.": "Tên mô hình trả về khác cả mô hình được yêu cầu lẫn mô hình gửi đến nhà cung cấp. Bí danh hoặc phiên bản theo ngày cũng có thể gây ra khác biệt này; chỉ cảnh báo này chưa đủ để kết luận mô hình đã bị thay thế.", @@ -7179,6 +7181,7 @@ "When enabled, newly created tokens start in the first auto group.": "Khi được bật, các token mới được tạo sẽ bắt đầu trong nhóm tự động đầu tiên.", "When enabled, prompts are scanned before reaching upstream models.": "Khi được bật,", "When enabled, the store field will be blocked": "Khi được bật, trường store sẽ bị chặn", + "When enabled, this API key can be used to check your account's remaining and used quota.": "Khi được bật, bạn có thể dùng khóa API này để xem hạn mức còn lại và hạn mức đã dùng của tài khoản.", "When enabled, users can pick this group when creating tokens.": "Khi bật, người dùng có thể chọn nhóm này khi tạo token.", "When enabled, violation requests will incur additional charges.": "Khi bật, các yêu cầu vi phạm sẽ phải chịu phí bổ sung.", "When enabled, zero-cost models also pre-consume quota before final settlement.": "Khi được bật, các mô hình không tốn phí cũng trừ trước hạn mức trước khi quyết toán cuối cùng.", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 85cdf4c4f..2cdffb31f 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -490,6 +490,7 @@ "Allow metadata sync": "允許資料同步", "Allow new users to register": "允許新用戶註冊", "Allow Private IPs": "允許私有 IP", + "Allow querying user quota": "允許查詢使用者額度", "Allow registration with password": "允許使用密碼註冊", "Allow requests to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)": "允許請求私有 IP 範圍 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)", "Allow Retry": "允許重試", @@ -6212,6 +6213,7 @@ "The unique name for this vendor": "此供應商的唯一名稱", "The upstream address this plugin sends requests to. The plugin declares no default, so it must be filled in.": "外掛傳送請求的上游位址。該外掛未宣告預設位址,必須手動填寫。", "The upstream channel that served the requests": "處理請求的上游渠道", + "The upstream key has unlimited quota. Turn on \"Allow querying user quota\" for this key on the upstream to see the real balance.": "上游令牌是無限額度,在上游為這個令牌開啟「允許查詢使用者額度」後才能查到真實餘額。", "The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "上游原生支援三種協議,所選路由均不經轉換直接轉發。", "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "上游回應是有效 JSON,但不符合 OpenAI credit_summary 格式,渠道餘額未更新。", "The upstream returned a model name different from both the requested and upstream models. Aliases or dated versions may also cause this; this warning alone does not prove model substitution.": "上游回傳的模型名稱與請求模型、上游請求模型皆不一致。模型別名或日期版本也可能造成差異,不能僅憑此警示認定模型遭到替換。", @@ -7179,6 +7181,7 @@ "When enabled, newly created tokens start in the first auto group.": "啟用後,新建立的令牌將從第一個自動分組開始。", "When enabled, prompts are scanned before reaching upstream models.": "啟用後,提示將在到達上游模型之前被掃描。", "When enabled, the store field will be blocked": "開啟後將阻止 store 欄位透傳", + "When enabled, this API key can be used to check your account's remaining and used quota.": "開啟後,可用這個令牌查詢帳戶的剩餘額度和已用額度。", "When enabled, users can pick this group when creating tokens.": "啟用後,用戶建立令牌時可以選擇該分組。", "When enabled, violation requests will incur additional charges.": "開啟後,違規請求將額外扣費。", "When enabled, zero-cost models also pre-consume quota before final settlement.": "啟用後,零成本模型也會在最終結算前預先消耗配額。", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index fe773684a..75d503d65 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -490,6 +490,7 @@ "Allow metadata sync": "允许资料同步", "Allow new users to register": "允许新用户注册", "Allow Private IPs": "允许私有 IP", + "Allow querying user quota": "允许查询用户额度", "Allow registration with password": "允许使用密码注册", "Allow requests to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)": "允许请求私有 IP 范围 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)", "Allow Retry": "允许重试", @@ -6212,6 +6213,7 @@ "The unique name for this vendor": "此供应商的唯一名称", "The upstream address this plugin sends requests to. The plugin declares no default, so it must be filled in.": "插件发送请求的上游地址。该插件未声明默认地址,必须手动填写。", "The upstream channel that served the requests": "处理请求的上游渠道", + "The upstream key has unlimited quota. Turn on \"Allow querying user quota\" for this key on the upstream to see the real balance.": "上游令牌是无限额度,在上游为这个令牌开启“允许查询用户额度”后才能查到真实余额。", "The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "上游原生支持三种协议,所选路由均不经转换直接转发。", "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "上游响应是有效 JSON,但不符合 OpenAI credit_summary 格式,渠道余额未更新。", "The upstream returned a model name different from both the requested and upstream models. Aliases or dated versions may also cause this; this warning alone does not prove model substitution.": "上游返回的模型名与请求模型、上游请求模型均不一致。模型别名或日期版本也可能导致差异,不能仅凭此警示认定模型被替换。", @@ -7179,6 +7181,7 @@ "When enabled, newly created tokens start in the first auto group.": "启用后,新创建的令牌将从第一个自动分组开始。", "When enabled, prompts are scanned before reaching upstream models.": "启用后,提示将在到达上游模型之前被扫描。", "When enabled, the store field will be blocked": "开启后将阻止 store 字段透传", + "When enabled, this API key can be used to check your account's remaining and used quota.": "开启后,可用这个令牌查询账户的剩余额度和已用额度。", "When enabled, users can pick this group when creating tokens.": "启用后,用户创建令牌时可以选择该分组。", "When enabled, violation requests will incur additional charges.": "开启后,违规请求将额外扣费。", "When enabled, zero-cost models also pre-consume quota before final settlement.": "启用后,零成本模型也会在最终结算前预先消耗配额。",