Commit 3f8a50cf by CaIon

feat(audit): complete token and quota operation records

Record successful and failed API token operations with safe target metadata. Capture quota adjustments in a transaction, synchronize committed cache differences, and correlate audit and top-up records.

Show operation targets, changes, quota balances, and failure details consistently across audit and usage logs, with translations for all seven locales.

Validated controller, middleware, and model tests; 78 frontend tests; typecheck and lint; real SQLite 3.50.4, MySQL 8.4.11, and PostgreSQL 16.15 with shared and separate log databases.
parent 0973dc2b
...@@ -62,6 +62,14 @@ web/ — Frontend (React 19, Rsbuild, Base UI, Tailwind) ...@@ -62,6 +62,14 @@ web/ — Frontend (React 19, Rsbuild, Base UI, Tailwind)
- A separate function is appropriate when it represents reusable behavior, a required interface/framework callback, an exported API, a test fixture, or complex business logic that deserves direct tests. - A separate function is appropriate when it represents reusable behavior, a required interface/framework callback, an exported API, a test fixture, or complex business logic that deserves direct tests.
- If a single-use helper is kept, its name must describe a durable domain concept rather than a mechanical step extracted only to shorten the caller. - If a single-use helper is kept, its name must describe a durable domain concept rather than a mechanical step extracted only to shorten the caller.
### Authentication Security (OWASP Mandatory)
- Any implementation, modification, or review involving authentication-related flows MUST comply with the applicable requirements of the latest stable [OWASP Application Security Verification Standard (ASVS)](https://owasp.org/www-project-application-security-verification-standard/) and the relevant [OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/). This applies to both backend and frontend changes, including registration, login/logout, password changes and recovery, email verification, MFA, WebAuthn/Passkeys, OAuth/OIDC, account linking/unlinking, sessions, JWTs, API credentials, and re-authentication for sensitive actions.
- Before changing these flows, read the applicable OWASP guidance, starting with the [Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html) and [Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html). Consult the password storage, forgot password, MFA, OAuth, and CSRF guidance when those mechanisms are involved. Identify the applicable controls before implementation; existing code is not a justification for retaining or introducing an insecure pattern.
- Enforce security controls on the server. Apply the relevant requirements for credential storage and transport, resistance to account enumeration and brute force, CSRF and replay protection, token/challenge expiry and single use where required, protocol-specific verification, session rotation and invalidation, and re-authentication for sensitive account changes. Frontend checks MUST NOT substitute for server-side enforcement, and recovery or alternative login paths MUST NOT bypass the required authentication assurance.
- Authentication audit events MUST exclude passwords, verification codes, recovery codes, private keys, and usable session or authentication tokens. Record enough non-secret context to investigate authentication failures and sensitive account changes.
- Verify affected security controls with focused regression tests, including applicable failure, expiry, replay, and bypass cases, following the existing backend/frontend test conventions. Record the OWASP references (including the ASVS version and requirement IDs when used), validation performed, and any unresolved gaps in the change summary or PR description. Do not claim compliance or completion while an applicable security requirement remains unmet or unverified.
### Backend Rules ### Backend Rules
**relaykit module independence:** The `relaykit/` Go module MUST remain independently buildable. **relaykit module independence:** The `relaykit/` Go module MUST remain independently buildable.
......
...@@ -74,4 +74,9 @@ const ( ...@@ -74,4 +74,9 @@ const (
// fallback in authHelper (finishAdminAudit) skips its record to avoid // fallback in authHelper (finishAdminAudit) skips its record to avoid
// duplicate entries. // duplicate entries.
ContextKeyAuditLogged ContextKey = "audit_logged" ContextKeyAuditLogged ContextKey = "audit_logged"
// ContextKeyTokenAuditParams contains only the API token operation's safe metadata.
ContextKeyTokenAuditParams ContextKey = "token_audit_params"
// ContextKeyTokenAuditSucceeded disambiguates token responses that exceed the audit buffer.
ContextKeyTokenAuditSucceeded ContextKey = "token_audit_succeeded"
) )
...@@ -138,3 +138,23 @@ func recordUserSecurityAudit(c *gin.Context, userId int, action string, params m ...@@ -138,3 +138,23 @@ func recordUserSecurityAudit(c *gin.Context, userId int, action string, params m
} }
model.RecordOperationAuditLog(userId, c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, nil, auditInfo, c) model.RecordOperationAuditLog(userId, c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, nil, auditInfo, c)
} }
func tokenAuditParams(c *gin.Context) model.AuditFields {
params, ok := common.GetContextKeyType[model.AuditFields](c, constant.ContextKeyTokenAuditParams)
if !ok {
params = model.AuditFields{}
common.SetContextKey(c, constant.ContextKeyTokenAuditParams, params)
}
return params
}
func tokenBatchAuditParams(c *gin.Context, ids []int) model.AuditFields {
params := tokenAuditParams(c)
params["total"] = len(ids)
// Bound audit payloads without changing the batch operation's limits.
params["requested_ids"] = append([]int{}, ids[:min(len(ids), 100)]...)
if len(ids) > 100 {
params["requested_ids_truncated"] = true
}
return params
}
...@@ -413,7 +413,7 @@ func recordSubscriptionResetUserLogs(c *gin.Context, result *model.SubscriptionR ...@@ -413,7 +413,7 @@ func recordSubscriptionResetUserLogs(c *gin.Context, result *model.SubscriptionR
} }
content := fmt.Sprintf("管理员重置订阅套餐 %s(ID: %d)额度", result.PlanTitle, result.PlanId) content := fmt.Sprintf("管理员重置订阅套餐 %s(ID: %d)额度", result.PlanTitle, result.PlanId)
for _, userId := range result.AffectedUserIds { for _, userId := range result.AffectedUserIds {
model.RecordLogWithAdminInfo(userId, model.LogTypeManage, content, adminInfo, c) model.RecordLogWithAdminInfo(userId, model.LogTypeManage, content, adminInfo, nil, c)
} }
} }
......
...@@ -197,6 +197,9 @@ func GetTokenKey(c *gin.Context) { ...@@ -197,6 +197,9 @@ func GetTokenKey(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
params := tokenAuditParams(c)
params["id"], params["name"] = token.Id, token.Name
common.SetContextKey(c, constant.ContextKeyTokenAuditSucceeded, true)
common.ApiSuccess(c, gin.H{ common.ApiSuccess(c, gin.H{
"key": token.GetFullKey(), "key": token.GetFullKey(),
}) })
...@@ -284,6 +287,8 @@ func AddToken(c *gin.Context) { ...@@ -284,6 +287,8 @@ func AddToken(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong) common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
return return
} }
params := tokenAuditParams(c)
params["name"] = token.Name
// 非无限额度时,检查额度值是否超出有效范围 // 非无限额度时,检查额度值是否超出有效范围
if !token.UnlimitedQuota { if !token.UnlimitedQuota {
if token.RemainQuota < 0 { if token.RemainQuota < 0 {
...@@ -345,6 +350,8 @@ func AddToken(c *gin.Context) { ...@@ -345,6 +350,8 @@ func AddToken(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
params["id"] = cleanToken.Id
common.SetContextKey(c, constant.ContextKeyTokenAuditSucceeded, true)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -354,11 +361,19 @@ func AddToken(c *gin.Context) { ...@@ -354,11 +361,19 @@ func AddToken(c *gin.Context) {
func DeleteToken(c *gin.Context) { func DeleteToken(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id")) id, _ := strconv.Atoi(c.Param("id"))
userId := c.GetInt("id") userId := c.GetInt("id")
err := model.DeleteTokenById(id, userId) token, err := model.GetTokenByIds(id, userId)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return 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{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -375,6 +390,10 @@ func UpdateToken(c *gin.Context) { ...@@ -375,6 +390,10 @@ func UpdateToken(c *gin.Context) {
return return
} }
token := request.Token token := request.Token
params := tokenAuditParams(c)
if token.Id > 0 {
params["id"] = token.Id
}
if len(token.Name) > 50 { if len(token.Name) > 50 {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong) common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
return return
...@@ -395,6 +414,8 @@ func UpdateToken(c *gin.Context) { ...@@ -395,6 +414,8 @@ func UpdateToken(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
params["name"] = cleanToken.Name
previous := *cleanToken
if token.Status == common.TokenStatusEnabled { if token.Status == common.TokenStatusEnabled {
if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 { if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 {
common.ApiErrorI18n(c, i18n.MsgTokenExpiredCannotEnable) common.ApiErrorI18n(c, i18n.MsgTokenExpiredCannotEnable)
...@@ -432,6 +453,34 @@ func UpdateToken(c *gin.Context) { ...@@ -432,6 +453,34 @@ func UpdateToken(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return 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)},
{"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{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -445,7 +494,12 @@ type TokenBatch struct { ...@@ -445,7 +494,12 @@ type TokenBatch struct {
func DeleteTokenBatch(c *gin.Context) { func DeleteTokenBatch(c *gin.Context) {
tokenBatch := TokenBatch{} tokenBatch := TokenBatch{}
if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 { if err := c.ShouldBindJSON(&tokenBatch); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
params := tokenBatchAuditParams(c, tokenBatch.Ids)
if len(tokenBatch.Ids) == 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams) common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return return
} }
...@@ -455,6 +509,8 @@ func DeleteTokenBatch(c *gin.Context) { ...@@ -455,6 +509,8 @@ func DeleteTokenBatch(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
params["count"] = count
common.SetContextKey(c, constant.ContextKeyTokenAuditSucceeded, true)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -464,7 +520,12 @@ func DeleteTokenBatch(c *gin.Context) { ...@@ -464,7 +520,12 @@ func DeleteTokenBatch(c *gin.Context) {
func GetTokenKeysBatch(c *gin.Context) { func GetTokenKeysBatch(c *gin.Context) {
tokenBatch := TokenBatch{} tokenBatch := TokenBatch{}
if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 { if err := c.ShouldBindJSON(&tokenBatch); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
params := tokenBatchAuditParams(c, tokenBatch.Ids)
if len(tokenBatch.Ids) == 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams) common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return return
} }
...@@ -479,8 +540,13 @@ func GetTokenKeysBatch(c *gin.Context) { ...@@ -479,8 +540,13 @@ func GetTokenKeysBatch(c *gin.Context) {
return return
} }
keysMap := make(map[int]string) keysMap := make(map[int]string)
returnedIDs := make([]int, 0, len(tokens))
for _, t := range tokens { for _, t := range tokens {
keysMap[t.Id] = t.GetFullKey() 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}) common.ApiSuccess(c, gin.H{"keys": keysMap})
} }
...@@ -4,6 +4,7 @@ import ( ...@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
...@@ -11,11 +12,16 @@ import ( ...@@ -11,11 +12,16 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
...@@ -578,3 +584,311 @@ func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) { ...@@ -578,3 +584,311 @@ func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) {
t.Fatalf("unauthorized key response leaked raw token key: %s", unauthorizedRecorder.Body.String()) t.Fatalf("unauthorized key response leaked raw token key: %s", unauthorizedRecorder.Body.String())
} }
} }
func TestAPITokenAuditDatabaseMatrix(t *testing.T) {
for _, database := range []struct {
name, env string
typ common.DatabaseType
}{
{"sqlite", "", common.DatabaseTypeSQLite},
{"mysql", "AUDIT_MYSQL_DSN", common.DatabaseTypeMySQL},
{"postgres", "AUDIT_POSTGRES_DSN", common.DatabaseTypePostgreSQL},
} {
for _, separateLog := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/separate_log=%v", database.name, separateLog), func(t *testing.T) {
dsn := os.Getenv(database.env)
if database.env != "" && dsn == "" {
t.Skip(database.env + " is not configured")
}
previousDB, previousLogDB := model.DB, model.LOG_DB
previousMain, previousLog := common.MainDatabaseType(), common.LogDatabaseType()
previousRedis, previousMaster, previousSecret := common.RedisEnabled, common.IsMasterNode, common.SessionSecret
t.Cleanup(func() {
model.DB, model.LOG_DB = previousDB, previousLogDB
common.SetDatabaseTypes(previousMain, previousLog)
common.RedisEnabled, common.IsMasterNode, common.SessionSecret = previousRedis, previousMaster, previousSecret
})
common.RedisEnabled, common.IsMasterNode = false, true
common.SessionSecret = "api-token-audit-test-secret"
t.Setenv("LOG_SQL_DSN", "")
db, _ := newAuditTestDatabase(t, database.name, dsn)
model.DB = db
common.SetDatabaseTypes(database.typ, database.typ)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.Token{}))
// Initialize production column quoting as well as the existing audit table.
require.NoError(t, model.InitLogDB())
if separateLog {
logDB, _ := newAuditTestDatabase(t, database.name, dsn)
model.LOG_DB = logDB
require.NoError(t, model.MigrateAuditLogs())
}
versionSQL := "SELECT version()"
if database.name == "sqlite" {
versionSQL = "SELECT sqlite_version()"
}
var version string
require.NoError(t, db.Raw(versionSQL).Scan(&version).Error)
t.Logf("database version: %s", version)
verifyAPITokenAudit(t)
if separateLog {
var count int64
require.NoError(t, db.Model(&model.AuditLog{}).Count(&count).Error)
assert.Zero(t, count, "all audit events must use the configured log database")
}
})
}
}
}
func verifyAPITokenAudit(t *testing.T) {
t.Helper()
pat := "api-token-audit-pat-secret"
user := &model.User{Username: "token-owner", Password: "placeholder", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AccessToken: &pat, AffCode: "token-owner"}
require.NoError(t, model.DB.Create(user).Error)
other := &model.User{Username: "other-owner", Password: "placeholder", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "other-owner"}
require.NoError(t, model.DB.Create(other).Error)
session := &model.UserSession{SID: "token-audit-session", UserID: user.Id, Version: 1, UserAuthVersion: 1, Status: model.UserSessionStatusActive, RefreshHash: "placeholder", LoginMethod: "password", LastActiveAt: time.Now().Unix(), ExpiresAt: time.Now().Add(time.Hour).Unix()}
require.NoError(t, model.CreateUserSession(session))
jwt, _, err := service.IssueAccessToken(service.AuthIdentity{UserID: user.Id, SessionID: session.SID, UserAuthVersion: 1, SessionVersion: 1})
require.NoError(t, err)
router := gin.New()
router.Use(middleware.RequestId(), middleware.AccessTokenAudit())
tokenRoutes := router.Group("/api/token", middleware.UserAuth(), middleware.TokenOperationAudit())
tokenRoutes.POST("/", AddToken)
tokenRoutes.PUT("/", UpdateToken)
tokenRoutes.DELETE("/:id", DeleteToken)
tokenRoutes.POST("/batch", DeleteTokenBatch)
tokenRoutes.POST("/batch/keys", GetTokenKeysBatch)
tokenRoutes.POST("/:id/key", func(c *gin.Context) {
if c.GetHeader("X-Test-Limit") != "" {
c.AbortWithStatusJSON(429, gin.H{"success": false})
return
}
c.Next()
}, GetTokenKey)
tokenRoutes.GET("/", GetAllTokens)
tokenRoutes.GET("/:id", GetToken)
tokenRoutes.GET("/search", SearchTokens)
router.GET("/api/audit/self", middleware.UserAuth(), GetAuditLogs)
for index, tc := range []struct {
name, method, path, body, action string
success bool
params string
initialStatus int
failWrite, rateLimit, usePAT bool
}{
{name: "create", method: "POST", path: "/", body: `{"name":"created","expired_time":-1,"unlimited_quota":true}`, action: "token.create", success: true},
{name: "invalid create", method: "POST", path: "/", body: `{"name":"attempt","remain_quota":-1}`, action: "token.create", params: `{"name":"attempt"}`},
{name: "malformed body", method: "POST", path: "/", body: `{"key":"raw-body-secret"`, action: "token.create", params: `{}`},
{name: "validation error exceeds audit buffer", method: "POST", path: "/", body: `{"remain_quota":` + strings.Repeat("9", 64*1024) + `}`, action: "token.create", params: `{}`},
{name: "create storage failure", method: "POST", path: "/", body: `{"name":"attempt","unlimited_quota":true}`, action: "token.create", params: `{"name":"attempt"}`, failWrite: true},
{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: "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}`},
{name: "disable", method: "PUT", path: "/?status_only=true", body: `{"id":$id,"status":2}`, action: "token.status_update", success: true, params: `{"id":$id,"name":"owned","from":1,"to":2}`},
{name: "enable", method: "PUT", path: "/?status_only=true", body: `{"id":$id,"status":1}`, action: "token.status_update", success: true, params: `{"id":$id,"name":"owned","from":2,"to":1}`, initialStatus: common.TokenStatusDisabled},
{name: "expired enable", method: "PUT", path: "/?status_only=true", body: `{"id":$id,"status":1}`, action: "token.status_update", params: `{"id":$id,"name":"owned"}`, initialStatus: common.TokenStatusExpired},
{name: "delete", method: "DELETE", path: "/$id", action: "token.delete", success: true, params: `{"id":$id,"name":"owned"}`},
{name: "foreign delete", method: "DELETE", path: "/$other", action: "token.delete", params: `{"id":$other}`},
{name: "missing delete", method: "DELETE", path: "/999999", action: "token.delete", params: `{"id":999999}`},
{name: "key view", method: "POST", path: "/$id/key", action: "token.key_view", success: true, params: `{"id":$id,"name":"owned"}`},
{name: "PAT key view", method: "POST", path: "/$id/key", action: "token.key_view", success: true, params: `{"id":$id,"name":"owned"}`, usePAT: true},
{name: "foreign key view", method: "POST", path: "/$other/key", action: "token.key_view", params: `{"id":$other}`},
{name: "rate limited key view", method: "POST", path: "/$id/key", action: "token.key_view", params: `{"id":$id}`, rateLimit: true},
{name: "batch delete partial and duplicate", method: "POST", path: "/batch", body: `{"ids":[$id,$id,$other,999999]}`, action: "token.delete_batch", success: true, params: `{"requested_ids":[$id,$id,$other,999999],"total":4,"count":1}`},
{name: "empty batch delete", method: "POST", path: "/batch", body: `{"ids":[]}`, action: "token.delete_batch", params: `{"requested_ids":[],"total":0}`},
{name: "batch keys partial and duplicate", method: "POST", path: "/batch/keys", body: `{"ids":[$id,$id,$other,999999]}`, action: "token.key_view_batch", success: true, params: `{"requested_ids":[$id,$id,$other,999999],"total":4,"count":1,"returned_ids":[$id]}`},
{name: "batch keys no matches", method: "POST", path: "/batch/keys", body: `{"ids":[$other,999999]}`, action: "token.key_view_batch", success: true, params: `{"requested_ids":[$other,999999],"total":2,"count":0,"returned_ids":[]}`},
{name: "empty batch keys", method: "POST", path: "/batch/keys", body: `{"ids":[]}`, action: "token.key_view_batch", params: `{"requested_ids":[],"total":0}`},
} {
t.Run(tc.name, func(t *testing.T) {
empty := ""
owned := &model.Token{UserId: user.Id, Name: "owned", Key: fmt.Sprintf("owned-key-secret-%d", index), Status: common.TokenStatusEnabled, ExpiredTime: -1, RemainQuota: 100, UnlimitedQuota: true, Group: "auto", CrossGroupRetry: true, AutoGroups: `["default"]`, AllowIps: &empty}
if tc.initialStatus != 0 {
owned.Status = tc.initialStatus
}
if owned.Status == common.TokenStatusExpired {
owned.ExpiredTime = 1
}
foreign := &model.Token{UserId: other.Id, Name: "private-foreign-name", Key: fmt.Sprintf("foreign-key-secret-%d", index)}
require.NoError(t, model.DB.Create(owned).Error)
require.NoError(t, model.DB.Create(foreign).Error)
replace := strings.NewReplacer("$id", strconv.Itoa(owned.Id), "$other", strconv.Itoa(foreign.Id))
if tc.failWrite {
fail := func(tx *gorm.DB) {
if tx.Statement.Table == "tokens" {
_ = tx.AddError(errors.New("raw-storage-error-secret"))
}
}
if tc.method == "POST" {
require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("token-audit:fail", fail))
t.Cleanup(func() { require.NoError(t, model.DB.Callback().Create().Remove("token-audit:fail")) })
} else {
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("token-audit:fail", fail))
t.Cleanup(func() { require.NoError(t, model.DB.Callback().Update().Remove("token-audit:fail")) })
}
}
request := httptest.NewRequest(tc.method, "/api/token"+replace.Replace(tc.path), strings.NewReader(replace.Replace(tc.body)))
credential := jwt
if tc.usePAT {
credential = pat
}
request.Header.Set("Authorization", "Bearer "+credential)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "api-token-audit-client")
request.RemoteAddr = "192.0.2.12:4321"
if tc.rateLimit {
request.Header.Set("X-Test-Limit", "1")
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if strings.Contains(tc.name, "exceeds audit buffer") {
assert.Greater(t, response.Body.Len(), 64*1024)
}
var events []model.AuditLog
require.NoError(t, model.LOG_DB.Where("request_id = ?", response.Header().Get(common.RequestIdKey)).Find(&events).Error)
expectedEvents := 1
if tc.usePAT {
expectedEvents = 2
}
require.Len(t, events, expectedEvents)
var operation *model.AuditLog
for i := range events {
if events[i].Category == model.AuditCategorySecurity {
require.Nil(t, operation, "one operation event per request")
operation = &events[i]
} else {
assert.Equal(t, model.AuditCategoryAccessToken, events[i].Category)
assert.Equal(t, model.AccessTokenFingerprint(pat), events[i].TokenRef)
}
}
require.NotNil(t, operation)
assert.Equal(t, tc.action, operation.Action)
assert.Equal(t, tc.success, operation.Success)
assert.Equal(t, response.Code, operation.Status)
if tc.rateLimit {
assert.Equal(t, 429, response.Code)
} else {
assert.Equal(t, 200, response.Code)
assert.Equal(t, tc.success, decodeAPIResponse(t, response).Success)
}
assert.Equal(t, user.Id, operation.UserId)
assert.Equal(t, user.Username, operation.Username)
assert.Equal(t, common.RoleCommonUser, operation.ActorRole)
assert.Equal(t, "192.0.2.12", operation.Ip)
assert.Equal(t, "api-token-audit-client", operation.UserAgent)
assert.Equal(t, tc.method, operation.Method)
expectedRoute := strings.NewReplacer("$id", ":id", "$other", ":id", "999999", ":id").Replace(strings.Split(tc.path, "?")[0])
assert.Equal(t, "/api/token"+expectedRoute, operation.Route)
assert.NotEmpty(t, operation.RequestId)
assert.Empty(t, operation.TokenRef)
assert.Nil(t, operation.Other.AdminInfo)
authMethod := "session"
if tc.usePAT {
authMethod = "access_token"
}
assert.Equal(t, authMethod, operation.AuthMethod)
require.NotNil(t, operation.Other.Op)
assert.Equal(t, tc.action, operation.Other.Op.Action)
params, err := common.Marshal(operation.Other.Op.Params)
require.NoError(t, err)
if tc.name == "create" {
var created model.Token
require.NoError(t, model.DB.Where("user_id = ? AND name = ?", user.Id, "created").First(&created).Error)
assert.JSONEq(t, fmt.Sprintf(`{"id":%d,"name":"created"}`, created.Id), string(params))
assert.NotContains(t, string(params), created.Key)
} else if tc.params == `{}` {
assert.Empty(t, operation.Other.Op.Params)
} else {
assert.JSONEq(t, replace.Replace(tc.params), string(params))
}
encoded, err := common.Marshal(events)
require.NoError(t, err)
for _, secret := range []string{pat, jwt, owned.Key, foreign.Key, foreign.Name, "raw-body-secret", "raw-storage-error-secret", "private-model-configuration", "203.0.113.57", "Authorization"} {
assert.NotContains(t, string(encoded), secret)
}
if tc.action == "token.key_view" && tc.success {
assert.Contains(t, response.Body.String(), owned.GetFullKey())
}
})
}
t.Run("bounded batch metadata", func(t *testing.T) {
ids := make([]int, 101)
for i := range ids {
ids[i] = 10000 + i
}
body, err := common.Marshal(TokenBatch{Ids: ids})
require.NoError(t, err)
for _, path := range []string{"/api/token/batch", "/api/token/batch/keys"} {
request := httptest.NewRequest("POST", path, bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+jwt)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
var event model.AuditLog
require.NoError(t, model.LOG_DB.Where("request_id = ?", response.Header().Get(common.RequestIdKey)).First(&event).Error)
var params struct {
IDs []int `json:"requested_ids"`
Truncated bool `json:"requested_ids_truncated"`
Total int `json:"total"`
Count *int `json:"count"`
}
encoded, err := common.Marshal(event.Other.Op.Params)
require.NoError(t, err)
require.NoError(t, common.Unmarshal(encoded, &params))
assert.Equal(t, ids[:100], params.IDs)
assert.True(t, params.Truncated)
assert.Equal(t, 101, params.Total)
assert.Equal(t, path == "/api/token/batch", event.Success)
if event.Success {
require.NotNil(t, params.Count)
assert.Zero(t, *params.Count)
} else {
assert.Nil(t, params.Count)
}
}
})
t.Run("reads and unauthenticated writes add no operation audit", func(t *testing.T) {
for _, tc := range []struct{ method, path, credential string }{
{"GET", "/api/token/", jwt}, {"GET", "/api/token/search?keyword=private-search", jwt},
{"GET", "/api/token/999999", jwt}, {"POST", "/api/token/", ""},
} {
response := auditRequest(router, tc.method, tc.path, tc.credential)
var count int64
require.NoError(t, model.LOG_DB.Model(&model.AuditLog{}).Where("request_id = ?", response.Header().Get(common.RequestIdKey)).Count(&count).Error)
assert.Zero(t, count)
}
})
t.Run("self audit excludes other owners", func(t *testing.T) {
model.RecordAuditLog(nil, model.AuditLog{UserId: other.Id, Username: other.Username, ActorRole: common.RoleCommonUser, Category: model.AuditCategorySecurity, Action: "token.delete", Content: "other-user-audit", Success: true})
response := auditRequest(router, "GET", "/api/audit/self?category=security&page_size=100", jwt)
assert.Equal(t, 200, response.Code)
assert.Contains(t, response.Body.String(), "token.create")
assert.NotContains(t, response.Body.String(), "other-user-audit")
})
t.Run("audit storage failure preserves operation result", func(t *testing.T) {
require.NoError(t, model.LOG_DB.Callback().Create().Before("gorm:create").Register("token-audit:log-fail", func(tx *gorm.DB) {
if tx.Statement.Table == "audit_logs" {
_ = tx.AddError(errors.New("audit unavailable"))
}
}))
t.Cleanup(func() { require.NoError(t, model.LOG_DB.Callback().Create().Remove("token-audit:log-fail")) })
request := httptest.NewRequest("POST", "/api/token/", strings.NewReader(`{"name":"audit-down","unlimited_quota":true}`))
request.Header.Set("Authorization", "Bearer "+jwt)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
require.True(t, decodeAPIResponse(t, response).Success)
var count int64
require.NoError(t, model.DB.Model(&model.Token{}).Where("name = ?", "audit-down").Count(&count).Error)
assert.EqualValues(t, 1, count)
})
}
...@@ -1066,6 +1066,10 @@ func ManageUser(c *gin.Context) { ...@@ -1066,6 +1066,10 @@ func ManageUser(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgInvalidParams) common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return return
} }
if req.Action == "add_quota" {
manageUserQuota(c, req)
return
}
user := model.User{ user := model.User{
Id: req.Id, Id: req.Id,
} }
...@@ -1136,59 +1140,6 @@ func ManageUser(c *gin.Context) { ...@@ -1136,59 +1140,6 @@ func ManageUser(c *gin.Context) {
return return
} }
user.Role = common.RoleCommonUser user.Role = common.RoleCommonUser
case "add_quota":
switch req.Mode {
case "add":
if req.Value <= 0 {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
return
}
if err := common.ValidateWalletQuota(req.Value); err != nil {
common.ApiError(c, err)
return
}
if err := model.IncreaseUserQuota(user.Id, req.Value, true); err != nil {
common.ApiError(c, err)
return
}
recordManageAuditFor(c, user.Id, "user.quota_add", map[string]interface{}{
"quota": logger.LogQuota(req.Value),
})
case "subtract":
if req.Value <= 0 {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
return
}
if err := model.DecreaseUserQuota(user.Id, req.Value, true); err != nil {
common.ApiError(c, err)
return
}
recordManageAuditFor(c, user.Id, "user.quota_subtract", map[string]interface{}{
"quota": logger.LogQuota(req.Value),
})
case "override":
if err := common.ValidateWalletQuota(req.Value); err != nil {
common.ApiError(c, err)
return
}
oldQuota := user.Quota
if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota", req.Value).Error; err != nil {
common.ApiError(c, err)
return
}
recordManageAuditFor(c, user.Id, "user.quota_override", map[string]interface{}{
"from": logger.LogQuota(oldQuota),
"to": logger.LogQuota(req.Value),
})
default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
default: default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams) common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return return
......
package controller package controller
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"sort"
"strconv"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service/authz" "github.com/QuantumNous/new-api/service/authz"
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm" "gorm.io/gorm"
...@@ -21,19 +29,27 @@ import ( ...@@ -21,19 +29,27 @@ import (
func setupManageUserTestDB(t *testing.T) *gorm.DB { func setupManageUserTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
require.NoError(t, i18n.Init())
previousDB, previousLogDB := model.DB, model.LOG_DB previousDB, previousLogDB := model.DB, model.LOG_DB
previousRedisEnabled := common.RedisEnabled previousRedisEnabled := common.RedisEnabled
previousMainDatabaseType, previousLogDatabaseType := common.MainDatabaseType(), common.LogDatabaseType() previousMainDatabaseType, previousLogDatabaseType := common.MainDatabaseType(), common.LogDatabaseType()
dialect := os.Getenv("TEST_MANAGE_USER_DIALECT")
if dialect == "" {
dialect = "sqlite"
}
databaseTypes := map[string]common.DatabaseType{
"sqlite": common.DatabaseTypeSQLite, "mysql": common.DatabaseTypeMySQL, "postgres": common.DatabaseTypePostgreSQL,
}
require.Contains(t, databaseTypes, dialect)
dsn := os.Getenv("TEST_" + strings.ToUpper(dialect) + "_DSN")
db, _ := newAuditTestDatabase(t, dialect, dsn)
logDB := db
if os.Getenv("TEST_MANAGE_USER_SEPARATE_LOG_DB") == "1" {
logDB, _ = newAuditTestDatabase(t, dialect, dsn)
}
model.DB, model.LOG_DB = db, logDB
common.RedisEnabled = false common.RedisEnabled = false
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite) common.SetDatabaseTypes(databaseTypes[dialect], databaseTypes[dialect])
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
model.DB, model.LOG_DB = db, db
require.NoError(t, db.AutoMigrate(
&model.User{}, &model.UserSession{}, &model.Log{}, &model.AuditLog{}, &model.CasbinRule{}, &model.AuthzRole{},
))
t.Cleanup(func() { t.Cleanup(func() {
model.DB, model.LOG_DB = previousDB, previousLogDB model.DB, model.LOG_DB = previousDB, previousLogDB
...@@ -43,7 +59,22 @@ func setupManageUserTestDB(t *testing.T) *gorm.DB { ...@@ -43,7 +59,22 @@ func setupManageUserTestDB(t *testing.T) *gorm.DB {
if err == nil { if err == nil {
_ = sqlDB.Close() _ = sqlDB.Close()
} }
if logDB != db {
sqlLogDB, err := logDB.DB()
if err == nil {
_ = sqlLogDB.Close()
}
}
}) })
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.CasbinRule{}, &model.AuthzRole{}))
require.NoError(t, logDB.AutoMigrate(&model.Log{}, &model.AuditLog{}))
versionQuery := "SELECT version()"
if dialect == "sqlite" {
versionQuery = "SELECT sqlite_version()"
}
var version string
require.NoError(t, db.Raw(versionQuery).Scan(&version).Error)
t.Logf("database: %s %s, separate log database: %v", dialect, version, logDB != db)
return db return db
} }
...@@ -57,6 +88,7 @@ func performManageUserRequest(t *testing.T, body string) *httptest.ResponseRecor ...@@ -57,6 +88,7 @@ func performManageUserRequest(t *testing.T, body string) *httptest.ResponseRecor
c.Set("id", 9999) c.Set("id", 9999)
c.Set("role", common.RoleRootUser) c.Set("role", common.RoleRootUser)
c.Set("username", "root-operator") c.Set("username", "root-operator")
c.Set(common.RequestIdKey, "quota-test-request")
ManageUser(c) ManageUser(c)
return recorder return recorder
} }
...@@ -160,23 +192,417 @@ func TestManageUserDeleteReturnsImmediatelyAndUnknownActionFails(t *testing.T) { ...@@ -160,23 +192,417 @@ func TestManageUserDeleteReturnsImmediatelyAndUnknownActionFails(t *testing.T) {
assert.Equal(t, common.UserStatusEnabled, unchanged.Status) assert.Equal(t, common.UserStatusEnabled, unchanged.Status)
} }
func TestManageUserQuotaRespectsWalletCeiling(t *testing.T) { func createQuotaTestOperator(t *testing.T, db *gorm.DB, role int) model.User {
db := setupManageUserTestDB(t) t.Helper()
user := model.User{ if role == 0 {
Username: "managed-quota-user", Password: "password", Role: common.RoleCommonUser, role = common.RoleRootUser
Status: common.UserStatusEnabled, Group: "default", Quota: common.MaxWalletQuota - 1,
} }
operator := model.User{Id: 9999, Username: "root-operator", Role: role, Status: common.UserStatusEnabled, AuthVersion: 1, AffCode: "root-operator-aff"}
require.NoError(t, db.Create(&operator).Error)
return operator
}
func TestManageUserQuotaRecordsTopupAndAudit(t *testing.T) {
for _, tc := range []struct {
name, mode, action, content string
value, wantQuota int
}{
{"add", "add", "user.quota_add", "Increased user quota by 500", 500, 1500},
{"subtract", "subtract", "user.quota_subtract", "Decreased user quota by 500", 500, 500},
{"override_up", "override", "user.quota_override", "Overrode user quota from 1000 to 1500", 1500, 1500},
{"override_down", "override", "user.quota_override", "Overrode user quota from 1000 to 500", 500, 500},
{"override_unchanged", "override", "user.quota_override", "Overrode user quota from 1000 to 1000", 1000, 1000},
{"override_zero", "override", "user.quota_override", "Overrode user quota from 1000 to 0", 0, 0},
{"override_negative", "override", "user.quota_override", "Overrode user quota from 1000 to -1", -1, -1},
} {
t.Run(tc.name, func(t *testing.T) {
db := setupManageUserTestDB(t)
user := model.User{Username: "quota-owner", Role: common.RoleCommonUser, Quota: 1000, AffCode: "quota-owner-aff"}
require.NoError(t, db.Create(&user).Error) require.NoError(t, db.Create(&user).Error)
createQuotaTestOperator(t, db, common.RoleRootUser)
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":"add","value":2}`, user.Id)) recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":%q,"value":%d}`, user.Id, tc.mode, tc.value))
assert.Contains(t, recorder.Body.String(), `"success":false`) assert.Equal(t, http.StatusOK, recorder.Code)
require.Contains(t, recorder.Body.String(), `"success":true`)
require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, tc.wantQuota, user.Quota)
var updated model.User logs, total, err := model.GetAllLogs(model.LogTypeTopup, 0, 0, "", "", "", 0, 20, 0, "", "", "")
require.NoError(t, db.First(&updated, user.Id).Error) require.NoError(t, err)
assert.Equal(t, common.MaxWalletQuota-1, updated.Quota) assert.EqualValues(t, 1, total)
require.Len(t, logs, 1)
assert.Equal(t, user.Id, logs[0].UserId)
assert.Equal(t, user.Username, logs[0].Username)
assert.Equal(t, tc.content, logs[0].Content)
model.FormatAdminLogs(logs)
var other model.AuditOther
require.NoError(t, common.UnmarshalJsonStr(logs[0].Other, &other))
assert.Equal(t, &model.AuditAdminInfo{AdminID: 9999, AdminUsername: "root-operator", AdminRole: common.RoleRootUser, AuthMethod: "session"}, other.AdminInfo)
logs, total, err = model.GetUserLogs(user.Id, model.LogTypeTopup, 0, 0, "", "", 0, 20, "", "", "")
require.NoError(t, err)
assert.EqualValues(t, 1, total)
require.Len(t, logs, 1)
other = model.AuditOther{}
require.NoError(t, common.UnmarshalJsonStr(logs[0].Other, &other))
assert.Nil(t, other.AdminInfo)
require.NotNil(t, other.Op)
assert.Equal(t, tc.action, other.Op.Action)
params, err := common.Marshal(other.Op.Params)
require.NoError(t, err)
expectedParams := model.AuditFields{"target_user_id": user.Id, "target_username": user.Username, "mode": tc.mode, "requested_quota": tc.value, "from": 1000, "to": tc.wantQuota}
if tc.mode != "override" {
expectedParams["quota"] = tc.value
}
expected, err := common.Marshal(expectedParams)
require.NoError(t, err)
assert.JSONEq(t, string(expected), string(params))
assert.Equal(t, "quota-test-request", logs[0].RequestId)
assert.Empty(t, logs[0].Ip, "recipient logs must not disclose the administrator IP")
logs, total, err = model.GetUserLogs(9999, model.LogTypeTopup, 0, 0, "", "", 0, 20, "", "", "")
require.NoError(t, err)
assert.Zero(t, total)
assert.Empty(t, logs)
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&audits).Error)
require.Len(t, audits, 1)
assert.Equal(t, 9999, audits[0].UserId)
assert.Equal(t, "root-operator", audits[0].Username)
assert.Equal(t, tc.action, audits[0].Action)
assert.True(t, audits[0].Success)
params, err = common.Marshal(audits[0].Other.Op.Params)
require.NoError(t, err)
assert.JSONEq(t, string(expected), string(params))
assert.Equal(t, "quota-test-request", audits[0].RequestId)
})
}
}
recorder = performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":"override","value":%d}`, user.Id, common.MaxWalletQuota+1)) func TestManageUserQuotaFailuresDoNotRecordTopup(t *testing.T) {
for _, tc := range []struct {
name, mode string
value int
failUpdate bool
}{
{"zero_add", "add", 0, false},
{"negative_subtract", "subtract", -1, false},
{"invalid_mode", "invalid", 500, false},
{"add_update_error", "add", 500, true},
{"subtract_update_error", "subtract", 500, true},
{"override_update_error", "override", 500, true},
} {
t.Run(tc.name, func(t *testing.T) {
db := setupManageUserTestDB(t)
createQuotaTestOperator(t, db, common.RoleRootUser)
user := model.User{Username: "quota-owner", Role: common.RoleCommonUser, Quota: 1000}
require.NoError(t, db.Create(&user).Error)
if tc.failUpdate {
require.NoError(t, db.Callback().Update().Before("gorm:update").Register("test:fail_quota_update", func(tx *gorm.DB) {
if tx.Statement.Table == "users" {
tx.AddError(errors.New("quota update unavailable"))
}
}))
}
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":%q,"value":%d}`, user.Id, tc.mode, tc.value))
assert.Contains(t, recorder.Body.String(), `"success":false`) assert.Contains(t, recorder.Body.String(), `"success":false`)
require.NoError(t, db.First(&updated, user.Id).Error) require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, common.MaxWalletQuota-1, updated.Quota) assert.Equal(t, 1000, user.Quota)
var logCount, auditCount int64
require.NoError(t, model.LOG_DB.Model(&model.Log{}).Count(&logCount).Error)
require.NoError(t, model.LOG_DB.Model(&model.AuditLog{}).Count(&auditCount).Error)
assert.Zero(t, logCount)
assert.EqualValues(t, 1, auditCount)
var audit model.AuditLog
require.NoError(t, model.LOG_DB.First(&audit).Error)
assert.False(t, audit.Success)
params, err := common.Marshal(audit.Other.Op.Params)
require.NoError(t, err)
assert.NotContains(t, string(params), `"from"`)
assert.NotContains(t, string(params), `"to"`)
assert.NotContains(t, string(params), `"target_username"`)
assert.NotContains(t, string(params), "quota update unavailable")
reason := "invalid_parameters"
if tc.failUpdate {
reason = "database_error"
}
assert.Contains(t, string(params), `"failure_reason":"`+reason+`"`)
assert.Contains(t, string(params), fmt.Sprintf(`"requested_quota":%d`, tc.value))
})
}
}
func TestManageUserQuotaLogFailureKeepsSuccessfulAdjustment(t *testing.T) {
for _, failedTable := range []string{"logs", "audit_logs"} {
t.Run(failedTable, func(t *testing.T) {
db := setupManageUserTestDB(t)
createQuotaTestOperator(t, db, common.RoleRootUser)
user := model.User{Username: "quota-owner", Role: common.RoleCommonUser, Quota: 1000}
require.NoError(t, db.Create(&user).Error)
require.NoError(t, model.LOG_DB.Callback().Create().Before("gorm:create").Register("test:fail_quota_log", func(tx *gorm.DB) {
if tx.Statement.Table == failedTable {
tx.AddError(errors.New("quota log unavailable"))
}
}))
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":"add","value":500}`, user.Id))
assert.Contains(t, recorder.Body.String(), `"success":true`)
require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, 1500, user.Quota)
var logCount, auditCount int64
require.NoError(t, model.LOG_DB.Model(&model.Log{}).Count(&logCount).Error)
require.NoError(t, model.LOG_DB.Model(&model.AuditLog{}).Count(&auditCount).Error)
if failedTable == "logs" {
assert.Zero(t, logCount)
assert.EqualValues(t, 1, auditCount)
} else {
assert.EqualValues(t, 1, logCount)
assert.Zero(t, auditCount)
}
})
}
}
func TestManageUserQuotaTargetsAndWalletBounds(t *testing.T) {
for _, tc := range []struct {
name, mode, reason string
before, value, targetID, targetRole, operatorRole int
deleted, failRead bool
}{
{name: "zero_id", mode: "add", value: 1, reason: "invalid_parameters"},
{name: "negative_id", mode: "subtract", value: 1, targetID: -1, reason: "invalid_parameters"},
{name: "missing", mode: "override", value: 1, targetID: 12345, reason: "target_not_found"},
{name: "deleted", mode: "add", value: 1, targetID: 1, deleted: true, reason: "target_not_found"},
{name: "peer_admin", mode: "add", value: 1, targetID: 1, targetRole: common.RoleAdminUser, operatorRole: common.RoleAdminUser, reason: "permission_denied"},
{name: "higher_role", mode: "override", value: 1, targetID: 1, targetRole: common.RoleRootUser, operatorRole: common.RoleAdminUser, reason: "permission_denied"},
{name: "read_error", mode: "add", value: 1, targetID: 1, failRead: true, reason: "database_error"},
{name: "add_overflow", mode: "add", before: common.MaxWalletQuota, value: 1, targetID: 1, reason: "quota_limit_exceeded"},
{name: "subtract_underflow", mode: "subtract", before: -common.MaxWalletQuota, value: 1, targetID: 1, reason: "quota_limit_exceeded"},
{name: "oversized_add", mode: "add", value: common.MaxWalletQuota + 1, targetID: 1, reason: "quota_limit_exceeded"},
{name: "oversized_subtract", mode: "subtract", value: common.MaxWalletQuota + 1, targetID: 1, reason: "quota_limit_exceeded"},
{name: "oversized_override", mode: "override", value: -common.MaxWalletQuota - 1, targetID: 1, reason: "quota_limit_exceeded"},
} {
t.Run(tc.name, func(t *testing.T) {
db := setupManageUserTestDB(t)
createQuotaTestOperator(t, db, tc.operatorRole)
user := model.User{Id: 1, Username: "private-target-name", Role: tc.targetRole, Quota: tc.before}
require.NoError(t, db.Create(&user).Error)
if tc.deleted {
require.NoError(t, db.Delete(&user).Error)
}
if tc.failRead {
require.NoError(t, db.Callback().Query().Before("gorm:query").Register("test:quota_read_error", func(tx *gorm.DB) {
if tx.Statement.Table == "users" && len(tx.Statement.Selects) == 0 {
tx.AddError(errors.New("private database error"))
}
}))
}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/user/manage", strings.NewReader(fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":%q,"value":%d}`, tc.targetID, tc.mode, tc.value)))
role := tc.operatorRole
if role == 0 {
role = common.RoleRootUser
}
c.Set("id", 9999)
c.Set("role", role)
ManageUser(c)
require.Contains(t, recorder.Body.String(), `"success":false`)
if tc.failRead {
require.NoError(t, db.Callback().Query().Remove("test:quota_read_error"))
}
require.NoError(t, db.Unscoped().First(&user, user.Id).Error)
assert.Equal(t, tc.before, user.Quota)
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&audits).Error)
require.Len(t, audits, 1)
assert.False(t, audits[0].Success)
params, err := common.Marshal(audits[0].Other.Op.Params)
require.NoError(t, err)
expected, err := common.Marshal(model.AuditFields{"target_user_id": tc.targetID, "mode": tc.mode, "requested_quota": tc.value, "failure_reason": tc.reason})
require.NoError(t, err)
assert.JSONEq(t, string(expected), string(params))
assert.NotContains(t, audits[0].Content, user.Username)
var count int64
require.NoError(t, model.LOG_DB.Model(&model.Log{}).Count(&count).Error)
assert.Zero(t, count)
})
}
}
func TestManageUserQuotaMiddlewareKeepsOneOperationPerRequest(t *testing.T) {
db := setupManageUserTestDB(t)
pat := "quota-middleware-test-token"
operator := model.User{Id: 9999, Username: "root-operator", Role: common.RoleRootUser, Status: common.UserStatusEnabled, AuthVersion: 1, AccessToken: &pat, Quota: 1000}
require.NoError(t, db.Create(&operator).Error)
router := gin.New()
router.Use(middleware.RequestId(), middleware.AccessTokenAudit())
router.POST("/api/user/manage", middleware.AdminAuth(), ManageUser)
for _, tc := range []struct {
body, action string
success bool
}{
{`{"id":9999,"action":"add_quota","mode":"add","value":100}`, "user.quota_add", true},
{`{"id":9999,"action":"add_quota","mode":"subtract","value":0}`, "user.quota_subtract", false},
{`{"id":9999,"action":"add_quota","mode":"invalid","value":1}`, "generic", false},
{`{"id":`, "generic", false},
} {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/user/manage", strings.NewReader(tc.body))
request.Header.Set("Authorization", "Bearer "+pat)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), fmt.Sprintf(`"success":%t`, tc.success))
requestID := recorder.Header().Get(common.RequestIdKey)
require.NotEmpty(t, requestID)
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Where("request_id = ?", requestID).Find(&audits).Error)
require.Len(t, audits, 2, "one operation audit and one PAT request audit")
for _, audit := range audits {
assert.Equal(t, tc.success, audit.Success)
if audit.Category != model.AuditCategoryOperation {
continue
}
assert.Equal(t, tc.action, audit.Action)
assert.Equal(t, operator.Id, audit.UserId)
assert.Equal(t, "/api/user/manage", audit.Route)
if tc.success {
params, err := common.Marshal(audit.Other.Op.Params)
require.NoError(t, err)
assert.Contains(t, string(params), `"target_user_id":9999`)
assert.Contains(t, string(params), `"target_username":"root-operator"`)
}
}
var count int64
require.NoError(t, model.LOG_DB.Model(&model.Log{}).Where("request_id = ?", requestID).Count(&count).Error)
if tc.success {
assert.EqualValues(t, 1, count)
} else {
assert.Zero(t, count)
}
}
require.NoError(t, db.First(&operator, operator.Id).Error)
assert.Equal(t, 1100, operator.Quota)
}
func TestManageUserQuotaConcurrentSnapshots(t *testing.T) {
db := setupManageUserTestDB(t)
user := model.User{Username: "concurrent-quota", Quota: 1000}
require.NoError(t, db.Create(&user).Error)
var ready sync.WaitGroup
ready.Add(2)
release := make(chan struct{})
require.NoError(t, db.Callback().Query().Before("gorm:query").Register("test:concurrent_quota_start", func(tx *gorm.DB) {
if tx.Statement.Table == "users" {
ready.Done()
<-release
}
}))
type result struct {
adjustment *model.UserQuotaAdjustment
err error
value int
}
results := make(chan result, 2)
for _, value := range []int{10, 20} {
go func(value int) {
adjustment, err := model.AdjustUserQuota(user.Id, common.RoleRootUser, "add", value)
results <- result{adjustment, err, value}
}(value)
}
ready.Wait()
close(release)
var committed []model.UserQuotaAdjustment
for range 2 {
result := <-results
if result.err != nil {
require.True(t, common.UsingMainDatabase(common.DatabaseTypeSQLite), "row-locking databases must serialize both adjustments: %v", result.err)
assert.Contains(t, strings.ToLower(result.err.Error()), "locked")
assert.Nil(t, result.adjustment)
continue
}
require.NotNil(t, result.adjustment)
assert.Equal(t, result.value, result.adjustment.After-result.adjustment.Before)
committed = append(committed, *result.adjustment)
}
require.NoError(t, db.Callback().Query().Remove("test:concurrent_quota_start"))
require.NotEmpty(t, committed)
sort.Slice(committed, func(i, j int) bool { return committed[i].Before < committed[j].Before })
balance := 1000
for _, adjustment := range committed {
assert.Equal(t, balance, adjustment.Before)
balance = adjustment.After
}
require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, balance, user.Quota)
}
func TestManageUserQuotaCacheUsesCommittedIntegerDifference(t *testing.T) {
for _, tc := range []struct {
name, mode string
before, cached, value, after, wantCached int
failUpdate, failCache, missingCache bool
}{
{name: "add_preserves_reservations", mode: "add", before: 1000, cached: 900, value: 500, after: 1500, wantCached: 1400},
{name: "subtract_preserves_reservations", mode: "subtract", before: 1000, cached: 900, value: 500, after: 500, wantCached: 400},
{name: "override_preserves_reservations", mode: "override", before: 1000, cached: 900, value: 2000, after: 2000, wantCached: 1900},
{name: "large_odd_difference", mode: "override", before: common.MaxWalletQuota - 1, cached: common.MaxWalletQuota - 1, value: -common.MaxWalletQuota, after: -common.MaxWalletQuota, wantCached: -common.MaxWalletQuota},
{name: "rollback_does_not_change_cache", mode: "subtract", before: 1000, cached: 900, value: 500, after: 1000, wantCached: 900, failUpdate: true},
{name: "cache_error_keeps_committed_change", mode: "add", before: 1000, value: 500, after: 1500, failCache: true},
{name: "missing_cache_is_not_partially_created", mode: "add", before: 1000, value: 500, after: 1500, missingCache: true},
} {
t.Run(tc.name, func(t *testing.T) {
db := setupManageUserTestDB(t)
operator := createQuotaTestOperator(t, db, common.RoleRootUser)
server := miniredis.RunT(t)
oldRDB := common.RDB
common.RDB = redis.NewClient(&redis.Options{Addr: server.Addr(), MaxRetries: -1})
common.RedisEnabled = true
t.Cleanup(func() { _ = common.RDB.Close(); common.RDB = oldRDB })
user := model.User{Username: "cached-quota", Quota: tc.before, AuthVersion: 1}
require.NoError(t, db.Create(&user).Error)
cache, err := model.GetUserCache(user.Id)
require.NoError(t, err)
assert.Equal(t, tc.before, cache.Quota)
_, err = model.GetUserCache(operator.Id)
require.NoError(t, err)
keys := server.Keys()
var quotaKey string
for _, key := range keys {
if server.HGet(key, "Id") == strconv.Itoa(user.Id) && server.HGet(key, "Quota") != "" {
quotaKey = key
break
}
}
require.NotEmpty(t, quotaKey)
server.HSet(quotaKey, "Quota", strconv.Itoa(tc.cached))
if tc.missingCache {
server.Del(quotaKey)
}
if tc.failCache {
server.SetError("ERR quota cache unavailable")
}
if tc.failUpdate {
require.NoError(t, db.Callback().Update().After("gorm:update").Register("test:cache_quota_rollback", func(tx *gorm.DB) {
if tx.Statement.Table == "users" {
tx.AddError(errors.New("quota rollback"))
}
}))
}
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":%q,"value":%d}`, user.Id, tc.mode, tc.value))
assert.Contains(t, recorder.Body.String(), fmt.Sprintf(`"success":%t`, !tc.failUpdate))
require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, tc.after, user.Quota)
if tc.missingCache {
// Log username lookup may hydrate the whole user after commit.
if server.Exists(quotaKey) {
assert.Equal(t, strconv.Itoa(user.Id), server.HGet(quotaKey, "Id"))
assert.NotEmpty(t, server.HGet(quotaKey, "CacheSchema"))
assert.Equal(t, strconv.Itoa(tc.after), server.HGet(quotaKey, "Quota"))
}
} else if !tc.failCache {
assert.Equal(t, strconv.Itoa(tc.wantCached), server.HGet(quotaKey, "Quota"))
}
})
}
} }
package controller
import (
"errors"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func manageUserQuota(c *gin.Context, req ManageRequest) {
action := "generic"
params := model.AuditFields{
"target_user_id": req.Id,
"mode": req.Mode,
"requested_quota": req.Value,
}
switch req.Mode {
case "add":
action = "user.quota_add"
case "subtract":
action = "user.quota_subtract"
case "override":
action = "user.quota_override"
default:
params["action"] = "add_quota"
params["method"] = c.Request.Method
params["route"] = c.FullPath()
}
success := false
defer func() {
content := auditContentEN(action, params)
if !success {
// Failed requests have no committed balance changes to render.
content = "Failed user quota adjustment"
}
model.RecordOperationAuditLog(c.GetInt("id"), c.GetInt("role"), content, c.ClientIP(), action, params,
auditOperatorInfo(c), &model.AuditRequestInfo{
Method: c.Request.Method, Route: c.FullPath(), Status: c.Writer.Status(), Success: success,
}, c)
markAuditLogged(c)
}()
adjustment, err := model.AdjustUserQuota(req.Id, c.GetInt("role"), req.Mode, req.Value)
if err != nil {
switch {
case errors.Is(err, model.ErrInvalidUserQuotaAdjustment):
params["failure_reason"] = "invalid_parameters"
if (req.Mode == "add" || req.Mode == "subtract") && req.Value <= 0 {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
} else {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
}
case errors.Is(err, model.ErrUserQuotaPermission):
params["failure_reason"] = "permission_denied"
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
case errors.Is(err, gorm.ErrRecordNotFound):
params["failure_reason"] = "target_not_found"
common.ApiErrorI18n(c, i18n.MsgUserNotExists)
case errors.Is(err, model.ErrWalletQuotaLimitExceeded):
params["failure_reason"] = "quota_limit_exceeded"
common.ApiError(c, err)
default:
params["failure_reason"] = "database_error"
common.ApiError(c, err)
}
return
}
params["target_username"] = adjustment.Username
params["from"] = adjustment.Before
params["to"] = adjustment.After
if req.Mode != "override" {
params["quota"] = req.Value
}
success = true
operation := model.AuditOperation{Action: action, Params: params}
model.RecordLogWithAdminInfo(adjustment.UserID, model.LogTypeTopup,
auditContentEN(action, params), auditOperatorInfo(c), &operation, c)
c.JSON(http.StatusOK, gin.H{"success": true, "message": ""})
}
...@@ -2,6 +2,7 @@ package middleware ...@@ -2,6 +2,7 @@ package middleware
import ( import (
"bytes" "bytes"
"strconv"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
...@@ -203,6 +204,56 @@ func auditResponseSuccess(status int, body []byte) bool { ...@@ -203,6 +204,56 @@ func auditResponseSuccess(status int, body []byte) bool {
const accessTokenAuditContextKey = "access_token_request_audit" const accessTokenAuditContextKey = "access_token_request_audit"
// TokenOperationAudit runs after UserAuth and before endpoint rate limits.
// Handlers add only allowlisted metadata; neither bodies nor raw errors are persisted.
func TokenOperationAudit() gin.HandlerFunc {
return func(c *gin.Context) {
var action, content string
switch c.Request.Method + " " + c.FullPath() {
case "POST /api/token/":
action, content = "token.create", "API token creation"
case "PUT /api/token/":
action, content = "token.update", "API token configuration update"
if c.Query("status_only") != "" {
action, content = "token.status_update", "API token status update"
}
case "DELETE /api/token/:id":
action, content = "token.delete", "API token deletion"
case "POST /api/token/batch":
action, content = "token.delete_batch", "API token batch deletion"
case "POST /api/token/:id/key":
action, content = "token.key_view", "API token key access"
case "POST /api/token/batch/keys":
action, content = "token.key_view_batch", "API token batch key access"
default:
c.Next()
return
}
params := model.AuditFields{}
if id, err := strconv.Atoi(c.Param("id")); err == nil && id > 0 {
params["id"] = id
}
common.SetContextKey(c, constant.ContextKeyTokenAuditParams, params)
entry := model.AuditLog{
UserId: c.GetInt("id"), Username: c.GetString("username"), ActorRole: c.GetInt("role"),
Category: model.AuditCategorySecurity, Action: action, Content: content,
Other: model.AuditOther{Op: &model.AuditOperation{Action: action, Params: params}},
}
writer := &auditResponseWriter{ResponseWriter: c.Writer, body: bytes.NewBuffer(nil), maxSize: 64 * 1024}
c.Writer = writer
c.Next()
entry.Status = writer.Status()
entry.Success = auditResponseSuccess(entry.Status, writer.body.Bytes())
if writer.body.Len() == writer.maxSize {
// JSON may be truncated before its success field. A completed handler
// supplies the result without retaining an unbounded response body.
entry.Success = entry.Status < 400 && common.GetContextKeyBool(c, constant.ContextKeyTokenAuditSucceeded)
}
model.RecordAuditLog(c, entry)
}
}
type accessTokenRequestAudit struct { type accessTokenRequestAudit struct {
entry model.AuditLog entry model.AuditLog
writer *auditResponseWriter writer *auditResponseWriter
......
...@@ -165,8 +165,9 @@ func RecordLog(userId int, logType int, content string) { ...@@ -165,8 +165,9 @@ func RecordLog(userId int, logType int, content string) {
} }
} }
// RecordLogWithAdminInfo 记录操作日志,并将管理员相关信息存入 Other.admin_info, // RecordLogWithAdminInfo stores operator metadata under other.admin_info and
func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo *AuditAdminInfo, request ...*gin.Context) { // an optional, user-visible operation descriptor under other.op for localization.
func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo *AuditAdminInfo, operation *AuditOperation, request ...*gin.Context) {
if logType == LogTypeConsume && !common.LogConsumeEnabled { if logType == LogTypeConsume && !common.LogConsumeEnabled {
return return
} }
...@@ -187,11 +188,14 @@ func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo * ...@@ -187,11 +188,14 @@ func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo *
if c != nil { if c != nil {
actorRole = c.GetInt("role") actorRole = c.GetInt("role")
} }
RecordAuditLog(c, AuditLog{UserId: userId, Username: username, ActorRole: actorRole, Category: AuditCategoryOperation, Content: content, Other: AuditOther{AdminInfo: adminInfo}, Success: true}) RecordAuditLog(c, AuditLog{UserId: userId, Username: username, ActorRole: actorRole, Category: AuditCategoryOperation, Content: content, Other: AuditOther{AdminInfo: adminInfo, Op: operation}, Success: true})
return return
} }
if adminInfo != nil { if len(request) > 0 && request[0] != nil {
data, err := common.Marshal(AuditOther{AdminInfo: adminInfo}) log.RequestId = request[0].GetString(common.RequestIdKey)
}
if adminInfo != nil || operation != nil {
data, err := common.Marshal(AuditOther{AdminInfo: adminInfo, Op: operation})
if err != nil { if err != nil {
common.SysError("failed to encode log admin info: " + err.Error()) common.SysError("failed to encode log admin info: " + err.Error())
return return
......
...@@ -36,7 +36,7 @@ if tonumber(redis.call('HGET', KEYS[1], 'Id') or '0') ~= tonumber(ARGV[2]) ...@@ -36,7 +36,7 @@ if tonumber(redis.call('HGET', KEYS[1], 'Id') or '0') ~= tonumber(ARGV[2])
or redis.call('HEXISTS', KEYS[1], 'Quota') == 0 then or redis.call('HEXISTS', KEYS[1], 'Quota') == 0 then
return -1 return -1
end end
redis.call('HINCRBY', KEYS[1], 'Quota', tonumber(ARGV[1])) redis.call('HINCRBY', KEYS[1], 'Quota', ARGV[1])
return 1` return 1`
const tokenQuotaReserveScript = ` const tokenQuotaReserveScript = `
......
package model
import (
"errors"
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
var (
ErrInvalidUserQuotaAdjustment = errors.New("invalid user quota adjustment")
ErrUserQuotaPermission = errors.New("cannot adjust quota for this user role")
)
// UserQuotaAdjustment is the immutable database snapshot of a committed manual
// adjustment. Pending relay deductions in the quota cache are not part of it.
type UserQuotaAdjustment struct {
UserID int
Username string
Before int
After int
}
func AdjustUserQuota(userID, operatorRole int, mode string, value int) (*UserQuotaAdjustment, error) {
if userID <= 0 || (mode != "add" && mode != "subtract" && mode != "override") {
return nil, ErrInvalidUserQuotaAdjustment
}
if mode != "override" && value <= 0 {
return nil, ErrInvalidUserQuotaAdjustment
}
if value > common.MaxWalletQuota || value < -common.MaxWalletQuota {
return nil, ErrWalletQuotaLimitExceeded
}
var adjustment UserQuotaAdjustment
err := DB.Transaction(func(tx *gorm.DB) error {
var user User
if err := lockForUpdate(tx).First(&user, userID).Error; err != nil {
return err
}
if operatorRole != common.RoleRootUser && operatorRole <= user.Role {
return ErrUserQuotaPermission
}
if user.Quota > common.MaxWalletQuota || user.Quota < -common.MaxWalletQuota {
return ErrWalletQuotaLimitExceeded
}
quota := decimal.NewFromInt(int64(value))
switch mode {
case "add":
quota = decimal.NewFromInt(int64(user.Quota)).Add(quota)
case "subtract":
quota = decimal.NewFromInt(int64(user.Quota)).Sub(quota)
}
after, err := common.WalletQuotaFromDecimalStrict(quota)
if err != nil {
return ErrWalletQuotaLimitExceeded
}
// An unchanged override is a successful operation, including on MySQL
// configurations that count only changed rows in RowsAffected.
if after != user.Quota {
result := tx.Model(&User{}).Where("id = ?", userID).Update("quota", after)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
}
adjustment = UserQuotaAdjustment{UserID: user.Id, Username: user.Username, Before: user.Quota, After: after}
return nil
})
if err != nil {
return nil, err
}
// Apply only the committed difference, preserving outstanding reservations.
// Both balances are bounded above, so their difference fits in int64.
delta := int64(adjustment.After) - int64(adjustment.Before)
if delta != 0 {
if err := cacheIncrUserQuota(userID, delta); err != nil {
common.SysError(fmt.Sprintf("failed to sync manual quota adjustment for user %d: %s", userID, err))
}
}
return &adjustment, nil
}
...@@ -261,6 +261,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -261,6 +261,7 @@ func SetApiRouter(router *gin.Engine) {
registerAuthzRoutes(apiRouter) registerAuthzRoutes(apiRouter)
tokenRoute := apiRouter.Group("/token") tokenRoute := apiRouter.Group("/token")
tokenRoute.Use(middleware.UserAuth()) tokenRoute.Use(middleware.UserAuth())
tokenRoute.Use(middleware.TokenOperationAudit())
{ {
tokenRoute.GET("/", controller.GetAllTokens) tokenRoute.GET("/", controller.GetAllTokens)
tokenRoute.GET("/search", middleware.SearchRateLimit(), controller.SearchTokens) tokenRoute.GET("/search", middleware.SearchRateLimit(), controller.SearchTokens)
......
...@@ -32,6 +32,7 @@ import zh from '@/i18n/locales/zh.json' ...@@ -32,6 +32,7 @@ import zh from '@/i18n/locales/zh.json'
import type { AuditLog } from '../api' import type { AuditLog } from '../api'
import { AuditLogDetailsDialog } from '../components/audit-log-details-dialog' import { AuditLogDetailsDialog } from '../components/audit-log-details-dialog'
import { buildAuditDetails } from '../lib/audit-details'
const userAgent = const userAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/152.0.0.0 Safari/537.36' 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/152.0.0.0 Safari/537.36'
...@@ -107,6 +108,216 @@ it('uses the returned authentication method for personal access records', async ...@@ -107,6 +108,216 @@ it('uses the returned authentication method for personal access records', async
expect(within(dialog).queryByText('Session')).not.toBeInTheDocument() expect(within(dialog).queryByText('Session')).not.toBeInTheDocument()
}) })
it.each([
['token.create', '创建 API 令牌「1」(ID: 11)'],
['token.update', '更新 API 令牌「1」(ID: 11)'],
['token.status_update', '更新 API 令牌「1」(ID: 11)'],
['token.delete', '删除 API 令牌「1」(ID: 11)'],
['token.delete_batch', '批量删除 API 令牌'],
['token.key_view', '查看 API 令牌「1」的密钥(ID: 11)'],
['token.key_view_batch', '批量查看 API 令牌密钥'],
])('localizes the neutral event summary for %s', async (action, summary) => {
const i18n = createInstance()
await i18n.init({ lng: 'zh', resources: { zh } })
const detail = buildAuditDetails(
{
...entry,
category: 'security',
action,
content: '',
other: { op: { action, params: { id: 11, name: '1' } } },
},
i18n.t
)
expect(detail.summary).toBe(summary)
})
it.each([
[[], 'No changes'],
[
['remain_quota', 'auto_groups', 'cross_group_retry'],
'Remaining quota, Auto Group Chain, Cross-group retry',
],
])(
'describes token configuration changes %j',
async (changedFields, expected) => {
const { dialog } = await openDetails({
...entry,
category: 'security',
action: 'token.update',
other: {
op: {
action: 'token.update',
params: { id: 42, name: 'client', changed_fields: changedFields },
},
},
})
expect(within(dialog).getAllByText(expected).length).toBeGreaterThan(0)
}
)
it.each([
[1, 2, 'Enabled', 'Disabled'],
[3, 4, 'Expired', 'Exhausted'],
])(
'formats token status transitions %i to %i',
async (from, to, before, after) => {
const { dialog } = await openDetails({
...entry,
category: 'security',
action: 'token.status_update',
other: { op: { action: 'token.status_update', params: { from, to } } },
})
expect(
within(dialog).getAllByText(`${before}${after}`).length
).toBeGreaterThan(0)
}
)
it('shows a failed token batch attempt without implying completion', async () => {
const { dialog } = await openDetails({
...entry,
category: 'security',
action: 'token.key_view_batch',
content: '',
success: false,
other: {
op: {
action: 'token.key_view_batch',
params: {
total: 101,
requested_ids: [42],
requested_ids_truncated: true,
},
},
},
})
expect(within(dialog).getByText('View API token keys in batch')).toBeVisible()
expect(within(dialog).getByText('Failed')).toBeVisible()
expect(within(dialog).getByText('Requested token IDs')).toBeVisible()
expect(
within(dialog).getByText(
'Only the first 1 IDs were recorded (101 requested)'
)
).toBeVisible()
expect(within(dialog).queryByText('Count')).not.toBeInTheDocument()
})
it('shows explicit token identity before the operator and request sections', async () => {
const { dialog } = await openDetails({
...entry,
action: 'token.create',
other: { op: { action: 'token.create', params: { id: 11, name: '1' } } },
})
expect(
within(dialog).getByText('Create API token “1” (ID: 11)')
).toBeVisible()
const name = within(dialog).getByText('Token Name')
const id = within(dialog).getByText('Token ID')
expect(name.parentElement).toHaveTextContent('1')
expect(id.parentElement).toHaveTextContent('11')
expect(
name.compareDocumentPosition(within(dialog).getByText('Request')) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
expect(within(dialog).queryByText('Target')).not.toBeInTheDocument()
})
it.each([
[{ id: 11 }, 'Create API token (ID: 11)'],
[{ name: '1' }, 'Create API token “1”'],
[{}, 'Create API token · Target not recorded'],
])(
'describes incomplete token targets without inventing values (%j)',
async (params, summary) => {
const { dialog } = await openDetails({
...entry,
action: 'token.create',
other: { op: { action: 'token.create', params } },
})
expect(within(dialog).getByText(summary)).toBeVisible()
}
)
it('distinguishes unchanged state from missing change metadata', async () => {
const i18n = createInstance()
await i18n.init({ lng: 'en' })
const unchanged = buildAuditDetails(
{
...entry,
action: 'token.status_update',
other: {
op: { action: 'token.status_update', params: { from: 1, to: 1 } },
},
},
i18n.t
)
const missing = buildAuditDetails(
{
...entry,
action: 'token.update',
other: { op: { action: 'token.update', params: {} } },
},
i18n.t
)
expect(unchanged.tokenOperation?.description).toBe('State unchanged: Enabled')
expect(missing.tokenOperation?.description).toBe(
'Field change details were not recorded'
)
})
it('does not present recorded changes as completed when the operation failed', async () => {
const { dialog } = await openDetails({
...entry,
success: false,
action: 'token.update',
other: {
op: {
action: 'token.update',
params: {
id: 11,
name: 'production',
changed_fields: ['remain_quota'],
},
},
},
})
expect(within(dialog).getByText('Failed')).toBeVisible()
expect(within(dialog).queryByText('Changed Fields')).not.toBeInTheDocument()
expect(within(dialog).queryByText(/Changed fields:/)).not.toBeInTheDocument()
})
it('renders batch IDs compactly, copies the full list and preserves an empty result', async () => {
const user = userEvent.setup()
const copy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
const { dialog } = await openDetails({
...entry,
action: 'token.key_view_batch',
other: {
op: {
action: 'token.key_view_batch',
params: {
total: 3,
count: 0,
requested_ids: [11, 11, 12],
returned_ids: [],
},
},
},
})
expect(within(dialog).getByText('11, 11, 12')).toBeVisible()
expect(
within(dialog).getByText('Returned token IDs').parentElement
).toHaveTextContent('None')
expect(
within(dialog).getByText('Returned keys').parentElement
).toHaveTextContent('0')
await user.click(
within(dialog).getByRole('button', { name: 'Copy Requested token IDs' })
)
expect(copy).toHaveBeenCalledWith('11, 11, 12')
})
async function openDetails(log: AuditLog = entry) { async function openDetails(log: AuditLog = entry) {
render(<AuditLogDetailsDialog entry={log} />) render(<AuditLogDetailsDialog entry={log} />)
const trigger = screen.getByRole('button', { name: 'Details' }) const trigger = screen.getByRole('button', { name: 'Details' })
...@@ -287,3 +498,122 @@ it.each([ ...@@ -287,3 +498,122 @@ it.each([
expect(within(dialog).getByText('user')).toBeVisible() expect(within(dialog).getByText('user')).toBeVisible()
} }
) )
it('shows the quota target and committed balances before operator information', async () => {
const user = userEvent.setup()
const copy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
const { dialog } = await openDetails({
...entry,
action: 'user.quota_override',
other: {
op: {
action: 'user.quota_override',
params: {
target_user_id: 42,
target_username: '1',
mode: 'override',
requested_quota: -500000,
from: 500000,
to: -500000,
},
},
},
})
expect(
within(dialog).getByText('Override quota for user “1” (ID: 42)')
).toBeVisible()
const target = within(dialog).getByText('Target username')
const operator = within(dialog).getByText('Operator')
expect(
target.compareDocumentPosition(operator) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
expect(within(dialog).getByText('Quota before adjustment')).toBeVisible()
expect(within(dialog).getByText('Quota after adjustment')).toBeVisible()
expect(within(dialog).getByText('$1')).toBeVisible()
expect(within(dialog).getAllByText('-$1')).toHaveLength(2)
await user.click(within(dialog).getByRole('button', { name: 'Copy User ID' }))
expect(copy).toHaveBeenCalledWith('42')
})
it('shows failed quota attempts without claiming a balance change', async () => {
const { dialog } = await openDetails({
...entry,
success: false,
action: 'user.quota_subtract',
other: {
op: {
action: 'user.quota_subtract',
params: {
target_user_id: 42,
mode: 'subtract',
requested_quota: 500000,
from: 1000000,
to: 500000,
failure_reason: 'permission_denied',
},
},
},
})
expect(within(dialog).getByText('Failed')).toBeVisible()
expect(within(dialog).getByText('Decrease user quota (ID: 42)')).toBeVisible()
expect(
within(dialog).getByText('Insufficient permission to adjust this user')
).toBeVisible()
expect(within(dialog).getByText('$1')).toBeVisible()
expect(within(dialog).queryByText('Quota before adjustment')).toBeNull()
expect(within(dialog).queryByText('Quota after adjustment')).toBeNull()
expect(dialog).not.toHaveTextContent('→')
})
it('distinguishes unchanged zero quota from missing or legacy balance metadata', async () => {
const i18n = createInstance()
await i18n.init({ lng: 'en' })
const unchanged = buildAuditDetails(
{
...entry,
action: 'user.quota_override',
other: {
op: {
action: 'user.quota_override',
params: { target_user_id: 42, requested_quota: 0, from: 0, to: 0 },
},
},
},
i18n.t
)
expect(unchanged.summary).toBe('Override user quota (ID: 42)')
expect(unchanged.operation?.description).toBe(
'Requested quota: $0 · Quota unchanged · $0 → $0'
)
const missing = buildAuditDetails(
{
...entry,
action: 'user.quota_add',
other: {
op: { action: 'user.quota_add', params: { quota: '¥1.000000额度' } },
},
},
i18n.t
)
expect(missing.summary).toContain('Target not recorded')
expect(missing.operation?.description).toBe(
'Requested quota: ¥1.000000额度 · Not recorded → Not recorded'
)
expect(missing.operation?.description).not.toContain('Quota unchanged')
const legacy = buildAuditDetails(
{
...entry,
action: 'user.quota_override',
other: {
op: {
action: 'user.quota_override',
params: { from: 'legacy before', to: 'legacy after' },
},
},
},
i18n.t
)
expect(legacy.operation?.description).toContain(
'legacy before → legacy after'
)
})
...@@ -44,6 +44,202 @@ import { useAuthStore } from '@/stores/auth-store' ...@@ -44,6 +44,202 @@ import { useAuthStore } from '@/stores/auth-store'
import { AuditLogs } from '..' import { AuditLogs } from '..'
import { AuditLogViewer } from '../components/audit-log-viewer' import { AuditLogViewer } from '../components/audit-log-viewer'
it.each([
[
'generic',
{
action: 'add_quota',
target_user_id: 11,
mode: 'unsupported',
requested_quota: 500000,
failure_reason: 'invalid_parameters',
},
'Adjust user quota',
'Requested quota: $1 · Invalid adjustment parameters',
],
[
'user.quota_add',
{
target_user_id: 11,
target_username: 'quota-owner',
requested_quota: 500000,
quota: 500000,
from: 500000,
to: 1000000,
},
'Increase quota for user “quota-owner”',
'Requested quota: $1 · $1 → $2',
],
[
'user.quota_subtract',
{
target_user_id: 11,
target_username: 'quota-owner',
requested_quota: 500000,
quota: 500000,
from: 1000000,
to: 500000,
},
'Decrease quota for user “quota-owner”',
'Requested quota: $1 · $2 → $1',
],
[
'user.quota_override',
{
target_user_id: 11,
target_username: 'quota-owner',
requested_quota: 0,
from: 500000,
to: 0,
},
'Override quota for user “quota-owner”',
'Requested quota: $0 · $1 → $0',
],
['token.create', { id: 11, name: '1' }, 'Create API token “1”', ''],
[
'token.update',
{
id: 11,
name: 'production',
changed_fields: ['remain_quota', 'expired_time'],
},
'Update API token “production”',
'Changed fields: Remaining quota, Expiration Time',
],
[
'token.status_update',
{ id: 11, name: 'production', from: 1, to: 2 },
'Update API token “production”',
'Enabled → Disabled',
],
[
'token.delete',
{ id: 11, name: 'production' },
'Delete API token “production”',
'',
],
[
'token.key_view',
{ id: 11, name: 'production' },
'View key for API token “production”',
'',
],
[
'token.delete_batch',
{ total: 4, count: 1, requested_ids: [11, 11, 12, 99] },
'Batch delete API tokens',
'Requested: 4 · Deleted: 1',
],
[
'token.key_view_batch',
{ total: 4, count: 0, returned_ids: [] },
'View API token keys in batch',
'Requested: 4 · Returned: 0',
],
])(
'shows the target and business outcome for %s directly in the event cell',
async (action, params, headline, outcome) => {
vi.spyOn(api, 'get').mockResolvedValue({
data: {
success: true,
data: {
total: 1,
items: [
{
event_id: 'token-event',
created_at: 1788600600,
username: 'root',
actor_role: 100,
category: 'security',
action,
success: action !== 'generic',
status: 200,
other: { op: { action, params } },
},
],
},
},
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<AuditLogViewer scope='self' />
</QueryClientProvider>
)
const cell = await screen.findByRole('cell', { name: new RegExp(headline) })
expect(cell).toHaveTextContent(headline)
if ('id' in params || 'target_user_id' in params) {
expect(cell).toHaveTextContent('(ID: 11)')
}
if (outcome) expect(cell).toHaveTextContent(outcome)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
}
)
it.each([false, true])(
'keeps the ID outside long-name truncation and shows complete details (mobile=%s)',
async (mobile) => {
const matchMedia = window.matchMedia.bind(window)
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...matchMedia(query),
matches: mobile && query === '(max-width: 640px)',
}))
const name = 'production-europe-primary-customer-routing-token'
vi.spyOn(api, 'get').mockResolvedValue({
data: {
success: true,
data: {
total: 1,
items: [
{
event_id: 'long-token-name',
created_at: 1788600600,
username: 'root',
actor_role: 100,
category: 'security',
action: 'token.status_update',
success: true,
status: 200,
other: {
op: {
action: 'token.status_update',
params: { id: 11, name, from: 1, to: 2 },
},
},
},
],
},
},
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<AuditLogViewer scope='self' />
</QueryClientProvider>
)
const id = await screen.findByText('(ID: 11)')
expect(id).toBeVisible()
expect(id).toHaveClass('shrink-0')
expect(id.closest('.truncate')).toBeNull()
expect(screen.getByText('Enabled → Disabled')).toBeVisible()
if (mobile) expect(screen.queryByRole('table')).not.toBeInTheDocument()
else expect(screen.getByRole('table')).toBeVisible()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Details' }))
const dialog = await screen.findByRole('dialog', { name: 'Log Details' })
expect(
within(dialog).getByText(`Update API token “${name}” (ID: 11)`)
).toBeVisible()
expect(
within(dialog).getByText('Token Name').parentElement
).toHaveTextContent(name)
}
)
it('uses the shared log toolbar and opens details in a keyboard-accessible dialog without expanding the row', async () => { it('uses the shared log toolbar and opens details in a keyboard-accessible dialog without expanding the row', async () => {
vi.spyOn(api, 'get').mockResolvedValue({ vi.spyOn(api, 'get').mockResolvedValue({
data: { data: {
......
...@@ -19,12 +19,14 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,12 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DetailRow } from '../../components/dialogs/log-detail-layout' import { DetailRow } from '../../components/dialogs/log-detail-layout'
import { auditFieldLabel, isAuditDetailObject } from '../lib/audit-details' import {
auditFieldLabel,
isAuditDetailObject,
type AuditDetailField,
} from '../lib/audit-details'
import { AuditDetailValue } from './audit-detail-value' import { AuditDetailValue } from './audit-detail-value'
export function AuditDetailFields(props: { export function AuditDetailFields(props: { fields: AuditDetailField[] }) {
fields: { label: string; value: unknown }[]
}) {
const { t } = useTranslation() const { t } = useTranslation()
return props.fields.map((field) => { return props.fields.map((field) => {
const value = field.value const value = field.value
...@@ -56,7 +58,13 @@ export function AuditDetailFields(props: { ...@@ -56,7 +58,13 @@ export function AuditDetailFields(props: {
<DetailRow <DetailRow
key={field.label} key={field.label}
label={field.label} label={field.label}
value={<AuditDetailValue label={field.label} value={text} />} value={
<AuditDetailValue
label={field.label}
value={text}
copyable={field.copyable}
/>
}
/> />
) )
}) })
......
...@@ -57,13 +57,49 @@ export function useAuditLogColumns( ...@@ -57,13 +57,49 @@ export function useAuditLogColumns(
{ {
id: 'event', id: 'event',
header: t('Event'), header: t('Event'),
size: 260, size: 360,
accessorFn: (entry) => buildAuditDetails(entry, t).summary, accessorFn: (entry) => {
cell: ({ getValue }) => ( const detail = buildAuditDetails(entry, t)
return [detail.summary, detail.operation?.description]
.filter(Boolean)
.join(' · ')
},
cell: ({ row, getValue }) => {
const operation = buildAuditDetails(row.original, t).operation
if (!operation) {
return (
<TruncatedCell className='max-w-64'> <TruncatedCell className='max-w-64'>
{getValue<string>()} {getValue<string>()}
</TruncatedCell> </TruncatedCell>
), )
}
return (
<div className='min-w-0 space-y-1'>
<div className='flex min-w-0 items-baseline gap-1'>
<TruncatedCell
className='min-w-0 font-medium'
tooltipContent={operation.summary}
>
{operation.headline}
</TruncatedCell>
{operation.identifier && (
<span className='shrink-0 whitespace-nowrap'>
{operation.identifier}
</span>
)}
</div>
{operation.description && (
<TruncatedCell
className='text-muted-foreground'
contentClassName='line-clamp-2 whitespace-normal break-words'
tooltipContent={operation.description}
>
{operation.description}
</TruncatedCell>
)}
</div>
)
},
meta: { label: t('Event') }, meta: { label: t('Event') },
} }
) )
......
...@@ -72,6 +72,11 @@ export function AuditLogDetailsDialog(props: { entry: AuditLog }) { ...@@ -72,6 +72,11 @@ export function AuditLogDetailsDialog(props: { entry: AuditLog }) {
<p className='text-sm leading-relaxed font-medium break-words'> <p className='text-sm leading-relaxed font-medium break-words'>
{detail.summary} {detail.summary}
</p> </p>
{detail.operation?.description && (
<p className='text-muted-foreground text-sm leading-relaxed break-words'>
{detail.operation.description}
</p>
)}
<div className='flex flex-wrap items-center gap-2 text-xs'> <div className='flex flex-wrap items-center gap-2 text-xs'>
<StatusBadge <StatusBadge
label={props.entry.success ? t('Success') : t('Failed')} label={props.entry.success ? t('Success') : t('Failed')}
...@@ -85,6 +90,17 @@ export function AuditLogDetailsDialog(props: { entry: AuditLog }) { ...@@ -85,6 +90,17 @@ export function AuditLogDetailsDialog(props: { entry: AuditLog }) {
)} )}
</div> </div>
</div> </div>
{detail.operation && (
<DetailSection
label={
detail.quotaOperation
? t('Quota adjustment details')
: t('Token operation details')
}
>
<AuditDetailFields fields={detail.operation.fields} />
</DetailSection>
)}
{hasOperation && ( {hasOperation && (
<DetailSection label={t('Operation Audit Info')}> <DetailSection label={t('Operation Audit Info')}>
{detail.actor && ( {detail.actor && (
...@@ -93,7 +109,7 @@ export function AuditLogDetailsDialog(props: { entry: AuditLog }) { ...@@ -93,7 +109,7 @@ export function AuditLogDetailsDialog(props: { entry: AuditLog }) {
{detail.actorRole && ( {detail.actorRole && (
<DetailRow label={t('Role')} value={detail.actorRole} /> <DetailRow label={t('Role')} value={detail.actorRole} />
)} )}
{detail.target && ( {!detail.operation && detail.target && (
<DetailRow label={t('Target')} value={detail.target} /> <DetailRow label={t('Target')} value={detail.target} />
)} )}
{detail.authentication && ( {detail.authentication && (
...@@ -102,7 +118,7 @@ export function AuditLogDetailsDialog(props: { entry: AuditLog }) { ...@@ -102,7 +118,7 @@ export function AuditLogDetailsDialog(props: { entry: AuditLog }) {
value={detail.authentication} value={detail.authentication}
/> />
)} )}
<AuditDetailFields fields={detail.fields} /> {!detail.operation && <AuditDetailFields fields={detail.fields} />}
{detail.metadataUnavailable && ( {detail.metadataUnavailable && (
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
{t('Audit metadata is unavailable')} {t('Audit metadata is unavailable')}
......
...@@ -22,6 +22,7 @@ import { loginMethodLabel } from '@/features/security/components/login-session-u ...@@ -22,6 +22,7 @@ import { loginMethodLabel } from '@/features/security/components/login-session-u
import { ROLE } from '@/lib/roles' import { ROLE } from '@/lib/roles'
import { renderAuditContent } from '../../lib/format' import { renderAuditContent } from '../../lib/format'
import { buildQuotaAuditOperation } from '../../lib/quota-audit-operation'
import type { LogOtherData } from '../../types' import type { LogOtherData } from '../../types'
import type { AuditLog } from '../api' import type { AuditLog } from '../api'
...@@ -32,6 +33,40 @@ const AUDIT_ROLE_NAMES: Record<number, string> = { ...@@ -32,6 +33,40 @@ const AUDIT_ROLE_NAMES: Record<number, string> = {
[ROLE.SUPER_ADMIN]: 'root', [ROLE.SUPER_ADMIN]: 'root',
} }
const TOKEN_AUDIT_OPERATIONS: Record<
string,
{ labelKey: string; namedKey?: string }
> = {
'token.create': {
labelKey: 'Create API token',
namedKey: 'Create API token “{{name}}”',
},
'token.update': {
labelKey: 'Update API token',
namedKey: 'Update API token “{{name}}”',
},
'token.status_update': {
labelKey: 'Update API token',
namedKey: 'Update API token “{{name}}”',
},
'token.delete': {
labelKey: 'Delete API token',
namedKey: 'Delete API token “{{name}}”',
},
'token.key_view': {
labelKey: 'View API token key',
namedKey: 'View key for API token “{{name}}”',
},
'token.delete_batch': { labelKey: 'Batch delete API tokens' },
'token.key_view_batch': { labelKey: 'View API token keys in batch' },
}
export type AuditDetailField = {
label: string
value: unknown
copyable?: boolean
}
export function isAuditDetailObject( export function isAuditDetailObject(
value: unknown value: unknown
): value is Record<string, unknown> { ): value is Record<string, unknown> {
...@@ -52,6 +87,28 @@ export function auditFieldLabel(key: string, t: TFunction): string { ...@@ -52,6 +87,28 @@ export function auditFieldLabel(key: string, t: TFunction): string {
return t('Count') return t('Count')
case 'total': case 'total':
return t('Total') return t('Total')
case 'requested_ids':
return t('Requested token IDs')
case 'returned_ids':
return t('Returned token IDs')
case 'requested_ids_truncated':
return t('Requested token IDs truncated')
case 'expired_time':
return t('Expiration Time')
case 'remain_quota':
return t('Remaining quota')
case 'unlimited_quota':
return t('Unlimited Quota')
case 'model_limits_enabled':
return t('Model limits enabled')
case 'model_limits':
return t('Model Limits')
case 'allow_ips':
return t('IP Whitelist (supports CIDR)')
case 'auto_groups':
return t('Auto Group Chain')
case 'cross_group_retry':
return t('Cross-group retry')
case 'sourceId': case 'sourceId':
return t('Source ID') return t('Source ID')
case 'id': case 'id':
...@@ -105,13 +162,182 @@ export function auditFieldLabel(key: string, t: TFunction): string { ...@@ -105,13 +162,182 @@ export function auditFieldLabel(key: string, t: TFunction): string {
} }
} }
function buildTokenAuditOperation(
action: string,
params: Record<string, unknown>,
success: boolean,
t: TFunction
) {
const operation = TOKEN_AUDIT_OPERATIONS[action]
if (!operation) return null
const fields: AuditDetailField[] = []
let headline = t(operation.labelKey)
let summary = headline
let identifier = ''
let description = ''
if (operation.namedKey) {
const name =
typeof params.name === 'string' && params.name.trim() ? params.name : ''
let id = ''
if (typeof params.id === 'number' && Number.isFinite(params.id)) {
id = String(params.id)
} else if (typeof params.id === 'string' && params.id.trim()) {
id = params.id
}
if (name) {
headline = t(operation.namedKey, { name })
fields.push({ label: t('Token Name'), value: name })
}
summary = headline
if (id) {
identifier = t('(ID: {{id}})', { id })
summary = t('{{operation}} (ID: {{id}})', { operation: headline, id })
fields.push({ label: t('Token ID'), value: id, copyable: true })
}
if (!name && !id) {
headline = `${headline} · ${t('Target not recorded')}`
summary = headline
fields.push({ label: t('Target'), value: t('Target not recorded') })
}
}
if (success && action === 'token.update') {
const changed = params.changed_fields
description = t('Field change details were not recorded')
let changes = description
if (
Array.isArray(changed) &&
changed.every((field) => typeof field === 'string')
) {
changes = changed.length
? changed.map((field) => auditFieldLabel(field, t)).join(', ')
: t('No changes')
description = changed.length
? t('Changed fields: {{fields}}', { fields: changes })
: changes
}
fields.push({ label: t('Changed Fields'), value: changes })
}
if (success && action === 'token.status_update') {
const statuses: Record<string, string> = {
'1': t('Enabled'),
'2': t('Disabled'),
'3': t('Expired'),
'4': t('Exhausted'),
}
const states = [params.from, params.to].map((value) => {
if (typeof value !== 'number' && typeof value !== 'string') return ''
const status = String(value)
return statuses[status] || status
})
if (!states[0] && !states[1]) {
description = t('Field change details were not recorded')
} else if (states[0] && states[0] === states[1]) {
description = t('State unchanged: {{status}}', { status: states[0] })
} else {
description = `${states[0] || t('Not recorded')}${states[1] || t('Not recorded')}`
}
fields.push({ label: t('Status change'), value: description })
}
if (!operation.namedKey) {
const total =
typeof params.total === 'number' &&
Number.isFinite(params.total) &&
params.total >= 0
? params.total
: undefined
const processed =
success &&
typeof params.count === 'number' &&
Number.isFinite(params.count) &&
params.count >= 0
? params.count
: undefined
if (total !== undefined) {
description = t('Requested: {{total}}', { total })
fields.push({ label: t('Requested items'), value: total })
}
if (processed !== undefined) {
if (action === 'token.delete_batch') {
description =
total === undefined
? t('Deleted: {{processed}}', { processed })
: t('Requested: {{total}} · Deleted: {{processed}}', {
total,
processed,
})
fields.push({ label: t('Deleted tokens'), value: processed })
} else {
description =
total === undefined
? t('Returned: {{processed}}', { processed })
: t('Requested: {{total}} · Returned: {{processed}}', {
total,
processed,
})
fields.push({ label: t('Returned keys'), value: processed })
}
}
for (const key of ['requested_ids', 'returned_ids']) {
if (key === 'returned_ids' && !success) continue
const ids = params[key]
if (ids === undefined) continue
const valid =
Array.isArray(ids) &&
ids.every((id) => typeof id === 'number' || typeof id === 'string')
let value = t('Not recorded')
if (valid) value = ids.length ? ids.join(', ') : t('None')
fields.push({
label: auditFieldLabel(key, t),
value,
copyable: valid && ids.length > 0,
})
if (
key === 'requested_ids' &&
valid &&
params.requested_ids_truncated === true
) {
const note =
total === undefined
? t('Only the first {{shown}} IDs were recorded', {
shown: ids.length,
})
: t(
'Only the first {{shown}} IDs were recorded ({{total}} requested)',
{ shown: ids.length, total }
)
fields.push({ label: t('Note'), value: note })
}
}
}
return { headline, summary, identifier, description, fields }
}
export function buildAuditDetails(entry: AuditLog, t: TFunction) { export function buildAuditDetails(entry: AuditLog, t: TFunction) {
const metadata = isAuditDetailObject(entry.other) ? entry.other : {} const metadata = isAuditDetailObject(entry.other) ? entry.other : {}
const metadataUnavailable = const metadataUnavailable =
entry.other != null && !isAuditDetailObject(entry.other) entry.other != null && !isAuditDetailObject(entry.other)
const op = isAuditDetailObject(metadata.op) ? metadata.op : {} const op = isAuditDetailObject(metadata.op) ? metadata.op : {}
const action = typeof op.action === 'string' ? op.action : entry.action const action = typeof op.action === 'string' ? op.action : entry.action || ''
const params = isAuditDetailObject(op.params) ? { ...op.params } : {} const params = isAuditDetailObject(op.params) ? { ...op.params } : {}
const tokenOperation = buildTokenAuditOperation(
action,
params,
entry.success,
t
)
const quotaOperation = buildQuotaAuditOperation(
action,
params,
entry.success,
t
)
const operation = tokenOperation ?? quotaOperation
const summaryParams: NonNullable<NonNullable<LogOtherData['op']>['params']> = const summaryParams: NonNullable<NonNullable<LogOtherData['op']>['params']> =
{} {}
for (const [key, value] of Object.entries(params)) { for (const [key, value] of Object.entries(params)) {
...@@ -189,18 +415,20 @@ export function buildAuditDetails(entry: AuditLog, t: TFunction) { ...@@ -189,18 +415,20 @@ export function buildAuditDetails(entry: AuditLog, t: TFunction) {
delete params.username delete params.username
} }
const fields: { label: string; value: unknown }[] = [] const fields: AuditDetailField[] = []
if ( if (
Array.isArray(params.changed_fields) && Array.isArray(params.changed_fields) &&
params.changed_fields.every((field) => typeof field === 'string') params.changed_fields.every((field) => typeof field === 'string')
) { ) {
let changes = t('Field change details were not recorded')
if (params.changed_fields.length) {
changes = params.changed_fields
.map((field) => auditFieldLabel(field, t))
.join(', ')
}
fields.push({ fields.push({
label: t('Changed Fields'), label: t('Changed Fields'),
value: params.changed_fields.length value: changes,
? params.changed_fields
.map((field) => auditFieldLabel(String(field), t))
.join(', ')
: t('Field change details were not recorded'),
}) })
delete params.changed_fields delete params.changed_fields
} }
...@@ -267,7 +495,10 @@ export function buildAuditDetails(entry: AuditLog, t: TFunction) { ...@@ -267,7 +495,10 @@ export function buildAuditDetails(entry: AuditLog, t: TFunction) {
if (Object.keys(auditExtra).length) extra.audit_info = auditExtra if (Object.keys(auditExtra).length) extra.audit_info = auditExtra
} }
return { return {
summary, summary: operation?.summary ?? summary,
tokenOperation,
quotaOperation,
operation,
actor, actor,
actorRole, actorRole,
target, target,
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table'
import { act, render, screen, within } from '@testing-library/react'
import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next'
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from 'vitest'
import en from '@/i18n/locales/en.json'
import zh from '@/i18n/locales/zh.json'
import {
DEFAULT_CURRENCY_CONFIG,
useSystemConfigStore,
} from '@/stores/system-config-store'
import type { UsageLog } from '../../data/schema'
import { renderAuditContent } from '../../lib/format'
import type { LogOtherData } from '../../types'
import { useCommonLogsColumns } from '../columns/common-logs-columns'
import { DetailsDialog } from '../dialogs/details-dialog'
// Provider icons are unused by quota logs; their browser-only dependencies
// cannot be loaded by Vitest's Node ESM resolver.
vi.mock('@lobehub/icons', () => ({}))
vi.hoisted(() => {
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
})
})
afterAll(() => vi.unstubAllGlobals())
function QuotaLogPreview(props: { log: UsageLog }) {
const table = useReactTable({
data: [props.log],
columns: useCommonLogsColumns(false, false),
getCoreRowModel: getCoreRowModel(),
})
const cell = table
.getRowModel()
.rows[0].getAllCells()
.find((item) => item.column.id === 'content')
if (!cell) throw new Error('The quota log must have a content column')
return (
<>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
<DetailsDialog
log={props.log}
isAdmin={false}
isRoot={false}
open
onOpenChange={() => undefined}
/>
</>
)
}
const cases = [
{
action: 'user.quota_add',
params: {
target_user_id: 42,
target_username: 'quota-owner',
mode: 'add',
requested_quota: 500000,
quota: 500000,
from: 500000,
to: 1000000,
},
english:
'Increase quota for user “quota-owner” (ID: 42) · Requested quota: $1 · $1 → $2',
chinese: '增加用户「quota-owner」的额度(ID: 42) · 请求数额:$1 · $1 → $2',
},
{
action: 'user.quota_add',
params: { quota: 500000 },
english:
'Increase user quota · Target not recorded · Requested quota: $1 · Not recorded → Not recorded',
chinese: '增加用户额度 · 目标未记录 · 请求数额:$1 · 未记录 → 未记录',
},
{
action: 'user.quota_subtract',
params: { quota: 500000 },
english:
'Decrease user quota · Target not recorded · Requested quota: $1 · Not recorded → Not recorded',
chinese: '减少用户额度 · 目标未记录 · 请求数额:$1 · 未记录 → 未记录',
},
{
action: 'user.quota_override',
params: { from: 500000, to: 0 },
english:
'Override user quota · Target not recorded · Requested quota: $0 · $1 → $0',
chinese: '覆盖用户额度 · 目标未记录 · 请求数额:$0 · $1 → $0',
},
]
describe('quota adjustment log localization', () => {
const previousConfig = useSystemConfigStore.getState().config
let queryClient: QueryClient
beforeEach(() => {
useSystemConfigStore.getState().setConfig({
currency: { ...DEFAULT_CURRENCY_CONFIG },
})
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
queryClient.setQueryData(['status'], {}, { updatedAt: Date.now() + 60_000 })
})
afterEach(() => {
queryClient.clear()
useSystemConfigStore.getState().setConfig(previousConfig)
})
test.each(cases)(
'$action switches language in the preview and details',
async (scenario) => {
const i18n = createInstance()
await i18n.init({
lng: 'en',
fallbackLng: 'en',
resources: { en, zh },
interpolation: { escapeValue: false },
})
const log: UsageLog = {
id: 1,
user_id: 1,
created_at: 1,
type: 1,
content: 'English export fallback',
username: 'quota-user',
token_name: '',
model_name: '',
quota: 0,
prompt_tokens: 0,
completion_tokens: 0,
use_time: 0,
is_stream: false,
channel: 0,
channel_name: '',
token_id: 0,
group: '',
ip: '',
request_id: 'quota-request',
upstream_request_id: '',
other: JSON.stringify({
op: { action: scenario.action, params: scenario.params },
}),
}
render(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}>
<QuotaLogPreview log={log} />
</QueryClientProvider>
</I18nextProvider>
)
// The modal makes the table preview inert, but both remain rendered.
expect(screen.getAllByText(scenario.english)).toHaveLength(2)
expect(
within(screen.getByRole('dialog')).getByText(scenario.english)
).toBeInTheDocument()
await act(() => i18n.changeLanguage('zh'))
expect(screen.getAllByText(scenario.chinese)).toHaveLength(2)
expect(
within(screen.getByRole('dialog')).getByText(scenario.chinese)
).toBeInTheDocument()
expect(screen.queryByText('English export fallback')).toBeNull()
if ('target_user_id' in scenario.params) {
const dialog = within(screen.getByRole('dialog'))
expect(dialog.getByText('quota-owner')).toBeVisible()
expect(dialog.getByText('调整前额度')).toBeVisible()
expect(dialog.getByText('调整后额度')).toBeVisible()
}
}
)
test('preserves legacy formatted quota parameters and unknown-action fallback', async () => {
const i18n = createInstance()
await i18n.init({ lng: 'en', resources: { en } })
const other: LogOtherData = {
op: {
action: 'user.quota_add',
params: { quota: 'legacy formatted quota' },
},
}
expect(renderAuditContent(other, i18n.t)).toBe(
'Increase user quota · Target not recorded · Requested quota: legacy formatted quota · Not recorded → Not recorded'
)
expect(
renderAuditContent({ op: { action: 'unknown', params: {} } }, i18n.t)
).toBeNull()
expect(renderAuditContent({}, i18n.t)).toBeNull()
})
})
...@@ -124,9 +124,8 @@ function buildTypeDetailSegments( ...@@ -124,9 +124,8 @@ function buildTypeDetailSegments(
other: LogOtherData | null, other: LogOtherData | null,
t: (key: string, opts?: Record<string, unknown>) => string t: (key: string, opts?: Record<string, unknown>) => string
): DetailSegment[] { ): DetailSegment[] {
// Audit (type=3) and login (type=7) logs: render localized content from the // Top-up, audit, and login logs can carry a localized operation descriptor.
// structured op descriptor instead of the raw (English-fallback) content. if (log.type === 1 || log.type === 3 || log.type === 7) {
if (log.type === 3 || log.type === 7) {
const text = renderAuditContent(other, t) const text = renderAuditContent(other, t)
return text ? [{ text }] : [] return text ? [{ text }] : []
} }
...@@ -739,6 +738,7 @@ export function useCommonLogsColumns( ...@@ -739,6 +738,7 @@ export function useCommonLogsColumns(
accessorKey: 'content', accessorKey: 'content',
header: t('Details'), header: t('Details'),
cell: function DetailsCell({ row }) { cell: function DetailsCell({ row }) {
const { t } = useTranslation()
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const log = row.original const log = row.original
const other = parseLogOther(log.other) const other = parseLogOther(log.other)
......
...@@ -63,6 +63,7 @@ import { formatBillingCurrencyFromUSD } from '@/lib/currency' ...@@ -63,6 +63,7 @@ import { formatBillingCurrencyFromUSD } from '@/lib/currency'
import { formatLogQuota, formatTokens, formatUseTime } from '@/lib/format' import { formatLogQuota, formatTokens, formatUseTime } from '@/lib/format'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { AuditDetailFields } from '../../audit/components/audit-detail-fields'
import type { UsageLog } from '../../data/schema' import type { UsageLog } from '../../data/schema'
import { import {
parseLogOther, parseLogOther,
...@@ -77,6 +78,7 @@ import { ...@@ -77,6 +78,7 @@ import {
getReasoningEffortVariant, getReasoningEffortVariant,
renderAuditContent, renderAuditContent,
} from '../../lib/format' } from '../../lib/format'
import { buildQuotaAuditOperation } from '../../lib/quota-audit-operation'
import { import {
getLogTypeConfig, getLogTypeConfig,
isPerCallBilling, isPerCallBilling,
...@@ -441,7 +443,6 @@ interface DetailsDialogProps { ...@@ -441,7 +443,6 @@ interface DetailsDialogProps {
export function DetailsDialog(props: DetailsDialogProps) { export function DetailsDialog(props: DetailsDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false }) const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
const details = props.log.content ?? ''
const other = parseLogOther(props.log.other) const other = parseLogOther(props.log.other)
const typeConfig = getLogTypeConfig(props.log.type) const typeConfig = getLogTypeConfig(props.log.type)
...@@ -517,9 +518,17 @@ export function DetailsDialog(props: DetailsDialogProps) { ...@@ -517,9 +518,17 @@ export function DetailsDialog(props: DetailsDialogProps) {
return String(adminInfo.auth_method) return String(adminInfo.auth_method)
})() })()
// Localized operation text rendered from the language-independent op // Top-up, audit, and login logs share the language-independent descriptor.
// descriptor (shared by audit type=3 and login type=7). const quotaOperation = isTopup
? buildQuotaAuditOperation(
other?.op?.action ?? '',
other?.op?.params ?? {},
true,
t
)
: null
const operationText = renderAuditContent(other, t) const operationText = renderAuditContent(other, t)
const details = (isTopup ? operationText : null) ?? props.log.content ?? ''
const auditRoute = isManage && props.isAdmin ? other?.audit_info : undefined const auditRoute = isManage && props.isAdmin ? other?.audit_info : undefined
// Channel update records which fields changed (stable field tokens); render // Channel update records which fields changed (stable field tokens); render
// them with their localized labels for admins. // them with their localized labels for admins.
...@@ -919,6 +928,12 @@ export function DetailsDialog(props: DetailsDialogProps) { ...@@ -919,6 +928,12 @@ export function DetailsDialog(props: DetailsDialogProps) {
</DetailSection> </DetailSection>
)} )}
{quotaOperation && (
<DetailSection label={t('Quota adjustment details')}>
<AuditDetailFields fields={quotaOperation.fields} />
</DetailSection>
)}
{/* Manage operator (type=3, admin only) */} {/* Manage operator (type=3, admin only) */}
{manageOperator && ( {manageOperator && (
<DetailRow <DetailRow
......
...@@ -26,6 +26,7 @@ import { ...@@ -26,6 +26,7 @@ import {
import type { UsageLog } from '../data/schema' import type { UsageLog } from '../data/schema'
import type { LogOtherData } from '../types' import type { LogOtherData } from '../types'
import { buildQuotaAuditOperation } from './quota-audit-operation'
export { normalizeTierLabel } export { normalizeTierLabel }
...@@ -389,6 +390,13 @@ export function formatDuration( ...@@ -389,6 +390,13 @@ export function formatDuration(
* translatable instead of being frozen to whatever language was written to DB. * translatable instead of being frozen to whatever language was written to DB.
*/ */
const AUDIT_TEMPLATES: Record<string, string> = { const AUDIT_TEMPLATES: Record<string, string> = {
'token.create': 'API token creation',
'token.update': 'API token configuration update',
'token.status_update': 'API token status update',
'token.delete': 'API token deletion',
'token.delete_batch': 'API token batch deletion',
'token.key_view': 'API token key access',
'token.key_view_batch': 'API token batch key access',
'access_token.generate': 'Generated a system access token', 'access_token.generate': 'Generated a system access token',
'access_token.revoke': 'Revoked the system access token', 'access_token.revoke': 'Revoked the system access token',
'user.2fa_setup': 'Started two-factor authentication setup', 'user.2fa_setup': 'Started two-factor authentication setup',
...@@ -488,7 +496,7 @@ const AUDIT_TEMPLATES: Record<string, string> = { ...@@ -488,7 +496,7 @@ const AUDIT_TEMPLATES: Record<string, string> = {
} }
/** /**
* Render the localized content of an audit/login log from its structured * Render the localized content of an operation log from its structured
* `other.op` descriptor. Returns null when the log has no recognized action, * `other.op` descriptor. Returns null when the log has no recognized action,
* letting callers fall back to the raw `content` field. * letting callers fall back to the raw `content` field.
*/ */
...@@ -500,5 +508,15 @@ export function renderAuditContent( ...@@ -500,5 +508,15 @@ export function renderAuditContent(
if (!op?.action) return null if (!op?.action) return null
const template = AUDIT_TEMPLATES[op.action] const template = AUDIT_TEMPLATES[op.action]
if (!template) return null if (!template) return null
return t(template, (op.params ?? {}) as Record<string, unknown>) const quotaOperation = buildQuotaAuditOperation(
op.action,
op.params ?? {},
other?.audit_info?.success !== false,
t
)
if (quotaOperation) {
return `${quotaOperation.summary} · ${quotaOperation.description}`
}
const params = { ...op.params }
return t(template, params)
} }
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { formatLogQuota } from '@/lib/format'
type Translate = (key: string, opts?: Record<string, unknown>) => string
const QUOTA_OPERATIONS: Record<string, { label: string; named: string }> = {
'user.quota_add': {
label: 'Increase user quota',
named: 'Increase quota for user “{{name}}”',
},
'user.quota_subtract': {
label: 'Decrease user quota',
named: 'Decrease quota for user “{{name}}”',
},
'user.quota_override': {
label: 'Override user quota',
named: 'Override quota for user “{{name}}”',
},
}
function quotaText(value: unknown, t: Translate): string {
if (typeof value === 'number' && Number.isFinite(value)) {
return formatLogQuota(value)
}
if (typeof value === 'string' && value.trim()) return value
return t('Not recorded')
}
export function buildQuotaAuditOperation(
action: string,
params: Record<string, unknown>,
success: boolean,
t: Translate
) {
const unknownMode = action === 'generic' && params.action === 'add_quota'
const operation = unknownMode
? { label: 'Adjust user quota', named: 'Adjust quota for user “{{name}}”' }
: QUOTA_OPERATIONS[action]
if (!operation) return null
const name =
typeof params.target_username === 'string'
? params.target_username.trim()
: ''
let id = ''
if (
typeof params.target_user_id === 'number' &&
Number.isFinite(params.target_user_id)
) {
id = String(params.target_user_id)
} else if (typeof params.target_user_id === 'string') {
id = params.target_user_id.trim()
}
let headline = name ? t(operation.named, { name }) : t(operation.label)
if (!name && !id) headline = `${headline} · ${t('Target not recorded')}`
const identifier = id ? t('(ID: {{id}})', { id }) : ''
const summary = id
? t('{{operation}} (ID: {{id}})', { operation: headline, id })
: headline
let requested = params.requested_quota ?? params.quota
if (requested === undefined && success && action === 'user.quota_override') {
requested = params.to
}
const amount = quotaText(requested, t)
let description = t('Requested quota: {{quota}}', { quota: amount })
const fields: { label: string; value: string; copyable?: boolean }[] = [
{ label: t('Target username'), value: name || t('Not recorded') },
{ label: t('User ID'), value: id || t('Not recorded'), copyable: !!id },
{
label: t('Adjustment mode'),
value: unknownMode
? String(params.mode || t('Not recorded'))
: t(operation.label),
},
{ label: t('Requested quota'), value: amount },
]
if (success) {
const before = quotaText(params.from, t)
const after = quotaText(params.to, t)
const unchanged =
params.from !== undefined &&
params.from !== null &&
params.from !== '' &&
params.from === params.to &&
(typeof params.from === 'string' || typeof params.from === 'number')
let change = `${before}${after}`
if (unchanged) change = `${t('Quota unchanged')} · ${change}`
description = `${description} · ${change}`
fields.push(
{ label: t('Quota before adjustment'), value: before },
{ label: t('Quota after adjustment'), value: after }
)
} else {
const reasons: Record<string, string> = {
invalid_parameters: t('Invalid adjustment parameters'),
permission_denied: t('Insufficient permission to adjust this user'),
target_not_found: t('Target user not found'),
quota_limit_exceeded: t('Wallet quota limit exceeded'),
database_error: t('Quota update failed'),
}
const reason =
typeof params.failure_reason === 'string'
? reasons[params.failure_reason]
: undefined
if (reason) description = `${description} · ${reason}`
fields.push({
label: t('Failure reason'),
value: reason || t('Not recorded'),
})
}
return { headline, summary, identifier, description, fields }
}
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
"(billed as vip itself, so base ratio of vip)": "(billed as vip itself, so base ratio of vip)", "(billed as vip itself, so base ratio of vip)": "(billed as vip itself, so base ratio of vip)",
"(falls back to billing as vip, so base ratio of vip)": "(falls back to billing as vip, so base ratio of vip)", "(falls back to billing as vip, so base ratio of vip)": "(falls back to billing as vip, so base ratio of vip)",
"(hits the override rule above)": "(hits the override rule above)", "(hits the override rule above)": "(hits the override rule above)",
"(ID: {{id}})": "(ID: {{id}})",
"(instead of {{ratio}})": "(instead of {{ratio}})", "(instead of {{ratio}})": "(instead of {{ratio}})",
"(Leave empty to dissolve tag)": "(Leave empty to dissolve tag)", "(Leave empty to dissolve tag)": "(Leave empty to dissolve tag)",
"(matrix cell vip × premium is set)": "(matrix cell vip × premium is set)", "(matrix cell vip × premium is set)": "(matrix cell vip × premium is set)",
...@@ -69,6 +70,7 @@ ...@@ -69,6 +70,7 @@
"{{modality}} not supported": "{{modality}} not supported", "{{modality}} not supported": "{{modality}} not supported",
"{{modality}} supported": "{{modality}} supported", "{{modality}} supported": "{{modality}} supported",
"{{n}} model(s) selected": "{{n}} model(s) selected", "{{n}} model(s) selected": "{{n}} model(s) selected",
"{{operation}} (ID: {{id}})": "{{operation}} (ID: {{id}})",
"{{processed}} of {{total}} log entries processed.": "{{processed}} of {{total}} log entries processed.", "{{processed}} of {{total}} log entries processed.": "{{processed}} of {{total}} log entries processed.",
"{{protocol}} auth name": "{{protocol}} auth name", "{{protocol}} auth name": "{{protocol}} auth name",
"{{protocol}} auth value": "{{protocol}} auth value", "{{protocol}} auth value": "{{protocol}} auth value",
...@@ -261,8 +263,11 @@ ...@@ -261,8 +263,11 @@
"Additional verification required": "Additional verification required", "Additional verification required": "Additional verification required",
"Adjust filters, then search to refresh the logs.": "Adjust filters, then search to refresh the logs.", "Adjust filters, then search to refresh the logs.": "Adjust filters, then search to refresh the logs.",
"Adjust Quota": "Adjust Quota", "Adjust Quota": "Adjust Quota",
"Adjust quota for user “{{name}}”": "Adjust quota for user “{{name}}”",
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Adjust response formatting, prompt behavior, proxy, and upstream automation.", "Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Adjust response formatting, prompt behavior, proxy, and upstream automation.",
"Adjust the appearance and layout to suit your preferences.": "Adjust the appearance and layout to suit your preferences.", "Adjust the appearance and layout to suit your preferences.": "Adjust the appearance and layout to suit your preferences.",
"Adjust user quota": "Adjust user quota",
"Adjustment mode": "Adjustment mode",
"Admin": "Admin", "Admin": "Admin",
"Admin access required": "Admin access required", "Admin access required": "Admin access required",
"Admin area": "Admin area", "Admin area": "Admin area",
...@@ -439,7 +444,14 @@ ...@@ -439,7 +444,14 @@
"API Private Key": "API Private Key", "API Private Key": "API Private Key",
"API Requests": "API Requests", "API Requests": "API Requests",
"API secret": "API secret", "API secret": "API secret",
"API token batch deletion": "API token batch deletion",
"API token batch key access": "API token batch key access",
"API token configuration update": "API token configuration update",
"API token creation": "API token creation",
"API token deletion": "API token deletion",
"API token key access": "API token key access",
"API token management": "API token management", "API token management": "API token management",
"API token status update": "API token status update",
"API URL": "API URL", "API URL": "API URL",
"API usage records": "API usage records", "API usage records": "API usage records",
"API version": "API version", "API version": "API version",
...@@ -649,6 +661,7 @@ ...@@ -649,6 +661,7 @@
"Basic Templates": "Basic Templates", "Basic Templates": "Basic Templates",
"Batch Add (one key per line)": "Batch Add (one key per line)", "Batch Add (one key per line)": "Batch Add (one key per line)",
"Batch channel test": "Batch channel test", "Batch channel test": "Batch channel test",
"Batch delete API tokens": "Batch delete API tokens",
"Batch delete failed": "Batch delete failed", "Batch delete failed": "Batch delete failed",
"Batch deleted {{count}} channels": "Batch deleted {{count}} channels", "Batch deleted {{count}} channels": "Batch deleted {{count}} channels",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed", "Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed",
...@@ -807,6 +820,7 @@ ...@@ -807,6 +820,7 @@
"Change To": "Change To", "Change To": "Change To",
"Changed / Total": "Changed / Total", "Changed / Total": "Changed / Total",
"Changed Fields": "Changed Fields", "Changed Fields": "Changed Fields",
"Changed fields: {{fields}}": "Changed fields: {{fields}}",
"Changes are written to the settings draft on save.": "Changes are written to the settings draft on save.", "Changes are written to the settings draft on save.": "Changes are written to the settings draft on save.",
"Changing...": "Changing...", "Changing...": "Changing...",
"Channel": "Channel", "Channel": "Channel",
...@@ -1232,6 +1246,8 @@ ...@@ -1232,6 +1246,8 @@
"Create an API key to unlock the real request": "Create an API key to unlock the real request", "Create an API key to unlock the real request": "Create an API key to unlock the real request",
"Create and review invite or credit codes.": "Create and review invite or credit codes.", "Create and review invite or credit codes.": "Create and review invite or credit codes.",
"Create API Key": "Create API Key", "Create API Key": "Create API Key",
"Create API token": "Create API token",
"Create API token “{{name}}”": "Create API token “{{name}}”",
"Create cache": "Create cache", "Create cache": "Create cache",
"Create cache ratio": "Create cache ratio", "Create cache ratio": "Create cache ratio",
"Create Channel": "Create Channel", "Create Channel": "Create Channel",
...@@ -1357,6 +1373,8 @@ ...@@ -1357,6 +1373,8 @@
"decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.", "decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.",
"decides which channels are used and which base ratio applies.": "decides which channels are used and which base ratio applies.", "decides which channels are used and which base ratio applies.": "decides which channels are used and which base ratio applies.",
"Declared capabilities": "Declared capabilities", "Declared capabilities": "Declared capabilities",
"Decrease quota for user “{{name}}”": "Decrease quota for user “{{name}}”",
"Decrease user quota": "Decrease user quota",
"Decreased user quota by {{quota}}": "Decreased user quota by {{quota}}", "Decreased user quota by {{quota}}": "Decreased user quota by {{quota}}",
"Deducted by subscription": "Deducted by subscription", "Deducted by subscription": "Deducted by subscription",
"DeepSeek": "DeepSeek", "DeepSeek": "DeepSeek",
...@@ -1393,6 +1411,8 @@ ...@@ -1393,6 +1411,8 @@
"Delete All Disabled": "Delete All Disabled", "Delete All Disabled": "Delete All Disabled",
"Delete All Disabled Channels?": "Delete All Disabled Channels?", "Delete All Disabled Channels?": "Delete All Disabled Channels?",
"Delete all stale": "Delete all stale", "Delete all stale": "Delete all stale",
"Delete API token": "Delete API token",
"Delete API token “{{name}}”": "Delete API token “{{name}}”",
"Delete Auto-Disabled": "Delete Auto-Disabled", "Delete Auto-Disabled": "Delete Auto-Disabled",
"Delete Channel": "Delete Channel", "Delete Channel": "Delete Channel",
"Delete Channels?": "Delete Channels?", "Delete Channels?": "Delete Channels?",
...@@ -1439,7 +1459,9 @@ ...@@ -1439,7 +1459,9 @@
"Deleted invalid redemption codes": "Deleted invalid redemption codes", "Deleted invalid redemption codes": "Deleted invalid redemption codes",
"Deleted stale instance": "Deleted stale instance", "Deleted stale instance": "Deleted stale instance",
"Deleted successfully": "Deleted successfully", "Deleted successfully": "Deleted successfully",
"Deleted tokens": "Deleted tokens",
"Deleted user {{username}} (ID: {{id}})": "Deleted user {{username}} (ID: {{id}})", "Deleted user {{username}} (ID: {{id}})": "Deleted user {{username}} (ID: {{id}})",
"Deleted: {{processed}}": "Deleted: {{processed}}",
"Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.", "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.",
"Deleting will permanently remove this subscription record (including benefit details). Continue?": "Deleting will permanently remove this subscription record (including benefit details). Continue?", "Deleting will permanently remove this subscription record (including benefit details). Continue?": "Deleting will permanently remove this subscription record (including benefit details). Continue?",
"Deleting...": "Deleting...", "Deleting...": "Deleting...",
...@@ -2077,6 +2099,7 @@ ...@@ -2077,6 +2099,7 @@
"Failed to update tag": "Failed to update tag", "Failed to update tag": "Failed to update tag",
"Failed to update user": "Failed to update user", "Failed to update user": "Failed to update user",
"Failure keywords": "Failure keywords", "Failure keywords": "Failure keywords",
"Failure reason": "Failure reason",
"Fair": "Fair", "Fair": "Fair",
"Fallback": "Fallback", "Fallback": "Fallback",
"Fallback base URL": "Fallback base URL", "Fallback base URL": "Fallback base URL",
...@@ -2491,6 +2514,8 @@ ...@@ -2491,6 +2514,8 @@
"Incoming path must not include query": "Incoming path must not include query", "Incoming path must not include query": "Incoming path must not include query",
"Incoming path must start with /": "Incoming path must start with /", "Incoming path must start with /": "Incoming path must start with /",
"Incomplete": "Incomplete", "Incomplete": "Incomplete",
"Increase quota for user “{{name}}”": "Increase quota for user “{{name}}”",
"Increase user quota": "Increase user quota",
"Increased user quota by {{quota}}": "Increased user quota by {{quota}}", "Increased user quota by {{quota}}": "Increased user quota by {{quota}}",
"Index": "Index", "Index": "Index",
"Index request failed with HTTP {{status}}": "Index request failed with HTTP {{status}}", "Index request failed with HTTP {{status}}": "Index request failed with HTTP {{status}}",
...@@ -2524,6 +2549,7 @@ ...@@ -2524,6 +2549,7 @@
"Instance": "Instance", "Instance": "Instance",
"Instances": "Instances", "Instances": "Instances",
"Insufficient balance": "Insufficient balance", "Insufficient balance": "Insufficient balance",
"Insufficient permission to adjust this user": "Insufficient permission to adjust this user",
"Integrations": "Integrations", "Integrations": "Integrations",
"Integrity check failed": "Integrity check failed", "Integrity check failed": "Integrity check failed",
"Integrity hash": "Integrity hash", "Integrity hash": "Integrity hash",
...@@ -2535,6 +2561,7 @@ ...@@ -2535,6 +2561,7 @@
"Internal Server Error!": "Internal Server Error!", "Internal Server Error!": "Internal Server Error!",
"Interval must be at least 1 minute": "Interval must be at least 1 minute", "Interval must be at least 1 minute": "Interval must be at least 1 minute",
"Invalid (NaN)": "Invalid (NaN)", "Invalid (NaN)": "Invalid (NaN)",
"Invalid adjustment parameters": "Invalid adjustment parameters",
"Invalid chat link. Please contact the administrator.": "Invalid chat link. Please contact the administrator.", "Invalid chat link. Please contact the administrator.": "Invalid chat link. Please contact the administrator.",
"Invalid chat link. Please contact your administrator.": "Invalid chat link. Please contact your administrator.", "Invalid chat link. Please contact your administrator.": "Invalid chat link. Please contact your administrator.",
"Invalid code": "Invalid code", "Invalid code": "Invalid code",
...@@ -2899,6 +2926,7 @@ ...@@ -2899,6 +2926,7 @@
"Model fixed pricing": "Model fixed pricing", "Model fixed pricing": "Model fixed pricing",
"Model Group": "Model Group", "Model Group": "Model Group",
"Model Limits": "Model Limits", "Model Limits": "Model Limits",
"Model limits enabled": "Model limits enabled",
"Model List": "Model List", "Model List": "Model List",
"Model Mapping": "Model Mapping", "Model Mapping": "Model Mapping",
"Model Mapping (JSON)": "Model Mapping (JSON)", "Model Mapping (JSON)": "Model Mapping (JSON)",
...@@ -3273,6 +3301,7 @@ ...@@ -3273,6 +3301,7 @@
"Not included": "Not included", "Not included": "Not included",
"Not installed": "Not installed", "Not installed": "Not installed",
"Not provided by this source": "Not provided by this source", "Not provided by this source": "Not provided by this source",
"Not recorded": "Not recorded",
"Not registered": "Not registered", "Not registered": "Not registered",
"Not set": "Not set", "Not set": "Not set",
"Not Set": "Not Set", "Not Set": "Not Set",
...@@ -3365,6 +3394,8 @@ ...@@ -3365,6 +3394,8 @@
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.",
"Only successful requests": "Only successful requests", "Only successful requests": "Only successful requests",
"Only successful requests count toward this limit.": "Only successful requests count toward this limit.", "Only successful requests count toward this limit.": "Only successful requests count toward this limit.",
"Only the first {{shown}} IDs were recorded": "Only the first {{shown}} IDs were recorded",
"Only the first {{shown}} IDs were recorded ({{total}} requested)": "Only the first {{shown}} IDs were recorded ({{total}} requested)",
"Only the last {{value}} log files will be retained; the rest will be deleted.": "Only the last {{value}} log files will be retained; the rest will be deleted.", "Only the last {{value}} log files will be retained; the rest will be deleted.": "Only the last {{value}} log files will be retained; the rest will be deleted.",
"Only used to find historical logs. New records are available in Audit Logs.": "Only used to find historical logs. New records are available in Audit Logs.", "Only used to find historical logs. New records are available in Audit Logs.": "Only used to find historical logs. New records are available in Audit Logs.",
"Oops! Page Not Found!": "Oops! Page Not Found!", "Oops! Page Not Found!": "Oops! Page Not Found!",
...@@ -3466,6 +3497,7 @@ ...@@ -3466,6 +3497,7 @@
"Override": "Override", "Override": "Override",
"Override auto-discovered endpoint": "Override auto-discovered endpoint", "Override auto-discovered endpoint": "Override auto-discovered endpoint",
"Override matrix": "Override matrix", "Override matrix": "Override matrix",
"Override quota for user “{{name}}”": "Override quota for user “{{name}}”",
"Override request headers": "Override request headers", "Override request headers": "Override request headers",
"Override request headers (JSON format)": "Override request headers (JSON format)", "Override request headers (JSON format)": "Override request headers (JSON format)",
"Override request parameters": "Override request parameters", "Override request parameters": "Override request parameters",
...@@ -3475,6 +3507,7 @@ ...@@ -3475,6 +3507,7 @@
"Override rule: when a vip user is billed as premium, the ratio is 0.3 instead of 0.5": "Override rule: when a vip user is billed as premium, the ratio is 0.3 instead of 0.5", "Override rule: when a vip user is billed as premium, the ratio is 0.3 instead of 0.5": "Override rule: when a vip user is billed as premium, the ratio is 0.3 instead of 0.5",
"Override Rules": "Override Rules", "Override Rules": "Override Rules",
"Override the endpoint used for testing. Leave empty to auto detect.": "Override the endpoint used for testing. Leave empty to auto detect.", "Override the endpoint used for testing. Leave empty to auto detect.": "Override the endpoint used for testing. Leave empty to auto detect.",
"Override user quota": "Override user quota",
"overrides for matching model prefix.": "overrides for matching model prefix.", "overrides for matching model prefix.": "overrides for matching model prefix.",
"Overrode user quota from {{from}} to {{to}}": "Overrode user quota from {{from}} to {{to}}", "Overrode user quota from {{from}} to {{to}}": "Overrode user quota from {{from}} to {{to}}",
"Overview": "Overview", "Overview": "Overview",
...@@ -3890,6 +3923,9 @@ ...@@ -3890,6 +3923,9 @@
"Quota": "Quota", "Quota": "Quota",
"Quota ({{currency}})": "Quota ({{currency}})", "Quota ({{currency}})": "Quota ({{currency}})",
"Quota adjusted successfully": "Quota adjusted successfully", "Quota adjusted successfully": "Quota adjusted successfully",
"Quota adjustment details": "Quota adjustment details",
"Quota after adjustment": "Quota after adjustment",
"Quota before adjustment": "Quota before adjustment",
"Quota clamped": "Quota clamped", "Quota clamped": "Quota clamped",
"Quota consumed before charging users": "Quota consumed before charging users", "Quota consumed before charging users": "Quota consumed before charging users",
"Quota Distribution": "Quota Distribution", "Quota Distribution": "Quota Distribution",
...@@ -3905,6 +3941,8 @@ ...@@ -3905,6 +3941,8 @@
"Quota saturation protection triggered": "Quota saturation protection triggered", "Quota saturation protection triggered": "Quota saturation protection triggered",
"Quota Settings": "Quota Settings", "Quota Settings": "Quota Settings",
"Quota Types": "Quota Types", "Quota Types": "Quota Types",
"Quota unchanged": "Quota unchanged",
"Quota update failed": "Quota update failed",
"Quota Warning Threshold": "Quota Warning Threshold", "Quota Warning Threshold": "Quota Warning Threshold",
"Quota:": "Quota:", "Quota:": "Quota:",
"Radius": "Radius", "Radius": "Radius",
...@@ -4104,6 +4142,14 @@ ...@@ -4104,6 +4142,14 @@
"Request success rate; {{incidents}} incident buckets in the last 24 hours": "Request success rate; {{incidents}} incident buckets in the last 24 hours", "Request success rate; {{incidents}} incident buckets in the last 24 hours": "Request success rate; {{incidents}} incident buckets in the last 24 hours",
"Request timed out, please refresh and restart GitHub login": "Request timed out, please refresh and restart GitHub login", "Request timed out, please refresh and restart GitHub login": "Request timed out, please refresh and restart GitHub login",
"Request-based": "Request-based", "Request-based": "Request-based",
"Requested items": "Requested items",
"Requested quota": "Requested quota",
"Requested quota: {{quota}}": "Requested quota: {{quota}}",
"Requested token IDs": "Requested token IDs",
"Requested token IDs truncated": "Requested token IDs truncated",
"Requested: {{total}}": "Requested: {{total}}",
"Requested: {{total}} · Deleted: {{processed}}": "Requested: {{total}} · Deleted: {{processed}}",
"Requested: {{total}} · Returned: {{processed}}": "Requested: {{total}} · Returned: {{processed}}",
"Requests": "Requests", "Requests": "Requests",
"Requests (24h)": "Requests (24h)", "Requests (24h)": "Requests (24h)",
"Requests / 24h": "Requests / 24h", "Requests / 24h": "Requests / 24h",
...@@ -4194,6 +4240,9 @@ ...@@ -4194,6 +4240,9 @@
"Return to dashboard": "Return to dashboard", "Return to dashboard": "Return to dashboard",
"Return to the original window to continue.": "Return to the original window to continue.", "Return to the original window to continue.": "Return to the original window to continue.",
"Return vector embeddings for inputs": "Return vector embeddings for inputs", "Return vector embeddings for inputs": "Return vector embeddings for inputs",
"Returned keys": "Returned keys",
"Returned token IDs": "Returned token IDs",
"Returned: {{processed}}": "Returned: {{processed}}",
"Reveal API key": "Reveal API key", "Reveal API key": "Reveal API key",
"Reveal key": "Reveal key", "Reveal key": "Reveal key",
"Revenue": "Revenue", "Revenue": "Revenue",
...@@ -4627,6 +4676,7 @@ ...@@ -4627,6 +4676,7 @@
"Started two-factor authentication setup": "Started two-factor authentication setup", "Started two-factor authentication setup": "Started two-factor authentication setup",
"STARTTLS": "STARTTLS", "STARTTLS": "STARTTLS",
"State changed": "State changed", "State changed": "State changed",
"State unchanged: {{status}}": "State unchanged: {{status}}",
"Static page describing the platform.": "Static page describing the platform.", "Static page describing the platform.": "Static page describing the platform.",
"Statistical count": "Statistical count", "Statistical count": "Statistical count",
"Statistical quota": "Statistical quota", "Statistical quota": "Statistical quota",
...@@ -4634,6 +4684,7 @@ ...@@ -4634,6 +4684,7 @@
"Statistics reset": "Statistics reset", "Statistics reset": "Statistics reset",
"Status": "Status", "Status": "Status",
"Status & Sync": "Status & Sync", "Status & Sync": "Status & Sync",
"Status change": "Status change",
"Status Code": "Status Code", "Status Code": "Status Code",
"Status Code Mapping": "Status Code Mapping", "Status Code Mapping": "Status Code Mapping",
"Status code mapping must use valid HTTP status codes": "Status code mapping must use valid HTTP status codes", "Status code mapping must use valid HTTP status codes": "Status code mapping must use valid HTTP status codes",
...@@ -4781,8 +4832,11 @@ ...@@ -4781,8 +4832,11 @@
"Target Field Path": "Target Field Path", "Target Field Path": "Target Field Path",
"Target group": "Target group", "Target group": "Target group",
"Target Header": "Target Header", "Target Header": "Target Header",
"Target not recorded": "Target not recorded",
"Target Path (optional)": "Target Path (optional)", "Target Path (optional)": "Target Path (optional)",
"Target User": "Target User", "Target User": "Target User",
"Target user not found": "Target user not found",
"Target username": "Target username",
"Task": "Task", "Task": "Task",
"Task billing": "Task billing", "Task billing": "Task billing",
"Task Details": "Task Details", "Task Details": "Task Details",
...@@ -5056,6 +5110,7 @@ ...@@ -5056,6 +5110,7 @@
"Token estimator": "Token estimator", "Token estimator": "Token estimator",
"Token group": "Token group", "Token group": "Token group",
"Token has no group": "Token has no group", "Token has no group": "Token has no group",
"Token ID": "Token ID",
"Token identifier": "Token identifier", "Token identifier": "Token identifier",
"Token Limits": "Token Limits", "Token Limits": "Token Limits",
"Token management": "Token management", "Token management": "Token management",
...@@ -5063,6 +5118,7 @@ ...@@ -5063,6 +5118,7 @@
"Token Mgmt": "Token Mgmt", "Token Mgmt": "Token Mgmt",
"Token Name": "Token Name", "Token Name": "Token Name",
"Token obtained from your Gotify application": "Token obtained from your Gotify application", "Token obtained from your Gotify application": "Token obtained from your Gotify application",
"Token operation details": "Token operation details",
"Token price for audio input.": "Token price for audio input.", "Token price for audio input.": "Token price for audio input.",
"Token price for audio output.": "Token price for audio output.", "Token price for audio output.": "Token price for audio output.",
"Token price for cache reads.": "Token price for cache reads.", "Token price for cache reads.": "Token price for cache reads.",
...@@ -5245,6 +5301,8 @@ ...@@ -5245,6 +5301,8 @@
"Update": "Update", "Update": "Update",
"Update All Balances": "Update All Balances", "Update All Balances": "Update All Balances",
"Update API Key": "Update API Key", "Update API Key": "Update API Key",
"Update API token": "Update API token",
"Update API token “{{name}}”": "Update API token “{{name}}”",
"Update Balance": "Update Balance", "Update Balance": "Update Balance",
"Update balance for:": "Update balance for:", "Update balance for:": "Update balance for:",
"Update Channel": "Update Channel", "Update Channel": "Update Channel",
...@@ -5504,6 +5562,8 @@ ...@@ -5504,6 +5562,8 @@
"Vidu": "Vidu", "Vidu": "Vidu",
"View": "View", "View": "View",
"View all currently available models": "View all currently available models", "View all currently available models": "View all currently available models",
"View API token key": "View API token key",
"View API token keys in batch": "View API token keys in batch",
"View audit records from user and admin roles. Root records are always excluded.": "View audit records from user and admin roles. Root records are always excluded.", "View audit records from user and admin roles. Root records are always excluded.": "View audit records from user and admin roles. Root records are always excluded.",
"View channel lists and details without secrets.": "View channel lists and details without secrets.", "View channel lists and details without secrets.": "View channel lists and details without secrets.",
"View channel secrets": "View channel secrets", "View channel secrets": "View channel secrets",
...@@ -5511,6 +5571,7 @@ ...@@ -5511,6 +5571,7 @@
"View details": "View details", "View details": "View details",
"View document": "View document", "View document": "View document",
"View issued reset credits, grant dates, and expiration.": "View issued reset credits, grant dates, and expiration.", "View issued reset credits, grant dates, and expiration.": "View issued reset credits, grant dates, and expiration.",
"View key for API token “{{name}}”": "View key for API token “{{name}}”",
"View logs": "View logs", "View logs": "View logs",
"View mode": "View mode", "View mode": "View mode",
"View model statistics and charts": "View model statistics and charts", "View model statistics and charts": "View model statistics and charts",
...@@ -5566,6 +5627,7 @@ ...@@ -5566,6 +5627,7 @@
"Wallet Management": "Wallet Management", "Wallet Management": "Wallet Management",
"Wallet management and personal preferences.": "Wallet management and personal preferences.", "Wallet management and personal preferences.": "Wallet management and personal preferences.",
"Wallet Only": "Wallet Only", "Wallet Only": "Wallet Only",
"Wallet quota limit exceeded": "Wallet quota limit exceeded",
"Warning": "Warning", "Warning": "Warning",
"Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.": "Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.", "Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.": "Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.",
"Warning: Disabling 2FA will make your account less secure.": "Warning: Disabling 2FA will make your account less secure.", "Warning: Disabling 2FA will make your account less secure.": "Warning: Disabling 2FA will make your account less secure.",
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
"(billed as vip itself, so base ratio of vip)": "(facturé comme vip lui-même, donc taux de base de vip)", "(billed as vip itself, so base ratio of vip)": "(facturé comme vip lui-même, donc taux de base de vip)",
"(falls back to billing as vip, so base ratio of vip)": "(retombe sur la facturation vip, donc taux de base de vip)", "(falls back to billing as vip, so base ratio of vip)": "(retombe sur la facturation vip, donc taux de base de vip)",
"(hits the override rule above)": "(correspond à la règle de remplacement ci-dessus)", "(hits the override rule above)": "(correspond à la règle de remplacement ci-dessus)",
"(ID: {{id}})": "(ID : {{id}})",
"(instead of {{ratio}})": "(au lieu de {{ratio}})", "(instead of {{ratio}})": "(au lieu de {{ratio}})",
"(Leave empty to dissolve tag)": "(Laisser vide pour dissoudre le tag)", "(Leave empty to dissolve tag)": "(Laisser vide pour dissoudre le tag)",
"(matrix cell vip × premium is set)": "(la cellule vip × premium est définie)", "(matrix cell vip × premium is set)": "(la cellule vip × premium est définie)",
...@@ -69,6 +70,7 @@ ...@@ -69,6 +70,7 @@
"{{modality}} not supported": "{{modality}} non pris en charge", "{{modality}} not supported": "{{modality}} non pris en charge",
"{{modality}} supported": "{{modality}} pris en charge", "{{modality}} supported": "{{modality}} pris en charge",
"{{n}} model(s) selected": "{{n}} modèle(s) sélectionné(s)", "{{n}} model(s) selected": "{{n}} modèle(s) sélectionné(s)",
"{{operation}} (ID: {{id}})": "{{operation}} (ID : {{id}})",
"{{processed}} of {{total}} log entries processed.": "{{processed}} sur {{total}} entrées de journal traitées.", "{{processed}} of {{total}} log entries processed.": "{{processed}} sur {{total}} entrées de journal traitées.",
"{{protocol}} auth name": "Nom d’authentification {{protocol}}", "{{protocol}} auth name": "Nom d’authentification {{protocol}}",
"{{protocol}} auth value": "Valeur d’authentification {{protocol}}", "{{protocol}} auth value": "Valeur d’authentification {{protocol}}",
...@@ -261,8 +263,11 @@ ...@@ -261,8 +263,11 @@
"Additional verification required": "Vérification supplémentaire requise", "Additional verification required": "Vérification supplémentaire requise",
"Adjust filters, then search to refresh the logs.": "Ajustez les filtres, puis lancez la recherche pour actualiser les journaux.", "Adjust filters, then search to refresh the logs.": "Ajustez les filtres, puis lancez la recherche pour actualiser les journaux.",
"Adjust Quota": "Ajuster le quota", "Adjust Quota": "Ajuster le quota",
"Adjust quota for user “{{name}}”": "Modifier le quota de « {{name}} »",
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Ajustez le formatage des réponses, le comportement des prompts, le proxy et l’automatisation amont.", "Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Ajustez le formatage des réponses, le comportement des prompts, le proxy et l’automatisation amont.",
"Adjust the appearance and layout to suit your preferences.": "Ajustez l'apparence et la mise en page selon vos préférences.", "Adjust the appearance and layout to suit your preferences.": "Ajustez l'apparence et la mise en page selon vos préférences.",
"Adjust user quota": "Modifier le quota utilisateur",
"Adjustment mode": "Mode de modification",
"Admin": "Administrateur", "Admin": "Administrateur",
"Admin access required": "Accès administrateur requis", "Admin access required": "Accès administrateur requis",
"Admin area": "Espace administrateur", "Admin area": "Espace administrateur",
...@@ -439,7 +444,14 @@ ...@@ -439,7 +444,14 @@
"API Private Key": "Clé privée de l'API", "API Private Key": "Clé privée de l'API",
"API Requests": "Requêtes API", "API Requests": "Requêtes API",
"API secret": "Secret API", "API secret": "Secret API",
"API token batch deletion": "Suppression groupée de jetons API",
"API token batch key access": "Consultation groupée des clés de jetons API",
"API token configuration update": "Modification de la configuration du jeton API",
"API token creation": "Création de jeton API",
"API token deletion": "Suppression de jeton API",
"API token key access": "Consultation de la clé du jeton API",
"API token management": "Gestion des tokens API", "API token management": "Gestion des tokens API",
"API token status update": "Modification du statut du jeton API",
"API URL": "URL de l'API", "API URL": "URL de l'API",
"API usage records": "Historique d'utilisation de l'API", "API usage records": "Historique d'utilisation de l'API",
"API version": "Version API", "API version": "Version API",
...@@ -649,6 +661,7 @@ ...@@ -649,6 +661,7 @@
"Basic Templates": "Modèles de base", "Basic Templates": "Modèles de base",
"Batch Add (one key per line)": "Ajout par lots (une clé par ligne)", "Batch Add (one key per line)": "Ajout par lots (une clé par ligne)",
"Batch channel test": "Test groupé des canaux", "Batch channel test": "Test groupé des canaux",
"Batch delete API tokens": "Supprimer des jetons API par lot",
"Batch delete failed": "Échec de la suppression par lots", "Batch delete failed": "Échec de la suppression par lots",
"Batch deleted {{count}} channels": "{{count}} canaux supprimés par lot", "Batch deleted {{count}} channels": "{{count}} canaux supprimés par lot",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "Détection par lots terminée : {{channels}} canaux, {{add}} à ajouter, {{remove}} à supprimer, {{fails}} échoués", "Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "Détection par lots terminée : {{channels}} canaux, {{add}} à ajouter, {{remove}} à supprimer, {{fails}} échoués",
...@@ -807,6 +820,7 @@ ...@@ -807,6 +820,7 @@
"Change To": "Changer en", "Change To": "Changer en",
"Changed / Total": "Modifiés / Total", "Changed / Total": "Modifiés / Total",
"Changed Fields": "Champs modifiés", "Changed Fields": "Champs modifiés",
"Changed fields: {{fields}}": "Champs modifiés : {{fields}}",
"Changes are written to the settings draft on save.": "Les modifications sont écrites dans le brouillon des paramètres lors de l’enregistrement.", "Changes are written to the settings draft on save.": "Les modifications sont écrites dans le brouillon des paramètres lors de l’enregistrement.",
"Changing...": "Modification en cours...", "Changing...": "Modification en cours...",
"Channel": "Canal", "Channel": "Canal",
...@@ -1232,6 +1246,8 @@ ...@@ -1232,6 +1246,8 @@
"Create an API key to unlock the real request": "Créez une clé API pour débloquer la requête réelle", "Create an API key to unlock the real request": "Créez une clé API pour débloquer la requête réelle",
"Create and review invite or credit codes.": "Créer et examiner les codes d'invitation ou de crédit.", "Create and review invite or credit codes.": "Créer et examiner les codes d'invitation ou de crédit.",
"Create API Key": "Créer une clé API", "Create API Key": "Créer une clé API",
"Create API token": "Créer un jeton API",
"Create API token “{{name}}”": "Créer le jeton API « {{name}} »",
"Create cache": "Créer le cache", "Create cache": "Créer le cache",
"Create cache ratio": "Créer un ratio de cache", "Create cache ratio": "Créer un ratio de cache",
"Create Channel": "Créer un canal", "Create Channel": "Créer un canal",
...@@ -1357,6 +1373,8 @@ ...@@ -1357,6 +1373,8 @@
"decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "détermine le taux de recharge, les groupes que l’utilisateur peut choisir pour ses jetons, et si un taux de remplacement s’applique.", "decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "détermine le taux de recharge, les groupes que l’utilisateur peut choisir pour ses jetons, et si un taux de remplacement s’applique.",
"decides which channels are used and which base ratio applies.": "détermine les canaux utilisés et le taux de base appliqué.", "decides which channels are used and which base ratio applies.": "détermine les canaux utilisés et le taux de base appliqué.",
"Declared capabilities": "Capacités déclarées", "Declared capabilities": "Capacités déclarées",
"Decrease quota for user “{{name}}”": "Réduire le quota de « {{name}} »",
"Decrease user quota": "Réduire le quota utilisateur",
"Decreased user quota by {{quota}}": "Quota de l'utilisateur diminué de {{quota}}", "Decreased user quota by {{quota}}": "Quota de l'utilisateur diminué de {{quota}}",
"Deducted by subscription": "Déduit par abonnement", "Deducted by subscription": "Déduit par abonnement",
"DeepSeek": "DeepSeek", "DeepSeek": "DeepSeek",
...@@ -1393,6 +1411,8 @@ ...@@ -1393,6 +1411,8 @@
"Delete All Disabled": "Supprimer tout ce qui est désactivé", "Delete All Disabled": "Supprimer tout ce qui est désactivé",
"Delete All Disabled Channels?": "Supprimer tous les canaux désactivés ?", "Delete All Disabled Channels?": "Supprimer tous les canaux désactivés ?",
"Delete all stale": "Supprimer toutes les expirées", "Delete all stale": "Supprimer toutes les expirées",
"Delete API token": "Supprimer un jeton API",
"Delete API token “{{name}}”": "Supprimer le jeton API « {{name}} »",
"Delete Auto-Disabled": "Supprimer les désactivés automatiquement", "Delete Auto-Disabled": "Supprimer les désactivés automatiquement",
"Delete Channel": "Supprimer le canal", "Delete Channel": "Supprimer le canal",
"Delete Channels?": "Supprimer les canaux ?", "Delete Channels?": "Supprimer les canaux ?",
...@@ -1439,7 +1459,9 @@ ...@@ -1439,7 +1459,9 @@
"Deleted invalid redemption codes": "Codes de réduction invalides supprimés", "Deleted invalid redemption codes": "Codes de réduction invalides supprimés",
"Deleted stale instance": "Instance expirée supprimée", "Deleted stale instance": "Instance expirée supprimée",
"Deleted successfully": "Supprimé avec succès", "Deleted successfully": "Supprimé avec succès",
"Deleted tokens": "Jetons supprimés",
"Deleted user {{username}} (ID: {{id}})": "Utilisateur {{username}} supprimé (ID : {{id}})", "Deleted user {{username}} (ID: {{id}})": "Utilisateur {{username}} supprimé (ID : {{id}})",
"Deleted: {{processed}}": "Supprimés : {{processed}}",
"Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "La suppression de cette version personnalisée ne désactive pas la plateforme. Le plugin intégré du même nom sera restauré automatiquement.", "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "La suppression de cette version personnalisée ne désactive pas la plateforme. Le plugin intégré du même nom sera restauré automatiquement.",
"Deleting will permanently remove this subscription record (including benefit details). Continue?": "La suppression supprimera définitivement cet enregistrement d'abonnement (y compris les détails des avantages). Continuer ?", "Deleting will permanently remove this subscription record (including benefit details). Continue?": "La suppression supprimera définitivement cet enregistrement d'abonnement (y compris les détails des avantages). Continuer ?",
"Deleting...": "Suppression...", "Deleting...": "Suppression...",
...@@ -2077,6 +2099,7 @@ ...@@ -2077,6 +2099,7 @@
"Failed to update tag": "Échec de la mise à jour de l'étiquette", "Failed to update tag": "Échec de la mise à jour de l'étiquette",
"Failed to update user": "Échec de la mise à jour de l'utilisateur", "Failed to update user": "Échec de la mise à jour de l'utilisateur",
"Failure keywords": "Mots-clés d'échec", "Failure keywords": "Mots-clés d'échec",
"Failure reason": "Motif de l’échec",
"Fair": "Correct", "Fair": "Correct",
"Fallback": "Repli", "Fallback": "Repli",
"Fallback base URL": "Base URL de fallback", "Fallback base URL": "Base URL de fallback",
...@@ -2491,6 +2514,8 @@ ...@@ -2491,6 +2514,8 @@
"Incoming path must not include query": "Le chemin entrant ne doit pas inclure de query", "Incoming path must not include query": "Le chemin entrant ne doit pas inclure de query",
"Incoming path must start with /": "Le chemin entrant doit commencer par /", "Incoming path must start with /": "Le chemin entrant doit commencer par /",
"Incomplete": "Incomplet", "Incomplete": "Incomplet",
"Increase quota for user “{{name}}”": "Augmenter le quota de « {{name}} »",
"Increase user quota": "Augmenter le quota utilisateur",
"Increased user quota by {{quota}}": "Quota de l'utilisateur augmenté de {{quota}}", "Increased user quota by {{quota}}": "Quota de l'utilisateur augmenté de {{quota}}",
"Index": "Index", "Index": "Index",
"Index request failed with HTTP {{status}}": "Échec de la requête d’index : HTTP {{status}}", "Index request failed with HTTP {{status}}": "Échec de la requête d’index : HTTP {{status}}",
...@@ -2524,6 +2549,7 @@ ...@@ -2524,6 +2549,7 @@
"Instance": "Instance", "Instance": "Instance",
"Instances": "Instances", "Instances": "Instances",
"Insufficient balance": "Solde insuffisant", "Insufficient balance": "Solde insuffisant",
"Insufficient permission to adjust this user": "Droits insuffisants pour modifier ce quota",
"Integrations": "Intégrations", "Integrations": "Intégrations",
"Integrity check failed": "Échec du contrôle d’intégrité", "Integrity check failed": "Échec du contrôle d’intégrité",
"Integrity hash": "Empreinte d’intégrité", "Integrity hash": "Empreinte d’intégrité",
...@@ -2535,6 +2561,7 @@ ...@@ -2535,6 +2561,7 @@
"Internal Server Error!": "Erreur interne du serveur !", "Internal Server Error!": "Erreur interne du serveur !",
"Interval must be at least 1 minute": "L'intervalle doit être d'au moins 1 minute", "Interval must be at least 1 minute": "L'intervalle doit être d'au moins 1 minute",
"Invalid (NaN)": "Invalide (NaN)", "Invalid (NaN)": "Invalide (NaN)",
"Invalid adjustment parameters": "Paramètres de modification invalides",
"Invalid chat link. Please contact the administrator.": "Lien de chat invalide. Veuillez contacter l'administrateur.", "Invalid chat link. Please contact the administrator.": "Lien de chat invalide. Veuillez contacter l'administrateur.",
"Invalid chat link. Please contact your administrator.": "Lien de chat invalide. Veuillez contacter votre administrateur.", "Invalid chat link. Please contact your administrator.": "Lien de chat invalide. Veuillez contacter votre administrateur.",
"Invalid code": "Code invalide", "Invalid code": "Code invalide",
...@@ -2899,6 +2926,7 @@ ...@@ -2899,6 +2926,7 @@
"Model fixed pricing": "Tarification fixe du modèle", "Model fixed pricing": "Tarification fixe du modèle",
"Model Group": "Groupe de modèles", "Model Group": "Groupe de modèles",
"Model Limits": "Limites du modèle", "Model Limits": "Limites du modèle",
"Model limits enabled": "Restrictions de modèles activées",
"Model List": "Liste des modèles", "Model List": "Liste des modèles",
"Model Mapping": "Mappage de modèle", "Model Mapping": "Mappage de modèle",
"Model Mapping (JSON)": "Mappage de modèle (JSON)", "Model Mapping (JSON)": "Mappage de modèle (JSON)",
...@@ -3273,6 +3301,7 @@ ...@@ -3273,6 +3301,7 @@
"Not included": "Non inclus", "Not included": "Non inclus",
"Not installed": "Non installé", "Not installed": "Non installé",
"Not provided by this source": "Non fourni par cette source", "Not provided by this source": "Non fourni par cette source",
"Not recorded": "Non enregistré",
"Not registered": "Non enregistré", "Not registered": "Non enregistré",
"Not set": "Non défini", "Not set": "Non défini",
"Not Set": "Non défini", "Not Set": "Non défini",
...@@ -3365,6 +3394,8 @@ ...@@ -3365,6 +3394,8 @@
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Seuls les champs sélectionnés seront écrasés. Vous pouvez relancer l'assistant de synchronisation si de nouveaux conflits apparaissent.", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Seuls les champs sélectionnés seront écrasés. Vous pouvez relancer l'assistant de synchronisation si de nouveaux conflits apparaissent.",
"Only successful requests": "Uniquement les requêtes réussies", "Only successful requests": "Uniquement les requêtes réussies",
"Only successful requests count toward this limit.": "Seules les requêtes réussies comptent pour cette limite.", "Only successful requests count toward this limit.": "Seules les requêtes réussies comptent pour cette limite.",
"Only the first {{shown}} IDs were recorded": "Seuls les {{shown}} premiers ID ont été enregistrés",
"Only the first {{shown}} IDs were recorded ({{total}} requested)": "Seuls les {{shown}} premiers ID ont été enregistrés ({{total}} demandés)",
"Only the last {{value}} log files will be retained; the rest will be deleted.": "Seuls les {{value}} derniers fichiers journaux seront conservés ; le reste sera supprimé.", "Only the last {{value}} log files will be retained; the rest will be deleted.": "Seuls les {{value}} derniers fichiers journaux seront conservés ; le reste sera supprimé.",
"Only used to find historical logs. New records are available in Audit Logs.": "Uniquement pour consulter les anciens journaux. Les nouveaux événements figurent dans les journaux d’audit.", "Only used to find historical logs. New records are available in Audit Logs.": "Uniquement pour consulter les anciens journaux. Les nouveaux événements figurent dans les journaux d’audit.",
"Oops! Page Not Found!": "Oups ! Page introuvable !", "Oops! Page Not Found!": "Oups ! Page introuvable !",
...@@ -3466,6 +3497,7 @@ ...@@ -3466,6 +3497,7 @@
"Override": "Remplacer", "Override": "Remplacer",
"Override auto-discovered endpoint": "Remplacer le point de terminaison auto-découvert", "Override auto-discovered endpoint": "Remplacer le point de terminaison auto-découvert",
"Override matrix": "Matrice de remplacement", "Override matrix": "Matrice de remplacement",
"Override quota for user “{{name}}”": "Remplacer le quota de « {{name}} »",
"Override request headers": "Remplacer les en-têtes de requête", "Override request headers": "Remplacer les en-têtes de requête",
"Override request headers (JSON format)": "Surcharge des en-têtes de requête (format JSON)", "Override request headers (JSON format)": "Surcharge des en-têtes de requête (format JSON)",
"Override request parameters": "Remplacer les paramètres de requête", "Override request parameters": "Remplacer les paramètres de requête",
...@@ -3475,6 +3507,7 @@ ...@@ -3475,6 +3507,7 @@
"Override rule: when a vip user is billed as premium, the ratio is 0.3 instead of 0.5": "Règle de remplacement : quand un utilisateur vip est facturé sous premium, le taux est 0,3 au lieu de 0,5", "Override rule: when a vip user is billed as premium, the ratio is 0.3 instead of 0.5": "Règle de remplacement : quand un utilisateur vip est facturé sous premium, le taux est 0,3 au lieu de 0,5",
"Override Rules": "Règles de remplacement", "Override Rules": "Règles de remplacement",
"Override the endpoint used for testing. Leave empty to auto detect.": "Remplacer le point de terminaison utilisé pour les tests. Laisser vide pour la détection automatique.", "Override the endpoint used for testing. Leave empty to auto detect.": "Remplacer le point de terminaison utilisé pour les tests. Laisser vide pour la détection automatique.",
"Override user quota": "Remplacer le quota utilisateur",
"overrides for matching model prefix.": "remplace le tarif si le modèle a ce préfixe.", "overrides for matching model prefix.": "remplace le tarif si le modèle a ce préfixe.",
"Overrode user quota from {{from}} to {{to}}": "Quota de l'utilisateur remplacé de {{from}} à {{to}}", "Overrode user quota from {{from}} to {{to}}": "Quota de l'utilisateur remplacé de {{from}} à {{to}}",
"Overview": "Vue d'ensemble", "Overview": "Vue d'ensemble",
...@@ -3890,6 +3923,9 @@ ...@@ -3890,6 +3923,9 @@
"Quota": "Quota", "Quota": "Quota",
"Quota ({{currency}})": "Quota ({{currency}})", "Quota ({{currency}})": "Quota ({{currency}})",
"Quota adjusted successfully": "Quota ajusté avec succès", "Quota adjusted successfully": "Quota ajusté avec succès",
"Quota adjustment details": "Détails de la modification du quota",
"Quota after adjustment": "Quota après modification",
"Quota before adjustment": "Quota avant modification",
"Quota clamped": "Quota plafonné", "Quota clamped": "Quota plafonné",
"Quota consumed before charging users": "Quota consommé avant de facturer les utilisateurs", "Quota consumed before charging users": "Quota consommé avant de facturer les utilisateurs",
"Quota Distribution": "Distribution des quotas", "Quota Distribution": "Distribution des quotas",
...@@ -3905,6 +3941,8 @@ ...@@ -3905,6 +3941,8 @@
"Quota saturation protection triggered": "Protection contre la saturation du quota déclenchée", "Quota saturation protection triggered": "Protection contre la saturation du quota déclenchée",
"Quota Settings": "Paramètres de quota", "Quota Settings": "Paramètres de quota",
"Quota Types": "Types de quotas", "Quota Types": "Types de quotas",
"Quota unchanged": "Quota inchangé",
"Quota update failed": "Échec de la mise à jour du quota",
"Quota Warning Threshold": "Seuil d'avertissement de quota", "Quota Warning Threshold": "Seuil d'avertissement de quota",
"Quota:": "Quota :", "Quota:": "Quota :",
"Radius": "Rayon", "Radius": "Rayon",
...@@ -4104,6 +4142,14 @@ ...@@ -4104,6 +4142,14 @@
"Request success rate; {{incidents}} incident buckets in the last 24 hours": "Taux de réussite des requêtes ; {{incidents}} créneaux avec incident sur les dernières 24 heures", "Request success rate; {{incidents}} incident buckets in the last 24 hours": "Taux de réussite des requêtes ; {{incidents}} créneaux avec incident sur les dernières 24 heures",
"Request timed out, please refresh and restart GitHub login": "Délai dépassé, veuillez actualiser la page puis relancer la connexion GitHub", "Request timed out, please refresh and restart GitHub login": "Délai dépassé, veuillez actualiser la page puis relancer la connexion GitHub",
"Request-based": "Selon la requête", "Request-based": "Selon la requête",
"Requested items": "Éléments demandés",
"Requested quota": "Montant demandé",
"Requested quota: {{quota}}": "Montant demandé : {{quota}}",
"Requested token IDs": "ID des jetons demandés",
"Requested token IDs truncated": "Liste des ID demandés tronquée",
"Requested: {{total}}": "Demandés : {{total}}",
"Requested: {{total}} · Deleted: {{processed}}": "Demandés : {{total}} · Supprimés : {{processed}}",
"Requested: {{total}} · Returned: {{processed}}": "Demandés : {{total}} · Renvoyés : {{processed}}",
"Requests": "Requêtes", "Requests": "Requêtes",
"Requests (24h)": "Requêtes (24 h)", "Requests (24h)": "Requêtes (24 h)",
"Requests / 24h": "Requêtes / 24 h", "Requests / 24h": "Requêtes / 24 h",
...@@ -4194,6 +4240,9 @@ ...@@ -4194,6 +4240,9 @@
"Return to dashboard": "Retour au tableau de bord", "Return to dashboard": "Retour au tableau de bord",
"Return to the original window to continue.": "Revenez à la fenêtre d’origine pour continuer.", "Return to the original window to continue.": "Revenez à la fenêtre d’origine pour continuer.",
"Return vector embeddings for inputs": "Renvoyer des embeddings vectoriels pour les entrées", "Return vector embeddings for inputs": "Renvoyer des embeddings vectoriels pour les entrées",
"Returned keys": "Clés renvoyées",
"Returned token IDs": "ID des jetons renvoyés",
"Returned: {{processed}}": "Renvoyés : {{processed}}",
"Reveal API key": "Afficher la clé API", "Reveal API key": "Afficher la clé API",
"Reveal key": "Révéler la clé", "Reveal key": "Révéler la clé",
"Revenue": "Revenu", "Revenue": "Revenu",
...@@ -4627,6 +4676,7 @@ ...@@ -4627,6 +4676,7 @@
"Started two-factor authentication setup": "Configuration de l’authentification à deux facteurs commencée", "Started two-factor authentication setup": "Configuration de l’authentification à deux facteurs commencée",
"STARTTLS": "STARTTLS", "STARTTLS": "STARTTLS",
"State changed": "État modifié", "State changed": "État modifié",
"State unchanged: {{status}}": "Statut inchangé : {{status}}",
"Static page describing the platform.": "Page statique décrivant la plateforme.", "Static page describing the platform.": "Page statique décrivant la plateforme.",
"Statistical count": "Nombre statistique", "Statistical count": "Nombre statistique",
"Statistical quota": "Quota statistique", "Statistical quota": "Quota statistique",
...@@ -4634,6 +4684,7 @@ ...@@ -4634,6 +4684,7 @@
"Statistics reset": "Statistiques réinitialisées", "Statistics reset": "Statistiques réinitialisées",
"Status": "Statut", "Status": "Statut",
"Status & Sync": "Statut et synchronisation", "Status & Sync": "Statut et synchronisation",
"Status change": "Changement de statut",
"Status Code": "Code de statut", "Status Code": "Code de statut",
"Status Code Mapping": "Mappage des codes d'état", "Status Code Mapping": "Mappage des codes d'état",
"Status code mapping must use valid HTTP status codes": "Le mappage des codes d'état doit utiliser des codes d'état HTTP valides", "Status code mapping must use valid HTTP status codes": "Le mappage des codes d'état doit utiliser des codes d'état HTTP valides",
...@@ -4781,8 +4832,11 @@ ...@@ -4781,8 +4832,11 @@
"Target Field Path": "Chemin du champ cible", "Target Field Path": "Chemin du champ cible",
"Target group": "Groupe cible", "Target group": "Groupe cible",
"Target Header": "En-tête cible", "Target Header": "En-tête cible",
"Target not recorded": "Cible non enregistrée",
"Target Path (optional)": "Chemin cible (optionnel)", "Target Path (optional)": "Chemin cible (optionnel)",
"Target User": "Utilisateur cible", "Target User": "Utilisateur cible",
"Target user not found": "Utilisateur cible introuvable",
"Target username": "Utilisateur cible",
"Task": "Tâche", "Task": "Tâche",
"Task billing": "Facturation de tâche", "Task billing": "Facturation de tâche",
"Task Details": "Détails de la tâche", "Task Details": "Détails de la tâche",
...@@ -5056,6 +5110,7 @@ ...@@ -5056,6 +5110,7 @@
"Token estimator": "Estimation des jetons", "Token estimator": "Estimation des jetons",
"Token group": "Groupe de jetons", "Token group": "Groupe de jetons",
"Token has no group": "Jeton sans groupe", "Token has no group": "Jeton sans groupe",
"Token ID": "ID du jeton",
"Token identifier": "Identifiant du jeton", "Token identifier": "Identifiant du jeton",
"Token Limits": "Limites de jetons", "Token Limits": "Limites de jetons",
"Token management": "Gestion des jetons", "Token management": "Gestion des jetons",
...@@ -5063,6 +5118,7 @@ ...@@ -5063,6 +5118,7 @@
"Token Mgmt": "Gestion des jetons", "Token Mgmt": "Gestion des jetons",
"Token Name": "Nom du jeton", "Token Name": "Nom du jeton",
"Token obtained from your Gotify application": "Jeton obtenu depuis votre application Gotify", "Token obtained from your Gotify application": "Jeton obtenu depuis votre application Gotify",
"Token operation details": "Détails de l’opération sur les jetons",
"Token price for audio input.": "Prix par token pour l’entrée audio.", "Token price for audio input.": "Prix par token pour l’entrée audio.",
"Token price for audio output.": "Prix par token pour la sortie audio.", "Token price for audio output.": "Prix par token pour la sortie audio.",
"Token price for cache reads.": "Prix par token pour les lectures du cache.", "Token price for cache reads.": "Prix par token pour les lectures du cache.",
...@@ -5245,6 +5301,8 @@ ...@@ -5245,6 +5301,8 @@
"Update": "Mettre à jour", "Update": "Mettre à jour",
"Update All Balances": "Mettre à jour tous les soldes", "Update All Balances": "Mettre à jour tous les soldes",
"Update API Key": "Mettre à jour la clé API", "Update API Key": "Mettre à jour la clé API",
"Update API token": "Modifier un jeton API",
"Update API token “{{name}}”": "Modifier le jeton API « {{name}} »",
"Update Balance": "Mettre à jour le solde", "Update Balance": "Mettre à jour le solde",
"Update balance for:": "Mise à jour du solde pour :", "Update balance for:": "Mise à jour du solde pour :",
"Update Channel": "Mettre à jour le canal", "Update Channel": "Mettre à jour le canal",
...@@ -5504,6 +5562,8 @@ ...@@ -5504,6 +5562,8 @@
"Vidu": "Vidu", "Vidu": "Vidu",
"View": "Afficher", "View": "Afficher",
"View all currently available models": "Voir tous les modèles actuellement disponibles", "View all currently available models": "Voir tous les modèles actuellement disponibles",
"View API token key": "Consulter la clé d’un jeton API",
"View API token keys in batch": "Consulter les clés de jetons API par lot",
"View audit records from user and admin roles. Root records are always excluded.": "Consulter les audits des rôles user et admin. Les événements du rôle root sont toujours exclus.", "View audit records from user and admin roles. Root records are always excluded.": "Consulter les audits des rôles user et admin. Les événements du rôle root sont toujours exclus.",
"View channel lists and details without secrets.": "Afficher les listes et détails des canaux sans secrets.", "View channel lists and details without secrets.": "Afficher les listes et détails des canaux sans secrets.",
"View channel secrets": "Voir les secrets des canaux", "View channel secrets": "Voir les secrets des canaux",
...@@ -5511,6 +5571,7 @@ ...@@ -5511,6 +5571,7 @@
"View details": "Voir les détails", "View details": "Voir les détails",
"View document": "Afficher le document", "View document": "Afficher le document",
"View issued reset credits, grant dates, and expiration.": "Voir les crédits de réinitialisation émis, les dates d'attribution et d'expiration.", "View issued reset credits, grant dates, and expiration.": "Voir les crédits de réinitialisation émis, les dates d'attribution et d'expiration.",
"View key for API token “{{name}}”": "Consulter la clé du jeton API « {{name}} »",
"View logs": "Voir les logs", "View logs": "Voir les logs",
"View mode": "Mode d'affichage", "View mode": "Mode d'affichage",
"View model statistics and charts": "Afficher les statistiques et graphiques des modèles", "View model statistics and charts": "Afficher les statistiques et graphiques des modèles",
...@@ -5566,6 +5627,7 @@ ...@@ -5566,6 +5627,7 @@
"Wallet Management": "Gestion du portefeuille", "Wallet Management": "Gestion du portefeuille",
"Wallet management and personal preferences.": "Gestion du portefeuille et préférences personnelles.", "Wallet management and personal preferences.": "Gestion du portefeuille et préférences personnelles.",
"Wallet Only": "Portefeuille uniquement", "Wallet Only": "Portefeuille uniquement",
"Wallet quota limit exceeded": "Limite du quota du portefeuille dépassée",
"Warning": "Avertissement", "Warning": "Avertissement",
"Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.": "Avertissement : L'URL de base ne doit pas se terminer par /v1. La nouvelle API le gérera automatiquement. Cela peut causer des échecs de requêtes.", "Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.": "Avertissement : L'URL de base ne doit pas se terminer par /v1. La nouvelle API le gérera automatiquement. Cela peut causer des échecs de requêtes.",
"Warning: Disabling 2FA will make your account less secure.": "Avertissement : La désactivation de la 2FA rendra votre compte moins sécurisé.", "Warning: Disabling 2FA will make your account less secure.": "Avertissement : La désactivation de la 2FA rendra votre compte moins sécurisé.",
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
"(billed as vip itself, so base ratio of vip)": "(vip 自体として課金されるため、vip の基本倍率)", "(billed as vip itself, so base ratio of vip)": "(vip 自体として課金されるため、vip の基本倍率)",
"(falls back to billing as vip, so base ratio of vip)": "(vip としての課金にフォールバックし、vip の基本倍率を使用)", "(falls back to billing as vip, so base ratio of vip)": "(vip としての課金にフォールバックし、vip の基本倍率を使用)",
"(hits the override rule above)": "(上の上書きルールに一致)", "(hits the override rule above)": "(上の上書きルールに一致)",
"(ID: {{id}})": "(ID: {{id}})",
"(instead of {{ratio}})": "(本来は {{ratio}})", "(instead of {{ratio}})": "(本来は {{ratio}})",
"(Leave empty to dissolve tag)": "(タグを解除するには空欄のままにしてください)", "(Leave empty to dissolve tag)": "(タグを解除するには空欄のままにしてください)",
"(matrix cell vip × premium is set)": "(マトリクスのセル vip × premium が設定済み)", "(matrix cell vip × premium is set)": "(マトリクスのセル vip × premium が設定済み)",
...@@ -69,6 +70,7 @@ ...@@ -69,6 +70,7 @@
"{{modality}} not supported": "{{modality}} はサポートされていません", "{{modality}} not supported": "{{modality}} はサポートされていません",
"{{modality}} supported": "{{modality}} をサポート", "{{modality}} supported": "{{modality}} をサポート",
"{{n}} model(s) selected": "{{n}} 件のモデルを選択済み", "{{n}} model(s) selected": "{{n}} 件のモデルを選択済み",
"{{operation}} (ID: {{id}})": "{{operation}}(ID: {{id}})",
"{{processed}} of {{total}} log entries processed.": "{{total}} 件中 {{processed}} 件のログを処理しました。", "{{processed}} of {{total}} log entries processed.": "{{total}} 件中 {{processed}} 件のログを処理しました。",
"{{protocol}} auth name": "{{protocol}} 認証名", "{{protocol}} auth name": "{{protocol}} 認証名",
"{{protocol}} auth value": "{{protocol}} 認証値", "{{protocol}} auth value": "{{protocol}} 認証値",
...@@ -261,8 +263,11 @@ ...@@ -261,8 +263,11 @@
"Additional verification required": "追加の確認が必要です", "Additional verification required": "追加の確認が必要です",
"Adjust filters, then search to refresh the logs.": "フィルターを調整してから検索し、ログを更新します。", "Adjust filters, then search to refresh the logs.": "フィルターを調整してから検索し、ログを更新します。",
"Adjust Quota": "クォータを調整", "Adjust Quota": "クォータを調整",
"Adjust quota for user “{{name}}”": "ユーザー「{{name}}」のクォータを調整",
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "レスポンス形式、プロンプト動作、プロキシ、上流自動化を調整します。", "Adjust response formatting, prompt behavior, proxy, and upstream automation.": "レスポンス形式、プロンプト動作、プロキシ、上流自動化を調整します。",
"Adjust the appearance and layout to suit your preferences.": "好みに合わせて外観とレイアウトを調整します。", "Adjust the appearance and layout to suit your preferences.": "好みに合わせて外観とレイアウトを調整します。",
"Adjust user quota": "ユーザーのクォータを調整",
"Adjustment mode": "調整方法",
"Admin": "管理者", "Admin": "管理者",
"Admin access required": "管理者アクセスが必要です", "Admin access required": "管理者アクセスが必要です",
"Admin area": "管理者エリア", "Admin area": "管理者エリア",
...@@ -439,7 +444,14 @@ ...@@ -439,7 +444,14 @@
"API Private Key": "API 秘密鍵", "API Private Key": "API 秘密鍵",
"API Requests": "APIリクエスト", "API Requests": "APIリクエスト",
"API secret": "APIシークレット", "API secret": "APIシークレット",
"API token batch deletion": "API トークンの一括削除",
"API token batch key access": "API トークンキーの一括表示",
"API token configuration update": "API トークン設定の更新",
"API token creation": "API トークンの作成",
"API token deletion": "API トークンの削除",
"API token key access": "API トークンキーの表示",
"API token management": "APIトークン管理", "API token management": "APIトークン管理",
"API token status update": "API トークンの状態更新",
"API URL": "API URL", "API URL": "API URL",
"API usage records": "API使用記録", "API usage records": "API使用記録",
"API version": "API バージョン", "API version": "API バージョン",
...@@ -649,6 +661,7 @@ ...@@ -649,6 +661,7 @@
"Basic Templates": "基本テンプレート", "Basic Templates": "基本テンプレート",
"Batch Add (one key per line)": "一括追加(1行に1つのキー)", "Batch Add (one key per line)": "一括追加(1行に1つのキー)",
"Batch channel test": "チャネル一括テスト", "Batch channel test": "チャネル一括テスト",
"Batch delete API tokens": "API トークンを一括削除",
"Batch delete failed": "一括削除に失敗しました", "Batch delete failed": "一括削除に失敗しました",
"Batch deleted {{count}} channels": "{{count}} 件のチャネルを一括削除しました", "Batch deleted {{count}} channels": "{{count}} 件のチャネルを一括削除しました",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "一括検出完了:{{channels}} チャネル、{{add}} 個追加、{{remove}} 個削除、{{fails}} 個失敗", "Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "一括検出完了:{{channels}} チャネル、{{add}} 個追加、{{remove}} 個削除、{{fails}} 個失敗",
...@@ -807,6 +820,7 @@ ...@@ -807,6 +820,7 @@
"Change To": "変更先", "Change To": "変更先",
"Changed / Total": "変更件数 / 合計", "Changed / Total": "変更件数 / 合計",
"Changed Fields": "変更されたフィールド", "Changed Fields": "変更されたフィールド",
"Changed fields: {{fields}}": "変更項目:{{fields}}",
"Changes are written to the settings draft on save.": "保存すると変更は設定ドラフトに書き込まれます。", "Changes are written to the settings draft on save.": "保存すると変更は設定ドラフトに書き込まれます。",
"Changing...": "変更中...", "Changing...": "変更中...",
"Channel": "チャネル", "Channel": "チャネル",
...@@ -1232,6 +1246,8 @@ ...@@ -1232,6 +1246,8 @@
"Create an API key to unlock the real request": "実際のリクエストを使うには API キーを作成してください", "Create an API key to unlock the real request": "実際のリクエストを使うには API キーを作成してください",
"Create and review invite or credit codes.": "招待コードまたはクレジットコードを作成および確認。", "Create and review invite or credit codes.": "招待コードまたはクレジットコードを作成および確認。",
"Create API Key": "APIキーを作成", "Create API Key": "APIキーを作成",
"Create API token": "API トークンを作成",
"Create API token “{{name}}”": "API トークン「{{name}}」を作成",
"Create cache": "キャッシュを作成", "Create cache": "キャッシュを作成",
"Create cache ratio": "キャッシュ倍率を作成", "Create cache ratio": "キャッシュ倍率を作成",
"Create Channel": "チャネルを作成", "Create Channel": "チャネルを作成",
...@@ -1357,6 +1373,8 @@ ...@@ -1357,6 +1373,8 @@
"decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "チャージ倍率、トークン作成時に選べるグループ、上書き倍率の適用有無を決めます。", "decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "チャージ倍率、トークン作成時に選べるグループ、上書き倍率の適用有無を決めます。",
"decides which channels are used and which base ratio applies.": "使用するチャネルと適用される基本倍率を決めます。", "decides which channels are used and which base ratio applies.": "使用するチャネルと適用される基本倍率を決めます。",
"Declared capabilities": "宣言された権限", "Declared capabilities": "宣言された権限",
"Decrease quota for user “{{name}}”": "ユーザー「{{name}}」のクォータを減額",
"Decrease user quota": "ユーザーのクォータを減額",
"Decreased user quota by {{quota}}": "ユーザーのクォータを {{quota}} 減らしました", "Decreased user quota by {{quota}}": "ユーザーのクォータを {{quota}} 減らしました",
"Deducted by subscription": "サブスクリプションで控除", "Deducted by subscription": "サブスクリプションで控除",
"DeepSeek": "DeepSeek", "DeepSeek": "DeepSeek",
...@@ -1393,6 +1411,8 @@ ...@@ -1393,6 +1411,8 @@
"Delete All Disabled": "すべての無効なものを削除", "Delete All Disabled": "すべての無効なものを削除",
"Delete All Disabled Channels?": "すべての無効なチャネルを削除しますか?", "Delete All Disabled Channels?": "すべての無効なチャネルを削除しますか?",
"Delete all stale": "期限切れをすべて削除", "Delete all stale": "期限切れをすべて削除",
"Delete API token": "API トークンを削除",
"Delete API token “{{name}}”": "API トークン「{{name}}」を削除",
"Delete Auto-Disabled": "自動無効化されたものを削除", "Delete Auto-Disabled": "自動無効化されたものを削除",
"Delete Channel": "チャネルを削除", "Delete Channel": "チャネルを削除",
"Delete Channels?": "チャネルを削除しますか?", "Delete Channels?": "チャネルを削除しますか?",
...@@ -1439,7 +1459,9 @@ ...@@ -1439,7 +1459,9 @@
"Deleted invalid redemption codes": "無効な引換コードを削除しました", "Deleted invalid redemption codes": "無効な引換コードを削除しました",
"Deleted stale instance": "期限切れインスタンスを削除しました", "Deleted stale instance": "期限切れインスタンスを削除しました",
"Deleted successfully": "削除しました", "Deleted successfully": "削除しました",
"Deleted tokens": "削除したトークン数",
"Deleted user {{username}} (ID: {{id}})": "ユーザー {{username}} を削除しました(ID: {{id}})", "Deleted user {{username}} (ID: {{id}})": "ユーザー {{username}} を削除しました(ID: {{id}})",
"Deleted: {{processed}}": "削除:{{processed}} 件",
"Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "このカスタムバージョンを削除してもプラットフォームは停止しません。同名の組み込みプラグインが自動的に復元されます。", "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "このカスタムバージョンを削除してもプラットフォームは停止しません。同名の組み込みプラグインが自動的に復元されます。",
"Deleting will permanently remove this subscription record (including benefit details). Continue?": "削除するとこのサブスクリプション記録(特典詳細を含む)が完全に削除されます。続行しますか?", "Deleting will permanently remove this subscription record (including benefit details). Continue?": "削除するとこのサブスクリプション記録(特典詳細を含む)が完全に削除されます。続行しますか?",
"Deleting...": "削除中...", "Deleting...": "削除中...",
...@@ -2077,6 +2099,7 @@ ...@@ -2077,6 +2099,7 @@
"Failed to update tag": "タグの更新に失敗しました", "Failed to update tag": "タグの更新に失敗しました",
"Failed to update user": "ユーザーの更新に失敗しました", "Failed to update user": "ユーザーの更新に失敗しました",
"Failure keywords": "失敗キーワード", "Failure keywords": "失敗キーワード",
"Failure reason": "失敗理由",
"Fair": "公平", "Fair": "公平",
"Fallback": "フォールバック", "Fallback": "フォールバック",
"Fallback base URL": "フォールバック Base URL", "Fallback base URL": "フォールバック Base URL",
...@@ -2491,6 +2514,8 @@ ...@@ -2491,6 +2514,8 @@
"Incoming path must not include query": "受信パスに query を含めることはできません", "Incoming path must not include query": "受信パスに query を含めることはできません",
"Incoming path must start with /": "受信パスは / で始める必要があります", "Incoming path must start with /": "受信パスは / で始める必要があります",
"Incomplete": "未完了", "Incomplete": "未完了",
"Increase quota for user “{{name}}”": "ユーザー「{{name}}」のクォータを増額",
"Increase user quota": "ユーザーのクォータを増額",
"Increased user quota by {{quota}}": "ユーザーのクォータを {{quota}} 増やしました", "Increased user quota by {{quota}}": "ユーザーのクォータを {{quota}} 増やしました",
"Index": "インデックス", "Index": "インデックス",
"Index request failed with HTTP {{status}}": "インデックスの取得に失敗しました(HTTP {{status}})", "Index request failed with HTTP {{status}}": "インデックスの取得に失敗しました(HTTP {{status}})",
...@@ -2524,6 +2549,7 @@ ...@@ -2524,6 +2549,7 @@
"Instance": "インスタンス", "Instance": "インスタンス",
"Instances": "インスタンス", "Instances": "インスタンス",
"Insufficient balance": "残高が不足しています", "Insufficient balance": "残高が不足しています",
"Insufficient permission to adjust this user": "このユーザーのクォータを調整する権限がありません",
"Integrations": "統合", "Integrations": "統合",
"Integrity check failed": "整合性チェックに失敗しました", "Integrity check failed": "整合性チェックに失敗しました",
"Integrity hash": "整合性ハッシュ", "Integrity hash": "整合性ハッシュ",
...@@ -2535,6 +2561,7 @@ ...@@ -2535,6 +2561,7 @@
"Internal Server Error!": "内部サーバーエラー!", "Internal Server Error!": "内部サーバーエラー!",
"Interval must be at least 1 minute": "間隔は1分以上にしてください", "Interval must be at least 1 minute": "間隔は1分以上にしてください",
"Invalid (NaN)": "無効 (NaN)", "Invalid (NaN)": "無効 (NaN)",
"Invalid adjustment parameters": "クォータ調整のパラメーターが無効です",
"Invalid chat link. Please contact the administrator.": "無効なチャットリンクです。管理者に連絡してください。", "Invalid chat link. Please contact the administrator.": "無効なチャットリンクです。管理者に連絡してください。",
"Invalid chat link. Please contact your administrator.": "無効なチャットリンクです。管理者に連絡してください。", "Invalid chat link. Please contact your administrator.": "無効なチャットリンクです。管理者に連絡してください。",
"Invalid code": "無効なコード", "Invalid code": "無効なコード",
...@@ -2899,6 +2926,7 @@ ...@@ -2899,6 +2926,7 @@
"Model fixed pricing": "モデルの固定価格設定", "Model fixed pricing": "モデルの固定価格設定",
"Model Group": "モデルグループ", "Model Group": "モデルグループ",
"Model Limits": "モデル制限", "Model Limits": "モデル制限",
"Model limits enabled": "モデル制限が有効",
"Model List": "モデル一覧", "Model List": "モデル一覧",
"Model Mapping": "モデルマッピング", "Model Mapping": "モデルマッピング",
"Model Mapping (JSON)": "モデルマッピング (JSON)", "Model Mapping (JSON)": "モデルマッピング (JSON)",
...@@ -3273,6 +3301,7 @@ ...@@ -3273,6 +3301,7 @@
"Not included": "未登録", "Not included": "未登録",
"Not installed": "未インストール", "Not installed": "未インストール",
"Not provided by this source": "このソースでは提供されていません", "Not provided by this source": "このソースでは提供されていません",
"Not recorded": "記録なし",
"Not registered": "未登録", "Not registered": "未登録",
"Not set": "未設定", "Not set": "未設定",
"Not Set": "未設定", "Not Set": "未設定",
...@@ -3365,6 +3394,8 @@ ...@@ -3365,6 +3394,8 @@
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "選択されたフィールドのみが上書きされます。新しい競合が発生した場合は、同期ウィザードを再実行できます。", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "選択されたフィールドのみが上書きされます。新しい競合が発生した場合は、同期ウィザードを再実行できます。",
"Only successful requests": "成功したリクエストのみ", "Only successful requests": "成功したリクエストのみ",
"Only successful requests count toward this limit.": "成功したリクエストのみがこの制限にカウントされます。", "Only successful requests count toward this limit.": "成功したリクエストのみがこの制限にカウントされます。",
"Only the first {{shown}} IDs were recorded": "先頭の {{shown}} 件の ID のみ記録されています",
"Only the first {{shown}} IDs were recorded ({{total}} requested)": "先頭の {{shown}} 件のみ記録されています(リクエスト:{{total}} 件)",
"Only the last {{value}} log files will be retained; the rest will be deleted.": "最新の{{value}}個のログファイルのみ保持され、残りは削除されます。", "Only the last {{value}} log files will be retained; the rest will be deleted.": "最新の{{value}}個のログファイルのみ保持され、残りは削除されます。",
"Only used to find historical logs. New records are available in Audit Logs.": "過去のログの検索専用です。新しい記録は監査ログで確認できます。", "Only used to find historical logs. New records are available in Audit Logs.": "過去のログの検索専用です。新しい記録は監査ログで確認できます。",
"Oops! Page Not Found!": "おっと!ページが見つかりません!", "Oops! Page Not Found!": "おっと!ページが見つかりません!",
...@@ -3466,6 +3497,7 @@ ...@@ -3466,6 +3497,7 @@
"Override": "上書き", "Override": "上書き",
"Override auto-discovered endpoint": "自動検出されたエンドポイントを上書きする", "Override auto-discovered endpoint": "自動検出されたエンドポイントを上書きする",
"Override matrix": "上書きマトリクス", "Override matrix": "上書きマトリクス",
"Override quota for user “{{name}}”": "ユーザー「{{name}}」のクォータを上書き",
"Override request headers": "リクエストヘッダーを上書きする", "Override request headers": "リクエストヘッダーを上書きする",
"Override request headers (JSON format)": "リクエストヘッダーのオーバーライド (JSON 形式)", "Override request headers (JSON format)": "リクエストヘッダーのオーバーライド (JSON 形式)",
"Override request parameters": "リクエストパラメータを上書き", "Override request parameters": "リクエストパラメータを上書き",
...@@ -3475,6 +3507,7 @@ ...@@ -3475,6 +3507,7 @@
"Override rule: when a vip user is billed as premium, the ratio is 0.3 instead of 0.5": "上書きルール:vip ユーザーが premium として課金される場合、倍率は 0.5 ではなく 0.3", "Override rule: when a vip user is billed as premium, the ratio is 0.3 instead of 0.5": "上書きルール:vip ユーザーが premium として課金される場合、倍率は 0.5 ではなく 0.3",
"Override Rules": "上書きルール", "Override Rules": "上書きルール",
"Override the endpoint used for testing. Leave empty to auto detect.": "テストに使用されるエンドポイントを上書きします。自動検出するには空のままにします。", "Override the endpoint used for testing. Leave empty to auto detect.": "テストに使用されるエンドポイントを上書きします。自動検出するには空のままにします。",
"Override user quota": "ユーザーのクォータを上書き",
"overrides for matching model prefix.": "は一致するモデル接頭辞に上書きします。", "overrides for matching model prefix.": "は一致するモデル接頭辞に上書きします。",
"Overrode user quota from {{from}} to {{to}}": "ユーザーのクォータを {{from}} から {{to}} に上書きしました", "Overrode user quota from {{from}} to {{to}}": "ユーザーのクォータを {{from}} から {{to}} に上書きしました",
"Overview": "概要", "Overview": "概要",
...@@ -3890,6 +3923,9 @@ ...@@ -3890,6 +3923,9 @@
"Quota": "クォータ", "Quota": "クォータ",
"Quota ({{currency}})": "クォータ ({{currency}})", "Quota ({{currency}})": "クォータ ({{currency}})",
"Quota adjusted successfully": "クォータの調整に成功しました", "Quota adjusted successfully": "クォータの調整に成功しました",
"Quota adjustment details": "クォータ調整の詳細",
"Quota after adjustment": "調整後のクォータ",
"Quota before adjustment": "調整前のクォータ",
"Quota clamped": "クォータ制限適用", "Quota clamped": "クォータ制限適用",
"Quota consumed before charging users": "ユーザーに請求する前に消費されるクォータ", "Quota consumed before charging users": "ユーザーに請求する前に消費されるクォータ",
"Quota Distribution": "クォータの分配", "Quota Distribution": "クォータの分配",
...@@ -3905,6 +3941,8 @@ ...@@ -3905,6 +3941,8 @@
"Quota saturation protection triggered": "クォータ飽和保護が作動しました", "Quota saturation protection triggered": "クォータ飽和保護が作動しました",
"Quota Settings": "クォータ設定", "Quota Settings": "クォータ設定",
"Quota Types": "クォータタイプ", "Quota Types": "クォータタイプ",
"Quota unchanged": "クォータの変更なし",
"Quota update failed": "クォータの更新に失敗しました",
"Quota Warning Threshold": "クォータ警告しきい値", "Quota Warning Threshold": "クォータ警告しきい値",
"Quota:": "クォータ:", "Quota:": "クォータ:",
"Radius": "角丸", "Radius": "角丸",
...@@ -4104,6 +4142,14 @@ ...@@ -4104,6 +4142,14 @@
"Request success rate; {{incidents}} incident buckets in the last 24 hours": "リクエスト成功率;過去 24 時間に {{incidents}} 個のインシデント時間枠", "Request success rate; {{incidents}} incident buckets in the last 24 hours": "リクエスト成功率;過去 24 時間に {{incidents}} 個のインシデント時間枠",
"Request timed out, please refresh and restart GitHub login": "タイムアウトしました。ページをリロードして GitHub ログインをやり直してください", "Request timed out, please refresh and restart GitHub login": "タイムアウトしました。ページをリロードして GitHub ログインをやり直してください",
"Request-based": "リクエスト条件あり", "Request-based": "リクエスト条件あり",
"Requested items": "リクエスト件数",
"Requested quota": "リクエスト額",
"Requested quota: {{quota}}": "リクエスト額:{{quota}}",
"Requested token IDs": "リクエストしたトークン ID",
"Requested token IDs truncated": "リクエストしたトークン ID の一覧は省略されています",
"Requested: {{total}}": "リクエスト:{{total}} 件",
"Requested: {{total}} · Deleted: {{processed}}": "リクエスト:{{total}} 件 · 削除:{{processed}} 件",
"Requested: {{total}} · Returned: {{processed}}": "リクエスト:{{total}} 件 · 返却:{{processed}} 件",
"Requests": "リクエスト", "Requests": "リクエスト",
"Requests (24h)": "リクエスト (24h)", "Requests (24h)": "リクエスト (24h)",
"Requests / 24h": "リクエスト / 24h", "Requests / 24h": "リクエスト / 24h",
...@@ -4194,6 +4240,9 @@ ...@@ -4194,6 +4240,9 @@
"Return to dashboard": "ダッシュボードに戻る", "Return to dashboard": "ダッシュボードに戻る",
"Return to the original window to continue.": "元のウィンドウに戻って続行してください。", "Return to the original window to continue.": "元のウィンドウに戻って続行してください。",
"Return vector embeddings for inputs": "入力に対してベクトル埋め込みを返却", "Return vector embeddings for inputs": "入力に対してベクトル埋め込みを返却",
"Returned keys": "返却したキー数",
"Returned token IDs": "返されたトークン ID",
"Returned: {{processed}}": "返却:{{processed}} 件",
"Reveal API key": "APIキーを表示", "Reveal API key": "APIキーを表示",
"Reveal key": "キーを表示", "Reveal key": "キーを表示",
"Revenue": "収益", "Revenue": "収益",
...@@ -4627,6 +4676,7 @@ ...@@ -4627,6 +4676,7 @@
"Started two-factor authentication setup": "二要素認証の設定を開始しました", "Started two-factor authentication setup": "二要素認証の設定を開始しました",
"STARTTLS": "STARTTLS", "STARTTLS": "STARTTLS",
"State changed": "状態の変更", "State changed": "状態の変更",
"State unchanged: {{status}}": "状態の変更なし:{{status}}",
"Static page describing the platform.": "プラットフォームを説明する静的ページ。", "Static page describing the platform.": "プラットフォームを説明する静的ページ。",
"Statistical count": "統計数", "Statistical count": "統計数",
"Statistical quota": "統計クォータ", "Statistical quota": "統計クォータ",
...@@ -4634,6 +4684,7 @@ ...@@ -4634,6 +4684,7 @@
"Statistics reset": "統計をリセットしました", "Statistics reset": "統計をリセットしました",
"Status": "ステータス", "Status": "ステータス",
"Status & Sync": "ステータスと同期", "Status & Sync": "ステータスと同期",
"Status change": "状態の変更",
"Status Code": "ステータスコード", "Status Code": "ステータスコード",
"Status Code Mapping": "ステータスコードマッピング", "Status Code Mapping": "ステータスコードマッピング",
"Status code mapping must use valid HTTP status codes": "ステータスコードマッピングには有効な HTTP ステータスコードを使用する必要があります", "Status code mapping must use valid HTTP status codes": "ステータスコードマッピングには有効な HTTP ステータスコードを使用する必要があります",
...@@ -4781,8 +4832,11 @@ ...@@ -4781,8 +4832,11 @@
"Target Field Path": "ターゲットフィールドパス", "Target Field Path": "ターゲットフィールドパス",
"Target group": "ターゲットグループ", "Target group": "ターゲットグループ",
"Target Header": "コピー先ヘッダー", "Target Header": "コピー先ヘッダー",
"Target not recorded": "対象の記録なし",
"Target Path (optional)": "ターゲットパス(任意)", "Target Path (optional)": "ターゲットパス(任意)",
"Target User": "対象ユーザー", "Target User": "対象ユーザー",
"Target user not found": "対象ユーザーが見つかりません",
"Target username": "対象ユーザー名",
"Task": "タスク", "Task": "タスク",
"Task billing": "タスク課金", "Task billing": "タスク課金",
"Task Details": "タスク詳細", "Task Details": "タスク詳細",
...@@ -5056,6 +5110,7 @@ ...@@ -5056,6 +5110,7 @@
"Token estimator": "トークン見積り", "Token estimator": "トークン見積り",
"Token group": "トークングループ", "Token group": "トークングループ",
"Token has no group": "トークンにグループなし", "Token has no group": "トークンにグループなし",
"Token ID": "トークン ID",
"Token identifier": "トークン識別子", "Token identifier": "トークン識別子",
"Token Limits": "トークン制限", "Token Limits": "トークン制限",
"Token management": "トークン管理", "Token management": "トークン管理",
...@@ -5063,6 +5118,7 @@ ...@@ -5063,6 +5118,7 @@
"Token Mgmt": "トークン管理", "Token Mgmt": "トークン管理",
"Token Name": "トークン名", "Token Name": "トークン名",
"Token obtained from your Gotify application": "Gotifyアプリケーションから取得したトークン", "Token obtained from your Gotify application": "Gotifyアプリケーションから取得したトークン",
"Token operation details": "トークン操作の詳細",
"Token price for audio input.": "音声入力のトークン価格。", "Token price for audio input.": "音声入力のトークン価格。",
"Token price for audio output.": "音声出力のトークン価格。", "Token price for audio output.": "音声出力のトークン価格。",
"Token price for cache reads.": "キャッシュ読み取りのトークン価格。", "Token price for cache reads.": "キャッシュ読み取りのトークン価格。",
...@@ -5245,6 +5301,8 @@ ...@@ -5245,6 +5301,8 @@
"Update": "更新", "Update": "更新",
"Update All Balances": "すべての残高を更新", "Update All Balances": "すべての残高を更新",
"Update API Key": "API キーを更新", "Update API Key": "API キーを更新",
"Update API token": "API トークンを更新",
"Update API token “{{name}}”": "API トークン「{{name}}」を更新",
"Update Balance": "残高を更新", "Update Balance": "残高を更新",
"Update balance for:": "残高を更新:", "Update balance for:": "残高を更新:",
"Update Channel": "チャネルを更新", "Update Channel": "チャネルを更新",
...@@ -5504,6 +5562,8 @@ ...@@ -5504,6 +5562,8 @@
"Vidu": "Vidu", "Vidu": "Vidu",
"View": "表示", "View": "表示",
"View all currently available models": "現在利用可能なすべてのモデルを表示", "View all currently available models": "現在利用可能なすべてのモデルを表示",
"View API token key": "API トークンキーを表示",
"View API token keys in batch": "API トークンキーを一括表示",
"View audit records from user and admin roles. Root records are always excluded.": "user と admin ロールの監査記録を表示します。root ロールの記録は常に除外されます。", "View audit records from user and admin roles. Root records are always excluded.": "user と admin ロールの監査記録を表示します。root ロールの記録は常に除外されます。",
"View channel lists and details without secrets.": "シークレットを含まないチャネル一覧と詳細を表示します。", "View channel lists and details without secrets.": "シークレットを含まないチャネル一覧と詳細を表示します。",
"View channel secrets": "チャンネルシークレットを表示", "View channel secrets": "チャンネルシークレットを表示",
...@@ -5511,6 +5571,7 @@ ...@@ -5511,6 +5571,7 @@
"View details": "詳細を表示", "View details": "詳細を表示",
"View document": "ドキュメントを表示", "View document": "ドキュメントを表示",
"View issued reset credits, grant dates, and expiration.": "発行済みリセット回数、付与日時、有効期限を表示します。", "View issued reset credits, grant dates, and expiration.": "発行済みリセット回数、付与日時、有効期限を表示します。",
"View key for API token “{{name}}”": "API トークン「{{name}}」のキーを表示",
"View logs": "ログを表示", "View logs": "ログを表示",
"View mode": "表示モード", "View mode": "表示モード",
"View model statistics and charts": "モデルの統計とグラフを表示", "View model statistics and charts": "モデルの統計とグラフを表示",
...@@ -5566,6 +5627,7 @@ ...@@ -5566,6 +5627,7 @@
"Wallet Management": "ウォレット管理", "Wallet Management": "ウォレット管理",
"Wallet management and personal preferences.": "ウォレット管理と個人設定。", "Wallet management and personal preferences.": "ウォレット管理と個人設定。",
"Wallet Only": "ウォレットのみ", "Wallet Only": "ウォレットのみ",
"Wallet quota limit exceeded": "ウォレットのクォータ範囲を超えています",
"Warning": "警告", "Warning": "警告",
"Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.": "警告: Base URL は /v1 で終わってはいけません。New API が自動的に処理します。これによりリクエストが失敗する可能性があります。", "Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.": "警告: Base URL は /v1 で終わってはいけません。New API が自動的に処理します。これによりリクエストが失敗する可能性があります。",
"Warning: Disabling 2FA will make your account less secure.": "警告: 2FAを無効にすると、アカウントのセキュリティが低下します。", "Warning: Disabling 2FA will make your account less secure.": "警告: 2FAを無効にすると、アカウントのセキュリティが低下します。",
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
"(billed as vip itself, so base ratio of vip)": "(тарифицируется как сама vip, поэтому базовый коэффициент vip)", "(billed as vip itself, so base ratio of vip)": "(тарифицируется как сама vip, поэтому базовый коэффициент vip)",
"(falls back to billing as vip, so base ratio of vip)": "(возврат к тарификации по vip, поэтому базовый коэффициент vip)", "(falls back to billing as vip, so base ratio of vip)": "(возврат к тарификации по vip, поэтому базовый коэффициент vip)",
"(hits the override rule above)": "(срабатывает правило переопределения выше)", "(hits the override rule above)": "(срабатывает правило переопределения выше)",
"(ID: {{id}})": "(ID: {{id}})",
"(instead of {{ratio}})": "(вместо {{ratio}})", "(instead of {{ratio}})": "(вместо {{ratio}})",
"(Leave empty to dissolve tag)": "(Оставьте пустым, чтобы удалить тег)", "(Leave empty to dissolve tag)": "(Оставьте пустым, чтобы удалить тег)",
"(matrix cell vip × premium is set)": "(ячейка матрицы vip × premium задана)", "(matrix cell vip × premium is set)": "(ячейка матрицы vip × premium задана)",
...@@ -69,6 +70,7 @@ ...@@ -69,6 +70,7 @@
"{{modality}} not supported": "{{modality}} не поддерживается", "{{modality}} not supported": "{{modality}} не поддерживается",
"{{modality}} supported": "{{modality}} поддерживается", "{{modality}} supported": "{{modality}} поддерживается",
"{{n}} model(s) selected": "Выбрано моделей: {{n}}", "{{n}} model(s) selected": "Выбрано моделей: {{n}}",
"{{operation}} (ID: {{id}})": "{{operation}} (ID: {{id}})",
"{{processed}} of {{total}} log entries processed.": "Обработано {{processed}} из {{total}} записей журнала.", "{{processed}} of {{total}} log entries processed.": "Обработано {{processed}} из {{total}} записей журнала.",
"{{protocol}} auth name": "Имя аутентификации {{protocol}}", "{{protocol}} auth name": "Имя аутентификации {{protocol}}",
"{{protocol}} auth value": "Значение аутентификации {{protocol}}", "{{protocol}} auth value": "Значение аутентификации {{protocol}}",
...@@ -261,8 +263,11 @@ ...@@ -261,8 +263,11 @@
"Additional verification required": "Требуется дополнительная проверка", "Additional verification required": "Требуется дополнительная проверка",
"Adjust filters, then search to refresh the logs.": "Настройте фильтры, затем выполните поиск, чтобы обновить журналы.", "Adjust filters, then search to refresh the logs.": "Настройте фильтры, затем выполните поиск, чтобы обновить журналы.",
"Adjust Quota": "Изменить квоту", "Adjust Quota": "Изменить квоту",
"Adjust quota for user “{{name}}”": "Изменить квоту пользователя «{{name}}»",
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Настройте форматирование ответов, поведение промпта, прокси и автоматизацию upstream.", "Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Настройте форматирование ответов, поведение промпта, прокси и автоматизацию upstream.",
"Adjust the appearance and layout to suit your preferences.": "Настройте внешний вид и макет в соответствии с вашими предпочтениями.", "Adjust the appearance and layout to suit your preferences.": "Настройте внешний вид и макет в соответствии с вашими предпочтениями.",
"Adjust user quota": "Изменить квоту пользователя",
"Adjustment mode": "Способ изменения",
"Admin": "Администратор", "Admin": "Администратор",
"Admin access required": "Требуется доступ администратора", "Admin access required": "Требуется доступ администратора",
"Admin area": "Область администратора", "Admin area": "Область администратора",
...@@ -439,7 +444,14 @@ ...@@ -439,7 +444,14 @@
"API Private Key": "Секретный ключ API", "API Private Key": "Секретный ключ API",
"API Requests": "Запросы API", "API Requests": "Запросы API",
"API secret": "Секрет API", "API secret": "Секрет API",
"API token batch deletion": "Массовое удаление токенов API",
"API token batch key access": "Массовый просмотр ключей токенов API",
"API token configuration update": "Изменение настроек токена API",
"API token creation": "Создание токена API",
"API token deletion": "Удаление токена API",
"API token key access": "Просмотр ключа токена API",
"API token management": "Управление API токенами", "API token management": "Управление API токенами",
"API token status update": "Изменение статуса токена API",
"API URL": "URL API", "API URL": "URL API",
"API usage records": "Записи использования API", "API usage records": "Записи использования API",
"API version": "Версия API", "API version": "Версия API",
...@@ -649,6 +661,7 @@ ...@@ -649,6 +661,7 @@
"Basic Templates": "Базовые шаблоны", "Basic Templates": "Базовые шаблоны",
"Batch Add (one key per line)": "Пакетное добавление (один ключ на строку)", "Batch Add (one key per line)": "Пакетное добавление (один ключ на строку)",
"Batch channel test": "Пакетное тестирование каналов", "Batch channel test": "Пакетное тестирование каналов",
"Batch delete API tokens": "Массовое удаление токенов API",
"Batch delete failed": "Пакетное удаление не удалось", "Batch delete failed": "Пакетное удаление не удалось",
"Batch deleted {{count}} channels": "Пакетно удалено каналов: {{count}}", "Batch deleted {{count}} channels": "Пакетно удалено каналов: {{count}}",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "Пакетное обнаружение завершено: {{channels}} каналов, {{add}} для добавления, {{remove}} для удаления, {{fails}} ошибок", "Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "Пакетное обнаружение завершено: {{channels}} каналов, {{add}} для добавления, {{remove}} для удаления, {{fails}} ошибок",
...@@ -807,6 +820,7 @@ ...@@ -807,6 +820,7 @@
"Change To": "Изменить на", "Change To": "Изменить на",
"Changed / Total": "Изменено / Всего", "Changed / Total": "Изменено / Всего",
"Changed Fields": "Изменённые поля", "Changed Fields": "Изменённые поля",
"Changed fields: {{fields}}": "Изменённые поля: {{fields}}",
"Changes are written to the settings draft on save.": "Изменения будут записаны в черновик настроек при сохранении.", "Changes are written to the settings draft on save.": "Изменения будут записаны в черновик настроек при сохранении.",
"Changing...": "Изменение...", "Changing...": "Изменение...",
"Channel": "Канал", "Channel": "Канал",
...@@ -1232,6 +1246,8 @@ ...@@ -1232,6 +1246,8 @@
"Create an API key to unlock the real request": "Создайте API-ключ, чтобы открыть реальный запрос", "Create an API key to unlock the real request": "Создайте API-ключ, чтобы открыть реальный запрос",
"Create and review invite or credit codes.": "Создать и просмотреть коды приглашений или кредитов.", "Create and review invite or credit codes.": "Создать и просмотреть коды приглашений или кредитов.",
"Create API Key": "Создать ключ API", "Create API Key": "Создать ключ API",
"Create API token": "Создание токена API",
"Create API token “{{name}}”": "Создание токена API «{{name}}»",
"Create cache": "Создать кеш", "Create cache": "Создать кеш",
"Create cache ratio": "Создать коэффициент кэширования", "Create cache ratio": "Создать коэффициент кэширования",
"Create Channel": "Создать канал", "Create Channel": "Создать канал",
...@@ -1357,6 +1373,8 @@ ...@@ -1357,6 +1373,8 @@
"decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "определяет коэффициент пополнения, какие группы пользователь может выбирать для токенов и применяется ли переопределение коэффициента.", "decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "определяет коэффициент пополнения, какие группы пользователь может выбирать для токенов и применяется ли переопределение коэффициента.",
"decides which channels are used and which base ratio applies.": "определяет используемые каналы и применяемый базовый коэффициент.", "decides which channels are used and which base ratio applies.": "определяет используемые каналы и применяемый базовый коэффициент.",
"Declared capabilities": "Заявленные возможности", "Declared capabilities": "Заявленные возможности",
"Decrease quota for user “{{name}}”": "Уменьшить квоту пользователя «{{name}}»",
"Decrease user quota": "Уменьшить квоту пользователя",
"Decreased user quota by {{quota}}": "Квота пользователя уменьшена на {{quota}}", "Decreased user quota by {{quota}}": "Квота пользователя уменьшена на {{quota}}",
"Deducted by subscription": "Списано по подписке", "Deducted by subscription": "Списано по подписке",
"DeepSeek": "DeepSeek", "DeepSeek": "DeepSeek",
...@@ -1393,6 +1411,8 @@ ...@@ -1393,6 +1411,8 @@
"Delete All Disabled": "Удалить все отключенные", "Delete All Disabled": "Удалить все отключенные",
"Delete All Disabled Channels?": "Удалить все отключенные каналы?", "Delete All Disabled Channels?": "Удалить все отключенные каналы?",
"Delete all stale": "Удалить все устаревшие", "Delete all stale": "Удалить все устаревшие",
"Delete API token": "Удаление токена API",
"Delete API token “{{name}}”": "Удаление токена API «{{name}}»",
"Delete Auto-Disabled": "Удалить автоматически отключенные", "Delete Auto-Disabled": "Удалить автоматически отключенные",
"Delete Channel": "Удалить канал", "Delete Channel": "Удалить канал",
"Delete Channels?": "Удалить каналы?", "Delete Channels?": "Удалить каналы?",
...@@ -1439,7 +1459,9 @@ ...@@ -1439,7 +1459,9 @@
"Deleted invalid redemption codes": "Недействительные коды погашения удалены", "Deleted invalid redemption codes": "Недействительные коды погашения удалены",
"Deleted stale instance": "Устаревший экземпляр удален", "Deleted stale instance": "Устаревший экземпляр удален",
"Deleted successfully": "Удалено успешно", "Deleted successfully": "Удалено успешно",
"Deleted tokens": "Удалённые токены",
"Deleted user {{username}} (ID: {{id}})": "Удалён пользователь {{username}} (ID: {{id}})", "Deleted user {{username}} (ID: {{id}})": "Удалён пользователь {{username}} (ID: {{id}})",
"Deleted: {{processed}}": "Удалено: {{processed}}",
"Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "Удаление этой пользовательской версии не отключит платформу. Одноимённый встроенный плагин будет восстановлен автоматически.", "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "Удаление этой пользовательской версии не отключит платформу. Одноимённый встроенный плагин будет восстановлен автоматически.",
"Deleting will permanently remove this subscription record (including benefit details). Continue?": "Удаление безвозвратно удалит запись подписки (включая детали льгот). Продолжить?", "Deleting will permanently remove this subscription record (including benefit details). Continue?": "Удаление безвозвратно удалит запись подписки (включая детали льгот). Продолжить?",
"Deleting...": "Удаление...", "Deleting...": "Удаление...",
...@@ -2077,6 +2099,7 @@ ...@@ -2077,6 +2099,7 @@
"Failed to update tag": "Не удалось обновить тег", "Failed to update tag": "Не удалось обновить тег",
"Failed to update user": "Не удалось обновить пользователя", "Failed to update user": "Не удалось обновить пользователя",
"Failure keywords": "Ключевые слова сбоя", "Failure keywords": "Ключевые слова сбоя",
"Failure reason": "Причина сбоя",
"Fair": "Удовлетворительно", "Fair": "Удовлетворительно",
"Fallback": "Резерв", "Fallback": "Резерв",
"Fallback base URL": "Base URL fallback", "Fallback base URL": "Base URL fallback",
...@@ -2491,6 +2514,8 @@ ...@@ -2491,6 +2514,8 @@
"Incoming path must not include query": "Входящий путь не должен содержать query", "Incoming path must not include query": "Входящий путь не должен содержать query",
"Incoming path must start with /": "Входящий путь должен начинаться с /", "Incoming path must start with /": "Входящий путь должен начинаться с /",
"Incomplete": "Не завершено", "Incomplete": "Не завершено",
"Increase quota for user “{{name}}”": "Увеличить квоту пользователя «{{name}}»",
"Increase user quota": "Увеличить квоту пользователя",
"Increased user quota by {{quota}}": "Квота пользователя увеличена на {{quota}}", "Increased user quota by {{quota}}": "Квота пользователя увеличена на {{quota}}",
"Index": "Индекс", "Index": "Индекс",
"Index request failed with HTTP {{status}}": "Запрос индекса завершился ошибкой HTTP {{status}}", "Index request failed with HTTP {{status}}": "Запрос индекса завершился ошибкой HTTP {{status}}",
...@@ -2524,6 +2549,7 @@ ...@@ -2524,6 +2549,7 @@
"Instance": "Экземпляр", "Instance": "Экземпляр",
"Instances": "Экземпляры", "Instances": "Экземпляры",
"Insufficient balance": "Недостаточно средств", "Insufficient balance": "Недостаточно средств",
"Insufficient permission to adjust this user": "Недостаточно прав для изменения квоты этого пользователя",
"Integrations": "Интеграции", "Integrations": "Интеграции",
"Integrity check failed": "Проверка целостности не пройдена", "Integrity check failed": "Проверка целостности не пройдена",
"Integrity hash": "Хеш целостности", "Integrity hash": "Хеш целостности",
...@@ -2535,6 +2561,7 @@ ...@@ -2535,6 +2561,7 @@
"Internal Server Error!": "Внутренняя ошибка сервера!", "Internal Server Error!": "Внутренняя ошибка сервера!",
"Interval must be at least 1 minute": "Интервал должен быть не менее 1 минуты", "Interval must be at least 1 minute": "Интервал должен быть не менее 1 минуты",
"Invalid (NaN)": "Недопустимо (NaN)", "Invalid (NaN)": "Недопустимо (NaN)",
"Invalid adjustment parameters": "Недопустимые параметры изменения квоты",
"Invalid chat link. Please contact the administrator.": "Неверная ссылка на чат. Пожалуйста, обратитесь к администратору.", "Invalid chat link. Please contact the administrator.": "Неверная ссылка на чат. Пожалуйста, обратитесь к администратору.",
"Invalid chat link. Please contact your administrator.": "Недействительная ссылка чата. Обратитесь к администратору.", "Invalid chat link. Please contact your administrator.": "Недействительная ссылка чата. Обратитесь к администратору.",
"Invalid code": "Неверный код", "Invalid code": "Неверный код",
...@@ -2899,6 +2926,7 @@ ...@@ -2899,6 +2926,7 @@
"Model fixed pricing": "Фиксированная цена модели", "Model fixed pricing": "Фиксированная цена модели",
"Model Group": "Группа моделей", "Model Group": "Группа моделей",
"Model Limits": "Лимиты модели", "Model Limits": "Лимиты модели",
"Model limits enabled": "Ограничения моделей включены",
"Model List": "Список моделей", "Model List": "Список моделей",
"Model Mapping": "Сопоставление моделей", "Model Mapping": "Сопоставление моделей",
"Model Mapping (JSON)": "Сопоставление моделей (JSON)", "Model Mapping (JSON)": "Сопоставление моделей (JSON)",
...@@ -3273,6 +3301,7 @@ ...@@ -3273,6 +3301,7 @@
"Not included": "Не включена", "Not included": "Не включена",
"Not installed": "Не установлен", "Not installed": "Не установлен",
"Not provided by this source": "Не предоставлено этим источником", "Not provided by this source": "Не предоставлено этим источником",
"Not recorded": "Не записано",
"Not registered": "Не зарегистрирован", "Not registered": "Не зарегистрирован",
"Not set": "Не задано", "Not set": "Не задано",
"Not Set": "Не установлено", "Not Set": "Не установлено",
...@@ -3365,6 +3394,8 @@ ...@@ -3365,6 +3394,8 @@
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Будут перезаписаны только выбранные поля. Вы можете повторно запустить мастер синхронизации, если появятся новые конфликты.", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Будут перезаписаны только выбранные поля. Вы можете повторно запустить мастер синхронизации, если появятся новые конфликты.",
"Only successful requests": "Только успешные запросы", "Only successful requests": "Только успешные запросы",
"Only successful requests count toward this limit.": "Только успешные запросы учитываются в этом лимите.", "Only successful requests count toward this limit.": "Только успешные запросы учитываются в этом лимите.",
"Only the first {{shown}} IDs were recorded": "Записаны только первые {{shown}} ID",
"Only the first {{shown}} IDs were recorded ({{total}} requested)": "Записаны только первые {{shown}} ID (запрошено: {{total}})",
"Only the last {{value}} log files will be retained; the rest will be deleted.": "Будут сохранены только последние {{value}} файлов журналов; остальные будут удалены.", "Only the last {{value}} log files will be retained; the rest will be deleted.": "Будут сохранены только последние {{value}} файлов журналов; остальные будут удалены.",
"Only used to find historical logs. New records are available in Audit Logs.": "Только для поиска старых журналов. Новые записи доступны в журнале аудита.", "Only used to find historical logs. New records are available in Audit Logs.": "Только для поиска старых журналов. Новые записи доступны в журнале аудита.",
"Oops! Page Not Found!": "Ой! Страница не найдена!", "Oops! Page Not Found!": "Ой! Страница не найдена!",
...@@ -3466,6 +3497,7 @@ ...@@ -3466,6 +3497,7 @@
"Override": "Перезаписать", "Override": "Перезаписать",
"Override auto-discovered endpoint": "Переопределить автоматически обнаруженную конечную точку", "Override auto-discovered endpoint": "Переопределить автоматически обнаруженную конечную точку",
"Override matrix": "Матрица переопределений", "Override matrix": "Матрица переопределений",
"Override quota for user “{{name}}”": "Заменить квоту пользователя «{{name}}»",
"Override request headers": "Переопределить заголовки запроса", "Override request headers": "Переопределить заголовки запроса",
"Override request headers (JSON format)": "Переопределение заголовков запроса (формат JSON)", "Override request headers (JSON format)": "Переопределение заголовков запроса (формат JSON)",
"Override request parameters": "Переопределить параметры запроса", "Override request parameters": "Переопределить параметры запроса",
...@@ -3475,6 +3507,7 @@ ...@@ -3475,6 +3507,7 @@
"Override rule: when a vip user is billed as premium, the ratio is 0.3 instead of 0.5": "Правило переопределения: когда пользователь vip тарифицируется по premium, коэффициент равен 0,3 вместо 0,5", "Override rule: when a vip user is billed as premium, the ratio is 0.3 instead of 0.5": "Правило переопределения: когда пользователь vip тарифицируется по premium, коэффициент равен 0,3 вместо 0,5",
"Override Rules": "Правила переопределения", "Override Rules": "Правила переопределения",
"Override the endpoint used for testing. Leave empty to auto detect.": "Переопределить конечную точку, используемую для тестирования. Оставьте пустым для автоматического определения.", "Override the endpoint used for testing. Leave empty to auto detect.": "Переопределить конечную точку, используемую для тестирования. Оставьте пустым для автоматического определения.",
"Override user quota": "Заменить квоту пользователя",
"overrides for matching model prefix.": "переопределяет цену по совпавшему префиксу модели.", "overrides for matching model prefix.": "переопределяет цену по совпавшему префиксу модели.",
"Overrode user quota from {{from}} to {{to}}": "Квота пользователя изменена с {{from}} на {{to}}", "Overrode user quota from {{from}} to {{to}}": "Квота пользователя изменена с {{from}} на {{to}}",
"Overview": "Обзор", "Overview": "Обзор",
...@@ -3890,6 +3923,9 @@ ...@@ -3890,6 +3923,9 @@
"Quota": "Квота", "Quota": "Квота",
"Quota ({{currency}})": "Квота ({{currency}})", "Quota ({{currency}})": "Квота ({{currency}})",
"Quota adjusted successfully": "Квота успешно изменена", "Quota adjusted successfully": "Квота успешно изменена",
"Quota adjustment details": "Подробности изменения квоты",
"Quota after adjustment": "Квота после изменения",
"Quota before adjustment": "Квота до изменения",
"Quota clamped": "Квота ограничена", "Quota clamped": "Квота ограничена",
"Quota consumed before charging users": "Квота, потребляемая до взимания платы с пользователей", "Quota consumed before charging users": "Квота, потребляемая до взимания платы с пользователей",
"Quota Distribution": "Распределение квоты", "Quota Distribution": "Распределение квоты",
...@@ -3905,6 +3941,8 @@ ...@@ -3905,6 +3941,8 @@
"Quota saturation protection triggered": "Сработала защита от переполнения квоты", "Quota saturation protection triggered": "Сработала защита от переполнения квоты",
"Quota Settings": "Настройки квоты", "Quota Settings": "Настройки квоты",
"Quota Types": "Типы квот", "Quota Types": "Типы квот",
"Quota unchanged": "Квота не изменилась",
"Quota update failed": "Не удалось обновить квоту",
"Quota Warning Threshold": "Порог предупреждения о квоте", "Quota Warning Threshold": "Порог предупреждения о квоте",
"Quota:": "Квота:", "Quota:": "Квота:",
"Radius": "Радиус", "Radius": "Радиус",
...@@ -4104,6 +4142,14 @@ ...@@ -4104,6 +4142,14 @@
"Request success rate; {{incidents}} incident buckets in the last 24 hours": "Доля успешных запросов; {{incidents}} интервалов с инцидентами за последние 24 часа", "Request success rate; {{incidents}} incident buckets in the last 24 hours": "Доля успешных запросов; {{incidents}} интервалов с инцидентами за последние 24 часа",
"Request timed out, please refresh and restart GitHub login": "Время ожидания истекло, обновите страницу и снова запустите вход через GitHub", "Request timed out, please refresh and restart GitHub login": "Время ожидания истекло, обновите страницу и снова запустите вход через GitHub",
"Request-based": "Зависит от запроса", "Request-based": "Зависит от запроса",
"Requested items": "Запрошенные элементы",
"Requested quota": "Запрошенная сумма",
"Requested quota: {{quota}}": "Запрошенная сумма: {{quota}}",
"Requested token IDs": "Запрошенные ID токенов",
"Requested token IDs truncated": "Список запрошенных ID токенов усечён",
"Requested: {{total}}": "Запрошено: {{total}}",
"Requested: {{total}} · Deleted: {{processed}}": "Запрошено: {{total}} · Удалено: {{processed}}",
"Requested: {{total}} · Returned: {{processed}}": "Запрошено: {{total}} · Возвращено: {{processed}}",
"Requests": "Запросы", "Requests": "Запросы",
"Requests (24h)": "Запросы (24 ч)", "Requests (24h)": "Запросы (24 ч)",
"Requests / 24h": "Запросы / 24 ч", "Requests / 24h": "Запросы / 24 ч",
...@@ -4194,6 +4240,9 @@ ...@@ -4194,6 +4240,9 @@
"Return to dashboard": "Вернуться на панель управления", "Return to dashboard": "Вернуться на панель управления",
"Return to the original window to continue.": "Вернитесь в исходное окно, чтобы продолжить.", "Return to the original window to continue.": "Вернитесь в исходное окно, чтобы продолжить.",
"Return vector embeddings for inputs": "Возвращать векторные эмбеддинги для входных данных", "Return vector embeddings for inputs": "Возвращать векторные эмбеддинги для входных данных",
"Returned keys": "Возвращённые ключи",
"Returned token IDs": "Возвращённые ID токенов",
"Returned: {{processed}}": "Возвращено: {{processed}}",
"Reveal API key": "Показать API ключ", "Reveal API key": "Показать API ключ",
"Reveal key": "Показать ключ", "Reveal key": "Показать ключ",
"Revenue": "Доход", "Revenue": "Доход",
...@@ -4627,6 +4676,7 @@ ...@@ -4627,6 +4676,7 @@
"Started two-factor authentication setup": "Начата настройка двухфакторной аутентификации", "Started two-factor authentication setup": "Начата настройка двухфакторной аутентификации",
"STARTTLS": "STARTTLS", "STARTTLS": "STARTTLS",
"State changed": "Состояние изменено", "State changed": "Состояние изменено",
"State unchanged: {{status}}": "Статус не изменился: {{status}}",
"Static page describing the platform.": "Статическая страница, описывающая платформу.", "Static page describing the platform.": "Статическая страница, описывающая платформу.",
"Statistical count": "Статистический подсчет", "Statistical count": "Статистический подсчет",
"Statistical quota": "Статистическая квота", "Statistical quota": "Статистическая квота",
...@@ -4634,6 +4684,7 @@ ...@@ -4634,6 +4684,7 @@
"Statistics reset": "Статистика сброшена", "Statistics reset": "Статистика сброшена",
"Status": "Статус", "Status": "Статус",
"Status & Sync": "Статус и синхронизация", "Status & Sync": "Статус и синхронизация",
"Status change": "Изменение статуса",
"Status Code": "Код статуса", "Status Code": "Код статуса",
"Status Code Mapping": "Сопоставление кодов состояния", "Status Code Mapping": "Сопоставление кодов состояния",
"Status code mapping must use valid HTTP status codes": "Сопоставление кодов состояния должно использовать допустимые HTTP-коды", "Status code mapping must use valid HTTP status codes": "Сопоставление кодов состояния должно использовать допустимые HTTP-коды",
...@@ -4781,8 +4832,11 @@ ...@@ -4781,8 +4832,11 @@
"Target Field Path": "Путь целевого поля", "Target Field Path": "Путь целевого поля",
"Target group": "Целевая группа", "Target group": "Целевая группа",
"Target Header": "Целевой заголовок", "Target Header": "Целевой заголовок",
"Target not recorded": "Объект не записан",
"Target Path (optional)": "Целевой путь (необязательно)", "Target Path (optional)": "Целевой путь (необязательно)",
"Target User": "Целевой пользователь", "Target User": "Целевой пользователь",
"Target user not found": "Целевой пользователь не найден",
"Target username": "Имя целевого пользователя",
"Task": "Задача", "Task": "Задача",
"Task billing": "Тарификация задач", "Task billing": "Тарификация задач",
"Task Details": "Сведения о задаче", "Task Details": "Сведения о задаче",
...@@ -5056,6 +5110,7 @@ ...@@ -5056,6 +5110,7 @@
"Token estimator": "Оценка токенов", "Token estimator": "Оценка токенов",
"Token group": "Группа токена", "Token group": "Группа токена",
"Token has no group": "У токена нет группы", "Token has no group": "У токена нет группы",
"Token ID": "ID токена",
"Token identifier": "Идентификатор токена", "Token identifier": "Идентификатор токена",
"Token Limits": "Ограничения токенов", "Token Limits": "Ограничения токенов",
"Token management": "Управление токенами", "Token management": "Управление токенами",
...@@ -5063,6 +5118,7 @@ ...@@ -5063,6 +5118,7 @@
"Token Mgmt": "Управление токенами", "Token Mgmt": "Управление токенами",
"Token Name": "Имя токена", "Token Name": "Имя токена",
"Token obtained from your Gotify application": "Токен, полученный из вашего приложения Gotify", "Token obtained from your Gotify application": "Токен, полученный из вашего приложения Gotify",
"Token operation details": "Подробности операции с токенами",
"Token price for audio input.": "Цена токенов для аудиовхода.", "Token price for audio input.": "Цена токенов для аудиовхода.",
"Token price for audio output.": "Цена токенов для аудиовыхода.", "Token price for audio output.": "Цена токенов для аудиовыхода.",
"Token price for cache reads.": "Цена токенов для чтения кэша.", "Token price for cache reads.": "Цена токенов для чтения кэша.",
...@@ -5245,6 +5301,8 @@ ...@@ -5245,6 +5301,8 @@
"Update": "Обновить", "Update": "Обновить",
"Update All Balances": "Обновить все балансы", "Update All Balances": "Обновить все балансы",
"Update API Key": "Обновить API-ключ", "Update API Key": "Обновить API-ключ",
"Update API token": "Изменение токена API",
"Update API token “{{name}}”": "Изменение токена API «{{name}}»",
"Update Balance": "Обновить баланс", "Update Balance": "Обновить баланс",
"Update balance for:": "Обновить баланс для:", "Update balance for:": "Обновить баланс для:",
"Update Channel": "Обновить канал", "Update Channel": "Обновить канал",
...@@ -5504,6 +5562,8 @@ ...@@ -5504,6 +5562,8 @@
"Vidu": "Vidu", "Vidu": "Vidu",
"View": "Просмотр", "View": "Просмотр",
"View all currently available models": "Просмотреть все доступные модели", "View all currently available models": "Просмотреть все доступные модели",
"View API token key": "Просмотр ключа токена API",
"View API token keys in batch": "Массовый просмотр ключей токенов API",
"View audit records from user and admin roles. Root records are always excluded.": "Просмотр записей аудита ролей user и admin. Записи роли root всегда исключены.", "View audit records from user and admin roles. Root records are always excluded.": "Просмотр записей аудита ролей user и admin. Записи роли root всегда исключены.",
"View channel lists and details without secrets.": "Просмотр списков и сведений о каналах без секретов.", "View channel lists and details without secrets.": "Просмотр списков и сведений о каналах без секретов.",
"View channel secrets": "Просматривать секреты каналов", "View channel secrets": "Просматривать секреты каналов",
...@@ -5511,6 +5571,7 @@ ...@@ -5511,6 +5571,7 @@
"View details": "Просмотреть детали", "View details": "Просмотреть детали",
"View document": "Просмотреть документ", "View document": "Просмотреть документ",
"View issued reset credits, grant dates, and expiration.": "Показать выданные сбросы лимита, даты выдачи и истечения.", "View issued reset credits, grant dates, and expiration.": "Показать выданные сбросы лимита, даты выдачи и истечения.",
"View key for API token “{{name}}”": "Просмотр ключа токена API «{{name}}»",
"View logs": "Просмотреть логи", "View logs": "Просмотреть логи",
"View mode": "Режим отображения", "View mode": "Режим отображения",
"View model statistics and charts": "Просмотр статистики и графиков моделей", "View model statistics and charts": "Просмотр статистики и графиков моделей",
...@@ -5566,6 +5627,7 @@ ...@@ -5566,6 +5627,7 @@
"Wallet Management": "Управление кошельком", "Wallet Management": "Управление кошельком",
"Wallet management and personal preferences.": "Управление кошельком и личные предпочтения.", "Wallet management and personal preferences.": "Управление кошельком и личные предпочтения.",
"Wallet Only": "Только кошелёк", "Wallet Only": "Только кошелёк",
"Wallet quota limit exceeded": "Превышены границы квоты кошелька",
"Warning": "Предупреждение", "Warning": "Предупреждение",
"Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.": "Предупреждение: базовый URL не должен заканчиваться на /v1. Новый API обработает это автоматически. Это может привести к сбоям запросов.", "Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.": "Предупреждение: базовый URL не должен заканчиваться на /v1. Новый API обработает это автоматически. Это может привести к сбоям запросов.",
"Warning: Disabling 2FA will make your account less secure.": "Внимание: Отключение 2FA сделает вашу учетную запись менее безопасной.", "Warning: Disabling 2FA will make your account less secure.": "Внимание: Отключение 2FA сделает вашу учетную запись менее безопасной.",
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
"(billed as vip itself, so base ratio of vip)": "(tính phí theo chính vip, nên dùng hệ số cơ bản của vip)", "(billed as vip itself, so base ratio of vip)": "(tính phí theo chính vip, nên dùng hệ số cơ bản của vip)",
"(falls back to billing as vip, so base ratio of vip)": "(quay về tính phí theo vip, nên dùng hệ số cơ bản của vip)", "(falls back to billing as vip, so base ratio of vip)": "(quay về tính phí theo vip, nên dùng hệ số cơ bản của vip)",
"(hits the override rule above)": "(khớp quy tắc ghi đè ở trên)", "(hits the override rule above)": "(khớp quy tắc ghi đè ở trên)",
"(ID: {{id}})": "(ID: {{id}})",
"(instead of {{ratio}})": "(thay vì {{ratio}})", "(instead of {{ratio}})": "(thay vì {{ratio}})",
"(Leave empty to dissolve tag)": "Để trống để xóa thẻ.", "(Leave empty to dissolve tag)": "Để trống để xóa thẻ.",
"(matrix cell vip × premium is set)": "(ô ma trận vip × premium đã được đặt)", "(matrix cell vip × premium is set)": "(ô ma trận vip × premium đã được đặt)",
...@@ -69,6 +70,7 @@ ...@@ -69,6 +70,7 @@
"{{modality}} not supported": "Không hỗ trợ {{modality}}", "{{modality}} not supported": "Không hỗ trợ {{modality}}",
"{{modality}} supported": "Hỗ trợ {{modality}}", "{{modality}} supported": "Hỗ trợ {{modality}}",
"{{n}} model(s) selected": "Đã chọn {{n}} model", "{{n}} model(s) selected": "Đã chọn {{n}} model",
"{{operation}} (ID: {{id}})": "{{operation}} (ID: {{id}})",
"{{processed}} of {{total}} log entries processed.": "Đã xử lý {{processed}}/{{total}} mục nhật ký.", "{{processed}} of {{total}} log entries processed.": "Đã xử lý {{processed}}/{{total}} mục nhật ký.",
"{{protocol}} auth name": "Tên xác thực {{protocol}}", "{{protocol}} auth name": "Tên xác thực {{protocol}}",
"{{protocol}} auth value": "Giá trị xác thực {{protocol}}", "{{protocol}} auth value": "Giá trị xác thực {{protocol}}",
...@@ -261,8 +263,11 @@ ...@@ -261,8 +263,11 @@
"Additional verification required": "Cần xác minh bổ sung", "Additional verification required": "Cần xác minh bổ sung",
"Adjust filters, then search to refresh the logs.": "Điều chỉnh bộ lọc, sau đó tìm kiếm để làm mới nhật ký.", "Adjust filters, then search to refresh the logs.": "Điều chỉnh bộ lọc, sau đó tìm kiếm để làm mới nhật ký.",
"Adjust Quota": "Điều chỉnh hạn mức", "Adjust Quota": "Điều chỉnh hạn mức",
"Adjust quota for user “{{name}}”": "Điều chỉnh hạn mức cho người dùng “{{name}}”",
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Điều chỉnh định dạng phản hồi, hành vi prompt, proxy và tự động hóa upstream.", "Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Điều chỉnh định dạng phản hồi, hành vi prompt, proxy và tự động hóa upstream.",
"Adjust the appearance and layout to suit your preferences.": "Điều chỉnh giao diện và bố cục để phù hợp với sở thích của bạn.", "Adjust the appearance and layout to suit your preferences.": "Điều chỉnh giao diện và bố cục để phù hợp với sở thích của bạn.",
"Adjust user quota": "Điều chỉnh hạn mức người dùng",
"Adjustment mode": "Cách điều chỉnh",
"Admin": "Quản trị viên", "Admin": "Quản trị viên",
"Admin access required": "Yêu cầu quyền truy cập Admin", "Admin access required": "Yêu cầu quyền truy cập Admin",
"Admin area": "Khu vực quản trị", "Admin area": "Khu vực quản trị",
...@@ -439,7 +444,14 @@ ...@@ -439,7 +444,14 @@
"API Private Key": "Khóa riêng API", "API Private Key": "Khóa riêng API",
"API Requests": "Yêu cầu API", "API Requests": "Yêu cầu API",
"API secret": "Bí mật API", "API secret": "Bí mật API",
"API token batch deletion": "Xóa hàng loạt mã thông báo API",
"API token batch key access": "Xem hàng loạt khóa mã thông báo API",
"API token configuration update": "Cập nhật cấu hình mã thông báo API",
"API token creation": "Tạo mã thông báo API",
"API token deletion": "Xóa mã thông báo API",
"API token key access": "Xem khóa mã thông báo API",
"API token management": "Quản lý token API", "API token management": "Quản lý token API",
"API token status update": "Cập nhật trạng thái mã thông báo API",
"API URL": "API URL", "API URL": "API URL",
"API usage records": "Lịch sử sử dụng API", "API usage records": "Lịch sử sử dụng API",
"API version": "Phiên bản API", "API version": "Phiên bản API",
...@@ -649,6 +661,7 @@ ...@@ -649,6 +661,7 @@
"Basic Templates": "Mẫu cơ bản", "Basic Templates": "Mẫu cơ bản",
"Batch Add (one key per line)": "Thêm hàng loạt (mỗi khóa một dòng)", "Batch Add (one key per line)": "Thêm hàng loạt (mỗi khóa một dòng)",
"Batch channel test": "Kiểm tra kênh hàng loạt", "Batch channel test": "Kiểm tra kênh hàng loạt",
"Batch delete API tokens": "Xóa hàng loạt mã thông báo API",
"Batch delete failed": "Xóa hàng loạt thất bại", "Batch delete failed": "Xóa hàng loạt thất bại",
"Batch deleted {{count}} channels": "Đã xóa hàng loạt {{count}} kênh", "Batch deleted {{count}} channels": "Đã xóa hàng loạt {{count}} kênh",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "Phát hiện hàng loạt hoàn tất: {{channels}} kênh, {{add}} để thêm, {{remove}} để xóa, {{fails}} thất bại", "Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "Phát hiện hàng loạt hoàn tất: {{channels}} kênh, {{add}} để thêm, {{remove}} để xóa, {{fails}} thất bại",
...@@ -807,6 +820,7 @@ ...@@ -807,6 +820,7 @@
"Change To": "Thay đổi thành", "Change To": "Thay đổi thành",
"Changed / Total": "Đã thay đổi / Tổng số", "Changed / Total": "Đã thay đổi / Tổng số",
"Changed Fields": "Trường đã thay đổi", "Changed Fields": "Trường đã thay đổi",
"Changed fields: {{fields}}": "Trường đã thay đổi: {{fields}}",
"Changes are written to the settings draft on save.": "Các thay đổi sẽ được ghi vào bản nháp cài đặt khi lưu.", "Changes are written to the settings draft on save.": "Các thay đổi sẽ được ghi vào bản nháp cài đặt khi lưu.",
"Changing...": "Đang thay đổi...", "Changing...": "Đang thay đổi...",
"Channel": "Kênh", "Channel": "Kênh",
...@@ -1232,6 +1246,8 @@ ...@@ -1232,6 +1246,8 @@
"Create an API key to unlock the real request": "Tạo khóa API để mở yêu cầu thật", "Create an API key to unlock the real request": "Tạo khóa API để mở yêu cầu thật",
"Create and review invite or credit codes.": "Tạo và xem xét mã mời hoặc mã tín dụng.", "Create and review invite or credit codes.": "Tạo và xem xét mã mời hoặc mã tín dụng.",
"Create API Key": "Tạo Khóa API", "Create API Key": "Tạo Khóa API",
"Create API token": "Tạo mã thông báo API",
"Create API token “{{name}}”": "Tạo mã thông báo API “{{name}}”",
"Create cache": "Tạo bộ nhớ đệm", "Create cache": "Tạo bộ nhớ đệm",
"Create cache ratio": "Tạo tỷ lệ bộ nhớ đệm", "Create cache ratio": "Tạo tỷ lệ bộ nhớ đệm",
"Create Channel": "Tạo Kênh", "Create Channel": "Tạo Kênh",
...@@ -1357,6 +1373,8 @@ ...@@ -1357,6 +1373,8 @@
"decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "quyết định hệ số nạp tiền, các nhóm người dùng có thể chọn cho token, và có áp dụng hệ số ghi đè hay không.", "decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "quyết định hệ số nạp tiền, các nhóm người dùng có thể chọn cho token, và có áp dụng hệ số ghi đè hay không.",
"decides which channels are used and which base ratio applies.": "quyết định dùng kênh nào và áp dụng hệ số cơ bản nào.", "decides which channels are used and which base ratio applies.": "quyết định dùng kênh nào và áp dụng hệ số cơ bản nào.",
"Declared capabilities": "Quyền được khai báo", "Declared capabilities": "Quyền được khai báo",
"Decrease quota for user “{{name}}”": "Giảm hạn mức cho người dùng “{{name}}”",
"Decrease user quota": "Giảm hạn mức người dùng",
"Decreased user quota by {{quota}}": "Đã giảm hạn mức người dùng {{quota}}", "Decreased user quota by {{quota}}": "Đã giảm hạn mức người dùng {{quota}}",
"Deducted by subscription": "Khấu trừ bởi gói đăng ký", "Deducted by subscription": "Khấu trừ bởi gói đăng ký",
"DeepSeek": "DeepSeek", "DeepSeek": "DeepSeek",
...@@ -1393,6 +1411,8 @@ ...@@ -1393,6 +1411,8 @@
"Delete All Disabled": "Xóa Tất Cả Đã Tắt", "Delete All Disabled": "Xóa Tất Cả Đã Tắt",
"Delete All Disabled Channels?": "Xóa tất cả kênh đã vô hiệu hóa?", "Delete All Disabled Channels?": "Xóa tất cả kênh đã vô hiệu hóa?",
"Delete all stale": "Xóa tất cả mất kết nối", "Delete all stale": "Xóa tất cả mất kết nối",
"Delete API token": "Xóa mã thông báo API",
"Delete API token “{{name}}”": "Xóa mã thông báo API “{{name}}”",
"Delete Auto-Disabled": "Xóa Tự động vô hiệu hóa", "Delete Auto-Disabled": "Xóa Tự động vô hiệu hóa",
"Delete Channel": "Xóa Kênh", "Delete Channel": "Xóa Kênh",
"Delete Channels?": "Xóa các kênh?", "Delete Channels?": "Xóa các kênh?",
...@@ -1439,7 +1459,9 @@ ...@@ -1439,7 +1459,9 @@
"Deleted invalid redemption codes": "Đã xóa các mã đổi thưởng không hợp lệ", "Deleted invalid redemption codes": "Đã xóa các mã đổi thưởng không hợp lệ",
"Deleted stale instance": "Đã xóa phiên bản mất kết nối", "Deleted stale instance": "Đã xóa phiên bản mất kết nối",
"Deleted successfully": "Xóa thành công", "Deleted successfully": "Xóa thành công",
"Deleted tokens": "Số mã thông báo đã xóa",
"Deleted user {{username}} (ID: {{id}})": "Đã xóa người dùng {{username}} (ID: {{id}})", "Deleted user {{username}} (ID: {{id}})": "Đã xóa người dùng {{username}} (ID: {{id}})",
"Deleted: {{processed}}": "Đã xóa: {{processed}}",
"Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "Xóa phiên bản tùy chỉnh này không làm vô hiệu nền tảng. Plugin tích hợp cùng tên sẽ tự động được khôi phục.", "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "Xóa phiên bản tùy chỉnh này không làm vô hiệu nền tảng. Plugin tích hợp cùng tên sẽ tự động được khôi phục.",
"Deleting will permanently remove this subscription record (including benefit details). Continue?": "Xóa sẽ xóa vĩnh viễn bản ghi đăng ký này (bao gồm chi tiết quyền lợi). Tiếp tục?", "Deleting will permanently remove this subscription record (including benefit details). Continue?": "Xóa sẽ xóa vĩnh viễn bản ghi đăng ký này (bao gồm chi tiết quyền lợi). Tiếp tục?",
"Deleting...": "Đang xóa...", "Deleting...": "Đang xóa...",
...@@ -2077,6 +2099,7 @@ ...@@ -2077,6 +2099,7 @@
"Failed to update tag": "Không thể cập nhật thẻ", "Failed to update tag": "Không thể cập nhật thẻ",
"Failed to update user": "Không thể cập nhật người dùng", "Failed to update user": "Không thể cập nhật người dùng",
"Failure keywords": "Từ khóa thất bại", "Failure keywords": "Từ khóa thất bại",
"Failure reason": "Lý do thất bại",
"Fair": "Công bằng", "Fair": "Công bằng",
"Fallback": "Dự phòng", "Fallback": "Dự phòng",
"Fallback base URL": "Base URL fallback", "Fallback base URL": "Base URL fallback",
...@@ -2491,6 +2514,8 @@ ...@@ -2491,6 +2514,8 @@
"Incoming path must not include query": "Path đầu vào không được chứa query", "Incoming path must not include query": "Path đầu vào không được chứa query",
"Incoming path must start with /": "Path đầu vào phải bắt đầu bằng /", "Incoming path must start with /": "Path đầu vào phải bắt đầu bằng /",
"Incomplete": "Chưa hoàn tất", "Incomplete": "Chưa hoàn tất",
"Increase quota for user “{{name}}”": "Tăng hạn mức cho người dùng “{{name}}”",
"Increase user quota": "Tăng hạn mức người dùng",
"Increased user quota by {{quota}}": "Đã tăng hạn mức người dùng thêm {{quota}}", "Increased user quota by {{quota}}": "Đã tăng hạn mức người dùng thêm {{quota}}",
"Index": "Chỉ mục", "Index": "Chỉ mục",
"Index request failed with HTTP {{status}}": "Yêu cầu chỉ mục thất bại với HTTP {{status}}", "Index request failed with HTTP {{status}}": "Yêu cầu chỉ mục thất bại với HTTP {{status}}",
...@@ -2524,6 +2549,7 @@ ...@@ -2524,6 +2549,7 @@
"Instance": "Phiên bản", "Instance": "Phiên bản",
"Instances": "Phiên bản", "Instances": "Phiên bản",
"Insufficient balance": "Số dư không đủ", "Insufficient balance": "Số dư không đủ",
"Insufficient permission to adjust this user": "Không đủ quyền điều chỉnh hạn mức của người dùng này",
"Integrations": "Tích hợp", "Integrations": "Tích hợp",
"Integrity check failed": "Kiểm tra tính toàn vẹn thất bại", "Integrity check failed": "Kiểm tra tính toàn vẹn thất bại",
"Integrity hash": "Mã băm toàn vẹn", "Integrity hash": "Mã băm toàn vẹn",
...@@ -2535,6 +2561,7 @@ ...@@ -2535,6 +2561,7 @@
"Internal Server Error!": "Lỗi máy chủ nội bộ!", "Internal Server Error!": "Lỗi máy chủ nội bộ!",
"Interval must be at least 1 minute": "Khoảng thời gian phải ít nhất 1 phút", "Interval must be at least 1 minute": "Khoảng thời gian phải ít nhất 1 phút",
"Invalid (NaN)": "Không hợp lệ (NaN)", "Invalid (NaN)": "Không hợp lệ (NaN)",
"Invalid adjustment parameters": "Tham số điều chỉnh hạn mức không hợp lệ",
"Invalid chat link. Please contact the administrator.": "Liên kết trò chuyện không hợp lệ. Vui lòng liên hệ quản trị viên.", "Invalid chat link. Please contact the administrator.": "Liên kết trò chuyện không hợp lệ. Vui lòng liên hệ quản trị viên.",
"Invalid chat link. Please contact your administrator.": "Liên kết trò chuyện không hợp lệ. Vui lòng liên hệ với quản trị viên của bạn.", "Invalid chat link. Please contact your administrator.": "Liên kết trò chuyện không hợp lệ. Vui lòng liên hệ với quản trị viên của bạn.",
"Invalid code": "Mã không hợp lệ", "Invalid code": "Mã không hợp lệ",
...@@ -2899,6 +2926,7 @@ ...@@ -2899,6 +2926,7 @@
"Model fixed pricing": "Fixed-price model", "Model fixed pricing": "Fixed-price model",
"Model Group": "Nhóm Mô hình", "Model Group": "Nhóm Mô hình",
"Model Limits": "Giới hạn Mô hình", "Model Limits": "Giới hạn Mô hình",
"Model limits enabled": "Đã bật giới hạn mô hình",
"Model List": "Danh sách mô hình", "Model List": "Danh sách mô hình",
"Model Mapping": "Ánh xạ mô hình", "Model Mapping": "Ánh xạ mô hình",
"Model Mapping (JSON)": "Ánh xạ mô hình (JSON)", "Model Mapping (JSON)": "Ánh xạ mô hình (JSON)",
...@@ -3273,6 +3301,7 @@ ...@@ -3273,6 +3301,7 @@
"Not included": "Không bao gồm", "Not included": "Không bao gồm",
"Not installed": "Chưa cài đặt", "Not installed": "Chưa cài đặt",
"Not provided by this source": "Nguồn này không cung cấp", "Not provided by this source": "Nguồn này không cung cấp",
"Not recorded": "Chưa ghi nhận",
"Not registered": "Chưa đăng ký", "Not registered": "Chưa đăng ký",
"Not set": "Chưa đặt", "Not set": "Chưa đặt",
"Not Set": "Chưa đặt", "Not Set": "Chưa đặt",
...@@ -3365,6 +3394,8 @@ ...@@ -3365,6 +3394,8 @@
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Chỉ các trường được chọn sẽ bị ghi đè. Bạn có thể chạy lại trình hướng dẫn đồng bộ hóa nếu có xung đột mới xuất hiện.", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Chỉ các trường được chọn sẽ bị ghi đè. Bạn có thể chạy lại trình hướng dẫn đồng bộ hóa nếu có xung đột mới xuất hiện.",
"Only successful requests": "Chỉ các yêu cầu thành công", "Only successful requests": "Chỉ các yêu cầu thành công",
"Only successful requests count toward this limit.": "Chỉ những yêu cầu thành công mới được tính vào giới hạn này.", "Only successful requests count toward this limit.": "Chỉ những yêu cầu thành công mới được tính vào giới hạn này.",
"Only the first {{shown}} IDs were recorded": "Chỉ ghi nhận {{shown}} ID đầu tiên",
"Only the first {{shown}} IDs were recorded ({{total}} requested)": "Chỉ ghi nhận {{shown}} ID đầu tiên (đã yêu cầu {{total}})",
"Only the last {{value}} log files will be retained; the rest will be deleted.": "Chỉ giữ lại {{value}} tệp nhật ký gần nhất; phần còn lại sẽ bị xóa.", "Only the last {{value}} log files will be retained; the rest will be deleted.": "Chỉ giữ lại {{value}} tệp nhật ký gần nhất; phần còn lại sẽ bị xóa.",
"Only used to find historical logs. New records are available in Audit Logs.": "Chỉ dùng để tìm nhật ký cũ. Bản ghi mới có trong Nhật ký kiểm toán.", "Only used to find historical logs. New records are available in Audit Logs.": "Chỉ dùng để tìm nhật ký cũ. Bản ghi mới có trong Nhật ký kiểm toán.",
"Oops! Page Not Found!": "Ối! Không tìm thấy trang!", "Oops! Page Not Found!": "Ối! Không tìm thấy trang!",
...@@ -3466,6 +3497,7 @@ ...@@ -3466,6 +3497,7 @@
"Override": "Ghi đè", "Override": "Ghi đè",
"Override auto-discovered endpoint": "Ghi đè điểm cuối tự động phát hiện", "Override auto-discovered endpoint": "Ghi đè điểm cuối tự động phát hiện",
"Override matrix": "Ma trận ghi đè", "Override matrix": "Ma trận ghi đè",
"Override quota for user “{{name}}”": "Ghi đè hạn mức cho người dùng “{{name}}”",
"Override request headers": "Ghi đè tiêu đề yêu cầu", "Override request headers": "Ghi đè tiêu đề yêu cầu",
"Override request headers (JSON format)": "Ghi đè tiêu đề yêu cầu (định dạng JSON)", "Override request headers (JSON format)": "Ghi đè tiêu đề yêu cầu (định dạng JSON)",
"Override request parameters": "Ghi đè tham số yêu cầu", "Override request parameters": "Ghi đè tham số yêu cầu",
...@@ -3475,6 +3507,7 @@ ...@@ -3475,6 +3507,7 @@
"Override rule: when a vip user is billed as premium, the ratio is 0.3 instead of 0.5": "Quy tắc ghi đè: khi người dùng vip được tính phí theo premium, hệ số là 0.3 thay vì 0.5", "Override rule: when a vip user is billed as premium, the ratio is 0.3 instead of 0.5": "Quy tắc ghi đè: khi người dùng vip được tính phí theo premium, hệ số là 0.3 thay vì 0.5",
"Override Rules": "Quy tắc ghi đè", "Override Rules": "Quy tắc ghi đè",
"Override the endpoint used for testing. Leave empty to auto detect.": "Ghi đè điểm cuối dùng để kiểm thử. Để trống để tự động phát hiện.", "Override the endpoint used for testing. Leave empty to auto detect.": "Ghi đè điểm cuối dùng để kiểm thử. Để trống để tự động phát hiện.",
"Override user quota": "Ghi đè hạn mức người dùng",
"overrides for matching model prefix.": "ghi đè theo tiền tố model tương ứng.", "overrides for matching model prefix.": "ghi đè theo tiền tố model tương ứng.",
"Overrode user quota from {{from}} to {{to}}": "Đã ghi đè hạn mức người dùng từ {{from}} thành {{to}}", "Overrode user quota from {{from}} to {{to}}": "Đã ghi đè hạn mức người dùng từ {{from}} thành {{to}}",
"Overview": "Tổng quan", "Overview": "Tổng quan",
...@@ -3890,6 +3923,9 @@ ...@@ -3890,6 +3923,9 @@
"Quota": "Hạn ngạch", "Quota": "Hạn ngạch",
"Quota ({{currency}})": "Hạn mức ({{currency}})", "Quota ({{currency}})": "Hạn mức ({{currency}})",
"Quota adjusted successfully": "Điều chỉnh hạn mức thành công", "Quota adjusted successfully": "Điều chỉnh hạn mức thành công",
"Quota adjustment details": "Chi tiết điều chỉnh hạn mức",
"Quota after adjustment": "Hạn mức sau điều chỉnh",
"Quota before adjustment": "Hạn mức trước điều chỉnh",
"Quota clamped": "Hạn ngạch bị giới hạn", "Quota clamped": "Hạn ngạch bị giới hạn",
"Quota consumed before charging users": "Hạn mức tiêu thụ trước khi tính phí người dùng", "Quota consumed before charging users": "Hạn mức tiêu thụ trước khi tính phí người dùng",
"Quota Distribution": "Phân bổ hạn ngạch", "Quota Distribution": "Phân bổ hạn ngạch",
...@@ -3905,6 +3941,8 @@ ...@@ -3905,6 +3941,8 @@
"Quota saturation protection triggered": "Đã kích hoạt bảo vệ chống tràn hạn ngạch", "Quota saturation protection triggered": "Đã kích hoạt bảo vệ chống tràn hạn ngạch",
"Quota Settings": "Cài đặt Hạn mức", "Quota Settings": "Cài đặt Hạn mức",
"Quota Types": "Các loại hạn ngạch", "Quota Types": "Các loại hạn ngạch",
"Quota unchanged": "Hạn mức không thay đổi",
"Quota update failed": "Cập nhật hạn mức thất bại",
"Quota Warning Threshold": "Ngưỡng cảnh báo hạn mức", "Quota Warning Threshold": "Ngưỡng cảnh báo hạn mức",
"Quota:": "Hạn ngạch:", "Quota:": "Hạn ngạch:",
"Radius": "Bo góc", "Radius": "Bo góc",
...@@ -4104,6 +4142,14 @@ ...@@ -4104,6 +4142,14 @@
"Request success rate; {{incidents}} incident buckets in the last 24 hours": "Tỷ lệ yêu cầu thành công; {{incidents}} khoảng có sự cố trong 24 giờ qua", "Request success rate; {{incidents}} incident buckets in the last 24 hours": "Tỷ lệ yêu cầu thành công; {{incidents}} khoảng có sự cố trong 24 giờ qua",
"Request timed out, please refresh and restart GitHub login": "Yêu cầu đã hết thời gian chờ, vui lòng làm mới và đăng nhập lại GitHub", "Request timed out, please refresh and restart GitHub login": "Yêu cầu đã hết thời gian chờ, vui lòng làm mới và đăng nhập lại GitHub",
"Request-based": "Theo yêu cầu", "Request-based": "Theo yêu cầu",
"Requested items": "Số mục yêu cầu",
"Requested quota": "Số tiền yêu cầu",
"Requested quota: {{quota}}": "Số tiền yêu cầu: {{quota}}",
"Requested token IDs": "ID mã thông báo được yêu cầu",
"Requested token IDs truncated": "Danh sách ID mã thông báo yêu cầu đã được cắt ngắn",
"Requested: {{total}}": "Yêu cầu: {{total}}",
"Requested: {{total}} · Deleted: {{processed}}": "Yêu cầu: {{total}} · Đã xóa: {{processed}}",
"Requested: {{total}} · Returned: {{processed}}": "Yêu cầu: {{total}} · Trả về: {{processed}}",
"Requests": "Yêu cầu", "Requests": "Yêu cầu",
"Requests (24h)": "Yêu cầu (24h)", "Requests (24h)": "Yêu cầu (24h)",
"Requests / 24h": "Yêu cầu / 24h", "Requests / 24h": "Yêu cầu / 24h",
...@@ -4194,6 +4240,9 @@ ...@@ -4194,6 +4240,9 @@
"Return to dashboard": "Quay lại bảng điều khiển", "Return to dashboard": "Quay lại bảng điều khiển",
"Return to the original window to continue.": "Quay lại cửa sổ ban đầu để tiếp tục.", "Return to the original window to continue.": "Quay lại cửa sổ ban đầu để tiếp tục.",
"Return vector embeddings for inputs": "Trả về vector embedding cho đầu vào", "Return vector embeddings for inputs": "Trả về vector embedding cho đầu vào",
"Returned keys": "Số khóa trả về",
"Returned token IDs": "ID mã thông báo được trả về",
"Returned: {{processed}}": "Trả về: {{processed}}",
"Reveal API key": "Hiển thị khóa API", "Reveal API key": "Hiển thị khóa API",
"Reveal key": "Display key", "Reveal key": "Display key",
"Revenue": "Doanh thu", "Revenue": "Doanh thu",
...@@ -4627,6 +4676,7 @@ ...@@ -4627,6 +4676,7 @@
"Started two-factor authentication setup": "Đã bắt đầu thiết lập xác thực hai yếu tố", "Started two-factor authentication setup": "Đã bắt đầu thiết lập xác thực hai yếu tố",
"STARTTLS": "STARTTLS", "STARTTLS": "STARTTLS",
"State changed": "Trạng thái đã thay đổi", "State changed": "Trạng thái đã thay đổi",
"State unchanged: {{status}}": "Trạng thái không đổi: {{status}}",
"Static page describing the platform.": "Trang tĩnh mô tả nền tảng.", "Static page describing the platform.": "Trang tĩnh mô tả nền tảng.",
"Statistical count": "Số đếm thống kê", "Statistical count": "Số đếm thống kê",
"Statistical quota": "Chỉ tiêu thống kê", "Statistical quota": "Chỉ tiêu thống kê",
...@@ -4634,6 +4684,7 @@ ...@@ -4634,6 +4684,7 @@
"Statistics reset": "Đã đặt lại thống kê", "Statistics reset": "Đã đặt lại thống kê",
"Status": "Trạng thái", "Status": "Trạng thái",
"Status & Sync": "Trạng thái & Đồng bộ", "Status & Sync": "Trạng thái & Đồng bộ",
"Status change": "Thay đổi trạng thái",
"Status Code": "Mã trạng thái", "Status Code": "Mã trạng thái",
"Status Code Mapping": "Ánh xạ mã trạng thái", "Status Code Mapping": "Ánh xạ mã trạng thái",
"Status code mapping must use valid HTTP status codes": "Ánh xạ mã trạng thái phải dùng mã trạng thái HTTP hợp lệ", "Status code mapping must use valid HTTP status codes": "Ánh xạ mã trạng thái phải dùng mã trạng thái HTTP hợp lệ",
...@@ -4781,8 +4832,11 @@ ...@@ -4781,8 +4832,11 @@
"Target Field Path": "Đường dẫn trường đích", "Target Field Path": "Đường dẫn trường đích",
"Target group": "Target audience", "Target group": "Target audience",
"Target Header": "Header đích", "Target Header": "Header đích",
"Target not recorded": "Chưa ghi nhận đối tượng",
"Target Path (optional)": "Đường dẫn đích (tùy chọn)", "Target Path (optional)": "Đường dẫn đích (tùy chọn)",
"Target User": "Người dùng mục tiêu", "Target User": "Người dùng mục tiêu",
"Target user not found": "Không tìm thấy người dùng đích",
"Target username": "Tên người dùng đích",
"Task": "Nhiệm vụ", "Task": "Nhiệm vụ",
"Task billing": "Tính phí tác vụ", "Task billing": "Tính phí tác vụ",
"Task Details": "Chi tiết tác vụ", "Task Details": "Chi tiết tác vụ",
...@@ -5056,6 +5110,7 @@ ...@@ -5056,6 +5110,7 @@
"Token estimator": "Ước tính token", "Token estimator": "Ước tính token",
"Token group": "Nhóm token", "Token group": "Nhóm token",
"Token has no group": "Token không có nhóm", "Token has no group": "Token không có nhóm",
"Token ID": "ID mã thông báo",
"Token identifier": "Mã định danh token", "Token identifier": "Mã định danh token",
"Token Limits": "Giới hạn token", "Token Limits": "Giới hạn token",
"Token management": "Quản lý token", "Token management": "Quản lý token",
...@@ -5063,6 +5118,7 @@ ...@@ -5063,6 +5118,7 @@
"Token Mgmt": "Quản lý Token", "Token Mgmt": "Quản lý Token",
"Token Name": "Tên mã thông báo", "Token Name": "Tên mã thông báo",
"Token obtained from your Gotify application": "Mã thông báo thu được từ ứng dụng Gotify của bạn", "Token obtained from your Gotify application": "Mã thông báo thu được từ ứng dụng Gotify của bạn",
"Token operation details": "Chi tiết thao tác mã thông báo",
"Token price for audio input.": "Giá token cho đầu vào âm thanh.", "Token price for audio input.": "Giá token cho đầu vào âm thanh.",
"Token price for audio output.": "Giá token cho đầu ra âm thanh.", "Token price for audio output.": "Giá token cho đầu ra âm thanh.",
"Token price for cache reads.": "Giá token cho lượt đọc cache.", "Token price for cache reads.": "Giá token cho lượt đọc cache.",
...@@ -5245,6 +5301,8 @@ ...@@ -5245,6 +5301,8 @@
"Update": "Cập nhật", "Update": "Cập nhật",
"Update All Balances": "Cập nhật tất cả số dư", "Update All Balances": "Cập nhật tất cả số dư",
"Update API Key": "Cập nhật Khóa API", "Update API Key": "Cập nhật Khóa API",
"Update API token": "Cập nhật mã thông báo API",
"Update API token “{{name}}”": "Cập nhật mã thông báo API “{{name}}”",
"Update Balance": "Cập nhật số dư", "Update Balance": "Cập nhật số dư",
"Update balance for:": "Cập nhật số dư cho:", "Update balance for:": "Cập nhật số dư cho:",
"Update Channel": "Cập nhật kênh", "Update Channel": "Cập nhật kênh",
...@@ -5504,6 +5562,8 @@ ...@@ -5504,6 +5562,8 @@
"Vidu": "Vidu", "Vidu": "Vidu",
"View": "Xem", "View": "Xem",
"View all currently available models": "Xem tất cả mô hình hiện có", "View all currently available models": "Xem tất cả mô hình hiện có",
"View API token key": "Xem khóa mã thông báo API",
"View API token keys in batch": "Xem hàng loạt khóa mã thông báo API",
"View audit records from user and admin roles. Root records are always excluded.": "Xem bản ghi kiểm toán của vai trò user và admin. Luôn loại trừ bản ghi của vai trò root.", "View audit records from user and admin roles. Root records are always excluded.": "Xem bản ghi kiểm toán của vai trò user và admin. Luôn loại trừ bản ghi của vai trò root.",
"View channel lists and details without secrets.": "Xem danh sách và chi tiết kênh không chứa bí mật.", "View channel lists and details without secrets.": "Xem danh sách và chi tiết kênh không chứa bí mật.",
"View channel secrets": "Xem bí mật kênh", "View channel secrets": "Xem bí mật kênh",
...@@ -5511,6 +5571,7 @@ ...@@ -5511,6 +5571,7 @@
"View details": "Xem chi tiết", "View details": "Xem chi tiết",
"View document": "Xem tài liệu", "View document": "Xem tài liệu",
"View issued reset credits, grant dates, and expiration.": "Xem các lượt đặt lại đã cấp, ngày cấp và thời điểm hết hạn.", "View issued reset credits, grant dates, and expiration.": "Xem các lượt đặt lại đã cấp, ngày cấp và thời điểm hết hạn.",
"View key for API token “{{name}}”": "Xem khóa mã thông báo API “{{name}}”",
"View logs": "Xem nhật ký", "View logs": "Xem nhật ký",
"View mode": "Chế độ xem", "View mode": "Chế độ xem",
"View model statistics and charts": "Xem thống kê và biểu đồ mô hình", "View model statistics and charts": "Xem thống kê và biểu đồ mô hình",
...@@ -5566,6 +5627,7 @@ ...@@ -5566,6 +5627,7 @@
"Wallet Management": "Quản lý ví", "Wallet Management": "Quản lý ví",
"Wallet management and personal preferences.": "Quản lý ví và sở thích cá nhân.", "Wallet management and personal preferences.": "Quản lý ví và sở thích cá nhân.",
"Wallet Only": "Chỉ dùng ví", "Wallet Only": "Chỉ dùng ví",
"Wallet quota limit exceeded": "Vượt phạm vi hạn mức ví",
"Warning": "Cảnh báo", "Warning": "Cảnh báo",
"Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.": "Cảnh báo: URL cơ sở không nên kết thúc bằng /v1. API mới sẽ xử lý tự động. Điều này có thể gây ra lỗi yêu cầu.", "Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.": "Cảnh báo: URL cơ sở không nên kết thúc bằng /v1. API mới sẽ xử lý tự động. Điều này có thể gây ra lỗi yêu cầu.",
"Warning: Disabling 2FA will make your account less secure.": "Cảnh báo: Vô hiệu hóa 2FA sẽ khiến tài khoản của bạn kém an toàn hơn.", "Warning: Disabling 2FA will make your account less secure.": "Cảnh báo: Vô hiệu hóa 2FA sẽ khiến tài khoản của bạn kém an toàn hơn.",
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
"(billed as vip itself, so base ratio of vip)": "(按 vip 自身收費,取 vip 的基礎倍率)", "(billed as vip itself, so base ratio of vip)": "(按 vip 自身收費,取 vip 的基礎倍率)",
"(falls back to billing as vip, so base ratio of vip)": "(回退按 vip 收費,用 vip 的基礎倍率)", "(falls back to billing as vip, so base ratio of vip)": "(回退按 vip 收費,用 vip 的基礎倍率)",
"(hits the override rule above)": "(命中上面的覆蓋規則)", "(hits the override rule above)": "(命中上面的覆蓋規則)",
"(ID: {{id}})": "(ID: {{id}})",
"(instead of {{ratio}})": "(原本是 {{ratio}})", "(instead of {{ratio}})": "(原本是 {{ratio}})",
"(Leave empty to dissolve tag)": "(留空以刪除標籤)", "(Leave empty to dissolve tag)": "(留空以刪除標籤)",
"(matrix cell vip × premium is set)": "(矩陣單元 vip × premium 已設定)", "(matrix cell vip × premium is set)": "(矩陣單元 vip × premium 已設定)",
...@@ -69,6 +70,7 @@ ...@@ -69,6 +70,7 @@
"{{modality}} not supported": "不支援 {{modality}}", "{{modality}} not supported": "不支援 {{modality}}",
"{{modality}} supported": "支援 {{modality}}", "{{modality}} supported": "支援 {{modality}}",
"{{n}} model(s) selected": "已選 {{n}} 個模型", "{{n}} model(s) selected": "已選 {{n}} 個模型",
"{{operation}} (ID: {{id}})": "{{operation}}(ID: {{id}})",
"{{processed}} of {{total}} log entries processed.": "已處理 {{processed}} / {{total}} 條日誌。", "{{processed}} of {{total}} log entries processed.": "已處理 {{processed}} / {{total}} 條日誌。",
"{{protocol}} auth name": "{{protocol}} 驗證名稱", "{{protocol}} auth name": "{{protocol}} 驗證名稱",
"{{protocol}} auth value": "{{protocol}} 驗證值", "{{protocol}} auth value": "{{protocol}} 驗證值",
...@@ -261,8 +263,11 @@ ...@@ -261,8 +263,11 @@
"Additional verification required": "需要進一步驗證", "Additional verification required": "需要進一步驗證",
"Adjust filters, then search to refresh the logs.": "調整篩選條件,然後搜尋以重新整理日誌。", "Adjust filters, then search to refresh the logs.": "調整篩選條件,然後搜尋以重新整理日誌。",
"Adjust Quota": "調整額度", "Adjust Quota": "調整額度",
"Adjust quota for user “{{name}}”": "調整使用者「{{name}}」的額度",
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "調整回應格式、提示詞行為、代理和上游自動化。", "Adjust response formatting, prompt behavior, proxy, and upstream automation.": "調整回應格式、提示詞行為、代理和上游自動化。",
"Adjust the appearance and layout to suit your preferences.": "調整外觀和佈局以配合您的偏好。", "Adjust the appearance and layout to suit your preferences.": "調整外觀和佈局以配合您的偏好。",
"Adjust user quota": "調整使用者額度",
"Adjustment mode": "調整方式",
"Admin": "管理員", "Admin": "管理員",
"Admin access required": "需要管理員權限", "Admin access required": "需要管理員權限",
"Admin area": "管理員區域", "Admin area": "管理員區域",
...@@ -439,7 +444,14 @@ ...@@ -439,7 +444,14 @@
"API Private Key": "API 私鑰", "API Private Key": "API 私鑰",
"API Requests": "API 請求", "API Requests": "API 請求",
"API secret": "API 密鑰", "API secret": "API 密鑰",
"API token batch deletion": "API 權杖批次刪除",
"API token batch key access": "API 權杖金鑰批次檢視",
"API token configuration update": "API 權杖設定更新",
"API token creation": "API 權杖建立",
"API token deletion": "API 權杖刪除",
"API token key access": "API 權杖金鑰檢視",
"API token management": "API令牌管理", "API token management": "API令牌管理",
"API token status update": "API 權杖狀態更新",
"API URL": "API URL", "API URL": "API URL",
"API usage records": "API使用記錄", "API usage records": "API使用記錄",
"API version": "API 版本", "API version": "API 版本",
...@@ -649,6 +661,7 @@ ...@@ -649,6 +661,7 @@
"Basic Templates": "基礎模板", "Basic Templates": "基礎模板",
"Batch Add (one key per line)": "大量新增(每行一個金鑰)", "Batch Add (one key per line)": "大量新增(每行一個金鑰)",
"Batch channel test": "渠道大量測試", "Batch channel test": "渠道大量測試",
"Batch delete API tokens": "批次刪除 API 權杖",
"Batch delete failed": "大量刪除失敗", "Batch delete failed": "大量刪除失敗",
"Batch deleted {{count}} channels": "大量刪除 {{count}} 個渠道", "Batch deleted {{count}} channels": "大量刪除 {{count}} 個渠道",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "大量檢測完成:渠道 {{channels}} 個,新增 {{add}} 個,刪除 {{remove}} 個,失敗 {{fails}} 個", "Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "大量檢測完成:渠道 {{channels}} 個,新增 {{add}} 個,刪除 {{remove}} 個,失敗 {{fails}} 個",
...@@ -807,6 +820,7 @@ ...@@ -807,6 +820,7 @@
"Change To": "更改為", "Change To": "更改為",
"Changed / Total": "已變更 / 總數", "Changed / Total": "已變更 / 總數",
"Changed Fields": "變更欄位", "Changed Fields": "變更欄位",
"Changed fields: {{fields}}": "修改欄位:{{fields}}",
"Changes are written to the settings draft on save.": "儲存後會寫入設定草稿。", "Changes are written to the settings draft on save.": "儲存後會寫入設定草稿。",
"Changing...": "修改中...", "Changing...": "修改中...",
"Channel": "渠道", "Channel": "渠道",
...@@ -1232,6 +1246,8 @@ ...@@ -1232,6 +1246,8 @@
"Create an API key to unlock the real request": "建立 API 金鑰以解鎖真實請求", "Create an API key to unlock the real request": "建立 API 金鑰以解鎖真實請求",
"Create and review invite or credit codes.": "建立和審查邀請或信用代碼。", "Create and review invite or credit codes.": "建立和審查邀請或信用代碼。",
"Create API Key": "建立 API 金鑰", "Create API Key": "建立 API 金鑰",
"Create API token": "建立 API 權杖",
"Create API token “{{name}}”": "建立 API 權杖「{{name}}」",
"Create cache": "建立緩存", "Create cache": "建立緩存",
"Create cache ratio": "建立緩存倍率", "Create cache ratio": "建立緩存倍率",
"Create Channel": "建立渠道", "Create Channel": "建立渠道",
...@@ -1357,6 +1373,8 @@ ...@@ -1357,6 +1373,8 @@
"decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "決定儲值倍率、用戶建令牌時可選哪些分組,以及是否命中覆蓋倍率。", "decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "決定儲值倍率、用戶建令牌時可選哪些分組,以及是否命中覆蓋倍率。",
"decides which channels are used and which base ratio applies.": "決定走哪些渠道、用哪個基礎倍率。", "decides which channels are used and which base ratio applies.": "決定走哪些渠道、用哪個基礎倍率。",
"Declared capabilities": "宣告的能力", "Declared capabilities": "宣告的能力",
"Decrease quota for user “{{name}}”": "減少使用者「{{name}}」的額度",
"Decrease user quota": "減少使用者額度",
"Decreased user quota by {{quota}}": "減少用戶額度 {{quota}}", "Decreased user quota by {{quota}}": "減少用戶額度 {{quota}}",
"Deducted by subscription": "由訂閱抵扣", "Deducted by subscription": "由訂閱抵扣",
"DeepSeek": "DeepSeek", "DeepSeek": "DeepSeek",
...@@ -1393,6 +1411,8 @@ ...@@ -1393,6 +1411,8 @@
"Delete All Disabled": "刪除所有已停用", "Delete All Disabled": "刪除所有已停用",
"Delete All Disabled Channels?": "刪除所有已停用的渠道?", "Delete All Disabled Channels?": "刪除所有已停用的渠道?",
"Delete all stale": "刪除所有失聯", "Delete all stale": "刪除所有失聯",
"Delete API token": "刪除 API 權杖",
"Delete API token “{{name}}”": "刪除 API 權杖「{{name}}」",
"Delete Auto-Disabled": "刪除自動停用", "Delete Auto-Disabled": "刪除自動停用",
"Delete Channel": "刪除渠道", "Delete Channel": "刪除渠道",
"Delete Channels?": "刪除渠道?", "Delete Channels?": "刪除渠道?",
...@@ -1439,7 +1459,9 @@ ...@@ -1439,7 +1459,9 @@
"Deleted invalid redemption codes": "刪除無效兌換碼", "Deleted invalid redemption codes": "刪除無效兌換碼",
"Deleted stale instance": "已刪除失聯實例", "Deleted stale instance": "已刪除失聯實例",
"Deleted successfully": "刪除成功", "Deleted successfully": "刪除成功",
"Deleted tokens": "實際刪除數",
"Deleted user {{username}} (ID: {{id}})": "刪除用戶 {{username}}(ID: {{id}})", "Deleted user {{username}} (ID: {{id}})": "刪除用戶 {{username}}(ID: {{id}})",
"Deleted: {{processed}}": "實際刪除 {{processed}} 個",
"Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "刪除此自定义版本不会停用平台,同名內建外掛将自动恢复。", "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "刪除此自定义版本不会停用平台,同名內建外掛将自动恢复。",
"Deleting will permanently remove this subscription record (including benefit details). Continue?": "刪除會徹底移除該訂閱記錄(含權益明細)。是否繼續?", "Deleting will permanently remove this subscription record (including benefit details). Continue?": "刪除會徹底移除該訂閱記錄(含權益明細)。是否繼續?",
"Deleting...": "刪除中...", "Deleting...": "刪除中...",
...@@ -2077,6 +2099,7 @@ ...@@ -2077,6 +2099,7 @@
"Failed to update tag": "更新標籤失敗", "Failed to update tag": "更新標籤失敗",
"Failed to update user": "更新用戶失敗", "Failed to update user": "更新用戶失敗",
"Failure keywords": "失敗關鍵詞", "Failure keywords": "失敗關鍵詞",
"Failure reason": "失敗原因",
"Fair": "公平", "Fair": "公平",
"Fallback": "兜底", "Fallback": "兜底",
"Fallback base URL": "兜底 Base URL", "Fallback base URL": "兜底 Base URL",
...@@ -2491,6 +2514,8 @@ ...@@ -2491,6 +2514,8 @@
"Incoming path must not include query": "入口路徑不能包含 query", "Incoming path must not include query": "入口路徑不能包含 query",
"Incoming path must start with /": "入口路徑必須以 / 開頭", "Incoming path must start with /": "入口路徑必須以 / 開頭",
"Incomplete": "未完成", "Incomplete": "未完成",
"Increase quota for user “{{name}}”": "增加使用者「{{name}}」的額度",
"Increase user quota": "增加使用者額度",
"Increased user quota by {{quota}}": "增加用戶額度 {{quota}}", "Increased user quota by {{quota}}": "增加用戶額度 {{quota}}",
"Index": "索引", "Index": "索引",
"Index request failed with HTTP {{status}}": "索引請求失敗,HTTP {{status}}", "Index request failed with HTTP {{status}}": "索引請求失敗,HTTP {{status}}",
...@@ -2524,6 +2549,7 @@ ...@@ -2524,6 +2549,7 @@
"Instance": "實例", "Instance": "實例",
"Instances": "實例", "Instances": "實例",
"Insufficient balance": "餘額不足", "Insufficient balance": "餘額不足",
"Insufficient permission to adjust this user": "無權調整此使用者的額度",
"Integrations": "整合", "Integrations": "整合",
"Integrity check failed": "完整性驗證失敗", "Integrity check failed": "完整性驗證失敗",
"Integrity hash": "完整性雜湊", "Integrity hash": "完整性雜湊",
...@@ -2535,6 +2561,7 @@ ...@@ -2535,6 +2561,7 @@
"Internal Server Error!": "內部伺服器錯誤!", "Internal Server Error!": "內部伺服器錯誤!",
"Interval must be at least 1 minute": "間隔必須至少為 1 分鐘", "Interval must be at least 1 minute": "間隔必須至少為 1 分鐘",
"Invalid (NaN)": "無效 (NaN)", "Invalid (NaN)": "無效 (NaN)",
"Invalid adjustment parameters": "額度調整參數無效",
"Invalid chat link. Please contact the administrator.": "無效的聊天連結。請聯絡管理員。", "Invalid chat link. Please contact the administrator.": "無效的聊天連結。請聯絡管理員。",
"Invalid chat link. Please contact your administrator.": "無效的聊天連結。請聯絡您的管理員。", "Invalid chat link. Please contact your administrator.": "無效的聊天連結。請聯絡您的管理員。",
"Invalid code": "無效代碼", "Invalid code": "無效代碼",
...@@ -2899,6 +2926,7 @@ ...@@ -2899,6 +2926,7 @@
"Model fixed pricing": "模型固定定價", "Model fixed pricing": "模型固定定價",
"Model Group": "模型分組", "Model Group": "模型分組",
"Model Limits": "模型限制", "Model Limits": "模型限制",
"Model limits enabled": "已啟用模型限制",
"Model List": "模型列表", "Model List": "模型列表",
"Model Mapping": "模型映射", "Model Mapping": "模型映射",
"Model Mapping (JSON)": "模型映射 (JSON)", "Model Mapping (JSON)": "模型映射 (JSON)",
...@@ -3273,6 +3301,7 @@ ...@@ -3273,6 +3301,7 @@
"Not included": "未加入", "Not included": "未加入",
"Not installed": "未安裝", "Not installed": "未安裝",
"Not provided by this source": "該來源未提供", "Not provided by this source": "該來源未提供",
"Not recorded": "未記錄",
"Not registered": "未注册", "Not registered": "未注册",
"Not set": "未設定", "Not set": "未設定",
"Not Set": "未設定", "Not Set": "未設定",
...@@ -3365,6 +3394,8 @@ ...@@ -3365,6 +3394,8 @@
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "僅選定的欄位將會被覆蓋。如果出現新的衝突,您可以重新執行同步精靈。", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "僅選定的欄位將會被覆蓋。如果出現新的衝突,您可以重新執行同步精靈。",
"Only successful requests": "僅成功的請求", "Only successful requests": "僅成功的請求",
"Only successful requests count toward this limit.": "僅成功的請求計入此限制。", "Only successful requests count toward this limit.": "僅成功的請求計入此限制。",
"Only the first {{shown}} IDs were recorded": "僅記錄前 {{shown}} 項 ID",
"Only the first {{shown}} IDs were recorded ({{total}} requested)": "僅記錄前 {{shown}} 項,共請求 {{total}} 項",
"Only the last {{value}} log files will be retained; the rest will be deleted.": "將只保留最近 {{value}} 個日誌檔案,其餘將被刪除。", "Only the last {{value}} log files will be retained; the rest will be deleted.": "將只保留最近 {{value}} 個日誌檔案,其餘將被刪除。",
"Only used to find historical logs. New records are available in Audit Logs.": "僅用於查詢歷史日誌,新記錄請前往稽核日誌查看。", "Only used to find historical logs. New records are available in Audit Logs.": "僅用於查詢歷史日誌,新記錄請前往稽核日誌查看。",
"Oops! Page Not Found!": "糟糕!頁面未找到!", "Oops! Page Not Found!": "糟糕!頁面未找到!",
...@@ -3466,6 +3497,7 @@ ...@@ -3466,6 +3497,7 @@
"Override": "覆蓋", "Override": "覆蓋",
"Override auto-discovered endpoint": "覆蓋自動發現的端點", "Override auto-discovered endpoint": "覆蓋自動發現的端點",
"Override matrix": "覆蓋矩陣", "Override matrix": "覆蓋矩陣",
"Override quota for user “{{name}}”": "覆寫使用者「{{name}}」的額度",
"Override request headers": "覆蓋請求標頭", "Override request headers": "覆蓋請求標頭",
"Override request headers (JSON format)": "覆蓋請求頭(JSON 格式)", "Override request headers (JSON format)": "覆蓋請求頭(JSON 格式)",
"Override request parameters": "覆蓋請求參數", "Override request parameters": "覆蓋請求參數",
...@@ -3475,6 +3507,7 @@ ...@@ -3475,6 +3507,7 @@
"Override rule: when a vip user is billed as premium, the ratio is 0.3 instead of 0.5": "覆蓋規則:vip 用戶按 premium 收費時,倍率用 0.3 而不是 0.5", "Override rule: when a vip user is billed as premium, the ratio is 0.3 instead of 0.5": "覆蓋規則:vip 用戶按 premium 收費時,倍率用 0.3 而不是 0.5",
"Override Rules": "覆蓋規則", "Override Rules": "覆蓋規則",
"Override the endpoint used for testing. Leave empty to auto detect.": "覆蓋用於測試的端點。留空以自動偵測。", "Override the endpoint used for testing. Leave empty to auto detect.": "覆蓋用於測試的端點。留空以自動偵測。",
"Override user quota": "覆寫使用者額度",
"overrides for matching model prefix.": "為匹配模型前綴的覆蓋價。", "overrides for matching model prefix.": "為匹配模型前綴的覆蓋價。",
"Overrode user quota from {{from}} to {{to}}": "覆蓋用戶額度,從 {{from}} 改為 {{to}}", "Overrode user quota from {{from}} to {{to}}": "覆蓋用戶額度,從 {{from}} 改為 {{to}}",
"Overview": "概覽", "Overview": "概覽",
...@@ -3890,6 +3923,9 @@ ...@@ -3890,6 +3923,9 @@
"Quota": "額度", "Quota": "額度",
"Quota ({{currency}})": "額度 ({{currency}})", "Quota ({{currency}})": "額度 ({{currency}})",
"Quota adjusted successfully": "調整額度成功", "Quota adjusted successfully": "調整額度成功",
"Quota adjustment details": "額度調整詳情",
"Quota after adjustment": "調整後額度",
"Quota before adjustment": "調整前額度",
"Quota clamped": "額度已限制", "Quota clamped": "額度已限制",
"Quota consumed before charging users": "向用戶收費前消耗的配額", "Quota consumed before charging users": "向用戶收費前消耗的配額",
"Quota Distribution": "消耗分佈", "Quota Distribution": "消耗分佈",
...@@ -3905,6 +3941,8 @@ ...@@ -3905,6 +3941,8 @@
"Quota saturation protection triggered": "已觸發額度飽和保護", "Quota saturation protection triggered": "已觸發額度飽和保護",
"Quota Settings": "額度設定", "Quota Settings": "額度設定",
"Quota Types": "配額類型", "Quota Types": "配額類型",
"Quota unchanged": "額度未變更",
"Quota update failed": "額度更新失敗",
"Quota Warning Threshold": "配額警告閾值", "Quota Warning Threshold": "配額警告閾值",
"Quota:": "Quota:", "Quota:": "Quota:",
"Radius": "圓角", "Radius": "圓角",
...@@ -4104,6 +4142,14 @@ ...@@ -4104,6 +4142,14 @@
"Request success rate; {{incidents}} incident buckets in the last 24 hours": "請求成功率;最近 24 小時 {{incidents}} 個異常桶", "Request success rate; {{incidents}} incident buckets in the last 24 hours": "請求成功率;最近 24 小時 {{incidents}} 個異常桶",
"Request timed out, please refresh and restart GitHub login": "請求逾時,請重新整理頁面後重新發起 GitHub 登入", "Request timed out, please refresh and restart GitHub login": "請求逾時,請重新整理頁面後重新發起 GitHub 登入",
"Request-based": "含請求條件", "Request-based": "含請求條件",
"Requested items": "請求項數",
"Requested quota": "請求金額",
"Requested quota: {{quota}}": "請求金額:{{quota}}",
"Requested token IDs": "請求的權杖 ID",
"Requested token IDs truncated": "請求的權杖 ID 已截斷",
"Requested: {{total}}": "請求 {{total}} 項",
"Requested: {{total}} · Deleted: {{processed}}": "請求 {{total}} 項,實際刪除 {{processed}} 個",
"Requested: {{total}} · Returned: {{processed}}": "請求 {{total}} 項,傳回 {{processed}} 個",
"Requests": "請求數", "Requests": "請求數",
"Requests (24h)": "請求數(24 小時)", "Requests (24h)": "請求數(24 小時)",
"Requests / 24h": "請求 / 24 小時", "Requests / 24h": "請求 / 24 小時",
...@@ -4194,6 +4240,9 @@ ...@@ -4194,6 +4240,9 @@
"Return to dashboard": "返回儀表板", "Return to dashboard": "返回儀表板",
"Return to the original window to continue.": "返回原視窗繼續操作。", "Return to the original window to continue.": "返回原視窗繼續操作。",
"Return vector embeddings for inputs": "為輸入返回向量嵌入", "Return vector embeddings for inputs": "為輸入返回向量嵌入",
"Returned keys": "傳回金鑰數",
"Returned token IDs": "傳回的權杖 ID",
"Returned: {{processed}}": "傳回 {{processed}} 個",
"Reveal API key": "顯示 API 金鑰", "Reveal API key": "顯示 API 金鑰",
"Reveal key": "顯示金鑰", "Reveal key": "顯示金鑰",
"Revenue": "收入", "Revenue": "收入",
...@@ -4627,6 +4676,7 @@ ...@@ -4627,6 +4676,7 @@
"Started two-factor authentication setup": "開始設定兩步驟驗證", "Started two-factor authentication setup": "開始設定兩步驟驗證",
"STARTTLS": "STARTTLS", "STARTTLS": "STARTTLS",
"State changed": "狀態已變更", "State changed": "狀態已變更",
"State unchanged: {{status}}": "狀態未變更:{{status}}",
"Static page describing the platform.": "描述平台的靜態頁面。", "Static page describing the platform.": "描述平台的靜態頁面。",
"Statistical count": "統計計數", "Statistical count": "統計計數",
"Statistical quota": "統計配額", "Statistical quota": "統計配額",
...@@ -4634,6 +4684,7 @@ ...@@ -4634,6 +4684,7 @@
"Statistics reset": "統計已重設", "Statistics reset": "統計已重設",
"Status": "狀態", "Status": "狀態",
"Status & Sync": "狀態與同步", "Status & Sync": "狀態與同步",
"Status change": "狀態變更",
"Status Code": "狀態碼", "Status Code": "狀態碼",
"Status Code Mapping": "狀態碼映射", "Status Code Mapping": "狀態碼映射",
"Status code mapping must use valid HTTP status codes": "狀態碼映射必須使用有效的 HTTP 狀態碼", "Status code mapping must use valid HTTP status codes": "狀態碼映射必須使用有效的 HTTP 狀態碼",
...@@ -4781,8 +4832,11 @@ ...@@ -4781,8 +4832,11 @@
"Target Field Path": "目標欄位路徑", "Target Field Path": "目標欄位路徑",
"Target group": "目標分組", "Target group": "目標分組",
"Target Header": "目標請求頭", "Target Header": "目標請求頭",
"Target not recorded": "未記錄目標",
"Target Path (optional)": "目標路徑(可選)", "Target Path (optional)": "目標路徑(可選)",
"Target User": "目標用戶", "Target User": "目標用戶",
"Target user not found": "目標使用者不存在",
"Target username": "目標使用者名稱",
"Task": "任務", "Task": "任務",
"Task billing": "任務計費", "Task billing": "任務計費",
"Task Details": "任務詳情", "Task Details": "任務詳情",
...@@ -5056,6 +5110,7 @@ ...@@ -5056,6 +5110,7 @@
"Token estimator": "Token 估算器", "Token estimator": "Token 估算器",
"Token group": "令牌分組", "Token group": "令牌分組",
"Token has no group": "令牌未設定分組", "Token has no group": "令牌未設定分組",
"Token ID": "權杖 ID",
"Token identifier": "權杖識別碼", "Token identifier": "權杖識別碼",
"Token Limits": "令牌限制", "Token Limits": "令牌限制",
"Token management": "令牌管理", "Token management": "令牌管理",
...@@ -5063,6 +5118,7 @@ ...@@ -5063,6 +5118,7 @@
"Token Mgmt": "令牌管理", "Token Mgmt": "令牌管理",
"Token Name": "令牌名稱", "Token Name": "令牌名稱",
"Token obtained from your Gotify application": "從您的 Gotify 套用程式獲取的 Token", "Token obtained from your Gotify application": "從您的 Gotify 套用程式獲取的 Token",
"Token operation details": "權杖操作詳情",
"Token price for audio input.": "音頻輸入 token 價格。", "Token price for audio input.": "音頻輸入 token 價格。",
"Token price for audio output.": "音頻輸出 token 價格。", "Token price for audio output.": "音頻輸出 token 價格。",
"Token price for cache reads.": "緩存讀取 token 價格。", "Token price for cache reads.": "緩存讀取 token 價格。",
...@@ -5245,6 +5301,8 @@ ...@@ -5245,6 +5301,8 @@
"Update": "更新", "Update": "更新",
"Update All Balances": "更新所有餘額", "Update All Balances": "更新所有餘額",
"Update API Key": "更新 API 金鑰", "Update API Key": "更新 API 金鑰",
"Update API token": "更新 API 權杖",
"Update API token “{{name}}”": "更新 API 權杖「{{name}}」",
"Update Balance": "更新餘額", "Update Balance": "更新餘額",
"Update balance for:": "更新餘額:", "Update balance for:": "更新餘額:",
"Update Channel": "更新渠道", "Update Channel": "更新渠道",
...@@ -5504,6 +5562,8 @@ ...@@ -5504,6 +5562,8 @@
"Vidu": "Vidu", "Vidu": "Vidu",
"View": "查看", "View": "查看",
"View all currently available models": "查看目前可用的所有模型", "View all currently available models": "查看目前可用的所有模型",
"View API token key": "檢視 API 權杖金鑰",
"View API token keys in batch": "批次檢視 API 權杖金鑰",
"View audit records from user and admin roles. Root records are always excluded.": "查看 user 和 admin 角色的稽核記錄,一律排除 root 層級記錄。", "View audit records from user and admin roles. Root records are always excluded.": "查看 user 和 admin 角色的稽核記錄,一律排除 root 層級記錄。",
"View channel lists and details without secrets.": "查看不含金鑰的渠道列表和詳情。", "View channel lists and details without secrets.": "查看不含金鑰的渠道列表和詳情。",
"View channel secrets": "查看渠道金鑰", "View channel secrets": "查看渠道金鑰",
...@@ -5511,6 +5571,7 @@ ...@@ -5511,6 +5571,7 @@
"View details": "查看詳情", "View details": "查看詳情",
"View document": "查看文檔", "View document": "查看文檔",
"View issued reset credits, grant dates, and expiration.": "查看已發放的重置次數、發放日期和到期時間。", "View issued reset credits, grant dates, and expiration.": "查看已發放的重置次數、發放日期和到期時間。",
"View key for API token “{{name}}”": "檢視 API 權杖「{{name}}」的金鑰",
"View logs": "查看日誌", "View logs": "查看日誌",
"View mode": "檢視模式", "View mode": "檢視模式",
"View model statistics and charts": "查看模型統計和圖表", "View model statistics and charts": "查看模型統計和圖表",
...@@ -5566,6 +5627,7 @@ ...@@ -5566,6 +5627,7 @@
"Wallet Management": "錢包管理", "Wallet Management": "錢包管理",
"Wallet management and personal preferences.": "錢包管理和個人偏好設定。", "Wallet management and personal preferences.": "錢包管理和個人偏好設定。",
"Wallet Only": "僅用錢包", "Wallet Only": "僅用錢包",
"Wallet quota limit exceeded": "超出錢包額度範圍",
"Warning": "警告", "Warning": "警告",
"Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.": "警告:基礎 URL 不應以 /v1 結尾。New API 將自動處理它。這可能導致請求失敗。", "Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.": "警告:基礎 URL 不應以 /v1 結尾。New API 將自動處理它。這可能導致請求失敗。",
"Warning: Disabling 2FA will make your account less secure.": "警告:停用雙重身份驗證將使您的用戶安全性降低。", "Warning: Disabling 2FA will make your account less secure.": "警告:停用雙重身份驗證將使您的用戶安全性降低。",
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
"(billed as vip itself, so base ratio of vip)": "(按 vip 自身计费,取 vip 的基础倍率)", "(billed as vip itself, so base ratio of vip)": "(按 vip 自身计费,取 vip 的基础倍率)",
"(falls back to billing as vip, so base ratio of vip)": "(回退按 vip 计费,用 vip 的基础倍率)", "(falls back to billing as vip, so base ratio of vip)": "(回退按 vip 计费,用 vip 的基础倍率)",
"(hits the override rule above)": "(命中上面的覆盖规则)", "(hits the override rule above)": "(命中上面的覆盖规则)",
"(ID: {{id}})": "(ID: {{id}})",
"(instead of {{ratio}})": "(原本是 {{ratio}})", "(instead of {{ratio}})": "(原本是 {{ratio}})",
"(Leave empty to dissolve tag)": "(留空以删除标签)", "(Leave empty to dissolve tag)": "(留空以删除标签)",
"(matrix cell vip × premium is set)": "(矩阵单元 vip × premium 已设置)", "(matrix cell vip × premium is set)": "(矩阵单元 vip × premium 已设置)",
...@@ -69,6 +70,7 @@ ...@@ -69,6 +70,7 @@
"{{modality}} not supported": "不支持 {{modality}}", "{{modality}} not supported": "不支持 {{modality}}",
"{{modality}} supported": "支持 {{modality}}", "{{modality}} supported": "支持 {{modality}}",
"{{n}} model(s) selected": "已选 {{n}} 个模型", "{{n}} model(s) selected": "已选 {{n}} 个模型",
"{{operation}} (ID: {{id}})": "{{operation}}(ID: {{id}})",
"{{processed}} of {{total}} log entries processed.": "已处理 {{processed}} / {{total}} 条日志。", "{{processed}} of {{total}} log entries processed.": "已处理 {{processed}} / {{total}} 条日志。",
"{{protocol}} auth name": "{{protocol}} 认证名称", "{{protocol}} auth name": "{{protocol}} 认证名称",
"{{protocol}} auth value": "{{protocol}} 认证值", "{{protocol}} auth value": "{{protocol}} 认证值",
...@@ -261,8 +263,11 @@ ...@@ -261,8 +263,11 @@
"Additional verification required": "需要进一步验证", "Additional verification required": "需要进一步验证",
"Adjust filters, then search to refresh the logs.": "调整筛选条件,然后搜索以刷新日志。", "Adjust filters, then search to refresh the logs.": "调整筛选条件,然后搜索以刷新日志。",
"Adjust Quota": "调整额度", "Adjust Quota": "调整额度",
"Adjust quota for user “{{name}}”": "调整用户「{{name}}」的额度",
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "调整响应格式、提示词行为、代理和上游自动化。", "Adjust response formatting, prompt behavior, proxy, and upstream automation.": "调整响应格式、提示词行为、代理和上游自动化。",
"Adjust the appearance and layout to suit your preferences.": "调整外观和布局以适应您的偏好。", "Adjust the appearance and layout to suit your preferences.": "调整外观和布局以适应您的偏好。",
"Adjust user quota": "调整用户额度",
"Adjustment mode": "调整方式",
"Admin": "管理员", "Admin": "管理员",
"Admin access required": "需要管理员权限", "Admin access required": "需要管理员权限",
"Admin area": "管理员区域", "Admin area": "管理员区域",
...@@ -439,7 +444,14 @@ ...@@ -439,7 +444,14 @@
"API Private Key": "API 私钥", "API Private Key": "API 私钥",
"API Requests": "API 请求", "API Requests": "API 请求",
"API secret": "API 秘钥", "API secret": "API 秘钥",
"API token batch deletion": "API 令牌批量删除",
"API token batch key access": "API 令牌密钥批量查看",
"API token configuration update": "API 令牌配置更新",
"API token creation": "API 令牌创建",
"API token deletion": "API 令牌删除",
"API token key access": "API 令牌密钥查看",
"API token management": "API令牌管理", "API token management": "API令牌管理",
"API token status update": "API 令牌状态更新",
"API URL": "API URL", "API URL": "API URL",
"API usage records": "API使用记录", "API usage records": "API使用记录",
"API version": "API 版本", "API version": "API 版本",
...@@ -649,6 +661,7 @@ ...@@ -649,6 +661,7 @@
"Basic Templates": "基础模板", "Basic Templates": "基础模板",
"Batch Add (one key per line)": "批量添加(每行一个密钥)", "Batch Add (one key per line)": "批量添加(每行一个密钥)",
"Batch channel test": "渠道批量测试", "Batch channel test": "渠道批量测试",
"Batch delete API tokens": "批量删除 API 令牌",
"Batch delete failed": "批量删除失败", "Batch delete failed": "批量删除失败",
"Batch deleted {{count}} channels": "批量删除 {{count}} 个渠道", "Batch deleted {{count}} channels": "批量删除 {{count}} 个渠道",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "批量检测完成:渠道 {{channels}} 个,新增 {{add}} 个,删除 {{remove}} 个,失败 {{fails}} 个", "Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "批量检测完成:渠道 {{channels}} 个,新增 {{add}} 个,删除 {{remove}} 个,失败 {{fails}} 个",
...@@ -807,6 +820,7 @@ ...@@ -807,6 +820,7 @@
"Change To": "更改为", "Change To": "更改为",
"Changed / Total": "已变更 / 总数", "Changed / Total": "已变更 / 总数",
"Changed Fields": "变更字段", "Changed Fields": "变更字段",
"Changed fields: {{fields}}": "修改字段:{{fields}}",
"Changes are written to the settings draft on save.": "保存后会写入设置草稿。", "Changes are written to the settings draft on save.": "保存后会写入设置草稿。",
"Changing...": "修改中...", "Changing...": "修改中...",
"Channel": "渠道", "Channel": "渠道",
...@@ -1232,6 +1246,8 @@ ...@@ -1232,6 +1246,8 @@
"Create an API key to unlock the real request": "创建 API 密钥以解锁真实请求", "Create an API key to unlock the real request": "创建 API 密钥以解锁真实请求",
"Create and review invite or credit codes.": "创建和审查邀请或信用代码。", "Create and review invite or credit codes.": "创建和审查邀请或信用代码。",
"Create API Key": "创建 API 密钥", "Create API Key": "创建 API 密钥",
"Create API token": "创建 API 令牌",
"Create API token “{{name}}”": "创建 API 令牌「{{name}}」",
"Create cache": "创建缓存", "Create cache": "创建缓存",
"Create cache ratio": "创建缓存倍率", "Create cache ratio": "创建缓存倍率",
"Create Channel": "创建渠道", "Create Channel": "创建渠道",
...@@ -1357,6 +1373,8 @@ ...@@ -1357,6 +1373,8 @@
"decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "决定充值倍率、用户建令牌时可选哪些分组,以及是否命中覆盖倍率。", "decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "决定充值倍率、用户建令牌时可选哪些分组,以及是否命中覆盖倍率。",
"decides which channels are used and which base ratio applies.": "决定走哪些渠道、用哪个基础倍率。", "decides which channels are used and which base ratio applies.": "决定走哪些渠道、用哪个基础倍率。",
"Declared capabilities": "声明的能力", "Declared capabilities": "声明的能力",
"Decrease quota for user “{{name}}”": "减少用户「{{name}}」的额度",
"Decrease user quota": "减少用户额度",
"Decreased user quota by {{quota}}": "减少用户额度 {{quota}}", "Decreased user quota by {{quota}}": "减少用户额度 {{quota}}",
"Deducted by subscription": "由订阅抵扣", "Deducted by subscription": "由订阅抵扣",
"DeepSeek": "DeepSeek", "DeepSeek": "DeepSeek",
...@@ -1393,6 +1411,8 @@ ...@@ -1393,6 +1411,8 @@
"Delete All Disabled": "删除所有已禁用", "Delete All Disabled": "删除所有已禁用",
"Delete All Disabled Channels?": "删除所有已禁用的渠道?", "Delete All Disabled Channels?": "删除所有已禁用的渠道?",
"Delete all stale": "删除所有失联", "Delete all stale": "删除所有失联",
"Delete API token": "删除 API 令牌",
"Delete API token “{{name}}”": "删除 API 令牌「{{name}}」",
"Delete Auto-Disabled": "删除自动禁用", "Delete Auto-Disabled": "删除自动禁用",
"Delete Channel": "删除渠道", "Delete Channel": "删除渠道",
"Delete Channels?": "删除渠道?", "Delete Channels?": "删除渠道?",
...@@ -1439,7 +1459,9 @@ ...@@ -1439,7 +1459,9 @@
"Deleted invalid redemption codes": "删除无效兑换码", "Deleted invalid redemption codes": "删除无效兑换码",
"Deleted stale instance": "已删除失联实例", "Deleted stale instance": "已删除失联实例",
"Deleted successfully": "删除成功", "Deleted successfully": "删除成功",
"Deleted tokens": "实际删除数",
"Deleted user {{username}} (ID: {{id}})": "删除用户 {{username}}(ID: {{id}})", "Deleted user {{username}} (ID: {{id}})": "删除用户 {{username}}(ID: {{id}})",
"Deleted: {{processed}}": "实际删除 {{processed}} 个",
"Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "删除此自定义版本不会停用平台,同名出厂插件将自动恢复。", "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "删除此自定义版本不会停用平台,同名出厂插件将自动恢复。",
"Deleting will permanently remove this subscription record (including benefit details). Continue?": "删除会彻底移除该订阅记录(含权益明细)。是否继续?", "Deleting will permanently remove this subscription record (including benefit details). Continue?": "删除会彻底移除该订阅记录(含权益明细)。是否继续?",
"Deleting...": "删除中...", "Deleting...": "删除中...",
...@@ -2077,6 +2099,7 @@ ...@@ -2077,6 +2099,7 @@
"Failed to update tag": "更新标签失败", "Failed to update tag": "更新标签失败",
"Failed to update user": "更新用户失败", "Failed to update user": "更新用户失败",
"Failure keywords": "失败关键词", "Failure keywords": "失败关键词",
"Failure reason": "失败原因",
"Fair": "公平", "Fair": "公平",
"Fallback": "兜底", "Fallback": "兜底",
"Fallback base URL": "兜底 Base URL", "Fallback base URL": "兜底 Base URL",
...@@ -2491,6 +2514,8 @@ ...@@ -2491,6 +2514,8 @@
"Incoming path must not include query": "入口路径不能包含 query", "Incoming path must not include query": "入口路径不能包含 query",
"Incoming path must start with /": "入口路径必须以 / 开头", "Incoming path must start with /": "入口路径必须以 / 开头",
"Incomplete": "未完成", "Incomplete": "未完成",
"Increase quota for user “{{name}}”": "增加用户「{{name}}」的额度",
"Increase user quota": "增加用户额度",
"Increased user quota by {{quota}}": "增加用户额度 {{quota}}", "Increased user quota by {{quota}}": "增加用户额度 {{quota}}",
"Index": "索引", "Index": "索引",
"Index request failed with HTTP {{status}}": "索引请求失败,HTTP {{status}}", "Index request failed with HTTP {{status}}": "索引请求失败,HTTP {{status}}",
...@@ -2524,6 +2549,7 @@ ...@@ -2524,6 +2549,7 @@
"Instance": "实例", "Instance": "实例",
"Instances": "实例", "Instances": "实例",
"Insufficient balance": "余额不足", "Insufficient balance": "余额不足",
"Insufficient permission to adjust this user": "无权调整该用户的额度",
"Integrations": "集成", "Integrations": "集成",
"Integrity check failed": "完整性校验失败", "Integrity check failed": "完整性校验失败",
"Integrity hash": "完整性哈希", "Integrity hash": "完整性哈希",
...@@ -2535,6 +2561,7 @@ ...@@ -2535,6 +2561,7 @@
"Internal Server Error!": "内部服务器错误!", "Internal Server Error!": "内部服务器错误!",
"Interval must be at least 1 minute": "间隔必须至少为 1 分钟", "Interval must be at least 1 minute": "间隔必须至少为 1 分钟",
"Invalid (NaN)": "无效 (NaN)", "Invalid (NaN)": "无效 (NaN)",
"Invalid adjustment parameters": "额度调整参数无效",
"Invalid chat link. Please contact the administrator.": "无效的聊天链接。请联系管理员。", "Invalid chat link. Please contact the administrator.": "无效的聊天链接。请联系管理员。",
"Invalid chat link. Please contact your administrator.": "无效的聊天链接。请联系您的管理员。", "Invalid chat link. Please contact your administrator.": "无效的聊天链接。请联系您的管理员。",
"Invalid code": "无效代码", "Invalid code": "无效代码",
...@@ -2899,6 +2926,7 @@ ...@@ -2899,6 +2926,7 @@
"Model fixed pricing": "模型固定定价", "Model fixed pricing": "模型固定定价",
"Model Group": "模型分组", "Model Group": "模型分组",
"Model Limits": "模型限制", "Model Limits": "模型限制",
"Model limits enabled": "已启用模型限制",
"Model List": "模型列表", "Model List": "模型列表",
"Model Mapping": "模型映射", "Model Mapping": "模型映射",
"Model Mapping (JSON)": "模型映射 (JSON)", "Model Mapping (JSON)": "模型映射 (JSON)",
...@@ -3273,6 +3301,7 @@ ...@@ -3273,6 +3301,7 @@
"Not included": "未加入", "Not included": "未加入",
"Not installed": "未安装", "Not installed": "未安装",
"Not provided by this source": "该源未提供", "Not provided by this source": "该源未提供",
"Not recorded": "未记录",
"Not registered": "未注册", "Not registered": "未注册",
"Not set": "未设置", "Not set": "未设置",
"Not Set": "未设置", "Not Set": "未设置",
...@@ -3365,6 +3394,8 @@ ...@@ -3365,6 +3394,8 @@
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "仅选定的字段将被覆盖。如果出现新的冲突,您可以重新运行同步向导。", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "仅选定的字段将被覆盖。如果出现新的冲突,您可以重新运行同步向导。",
"Only successful requests": "仅成功的请求", "Only successful requests": "仅成功的请求",
"Only successful requests count toward this limit.": "仅成功的请求计入此限制。", "Only successful requests count toward this limit.": "仅成功的请求计入此限制。",
"Only the first {{shown}} IDs were recorded": "仅记录前 {{shown}} 项 ID",
"Only the first {{shown}} IDs were recorded ({{total}} requested)": "仅记录前 {{shown}} 项,共请求 {{total}} 项",
"Only the last {{value}} log files will be retained; the rest will be deleted.": "将只保留最近 {{value}} 个日志文件,其余将被删除。", "Only the last {{value}} log files will be retained; the rest will be deleted.": "将只保留最近 {{value}} 个日志文件,其余将被删除。",
"Only used to find historical logs. New records are available in Audit Logs.": "仅用于查询历史日志,新记录请前往审计日志查看。", "Only used to find historical logs. New records are available in Audit Logs.": "仅用于查询历史日志,新记录请前往审计日志查看。",
"Oops! Page Not Found!": "糟糕!页面未找到!", "Oops! Page Not Found!": "糟糕!页面未找到!",
...@@ -3466,6 +3497,7 @@ ...@@ -3466,6 +3497,7 @@
"Override": "覆盖", "Override": "覆盖",
"Override auto-discovered endpoint": "覆盖自动发现的端点", "Override auto-discovered endpoint": "覆盖自动发现的端点",
"Override matrix": "覆盖矩阵", "Override matrix": "覆盖矩阵",
"Override quota for user “{{name}}”": "覆盖用户「{{name}}」的额度",
"Override request headers": "覆盖请求标头", "Override request headers": "覆盖请求标头",
"Override request headers (JSON format)": "覆盖请求头(JSON 格式)", "Override request headers (JSON format)": "覆盖请求头(JSON 格式)",
"Override request parameters": "覆盖请求参数", "Override request parameters": "覆盖请求参数",
...@@ -3475,6 +3507,7 @@ ...@@ -3475,6 +3507,7 @@
"Override rule: when a vip user is billed as premium, the ratio is 0.3 instead of 0.5": "覆盖规则:vip 用户按 premium 计费时,倍率用 0.3 而不是 0.5", "Override rule: when a vip user is billed as premium, the ratio is 0.3 instead of 0.5": "覆盖规则:vip 用户按 premium 计费时,倍率用 0.3 而不是 0.5",
"Override Rules": "覆盖规则", "Override Rules": "覆盖规则",
"Override the endpoint used for testing. Leave empty to auto detect.": "覆盖用于测试的端点。留空以自动检测。", "Override the endpoint used for testing. Leave empty to auto detect.": "覆盖用于测试的端点。留空以自动检测。",
"Override user quota": "覆盖用户额度",
"overrides for matching model prefix.": "为匹配模型前缀的覆盖价。", "overrides for matching model prefix.": "为匹配模型前缀的覆盖价。",
"Overrode user quota from {{from}} to {{to}}": "覆盖用户额度,从 {{from}} 改为 {{to}}", "Overrode user quota from {{from}} to {{to}}": "覆盖用户额度,从 {{from}} 改为 {{to}}",
"Overview": "概览", "Overview": "概览",
...@@ -3890,6 +3923,9 @@ ...@@ -3890,6 +3923,9 @@
"Quota": "额度", "Quota": "额度",
"Quota ({{currency}})": "额度 ({{currency}})", "Quota ({{currency}})": "额度 ({{currency}})",
"Quota adjusted successfully": "调整额度成功", "Quota adjusted successfully": "调整额度成功",
"Quota adjustment details": "额度调整详情",
"Quota after adjustment": "调整后额度",
"Quota before adjustment": "调整前额度",
"Quota clamped": "额度已钳制", "Quota clamped": "额度已钳制",
"Quota consumed before charging users": "向用户收费前消耗的配额", "Quota consumed before charging users": "向用户收费前消耗的配额",
"Quota Distribution": "消耗分布", "Quota Distribution": "消耗分布",
...@@ -3905,6 +3941,8 @@ ...@@ -3905,6 +3941,8 @@
"Quota saturation protection triggered": "额度饱和保护已触发", "Quota saturation protection triggered": "额度饱和保护已触发",
"Quota Settings": "额度设置", "Quota Settings": "额度设置",
"Quota Types": "配额类型", "Quota Types": "配额类型",
"Quota unchanged": "额度未变化",
"Quota update failed": "额度更新失败",
"Quota Warning Threshold": "配额警告阈值", "Quota Warning Threshold": "配额警告阈值",
"Quota:": "Quota:", "Quota:": "Quota:",
"Radius": "圆角", "Radius": "圆角",
...@@ -4104,6 +4142,14 @@ ...@@ -4104,6 +4142,14 @@
"Request success rate; {{incidents}} incident buckets in the last 24 hours": "请求成功率;最近 24 小时 {{incidents}} 个异常桶", "Request success rate; {{incidents}} incident buckets in the last 24 hours": "请求成功率;最近 24 小时 {{incidents}} 个异常桶",
"Request timed out, please refresh and restart GitHub login": "请求超时,请刷新页面后重新发起 GitHub 登录", "Request timed out, please refresh and restart GitHub login": "请求超时,请刷新页面后重新发起 GitHub 登录",
"Request-based": "含请求条件", "Request-based": "含请求条件",
"Requested items": "请求项数",
"Requested quota": "请求数额",
"Requested quota: {{quota}}": "请求数额:{{quota}}",
"Requested token IDs": "请求的令牌 ID",
"Requested token IDs truncated": "请求的令牌 ID 已截断",
"Requested: {{total}}": "请求 {{total}} 项",
"Requested: {{total}} · Deleted: {{processed}}": "请求 {{total}} 项,实际删除 {{processed}} 个",
"Requested: {{total}} · Returned: {{processed}}": "请求 {{total}} 项,返回 {{processed}} 个",
"Requests": "请求数", "Requests": "请求数",
"Requests (24h)": "请求数(24 小时)", "Requests (24h)": "请求数(24 小时)",
"Requests / 24h": "请求 / 24 小时", "Requests / 24h": "请求 / 24 小时",
...@@ -4194,6 +4240,9 @@ ...@@ -4194,6 +4240,9 @@
"Return to dashboard": "返回仪表盘", "Return to dashboard": "返回仪表盘",
"Return to the original window to continue.": "返回原窗口继续操作。", "Return to the original window to continue.": "返回原窗口继续操作。",
"Return vector embeddings for inputs": "为输入返回向量嵌入", "Return vector embeddings for inputs": "为输入返回向量嵌入",
"Returned keys": "返回密钥数",
"Returned token IDs": "返回的令牌 ID",
"Returned: {{processed}}": "返回 {{processed}} 个",
"Reveal API key": "显示 API 密钥", "Reveal API key": "显示 API 密钥",
"Reveal key": "显示密钥", "Reveal key": "显示密钥",
"Revenue": "收入", "Revenue": "收入",
...@@ -4627,6 +4676,7 @@ ...@@ -4627,6 +4676,7 @@
"Started two-factor authentication setup": "开始设置两步验证", "Started two-factor authentication setup": "开始设置两步验证",
"STARTTLS": "STARTTLS", "STARTTLS": "STARTTLS",
"State changed": "状态已变更", "State changed": "状态已变更",
"State unchanged: {{status}}": "状态未变化:{{status}}",
"Static page describing the platform.": "描述平台的静态页面。", "Static page describing the platform.": "描述平台的静态页面。",
"Statistical count": "统计计数", "Statistical count": "统计计数",
"Statistical quota": "统计配额", "Statistical quota": "统计配额",
...@@ -4634,6 +4684,7 @@ ...@@ -4634,6 +4684,7 @@
"Statistics reset": "统计已重置", "Statistics reset": "统计已重置",
"Status": "状态", "Status": "状态",
"Status & Sync": "状态与同步", "Status & Sync": "状态与同步",
"Status change": "状态变化",
"Status Code": "状态码", "Status Code": "状态码",
"Status Code Mapping": "状态码映射", "Status Code Mapping": "状态码映射",
"Status code mapping must use valid HTTP status codes": "状态码映射必须使用有效的 HTTP 状态码", "Status code mapping must use valid HTTP status codes": "状态码映射必须使用有效的 HTTP 状态码",
...@@ -4781,8 +4832,11 @@ ...@@ -4781,8 +4832,11 @@
"Target Field Path": "目标字段路径", "Target Field Path": "目标字段路径",
"Target group": "目标分组", "Target group": "目标分组",
"Target Header": "目标请求头", "Target Header": "目标请求头",
"Target not recorded": "目标未记录",
"Target Path (optional)": "目标路径(可选)", "Target Path (optional)": "目标路径(可选)",
"Target User": "目标用户", "Target User": "目标用户",
"Target user not found": "目标用户不存在",
"Target username": "目标用户名",
"Task": "任务", "Task": "任务",
"Task billing": "任务计费", "Task billing": "任务计费",
"Task Details": "任务详情", "Task Details": "任务详情",
...@@ -5056,6 +5110,7 @@ ...@@ -5056,6 +5110,7 @@
"Token estimator": "Token 估算器", "Token estimator": "Token 估算器",
"Token group": "令牌分组", "Token group": "令牌分组",
"Token has no group": "令牌未设置分组", "Token has no group": "令牌未设置分组",
"Token ID": "令牌 ID",
"Token identifier": "令牌标识", "Token identifier": "令牌标识",
"Token Limits": "令牌限制", "Token Limits": "令牌限制",
"Token management": "令牌管理", "Token management": "令牌管理",
...@@ -5063,6 +5118,7 @@ ...@@ -5063,6 +5118,7 @@
"Token Mgmt": "令牌管理", "Token Mgmt": "令牌管理",
"Token Name": "令牌名称", "Token Name": "令牌名称",
"Token obtained from your Gotify application": "从您的 Gotify 应用程序获取的 Token", "Token obtained from your Gotify application": "从您的 Gotify 应用程序获取的 Token",
"Token operation details": "令牌操作详情",
"Token price for audio input.": "音频输入 token 价格。", "Token price for audio input.": "音频输入 token 价格。",
"Token price for audio output.": "音频输出 token 价格。", "Token price for audio output.": "音频输出 token 价格。",
"Token price for cache reads.": "缓存读取 token 价格。", "Token price for cache reads.": "缓存读取 token 价格。",
...@@ -5245,6 +5301,8 @@ ...@@ -5245,6 +5301,8 @@
"Update": "更新", "Update": "更新",
"Update All Balances": "更新所有余额", "Update All Balances": "更新所有余额",
"Update API Key": "更新 API 密钥", "Update API Key": "更新 API 密钥",
"Update API token": "更新 API 令牌",
"Update API token “{{name}}”": "更新 API 令牌「{{name}}」",
"Update Balance": "更新余额", "Update Balance": "更新余额",
"Update balance for:": "更新余额:", "Update balance for:": "更新余额:",
"Update Channel": "更新渠道", "Update Channel": "更新渠道",
...@@ -5504,6 +5562,8 @@ ...@@ -5504,6 +5562,8 @@
"Vidu": "Vidu", "Vidu": "Vidu",
"View": "查看", "View": "查看",
"View all currently available models": "查看当前可用的所有模型", "View all currently available models": "查看当前可用的所有模型",
"View API token key": "查看 API 令牌密钥",
"View API token keys in batch": "批量查看 API 令牌密钥",
"View audit records from user and admin roles. Root records are always excluded.": "查看 user 和 admin 角色的审计记录,始终排除 root 级别记录。", "View audit records from user and admin roles. Root records are always excluded.": "查看 user 和 admin 角色的审计记录,始终排除 root 级别记录。",
"View channel lists and details without secrets.": "查看不含密钥的渠道列表和详情。", "View channel lists and details without secrets.": "查看不含密钥的渠道列表和详情。",
"View channel secrets": "查看渠道密钥", "View channel secrets": "查看渠道密钥",
...@@ -5511,6 +5571,7 @@ ...@@ -5511,6 +5571,7 @@
"View details": "查看详情", "View details": "查看详情",
"View document": "查看文档", "View document": "查看文档",
"View issued reset credits, grant dates, and expiration.": "查看已发放的重置次数、发放日期和到期时间。", "View issued reset credits, grant dates, and expiration.": "查看已发放的重置次数、发放日期和到期时间。",
"View key for API token “{{name}}”": "查看 API 令牌「{{name}}」的密钥",
"View logs": "查看日志", "View logs": "查看日志",
"View mode": "视图模式", "View mode": "视图模式",
"View model statistics and charts": "查看模型统计和图表", "View model statistics and charts": "查看模型统计和图表",
...@@ -5566,6 +5627,7 @@ ...@@ -5566,6 +5627,7 @@
"Wallet Management": "钱包管理", "Wallet Management": "钱包管理",
"Wallet management and personal preferences.": "钱包管理和个人偏好设置。", "Wallet management and personal preferences.": "钱包管理和个人偏好设置。",
"Wallet Only": "仅用钱包", "Wallet Only": "仅用钱包",
"Wallet quota limit exceeded": "超出钱包额度范围",
"Warning": "警告", "Warning": "警告",
"Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.": "警告:基础 URL 不应以 /v1 结尾。New API 将自动处理它。这可能会导致请求失败。", "Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.": "警告:基础 URL 不应以 /v1 结尾。New API 将自动处理它。这可能会导致请求失败。",
"Warning: Disabling 2FA will make your account less secure.": "警告:禁用双重身份验证将使您的账户安全性降低。", "Warning: Disabling 2FA will make your account less secure.": "警告:禁用双重身份验证将使您的账户安全性降低。",
......
...@@ -552,6 +552,25 @@ export const STATIC_I18N_KEYS = [ ...@@ -552,6 +552,25 @@ export const STATIC_I18N_KEYS = [
'The model that was requested', 'The model that was requested',
'The upstream channel that served the requests', 'The upstream channel that served the requests',
// API token audit events
'Create API token',
'Create API token “{{name}}”',
'Update API token',
'Update API token “{{name}}”',
'Delete API token',
'Delete API token “{{name}}”',
'View API token key',
'View key for API token “{{name}}”',
'Batch delete API tokens',
'View API token keys in batch',
'API token creation',
'API token configuration update',
'API token status update',
'API token deletion',
'API token batch deletion',
'API token key access',
'API token batch key access',
// Channel status audit events // Channel status audit events
"View other accounts' audit logs", "View other accounts' audit logs",
'View audit records from user and admin roles. Root records are always excluded.', 'View audit records from user and admin roles. Root records are always excluded.',
...@@ -588,6 +607,14 @@ export const STATIC_I18N_KEYS = [ ...@@ -588,6 +607,14 @@ export const STATIC_I18N_KEYS = [
"Verification does not match this action's details. Please verify again.", "Verification does not match this action's details. Please verify again.",
'The action details are invalid.', 'The action details are invalid.',
'You do not have permission to perform this action.', 'You do not have permission to perform this action.',
'Increase user quota',
'Decrease user quota',
'Override user quota',
'Increase quota for user “{{name}}”',
'Decrease quota for user “{{name}}”',
'Override quota for user “{{name}}”',
'Adjust user quota',
'Adjust quota for user “{{name}}”',
// Account binding and password-operation messages. // Account binding and password-operation messages.
'Account bindings have changed. Start this operation again.', 'Account bindings have changed. Start this operation again.',
'Add another login method before unlinking this account.', 'Add another login method before unlinking this account.',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment