Commit bae799cc by CaIon

fix(billing): surface quota saturation events for admin auditing

Thread int32 saturation clamps from tiered settlement and video task
recompute into the consume/task logs under admin_info, so oversized or
malformed billing inputs stay auditable. Clamp negative audio duration
before token conversion and gate the saturation UI markers on admin.
parent c9943d37
......@@ -103,7 +103,8 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag
- Every user-controlled quantity that becomes a billing multiplier (image `n`, video `seconds`/`duration`, resolution/quality ratios, batch counts) MUST be bounded before it reaches quota calculation. Reject out-of-range values at request validation with a 400. Existing bounds: `dto.MaxImageN` for image generation count, `relaycommon.MaxTaskDurationSeconds` for task video duration, `maxTokensLimit` (`relay/helper/valid_request.go`) for `max_tokens`-family fields on every relay format (OpenAI, Claude, Gemini, Responses). Reuse these constants instead of introducing new ad hoc limits for the same concepts. When adding a new relay format or request DTO, bound its max-tokens and count fields in its validator from day one.
- Watch for validation bypass paths: passthrough fields (e.g. `Extra["parameters"]`), task `metadata` maps, and multipart form fields can carry the same quantities around the standard DTO validation. Any adaptor that reads a multiplier from such a path must enforce the same bound (or clamp) locally.
- Durations parsed from media metadata are user/upstream-controlled too: audio file headers (transcription token counting, TTS response duration) and upstream deduction numbers (e.g. Kling `FinalUnitDeduction`) can claim absurd values. Convert them with saturation before they become token counts.
- Never convert a computed quota or token count to `int` with a bare cast like `int(float64(quota) * ratio)`, `int(math.Round(...))` on unbounded input, or `int(decimal.IntPart())`. Use the saturating converters: `common.QuotaFromFloat` for float products, `decimalToQuota` in `service` for decimal products, `billingexpr.QuotaRound` (saturating) for tiered-expression results. Saturation bounds are int32 because quota columns (user/token/log) are 32-bit integers in the database.
- Never convert a computed quota or token count to `int` with a bare cast like `int(float64(quota) * ratio)`, `int(math.Round(...))` on unbounded input, or `int(decimal.IntPart())`. All quota rounding/conversion is centralized in `common/quota_math.go`; use those helpers: `common.QuotaFromFloat` (truncating) for float products, `common.QuotaRound` (half-away-from-zero) where rounding is intended, and `common.QuotaFromDecimal` for decimal products. `billingexpr.QuotaRound` delegates to `common.QuotaRound`. Do not reintroduce local conversion helpers or bare casts. Saturation bounds are int32 because quota columns (user/token/log) are 32-bit integers in the database, and every clamp/NaN fallback is logged via `common.SysError` since a single request should never approach those bounds.
- Saturation events are also audited: each helper has a `*Checked` variant (`common.QuotaFromFloatChecked` / `QuotaRoundChecked` / `QuotaFromDecimalChecked`) that additionally returns a `*common.QuotaClamp` when clamping occurred. Billing paths that compute a charge capture that clamp onto `relayInfo.QuotaClamp` (or thread it into task settlement) and, right before writing the consume/task log, call `attachQuotaSaturation` (in `service/log_info_generate.go`) which nests the marker under the log's `other.admin_info.quota_saturation` and emits a request-correlated `logger.LogWarn`. Nesting under `admin_info` makes it admin-only for free (non-admin log views strip `admin_info`). When adding a new billing path, use the `*Checked` variant and surface the clamp the same way so the anomaly stays auditable in both the admin log UI and backend logs.
- Multiplier maps go through `types.PriceData.AddOtherRatio`, which rejects non-positive, NaN, and +Inf ratios. Do not write to `PriceData.OtherRatios` directly, and do not weaken these guards.
- Pre-consume (预扣费) and settle (结算/差额) must both be safe: a saturated oversized quota must fail pre-consume with insufficient-quota, never silently wrap. When adding a new billing path (new relay format, new task platform, new adjustment hook), trace the full chain — validation → EstimateBilling/OtherRatios → quota conversion → pre-consume → settle/refund — and confirm each step preserves these invariants.
- Fields parsed into unsigned types (`*uint`) accept huge positive JSON numbers (e.g. `18446744073686646784`, a wrapped negative); a `>= 0` check is not sufficient, an upper bound is mandatory.
......
package common
import "math"
import (
"fmt"
"math"
// QuotaFromFloat converts a computed quota value to int with saturation.
// Quota products can include user-controlled multipliers (image n, video
// seconds, resolution ratios); an oversized product must never wrap around
// and turn a charge into a credit. The bound is int32 because quota columns
// (user/token/log) are 32-bit integers in the database.
func QuotaFromFloat(value float64) int {
if math.IsNaN(value) {
return 0
"github.com/shopspring/decimal"
)
// Quota conversions are centralized here so every billing path shares one
// saturation + logging policy. Quota columns (user/token/log) are 32-bit
// integers in the database, so an oversized product must clamp to the int32
// range instead of wrapping around and turning a charge into a credit.
const (
MaxQuota = math.MaxInt32
MinQuota = math.MinInt32
)
// Clamp kinds reported by QuotaClamp.Kind.
const (
QuotaClampOverflow = "overflow"
QuotaClampUnderflow = "underflow"
QuotaClampNaN = "nan"
)
// QuotaClamp describes a single saturation event: a quota conversion whose
// input fell outside the representable int32 range (or was NaN) and was
// therefore clamped. It is surfaced to billing callers so the event can be
// recorded on the related consume/task log for admin auditing.
type QuotaClamp struct {
Op string `json:"op"` // "QuotaFromFloat" | "QuotaRound" | "QuotaFromDecimal"
Kind string `json:"kind"` // "overflow" | "underflow" | "nan"
Original float64 `json:"original"` // best-effort pre-clamp value (decimal -> float64 approx)
Clamped int `json:"clamped"` // the saturated result actually used
}
// AuditMap renders the clamp as the marker stored under a log's
// admin_info.quota_saturation. Centralized here so every billing path (consume
// logs, task billing logs, task compensation logs) records the same shape.
func (c *QuotaClamp) AuditMap() map[string]interface{} {
if c == nil {
return nil
}
if value >= math.MaxInt32 {
return math.MaxInt32
return map[string]interface{}{
"op": c.Op,
"kind": c.Kind,
"original": c.Original,
"clamped": c.Clamped,
}
if value <= math.MinInt32 {
return math.MinInt32
}
// saturateQuota converts an already-rounded quota value to int, clamping to
// the int32 range. Whenever clamping (what would otherwise be an integer
// wraparound) or a NaN fallback is triggered it logs a warning, because in
// normal operation a single request never approaches these bounds — hitting
// them signals a bug or an abusive request. `op` names the caller. When a
// clamp occurs it returns a non-nil *QuotaClamp so callers can additionally
// record the event (e.g. on the consume log); the returned pointer is nil for
// in-range values.
func saturateQuota(value float64, op string) (int, *QuotaClamp) {
switch {
case math.IsNaN(value):
SysError(fmt.Sprintf("quota conversion (%s) received NaN, falling back to 0", op))
return 0, &QuotaClamp{Op: op, Kind: QuotaClampNaN, Original: value, Clamped: 0}
case value >= MaxQuota:
SysError(fmt.Sprintf("quota conversion (%s) overflow: %g exceeds max quota, clamped to %d", op, value, MaxQuota))
return MaxQuota, &QuotaClamp{Op: op, Kind: QuotaClampOverflow, Original: value, Clamped: MaxQuota}
case value <= MinQuota:
SysError(fmt.Sprintf("quota conversion (%s) underflow: %g below min quota, clamped to %d", op, value, MinQuota))
return MinQuota, &QuotaClamp{Op: op, Kind: QuotaClampUnderflow, Original: value, Clamped: MinQuota}
default:
return int(value), nil
}
return int(value)
}
// QuotaFromFloat converts a computed quota value to int, truncating toward
// zero, with saturation. Use for float products of prices, ratios, and
// user-controlled multipliers (image n, video seconds, resolution ratios).
func QuotaFromFloat(value float64) int {
quota, _ := QuotaFromFloatChecked(value)
return quota
}
// QuotaFromFloatChecked is QuotaFromFloat but also returns a non-nil
// *QuotaClamp when the value was clamped, so billing callers can audit it.
func QuotaFromFloatChecked(value float64) (int, *QuotaClamp) {
return saturateQuota(value, "QuotaFromFloat")
}
// QuotaRound converts a float64 quota value to int using half-away-from-zero
// rounding, with saturation. Every tiered billing path (pre-consume,
// settlement, breakdown validation, log fields) MUST use this to avoid +-1
// discrepancies.
func QuotaRound(value float64) int {
quota, _ := QuotaRoundChecked(value)
return quota
}
// QuotaRoundChecked is QuotaRound but also returns a non-nil *QuotaClamp when
// the value was clamped, so billing callers can audit it.
func QuotaRoundChecked(value float64) (int, *QuotaClamp) {
return saturateQuota(math.Round(value), "QuotaRound")
}
// QuotaFromDecimal converts a computed quota decimal to int with saturation.
// The decimal is rounded (half away from zero) before conversion.
func QuotaFromDecimal(d decimal.Decimal) int {
quota, _ := QuotaFromDecimalChecked(d)
return quota
}
// QuotaFromDecimalChecked is QuotaFromDecimal but also returns a non-nil
// *QuotaClamp when the value was clamped, so billing callers can audit it.
func QuotaFromDecimalChecked(d decimal.Decimal) (int, *QuotaClamp) {
f, _ := d.Round(0).Float64()
return saturateQuota(f, "QuotaFromDecimal")
}
......@@ -4,19 +4,105 @@ import (
"math"
"testing"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
)
// 2000 quota per call * n=18446744073686646784 overflows int64; the constant
// below reproduces that oversized product for the saturation checks.
const overflowingProduct = 2000 * 1.8446744073686647e19
// TestQuotaFromFloat guards the billing invariant that oversized quota
// products (e.g. price multiplied by a huge user-supplied count) saturate
// instead of wrapping into a negative charge (credit).
// instead of wrapping into a negative charge (credit). QuotaFromFloat
// truncates toward zero.
func TestQuotaFromFloat(t *testing.T) {
assert.Equal(t, 42, QuotaFromFloat(42.4))
assert.Equal(t, -42, QuotaFromFloat(-42.4))
// 2000 quota per call * n=18446744073686646784 overflows int64.
assert.Equal(t, math.MaxInt32, QuotaFromFloat(2000*1.8446744073686647e19))
assert.Equal(t, math.MinInt32, QuotaFromFloat(-2000*1.8446744073686647e19))
assert.Equal(t, math.MaxInt32, QuotaFromFloat(math.Inf(1)))
assert.Equal(t, math.MinInt32, QuotaFromFloat(math.Inf(-1)))
assert.Equal(t, 42, QuotaFromFloat(42.9))
assert.Equal(t, -42, QuotaFromFloat(-42.9))
assert.Equal(t, MaxQuota, QuotaFromFloat(overflowingProduct))
assert.Equal(t, MinQuota, QuotaFromFloat(-overflowingProduct))
assert.Equal(t, MaxQuota, QuotaFromFloat(math.Inf(1)))
assert.Equal(t, MinQuota, QuotaFromFloat(math.Inf(-1)))
assert.Equal(t, 0, QuotaFromFloat(math.NaN()))
}
// TestQuotaRound checks half-away-from-zero rounding with the same
// saturation policy.
func TestQuotaRound(t *testing.T) {
assert.Equal(t, 42, QuotaRound(41.5))
assert.Equal(t, 43, QuotaRound(42.5))
assert.Equal(t, -43, QuotaRound(-42.5))
assert.Equal(t, MaxQuota, QuotaRound(overflowingProduct))
assert.Equal(t, MinQuota, QuotaRound(-overflowingProduct))
assert.Equal(t, 0, QuotaRound(math.NaN()))
}
// TestQuotaFromDecimal checks the decimal entry point rounds and saturates
// consistently with the float variants.
func TestQuotaFromDecimal(t *testing.T) {
assert.Equal(t, 43, QuotaFromDecimal(decimal.NewFromFloat(42.5)))
assert.Equal(t, 42, QuotaFromDecimal(decimal.NewFromFloat(41.7)))
assert.Equal(t, MaxQuota, QuotaFromDecimal(decimal.NewFromInt(2000).Mul(decimal.NewFromFloat(1.8446744073686647e19))))
assert.Equal(t, MinQuota, QuotaFromDecimal(decimal.NewFromInt(-2000).Mul(decimal.NewFromFloat(1.8446744073686647e19))))
}
// TestQuotaFromFloatChecked verifies the clamp descriptor is nil in range and
// carries the correct kind/clamped value on saturation, so billing callers can
// audit the event.
func TestQuotaFromFloatChecked(t *testing.T) {
quota, clamp := QuotaFromFloatChecked(42.9)
assert.Equal(t, 42, quota)
assert.Nil(t, clamp)
quota, clamp = QuotaFromFloatChecked(overflowingProduct)
assert.Equal(t, MaxQuota, quota)
if assert.NotNil(t, clamp) {
assert.Equal(t, "QuotaFromFloat", clamp.Op)
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
assert.Equal(t, MaxQuota, clamp.Clamped)
}
quota, clamp = QuotaFromFloatChecked(-overflowingProduct)
assert.Equal(t, MinQuota, quota)
if assert.NotNil(t, clamp) {
assert.Equal(t, QuotaClampUnderflow, clamp.Kind)
assert.Equal(t, MinQuota, clamp.Clamped)
}
quota, clamp = QuotaFromFloatChecked(math.NaN())
assert.Equal(t, 0, quota)
if assert.NotNil(t, clamp) {
assert.Equal(t, QuotaClampNaN, clamp.Kind)
assert.Equal(t, 0, clamp.Clamped)
}
}
// TestQuotaRoundChecked verifies the rounding entry point reports clamps the
// same way.
func TestQuotaRoundChecked(t *testing.T) {
quota, clamp := QuotaRoundChecked(42.5)
assert.Equal(t, 43, quota)
assert.Nil(t, clamp)
quota, clamp = QuotaRoundChecked(overflowingProduct)
assert.Equal(t, MaxQuota, quota)
if assert.NotNil(t, clamp) {
assert.Equal(t, "QuotaRound", clamp.Op)
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
}
}
// TestQuotaFromDecimalChecked verifies the decimal entry point reports clamps.
func TestQuotaFromDecimalChecked(t *testing.T) {
quota, clamp := QuotaFromDecimalChecked(decimal.NewFromFloat(41.7))
assert.Equal(t, 42, quota)
assert.Nil(t, clamp)
quota, clamp = QuotaFromDecimalChecked(decimal.NewFromInt(2000).Mul(decimal.NewFromFloat(1.8446744073686647e19)))
assert.Equal(t, MaxQuota, quota)
if assert.NotNil(t, clamp) {
assert.Equal(t, "QuotaFromDecimal", clamp.Op)
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
}
}
......@@ -179,7 +179,11 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
}
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio(饱和转换,防止溢出成负数)
actualQuota := common.QuotaFromFloat(float64(taskResult.TotalTokens) * modelRatio * finalGroupRatio)
actualQuota, clamp := common.QuotaFromFloatChecked(float64(taskResult.TotalTokens) * modelRatio * finalGroupRatio)
if clamp != nil {
logger.LogWarn(ctx, fmt.Sprintf("quota saturation on video task %s: op=%s kind=%s original=%g clamped=%d user=%d",
task.TaskID, clamp.Op, clamp.Kind, clamp.Original, clamp.Clamped, task.UserId))
}
// 计算差额
preConsumedQuota := task.Quota
......@@ -205,7 +209,12 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,补扣费 %s",
modelRatio, finalGroupRatio, taskResult.TotalTokens,
logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(quotaDelta))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
if clamp != nil {
model.RecordLogWithAdminInfo(task.UserId, model.LogTypeSystem, logContent,
map[string]interface{}{"quota_saturation": clamp.AuditMap()})
} else {
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
}
} else if quotaDelta < 0 {
// 需要退还多扣的费用
......@@ -226,7 +235,12 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,退还 %s",
modelRatio, finalGroupRatio, taskResult.TotalTokens,
logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(refundQuota))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
if clamp != nil {
model.RecordLogWithAdminInfo(task.UserId, model.LogTypeSystem, logContent,
map[string]interface{}{"quota_saturation": clamp.AuditMap()})
} else {
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
}
} else {
// quotaDelta == 0, 预扣费刚好准确
......
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/require"
)
// TestFormatUserLogsStripsQuotaSaturation verifies the admin-only quota
// saturation marker (nested under other.admin_info) is removed for non-admin
// log views, since formatUserLogs strips the whole admin_info object.
func TestFormatUserLogsStripsQuotaSaturation(t *testing.T) {
other := common.MapToJsonStr(map[string]interface{}{
"model_price": 0.004,
"admin_info": map[string]interface{}{
"quota_saturation": map[string]interface{}{
"op": "QuotaFromDecimal",
"kind": "overflow",
"clamped": common.MaxQuota,
},
},
})
logs := []*Log{{Other: other}}
formatUserLogs(logs, 0)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
_, hasAdminInfo := parsed["admin_info"]
require.False(t, hasAdminInfo, "admin_info (and nested quota_saturation) must be stripped for non-admin views")
// Non-admin billing fields remain visible.
require.Contains(t, parsed, "model_price")
}
......@@ -291,12 +291,9 @@ func TestQuotaRound(t *testing.T) {
{999.4999, 999},
{999.5, 1000},
{1e9 + 0.5, 1e9 + 1},
// Oversized expression results must saturate at int32 bounds
// instead of wrapping into a negative charge.
// Oversized expression results saturate at int32 (delegated to
// common.QuotaRound); full saturation coverage lives in common.
{3.6893488147419103e19, math.MaxInt32},
{-3.6893488147419103e19, math.MinInt32},
{math.Inf(1), math.MaxInt32},
{math.NaN(), 0},
}
for _, tt := range tests {
got := billingexpr.QuotaRound(tt.in)
......
package billingexpr
import "math"
import "github.com/QuantumNous/new-api/common"
// QuotaRound converts a float64 quota value to int using half-away-from-zero
// rounding. Every tiered billing path (pre-consume, settlement, breakdown
// validation, log fields) MUST use this function to avoid +-1 discrepancies.
// rounding with int32 saturation. Every tiered billing path (pre-consume,
// settlement, breakdown validation, log fields) MUST use this function to
// avoid +-1 discrepancies.
//
// The result saturates at int32 bounds: quota columns are 32-bit integers in
// the database, and an oversized expression result must never wrap around
// and turn a charge into a credit.
// It delegates to common.QuotaRound so all quota rounding/conversion shares
// one saturation + logging policy (see common/quota_math.go).
func QuotaRound(f float64) int {
r := math.Round(f)
if math.IsNaN(r) {
return 0
}
if r >= math.MaxInt32 {
return math.MaxInt32
}
if r <= math.MinInt32 {
return math.MinInt32
}
return int(r)
return common.QuotaRound(f)
}
package billingexpr
import "github.com/QuantumNous/new-api/common"
// quotaConversion converts raw expression output to quota based on the
// expression version. This is the central dispatch point for future versions
// that may use a different conversion formula.
......@@ -23,7 +25,7 @@ func ComputeTieredQuotaWithRequest(snap *BillingSnapshot, params TokenParams, re
}
quotaBeforeGroup := quotaConversion(cost, snap)
afterGroup := QuotaRound(quotaBeforeGroup * snap.GroupRatio)
afterGroup, clamp := common.QuotaRoundChecked(quotaBeforeGroup * snap.GroupRatio)
crossed := trace.MatchedTier != snap.EstimatedTier
return TieredResult{
......@@ -31,5 +33,6 @@ func ComputeTieredQuotaWithRequest(snap *BillingSnapshot, params TokenParams, re
ActualQuotaAfterGroup: afterGroup,
MatchedTier: trace.MatchedTier,
CrossedTier: crossed,
Clamp: clamp,
}, nil
}
package billingexpr_test
import (
"math"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestComputeTieredQuota_ClampOnOverflow guards the billing-safety invariant
// that an oversized tiered settlement clamps to the int32 max instead of
// wrapping into a credit, and that the saturation event is surfaced on the
// result so callers can record it for admin auditing.
func TestComputeTieredQuota_ClampOnOverflow(t *testing.T) {
// exprOutput = p * 1e9 = 1e18; quotaBeforeGroup = 1e18 / 1e6 * 5e5 = 5e17,
// which far exceeds MaxInt32 and must saturate.
exprStr := `tier("base", p * 1000000000)`
snap := &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: exprStr,
ExprHash: billingexpr.ExprHashString(exprStr),
GroupRatio: 1.0,
QuotaPerUnit: 500_000,
}
result, err := billingexpr.ComputeTieredQuota(snap, billingexpr.TokenParams{P: 1_000_000_000})
require.NoError(t, err)
assert.Equal(t, math.MaxInt32, result.ActualQuotaAfterGroup, "oversized quota must clamp to int32 max, never wrap negative")
require.NotNil(t, result.Clamp, "clamp event must be surfaced so it can be audited")
assert.Equal(t, common.QuotaClampOverflow, result.Clamp.Kind)
assert.Equal(t, math.MaxInt32, result.Clamp.Clamped)
}
// TestComputeTieredQuota_NoClampInRange confirms an in-range settlement leaves
// Clamp nil, so the audit path is a no-op in the common case.
func TestComputeTieredQuota_NoClampInRange(t *testing.T) {
exprStr := `tier("base", p * 2 + c * 10)`
snap := &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: exprStr,
ExprHash: billingexpr.ExprHashString(exprStr),
GroupRatio: 1.0,
QuotaPerUnit: 500_000,
}
result, err := billingexpr.ComputeTieredQuota(snap, billingexpr.TokenParams{P: 1000, C: 500})
require.NoError(t, err)
assert.Nil(t, result.Clamp, "in-range settlement must not report a clamp")
}
......@@ -3,6 +3,8 @@ package billingexpr
import (
"crypto/sha256"
"fmt"
"github.com/QuantumNous/new-api/common"
)
type RequestInput struct {
......@@ -57,6 +59,11 @@ type TieredResult struct {
ActualQuotaAfterGroup int `json:"actual_quota_after_group"`
MatchedTier string `json:"matched_tier"`
CrossedTier bool `json:"crossed_tier"`
// Clamp records an int32 saturation event during quota conversion so the
// caller can surface it on the consume log for admin auditing. Nil when no
// clamping occurred. Not serialized: the marker is attached separately via
// the shared quota-saturation audit path.
Clamp *common.QuotaClamp `json:"-"`
}
// ExprHashString returns the SHA-256 hex digest of an expression string.
......
......@@ -105,7 +105,7 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
} else if duration > 0 {
// 计算 token: ceil(duration) / 60.0 * 1000,即每分钟 1000 tokens。
// duration 解析自上游返回的音频元数据,饱和转换防止 int 回绕。
completionTokens := common.QuotaFromFloat(math.Round(math.Ceil(duration) / 60.0 * 1000))
completionTokens := common.QuotaRound(math.Ceil(duration) / 60.0 * 1000)
usage.CompletionTokens = completionTokens
usage.CompletionTokenDetails.AudioTokens = completionTokens
}
......
......@@ -163,6 +163,11 @@ type RelayInfo struct {
PriceData types.PriceData
// QuotaClamp is set (non-nil) when a quota conversion saturated at the
// int32 bound (or NaN fallback) while computing this request's charge.
// It is surfaced onto the consume/task log's admin_info for auditing.
QuotaClamp *common.QuotaClamp
// TieredBillingSnapshot is a frozen snapshot of tiered billing rules
// captured at pre-consume time. Non-nil only when billing mode is "tiered_expr".
TieredBillingSnapshot *billingexpr.BillingSnapshot
......
......@@ -202,7 +202,9 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe
quotaWithRatios *= ra
}
}
info.PriceData.Quota = common.QuotaFromFloat(quotaWithRatios)
quota, clamp := common.QuotaFromFloatChecked(quotaWithRatios)
info.PriceData.Quota = quota
noteTaskQuotaClamp(info, clamp)
}
// 7. 预扣费(仅首次 — 重试时 info.Billing 已存在,跳过)
......@@ -278,7 +280,21 @@ func recalcQuotaFromRatios(info *relaycommon.RelayInfo, ratios map[string]float6
result *= ra
}
}
return common.QuotaFromFloat(result)
quota, clamp := common.QuotaFromFloatChecked(result)
noteTaskQuotaClamp(info, clamp)
return quota
}
// noteTaskQuotaClamp records the first quota saturation event onto the task's
// RelayInfo so LogTaskConsumption can surface it on the submit log's
// admin_info. First non-nil clamp wins.
func noteTaskQuotaClamp(info *relaycommon.RelayInfo, clamp *common.QuotaClamp) {
if clamp == nil || info == nil {
return
}
if info.QuotaClamp == nil {
info.QuotaClamp = clamp
}
}
var fetchRespBuilders = map[int]func(c *gin.Context) (respBody []byte, taskResp *dto.TaskError){
......
......@@ -2,11 +2,13 @@ package service
import (
"encoding/base64"
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/types"
......@@ -14,6 +16,39 @@ import (
"github.com/gin-gonic/gin"
)
// attachQuotaSaturationToOther nests a quota saturation marker under
// other.admin_info.quota_saturation. Nesting under admin_info makes it
// admin-only for free, since model.formatUserLogs strips the whole admin_info
// object for non-admin viewers. Creates admin_info if absent. No-op when the
// clamp is nil (the common case: no saturation happened).
func attachQuotaSaturationToOther(other map[string]interface{}, clamp *common.QuotaClamp) {
if clamp == nil || other == nil {
return
}
adminInfo, ok := other["admin_info"].(map[string]interface{})
if !ok || adminInfo == nil {
adminInfo = map[string]interface{}{}
other["admin_info"] = adminInfo
}
adminInfo["quota_saturation"] = clamp.AuditMap()
}
// attachQuotaSaturation records the request's quota clamp (if any) onto the
// consume log's other.admin_info and emits a request-correlated backend audit
// line. Called right before RecordConsumeLog on the text/audio/wss paths.
func attachQuotaSaturation(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
if relayInfo == nil {
return
}
clamp := relayInfo.QuotaClamp
if clamp == nil {
return
}
attachQuotaSaturationToOther(other, clamp)
logger.LogWarn(ctx, fmt.Sprintf("quota saturation on consume log: op=%s kind=%s original=%g clamped=%d user=%d model=%s",
clamp.Op, clamp.Kind, clamp.Original, clamp.Clamped, relayInfo.UserId, relayInfo.OriginModelName))
}
func appendRequestPath(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
if other == nil {
return
......
......@@ -47,14 +47,14 @@ func hasCustomModelRatio(modelName string, currentRatio float64) bool {
return currentRatio != defaultRatio
}
func calculateAudioQuota(info QuotaInfo) int {
func calculateAudioQuota(info QuotaInfo) (int, *common.QuotaClamp) {
if info.UsePrice {
modelPrice := decimal.NewFromFloat(info.ModelPrice)
quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
groupRatio := decimal.NewFromFloat(info.GroupRatio)
quota := modelPrice.Mul(quotaPerUnit).Mul(groupRatio)
return decimalToQuota(quota)
return common.QuotaFromDecimalChecked(quota)
}
completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(info.ModelName))
......@@ -83,7 +83,7 @@ func calculateAudioQuota(info QuotaInfo) int {
quota = decimal.NewFromInt(1)
}
return decimalToQuota(quota)
return common.QuotaFromDecimalChecked(quota)
}
func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage) error {
......@@ -136,7 +136,8 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
GroupRatio: actualGroupRatio,
}
quota := calculateAudioQuota(quotaInfo)
quota, clamp := calculateAudioQuota(quotaInfo)
noteQuotaClamp(relayInfo, clamp)
if userQuota < quota {
return fmt.Errorf("user quota is not enough, user quota: %s, need quota: %s", logger.FormatQuota(userQuota), logger.FormatQuota(quota))
......@@ -199,7 +200,8 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
GroupRatio: groupRatio,
}
quota := calculateAudioQuota(quotaInfo)
quota, clamp := calculateAudioQuota(quotaInfo)
noteQuotaClamp(relayInfo, clamp)
if tieredOk {
quota = tieredQuota
}
......@@ -239,6 +241,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
if tieredResult != nil {
InjectTieredBillingInfo(other, relayInfo, tieredResult)
}
attachQuotaSaturation(ctx, relayInfo, other)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: usage.InputTokens,
......@@ -320,7 +323,8 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
GroupRatio: groupRatio,
}
quota := calculateAudioQuota(quotaInfo)
quota, clamp := calculateAudioQuota(quotaInfo)
noteQuotaClamp(relayInfo, clamp)
if tieredOk {
quota = tieredQuota
}
......@@ -360,6 +364,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
if tieredResult != nil {
InjectTieredBillingInfo(other, relayInfo, tieredResult)
}
attachQuotaSaturation(ctx, relayInfo, other)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: usage.PromptTokens,
......
package service
import (
"testing"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// TestAttachQuotaSaturationNestsUnderAdminInfo verifies the saturation marker
// is nested under other.admin_info.quota_saturation so it is admin-only (the
// log formatter strips admin_info for non-admin viewers).
func TestAttachQuotaSaturationNestsUnderAdminInfo(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(nil)
relayInfo := &relaycommon.RelayInfo{
UserId: 7,
OriginModelName: "gpt-image-1",
QuotaClamp: &common.QuotaClamp{
Op: "QuotaFromDecimal",
Kind: common.QuotaClampOverflow,
Original: 1.8e19,
Clamped: common.MaxQuota,
},
}
other := map[string]interface{}{"model_price": 0.004}
attachQuotaSaturation(ctx, relayInfo, other)
adminInfo, ok := other["admin_info"].(map[string]interface{})
require.True(t, ok, "admin_info should be created")
sat, ok := adminInfo["quota_saturation"].(map[string]interface{})
require.True(t, ok, "quota_saturation should be nested under admin_info")
require.Equal(t, "QuotaFromDecimal", sat["op"])
require.Equal(t, common.QuotaClampOverflow, sat["kind"])
require.Equal(t, common.MaxQuota, sat["clamped"])
}
// TestAttachQuotaSaturationPreservesExistingAdminInfo verifies the marker is
// merged into a pre-existing admin_info map without clobbering it.
func TestAttachQuotaSaturationPreservesExistingAdminInfo(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(nil)
relayInfo := &relaycommon.RelayInfo{
QuotaClamp: &common.QuotaClamp{Op: "QuotaFromFloat", Kind: common.QuotaClampUnderflow, Clamped: common.MinQuota},
}
other := map[string]interface{}{
"admin_info": map[string]interface{}{"admin_username": "root"},
}
attachQuotaSaturation(ctx, relayInfo, other)
adminInfo := other["admin_info"].(map[string]interface{})
require.Equal(t, "root", adminInfo["admin_username"], "existing admin_info fields preserved")
require.NotNil(t, adminInfo["quota_saturation"])
}
// TestAttachQuotaSaturationNoClampNoMarker verifies the common case (no
// saturation) leaves the log untouched.
func TestAttachQuotaSaturationNoClampNoMarker(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(nil)
relayInfo := &relaycommon.RelayInfo{QuotaClamp: nil}
other := map[string]interface{}{"model_price": 0.004}
attachQuotaSaturation(ctx, relayInfo, other)
_, hasAdmin := other["admin_info"]
require.False(t, hasAdmin, "no admin_info should be added when there is no clamp")
}
......@@ -50,6 +50,7 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
other["is_model_mapped"] = true
other["upstream_model_name"] = info.UpstreamModelName
}
attachQuotaSaturation(c, info, other)
model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{
ChannelId: info.ChannelId,
ModelName: info.OriginModelName,
......@@ -184,7 +185,8 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) {
// RecalculateTaskQuota 通用的异步差额结算。
// actualQuota 是任务完成后的实际应扣额度,与预扣额度 (task.Quota) 做差额结算。
// reason 用于日志记录(例如 "token重算" 或 "adaptor调整")。
func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int, reason string) {
// clamps 可选:若计算 actualQuota 时发生额度饱和,将其记入日志 admin_info(仅管理员可见)。
func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int, reason string, clamps ...*common.QuotaClamp) {
if actualQuota <= 0 {
return
}
......@@ -234,6 +236,9 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
other["task_id"] = task.TaskID
other["pre_consumed_quota"] = preConsumedQuota
other["actual_quota"] = actualQuota
for _, clamp := range clamps {
attachQuotaSaturationToOther(other, clamp)
}
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId,
LogType: logType,
......@@ -298,8 +303,8 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo
}
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio * otherMultiplier(饱和转换,防止溢出成负数)
actualQuota := common.QuotaFromFloat(float64(totalTokens) * modelRatio * finalGroupRatio * otherMultiplier)
actualQuota, clamp := common.QuotaFromFloatChecked(float64(totalTokens) * modelRatio * finalGroupRatio * otherMultiplier)
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, otherMultiplier=%.4f", totalTokens, modelRatio, finalGroupRatio, otherMultiplier)
RecalculateTaskQuota(ctx, task, actualQuota, reason)
RecalculateTaskQuota(ctx, task, actualQuota, reason, clamp)
}
......@@ -138,6 +138,18 @@ func calculateTextToolCallSurcharge(ctx *gin.Context, relayInfo *relaycommon.Rel
return surcharge
}
// noteQuotaClamp records the first quota saturation event onto relayInfo so it
// can later be attached to the consume/task log for admin auditing. First
// non-nil clamp wins (a single request may hit multiple conversions).
func noteQuotaClamp(relayInfo *relaycommon.RelayInfo, clamp *common.QuotaClamp) {
if clamp == nil || relayInfo == nil {
return
}
if relayInfo.QuotaClamp == nil {
relayInfo.QuotaClamp = clamp
}
}
func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaSummary, tieredQuota int, tieredResult *billingexpr.TieredResult) int {
if summary.ToolCallSurchargeQuota.IsZero() {
return tieredQuota
......@@ -145,13 +157,17 @@ func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaS
if tieredResult != nil {
if snap := relayInfo.TieredBillingSnapshot; snap != nil {
return decimalToQuota(decimal.NewFromFloat(tieredResult.ActualQuotaBeforeGroup).
quota, clamp := common.QuotaFromDecimalChecked(decimal.NewFromFloat(tieredResult.ActualQuotaBeforeGroup).
Mul(decimal.NewFromFloat(snap.GroupRatio)).
Add(summary.ToolCallSurchargeQuota))
noteQuotaClamp(relayInfo, clamp)
return quota
}
}
return tieredQuota + decimalToQuota(summary.ToolCallSurchargeQuota)
surcharge, clamp := common.QuotaFromDecimalChecked(summary.ToolCallSurchargeQuota)
noteQuotaClamp(relayInfo, clamp)
return tieredQuota + surcharge
}
func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) textQuotaSummary {
......@@ -285,7 +301,9 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf
if !ratio.IsZero() && quotaCalculateDecimal.LessThanOrEqual(decimal.Zero) {
quotaCalculateDecimal = decimal.NewFromInt(1)
}
summary.Quota = decimalToQuota(quotaCalculateDecimal)
quota, clamp := common.QuotaFromDecimalChecked(quotaCalculateDecimal)
summary.Quota = quota
noteQuotaClamp(relayInfo, clamp)
} else {
quotaCalculateDecimal := dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
quotaCalculateDecimal = quotaCalculateDecimal.Add(summary.ToolCallSurchargeQuota)
......@@ -295,7 +313,9 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf
quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(otherRatio))
}
}
summary.Quota = decimalToQuota(quotaCalculateDecimal)
quota, clamp := common.QuotaFromDecimalChecked(quotaCalculateDecimal)
summary.Quota = quota
noteQuotaClamp(relayInfo, clamp)
}
if summary.TotalTokens == 0 {
......@@ -307,14 +327,6 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf
return summary
}
// decimalToQuota converts a computed quota decimal to int with saturation
// (see common.QuotaFromFloat). Oversized multipliers (e.g. an absurd image
// generation count) must never wrap around and turn a charge into a credit.
func decimalToQuota(d decimal.Decimal) int {
f, _ := d.Round(0).Float64()
return common.QuotaFromFloat(f)
}
func usageSemanticFromUsage(relayInfo *relaycommon.RelayInfo, usage *dto.Usage) string {
if usage != nil && usage.UsageSemantic != "" {
return usage.UsageSemantic
......@@ -465,6 +477,8 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
InjectTieredBillingInfo(other, relayInfo, tieredResult)
}
attachQuotaSaturation(ctx, relayInfo, other)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: summary.PromptTokens,
......
......@@ -6,6 +6,7 @@ import (
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/pkg/billingexpr"
......@@ -13,22 +14,9 @@ import (
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/require"
)
// TestDecimalToQuotaSaturation guards the billing invariant that an oversized
// quota product (e.g. per-call price multiplied by a huge image n ratio) must
// saturate instead of wrapping into a negative charge (credit).
func TestDecimalToQuotaSaturation(t *testing.T) {
// 2000 quota per call * n=18446744073686646784 overflows int64.
overflowing := decimal.NewFromInt(2000).Mul(decimal.NewFromFloat(1.8446744073686647e19))
require.Equal(t, math.MaxInt32, decimalToQuota(overflowing))
require.Equal(t, math.MinInt32, decimalToQuota(overflowing.Neg()))
require.Equal(t, 42, decimalToQuota(decimal.NewFromFloat(41.7)))
}
func TestCalculateTextQuotaSummaryUnifiedForClaudeSemantic(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
......@@ -453,3 +441,52 @@ func TestComposeTieredTextQuotaErrorFallbackUsesPreConsumedQuota(t *testing.T) {
require.Equal(t, int64(12500), summary.ToolCallSurchargeQuota.Round(0).IntPart())
require.Equal(t, 14500, quota)
}
// TestTryTieredSettleRecordsClampOnOverflow guards that an oversized tiered
// settlement both saturates the quota and records the clamp on RelayInfo, so
// every consume path (text, audio, WSS) can surface it under admin_info.
func TestTryTieredSettleRecordsClampOnOverflow(t *testing.T) {
// exprOutput = p * 1e9; quotaBeforeGroup = p*1e9 / 1e6 * 5e5 far exceeds
// MaxInt32 and must saturate.
exprStr := `tier("base", p * 1000000000)`
relayInfo := &relaycommon.RelayInfo{
OriginModelName: "overflow-model",
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: exprStr,
ExprHash: billingexpr.ExprHashString(exprStr),
GroupRatio: 1,
QuotaPerUnit: 500_000,
},
}
ok, quota, result := TryTieredSettle(relayInfo, billingexpr.TokenParams{P: 1_000_000_000})
require.True(t, ok)
require.NotNil(t, result)
require.Equal(t, math.MaxInt32, quota, "oversized settlement must clamp, never wrap negative")
require.NotNil(t, relayInfo.QuotaClamp, "clamp must be recorded on RelayInfo for admin auditing")
require.Equal(t, common.QuotaClampOverflow, relayInfo.QuotaClamp.Kind)
}
// TestTryTieredSettleNoClampInRange confirms an in-range settlement leaves
// RelayInfo.QuotaClamp nil.
func TestTryTieredSettleNoClampInRange(t *testing.T) {
exprStr := `tier("base", p * 2 + c * 10)`
relayInfo := &relaycommon.RelayInfo{
OriginModelName: "in-range-model",
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: exprStr,
ExprHash: billingexpr.ExprHashString(exprStr),
GroupRatio: 1,
QuotaPerUnit: 500_000,
},
}
ok, _, result := TryTieredSettle(relayInfo, billingexpr.TokenParams{P: 1000, C: 500})
require.True(t, ok)
require.NotNil(t, result)
require.Nil(t, relayInfo.QuotaClamp, "in-range settlement must not record a clamp")
}
......@@ -112,5 +112,10 @@ func TryTieredSettle(relayInfo *relaycommon.RelayInfo, params billingexpr.TokenP
return true, quota, nil
}
// Surface any int32 saturation from settlement onto RelayInfo so the
// consume log records it under admin_info, regardless of which caller
// (text, audio, WSS) consumes the returned quota. First non-nil wins.
noteQuotaClamp(relayInfo, tr.Clamp)
return true, tr.ActualQuotaAfterGroup, &tr
}
......@@ -208,14 +208,13 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela
if err != nil {
return 0, fmt.Errorf("error getting audio duration: %v", err)
}
// 一分钟 1000 token,与 $price / minute 对齐。
// duration 来自用户上传文件的元数据,可被伪造成天文数字,
// 必须饱和转换防止 int 回绕成负数 token。
audioTokens := common.QuotaFromFloat(math.Round(math.Ceil(duration) / 60.0 * 1000))
if audioTokens < 0 {
audioTokens = 0
// duration 来自用户上传文件的元数据,可被伪造成天文数字或负数。
// 负值会让 token 估算变成负数(低估预扣费),先钳到 0 再转换。
if duration < 0 {
duration = 0
}
totalAudioToken += audioTokens
// 一分钟 1000 token,与 $price / minute 对齐。
totalAudioToken += common.QuotaRound(math.Ceil(duration) / 60.0 * 1000)
}
return totalAudioToken, nil
}
......
package service
import (
"math"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/operation_setting"
)
......@@ -49,7 +47,7 @@ func ComputeToolCallQuota(usage ToolCallUsage, groupRatio float64) ToolCallResul
return
}
totalPrice := pricePer1K * float64(count) / 1000
quota := common.QuotaFromFloat(math.Round(totalPrice * common.QuotaPerUnit * groupRatio))
quota := common.QuotaRound(totalPrice * common.QuotaPerUnit * groupRatio)
items = append(items, ToolCallItem{
Name: toolName,
CallCount: count,
......@@ -70,7 +68,7 @@ func ComputeToolCallQuota(usage ToolCallUsage, groupRatio float64) ToolCallResul
if usage.ImageGenerationCall {
price := operation_setting.GetGPTImage1PriceOnceCall(usage.ImageGenerationQuality, usage.ImageGenerationSize)
quota := common.QuotaFromFloat(math.Round(price * common.QuotaPerUnit * groupRatio))
quota := common.QuotaRound(price * common.QuotaPerUnit * groupRatio)
items = append(items, ToolCallItem{
Name: "image_generation",
CallCount: 1,
......
......@@ -106,6 +106,23 @@ function splitQuotaDisplay(value: string): { prefix: string; amount: string } {
function buildDetailSegments(
log: UsageLog,
other: LogOtherData | null,
t: (key: string, opts?: Record<string, unknown>) => string,
isAdmin: boolean
): DetailSegment[] {
const segments = buildTypeDetailSegments(log, other, t)
// Quota saturation is a rare, admin-only anomaly marker; surface it first
// and in danger styling so it stands out on the related billing log. The
// backend already strips admin_info for non-admins; gate on isAdmin too as
// defense in depth so the marker never leaks if that changes.
if (isAdmin && other?.admin_info?.quota_saturation) {
return [{ text: t('Quota clamped'), danger: true }, ...segments]
}
return segments
}
function buildTypeDetailSegments(
log: UsageLog,
other: LogOtherData | null,
t: (key: string, opts?: Record<string, unknown>) => string
): DetailSegment[] {
// Audit (type=3) and login (type=7) logs: render localized content from the
......@@ -817,7 +834,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
const log = row.original
const other = parseLogOther(log.other)
const segments = buildDetailSegments(log, other, t)
const segments = buildDetailSegments(log, other, t, isAdmin)
const primary = segments[0]
const hasMore = segments.length > 1
......
......@@ -143,6 +143,15 @@ function formatRatio(ratio: number | undefined): string {
return ratio.toFixed(4)
}
function quotaSaturationKindLabel(
kind: 'overflow' | 'underflow' | 'nan',
t: (key: string) => string
): string {
if (kind === 'overflow') return t('Overflow')
if (kind === 'underflow') return t('Underflow')
return t('Invalid (NaN)')
}
function BillingBreakdown(props: {
log: UsageLog
other: LogOtherData
......@@ -704,6 +713,41 @@ export function DetailsDialog(props: DetailsDialogProps) {
</DetailSection>
)}
{/* Quota saturation marker (admin only) */}
{props.isAdmin && other?.admin_info?.quota_saturation && (
<DetailSection
icon={<AlertTriangle className='size-3.5' aria-hidden='true' />}
label={t('Quota clamped')}
variant='danger'
>
<p className='mb-1 text-xs wrap-break-word'>
{t('Quota saturation protection triggered')}
</p>
<DetailRow
label={t('Kind')}
value={quotaSaturationKindLabel(
other.admin_info.quota_saturation.kind,
t
)}
/>
<DetailRow
label={t('Original value')}
value={String(other.admin_info.quota_saturation.original)}
mono
/>
<DetailRow
label={t('Clamped to')}
value={String(other.admin_info.quota_saturation.clamped)}
mono
/>
<DetailRow
label={t('Operation')}
value={other.admin_info.quota_saturation.op}
mono
/>
</DetailSection>
)}
{/* Reject reason (admin only) */}
{props.isAdmin && other?.reject_reason && (
<DetailSection
......
......@@ -38,6 +38,7 @@ import {
LOG_TYPE_ENUM,
} from '../constants'
import { useColumnsByCategory } from '../lib/columns'
import { parseLogOther } from '../lib/format'
import { fetchLogsByCategory } from '../lib/utils'
import type { LogCategory } from '../types'
import { CommonLogsFilterBar } from './common-logs-filter-bar'
......@@ -51,6 +52,10 @@ const logTypeRowTint: Record<number, string> = {
[LOG_TYPE_ENUM.REFUND]: 'bg-blue-50/30 dark:bg-blue-950/15',
}
// Warning tint for logs where a quota conversion saturated (admin-only marker).
// Takes precedence over the per-type tint since it flags a billing anomaly.
const quotaSaturationRowTint = 'bg-amber-50/60 dark:bg-amber-950/25'
function getColumnVisibilityStorageKey(
logCategory: LogCategory,
isAdmin: boolean
......@@ -204,8 +209,16 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
const logType = (row.original as Record<string, unknown>).type as
| number
| undefined
const tintClass =
let tintClass =
isCommon && logType != null ? (logTypeRowTint[logType] ?? '') : ''
if (isCommon && isAdmin) {
const other = parseLogOther(
((row.original as Record<string, unknown>).other as string) ?? ''
)
if (other?.admin_info?.quota_saturation) {
tintClass = quotaSaturationRowTint
}
}
return (
<DataTableRow
......
......@@ -111,6 +111,15 @@ export interface LogOtherData {
admin_id?: number | string
admin_role?: number
auth_method?: 'session' | 'access_token' | string
// Quota saturation marker: set when a quota conversion clamped at the
// int32 bound (overflow/underflow) or hit a NaN fallback while computing
// this request's charge. Admin-only (nested under admin_info).
quota_saturation?: {
op: string
kind: 'overflow' | 'underflow' | 'nan'
original: number
clamped: number
}
}
// Language-independent operation descriptor (audit/login logs).
// Frontend renders localized content from action + params via i18n templates.
......
......@@ -807,6 +807,7 @@
"Choose the default charts, range, and time granularity for model analytics.": "Choose the default charts, range, and time granularity for model analytics.",
"Choose where to fetch upstream metadata.": "Choose where to fetch upstream metadata.",
"Choose which charts are selected by default when opening model analytics.": "Choose which charts are selected by default when opening model analytics.",
"Clamped to": "Clamped to",
"Classic (Legacy Frontend)": "Classic (Legacy Frontend)",
"Claude": "Claude",
"Claude CLI Header Passthrough": "Claude CLI Header Passthrough",
......@@ -2284,6 +2285,7 @@
"Internal Notes": "Internal Notes",
"Internal notes (not shown to users)": "Internal notes (not shown to users)",
"Internal Server Error!": "Internal Server Error!",
"Invalid (NaN)": "Invalid (NaN)",
"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",
......@@ -2364,6 +2366,7 @@
"Key Summary": "Key Summary",
"Key Update Mode": "Key Update Mode",
"Keys, OAuth credentials, and multi-key update behavior.": "Keys, OAuth credentials, and multi-key update behavior.",
"Kind": "Kind",
"Kling": "Kling",
"Knowledge Base ID *": "Knowledge Base ID *",
"Knowledge cutoff": "Knowledge cutoff",
......@@ -3089,6 +3092,7 @@
"Order Payment Method": "Order Payment Method",
"org-...": "org-...",
"Original Model": "Original Model",
"Original value": "Original value",
"Other": "Other",
"Other channels": "Other channels",
"Other groups": "Other groups",
......@@ -3106,6 +3110,7 @@
"Output Tokens": "Output Tokens",
"Overage limited": "Overage limited",
"overall": "overall",
"Overflow": "Overflow",
"Overflow items": "Overflow items",
"Overnight range": "Overnight range",
"override": "override",
......@@ -3492,6 +3497,7 @@
"Quota": "Quota",
"Quota ({{currency}})": "Quota ({{currency}})",
"Quota adjusted successfully": "Quota adjusted successfully",
"Quota clamped": "Quota clamped",
"Quota consumed before charging users": "Quota consumed before charging users",
"Quota Distribution": "Quota Distribution",
"Quota given to invited users": "Quota given to invited users",
......@@ -3501,6 +3507,7 @@
"Quota Per Unit": "Quota Per Unit",
"Quota reminder (tokens)": "Quota reminder (tokens)",
"Quota Reset": "Quota Reset",
"Quota saturation protection triggered": "Quota saturation protection triggered",
"Quota Settings": "Quota Settings",
"Quota Types": "Quota Types",
"Quota Warning Threshold": "Quota Warning Threshold",
......@@ -4411,6 +4418,7 @@
"This FAQ entry will be removed from the list.": "This FAQ entry will be removed from the list.",
"This feature is experimental. Configuration format and behavior may change.": "This feature is experimental. Configuration format and behavior may change.",
"This feature requires server-side WeChat configuration": "This feature requires server-side WeChat configuration",
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.",
"This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.",
"This may cause cache failures.": "This may cause cache failures.",
......@@ -4423,7 +4431,6 @@
"This page has not been created yet.": "This page has not been created yet.",
"This plan does not allow balance redemption": "This plan does not allow balance redemption",
"This project must be used in compliance with the": "This project must be used in compliance with the",
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "This removes {{count}} failed models from this channel. This action cannot be undone.",
"This site currently has {{count}} models enabled": "This site currently has {{count}} models enabled",
"This tier catches any request that did not match earlier tiers.": "This tier catches any request that did not match earlier tiers.",
......@@ -4639,6 +4646,7 @@
"Unbind": "Unbind",
"Unbind failed": "Unbind failed",
"Unbound {{provider}}": "Unbound {{provider}}",
"Underflow": "Underflow",
"Underground": "Underground",
"Understand how user groups, token groups, ratios, and special rules work together.": "Understand how user groups, token groups, ratios, and special rules work together.",
"Understand image inputs alongside text": "Understand image inputs alongside text",
......
......@@ -807,6 +807,7 @@
"Choose the default charts, range, and time granularity for model analytics.": "Choisissez les graphiques, la plage et la granularité temporelle par défaut pour l'analyse des modèles.",
"Choose where to fetch upstream metadata.": "Choisissez où récupérer les métadonnées amont.",
"Choose which charts are selected by default when opening model analytics.": "Choisissez les graphiques sélectionnés par défaut à l'ouverture de l'analyse des modèles.",
"Clamped to": "Limité à",
"Classic (Legacy Frontend)": "Classique (Ancien frontend)",
"Claude": "Claude",
"Claude CLI Header Passthrough": "Passthrough en-tête Claude CLI",
......@@ -2284,6 +2285,7 @@
"Internal Notes": "Notes internes",
"Internal notes (not shown to users)": "Notes internes (non visibles par les utilisateurs)",
"Internal Server Error!": "Erreur interne du serveur !",
"Invalid (NaN)": "Invalide (NaN)",
"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 code": "Code invalide",
......@@ -2364,6 +2366,7 @@
"Key Summary": "Résumé de clé",
"Key Update Mode": "Mode de mise à jour de la clé",
"Keys, OAuth credentials, and multi-key update behavior.": "Clés, identifiants OAuth et comportement de mise à jour multi-clés.",
"Kind": "Type",
"Kling": "Kling",
"Knowledge Base ID *": "ID de la base de connaissances *",
"Knowledge cutoff": "Date de coupure des connaissances",
......@@ -3089,6 +3092,7 @@
"Order Payment Method": "Moyen de paiement (commande)",
"org-...": "org-...",
"Original Model": "Modèle Original",
"Original value": "Valeur d'origine",
"Other": "Autre",
"Other channels": "Autres canaux",
"Other groups": "Autres groupes",
......@@ -3106,6 +3110,7 @@
"Output Tokens": "Tokens de sortie",
"Overage limited": "Dépassement limité",
"overall": "global",
"Overflow": "Débordement",
"Overflow items": "Éléments excédentaires",
"Overnight range": "Plage nocturne",
"override": "remplacer",
......@@ -3492,6 +3497,7 @@
"Quota": "Quota",
"Quota ({{currency}})": "Quota ({{currency}})",
"Quota adjusted successfully": "Quota ajusté avec succès",
"Quota clamped": "Quota plafonné",
"Quota consumed before charging users": "Quota consommé avant de facturer les utilisateurs",
"Quota Distribution": "Distribution des quotas",
"Quota given to invited users": "Quota attribué aux utilisateurs invités",
......@@ -3501,6 +3507,7 @@
"Quota Per Unit": "Quota par unité",
"Quota reminder (tokens)": "Rappel de quota (jetons)",
"Quota Reset": "Réinitialisation du quota",
"Quota saturation protection triggered": "Protection contre la saturation du quota déclenchée",
"Quota Settings": "Paramètres de quota",
"Quota Types": "Types de quotas",
"Quota Warning Threshold": "Seuil d'avertissement de quota",
......@@ -4411,6 +4418,7 @@
"This FAQ entry will be removed from the list.": "Cette entrée de FAQ sera retirée de la liste.",
"This feature is experimental. Configuration format and behavior may change.": "Cette fonctionnalité est expérimentale. Le format de configuration et le comportement peuvent changer.",
"This feature requires server-side WeChat configuration": "Cette fonctionnalité nécessite une configuration WeChat côté serveur",
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "Cet enregistrement historique date d'avant le suivi des informations d'audit et ne peut pas être complété rétroactivement. La version actuelle enregistre déjà l'IP du serveur, l'IP de rappel, le mode de paiement et la version du système pour les nouveaux paiements à venir.",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "Cet identifiant est envoyé au backend de paiement lors de la création d’une commande. Utilisez alipay pour Alipay, wxpay pour WeChat Pay, stripe pour Stripe. Les valeurs personnalisées doivent être prises en charge par votre fournisseur de paiement.",
"This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "Cette instance utilise un nom d’hôte automatique. Définissez NODE_NAME sur une valeur stable et unique pour la gestion multi-instance.",
"This may cause cache failures.": "Cela peut provoquer des échecs de cache.",
......@@ -4423,7 +4431,6 @@
"This page has not been created yet.": "Cette page n'a pas encore été créée.",
"This plan does not allow balance redemption": "Ce forfait ne permet pas le paiement avec le solde",
"This project must be used in compliance with the": "Ce projet doit être utilisé conformément aux",
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "Cet enregistrement historique date d'avant le suivi des informations d'audit et ne peut pas être complété rétroactivement. La version actuelle enregistre déjà l'IP du serveur, l'IP de rappel, le mode de paiement et la version du système pour les nouveaux paiements à venir.",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "Cela supprime {{count}} modèles en échec de ce canal. Cette action est irréversible.",
"This site currently has {{count}} models enabled": "Ce site compte actuellement {{count}} modèles activés",
"This tier catches any request that did not match earlier tiers.": "Ce palier récupère toute requête qui ne correspond à aucun palier précédent.",
......@@ -4639,6 +4646,7 @@
"Unbind": "Dissocier",
"Unbind failed": "Échec de la dissociation",
"Unbound {{provider}}": "{{provider}} dissocié",
"Underflow": "Sous-dépassement",
"Underground": "Underground",
"Understand how user groups, token groups, ratios, and special rules work together.": "Comprendre comment les groupes utilisateur, groupes de jetons, ratios et règles spéciales fonctionnent ensemble.",
"Understand image inputs alongside text": "Comprendre des entrées image en complément du texte",
......
......@@ -807,6 +807,7 @@
"Choose the default charts, range, and time granularity for model analytics.": "モデル分析のデフォルトチャート、範囲、時間粒度を選択します。",
"Choose where to fetch upstream metadata.": "アップストリームのメタデータをどこからフェッチするかを選択してください。",
"Choose which charts are selected by default when opening model analytics.": "モデル分析を開いたときにデフォルトで選択されるチャートを選択します。",
"Clamped to": "制限後の値",
"Classic (Legacy Frontend)": "クラシック(旧フロントエンド)",
"Claude": "Claude",
"Claude CLI Header Passthrough": "Claude CLI ヘッダーパススルー",
......@@ -2284,6 +2285,7 @@
"Internal Notes": "内部メモ",
"Internal notes (not shown to users)": ":内部メモ(ユーザーには表示されません)",
"Internal Server Error!": "内部サーバーエラー!",
"Invalid (NaN)": "無効 (NaN)",
"Invalid chat link. Please contact the administrator.": "無効なチャットリンクです。管理者に連絡してください。",
"Invalid chat link. Please contact your administrator.": "無効なチャットリンクです。管理者に連絡してください。",
"Invalid code": "無効なコード",
......@@ -2364,6 +2366,7 @@
"Key Summary": "キー概要",
"Key Update Mode": "キー更新モード",
"Keys, OAuth credentials, and multi-key update behavior.": "キー、OAuth 認証情報、マルチキー更新動作を管理します。",
"Kind": "種類",
"Kling": "Kling",
"Knowledge Base ID *": "ナレッジベースID *",
"Knowledge cutoff": "知識のカットオフ",
......@@ -3089,6 +3092,7 @@
"Order Payment Method": "注文の支払い方法",
"org-...": "org-...",
"Original Model": "オリジナルモデル",
"Original value": "元の値",
"Other": "その他",
"Other channels": "その他のチャネル",
"Other groups": "その他のグループ",
......@@ -3106,6 +3110,7 @@
"Output Tokens": "出力トークン",
"Overage limited": "超過利用制限中",
"overall": "全体",
"Overflow": "オーバーフロー",
"Overflow items": "超過項目",
"Overnight range": "日跨ぎ範囲",
"override": "上書き",
......@@ -3492,6 +3497,7 @@
"Quota": "クォータ",
"Quota ({{currency}})": "クォータ ({{currency}})",
"Quota adjusted successfully": "クォータの調整に成功しました",
"Quota clamped": "クォータ制限適用",
"Quota consumed before charging users": "ユーザーに請求する前に消費されるクォータ",
"Quota Distribution": "クォータの分配",
"Quota given to invited users": "招待されたユーザーに付与されるクォータ",
......@@ -3501,6 +3507,7 @@
"Quota Per Unit": "ユニットあたりのクォータ",
"Quota reminder (tokens)": "クォータリマインダー (トークン)",
"Quota Reset": "クォータリセット",
"Quota saturation protection triggered": "クォータ飽和保護が作動しました",
"Quota Settings": "クォータ設定",
"Quota Types": "クォータタイプ",
"Quota Warning Threshold": "クォータ警告しきい値",
......@@ -4411,6 +4418,7 @@
"This FAQ entry will be removed from the list.": "この FAQ 項目はリストから削除されます。",
"This feature is experimental. Configuration format and behavior may change.": "この機能は実験的です。設定フォーマットや動作は変更される可能性があります。",
"This feature requires server-side WeChat configuration": "この機能にはサーバー側のWeChat設定が必要です",
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "このレコードは監査情報の記録に対応する前の履歴データのため、監査情報がありません。現在のバージョンではサーバーIP、コールバックIP、支払い方法、システムバージョンなどの監査情報を記録できますが、これらは今後新しく作成されるレコードにのみ適用され、過去のレコードを遡って補完することはできません。",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "注文作成時に、この識別子が決済バックエンドへ送信されます。Alipay は alipay、WeChat Pay は wxpay、Stripe は stripe を使ってください。カスタム値は決済サービス側で対応している必要があります。",
"This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "このインスタンスは自動ホスト名を使用しています。マルチインスタンス管理のために、安定した一意の NODE_NAME を設定してください。",
"This may cause cache failures.": "これによりキャッシュ障害が発生する可能性があります。",
......@@ -4423,7 +4431,6 @@
"This page has not been created yet.": "このページはまだ作成されていません。",
"This plan does not allow balance redemption": "このプランでは残高での交換は許可されていません",
"This project must be used in compliance with the": "このプロジェクトは、以下を遵守して使用する必要があります",
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "このレコードは監査情報の記録に対応する前の履歴データのため、監査情報がありません。現在のバージョンではサーバーIP、コールバックIP、支払い方法、システムバージョンなどの監査情報を記録できますが、これらは今後新しく作成されるレコードにのみ適用され、過去のレコードを遡って補完することはできません。",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "この操作はこのチャンネルから失敗した {{count}} 個のモデルを削除します。元に戻せません。",
"This site currently has {{count}} models enabled": "このサイトでは現在 {{count}} 個のモデルが有効です",
"This tier catches any request that did not match earlier tiers.": "この段階は、前の段階に一致しなかったすべてのリクエストを受け取ります。",
......@@ -4639,6 +4646,7 @@
"Unbind": "連携解除",
"Unbind failed": "連携解除に失敗しました",
"Unbound {{provider}}": "{{provider}}の連携を解除しました",
"Underflow": "アンダーフロー",
"Underground": "アンダーグラウンド",
"Understand how user groups, token groups, ratios, and special rules work together.": "ユーザーグループ、トークングループ、倍率、特殊ルールがどのように連携するかを理解します。",
"Understand image inputs alongside text": "テキストとともに画像入力を理解",
......
......@@ -807,6 +807,7 @@
"Choose the default charts, range, and time granularity for model analytics.": "Выберите графики, диапазон и временную детализацию по умолчанию для аналитики моделей.",
"Choose where to fetch upstream metadata.": "Выберите, откуда получать метаданные вышестоящего источника.",
"Choose which charts are selected by default when opening model analytics.": "Выберите графики, которые будут выбраны по умолчанию при открытии аналитики моделей.",
"Clamped to": "Ограничено до",
"Classic (Legacy Frontend)": "Классический (Старый интерфейс)",
"Claude": "Клод",
"Claude CLI Header Passthrough": "Проброс заголовков Claude CLI",
......@@ -2284,6 +2285,7 @@
"Internal Notes": "Внутренние заметки",
"Internal notes (not shown to users)": "Внутренние заметки (не показываются пользователям)",
"Internal Server Error!": "Внутренняя ошибка сервера!",
"Invalid (NaN)": "Недопустимо (NaN)",
"Invalid chat link. Please contact the administrator.": "Неверная ссылка на чат. Пожалуйста, обратитесь к администратору.",
"Invalid chat link. Please contact your administrator.": "Недействительная ссылка чата. Обратитесь к администратору.",
"Invalid code": "Неверный код",
......@@ -2364,6 +2366,7 @@
"Key Summary": "Сводка ключа",
"Key Update Mode": "Режим обновления ключа",
"Keys, OAuth credentials, and multi-key update behavior.": "Ключи, учетные данные OAuth и поведение обновления нескольких ключей.",
"Kind": "Тип",
"Kling": "Kling",
"Knowledge Base ID *": "ID базы знаний *",
"Knowledge cutoff": "Дата актуальности данных",
......@@ -3089,6 +3092,7 @@
"Order Payment Method": "Способ оплаты (заказа)",
"org-...": "орг-...",
"Original Model": "Оригинальная модель",
"Original value": "Исходное значение",
"Other": "Другое",
"Other channels": "Другие каналы",
"Other groups": "Другие группы",
......@@ -3106,6 +3110,7 @@
"Output Tokens": "Выходные токены",
"Overage limited": "Ограничение перерасхода",
"overall": "всего",
"Overflow": "Переполнение",
"Overflow items": "Элементы сверх лимита",
"Overnight range": "Диапазон через полночь",
"override": "переопределить",
......@@ -3492,6 +3497,7 @@
"Quota": "Квота",
"Quota ({{currency}})": "Квота ({{currency}})",
"Quota adjusted successfully": "Квота успешно изменена",
"Quota clamped": "Квота ограничена",
"Quota consumed before charging users": "Квота, потребляемая до взимания платы с пользователей",
"Quota Distribution": "Распределение квоты",
"Quota given to invited users": "Квота, предоставляемая приглашенным пользователям",
......@@ -3501,6 +3507,7 @@
"Quota Per Unit": "Квота на единицу",
"Quota reminder (tokens)": "Напоминание о квоте (токены)",
"Quota Reset": "Сброс квоты",
"Quota saturation protection triggered": "Сработала защита от переполнения квоты",
"Quota Settings": "Настройки квоты",
"Quota Types": "Типы квот",
"Quota Warning Threshold": "Порог предупреждения о квоте",
......@@ -4411,6 +4418,7 @@
"This FAQ entry will be removed from the list.": "Эта запись FAQ будет удалена из списка.",
"This feature is experimental. Configuration format and behavior may change.": "Эта функция является экспериментальной. Формат конфигурации и поведение могут измениться.",
"This feature requires server-side WeChat configuration": "Эта функция требует серверной конфигурации WeChat",
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "Эта историческая запись была создана до появления функции аудита и не содержит данных аудита. Текущая версия уже поддерживает запись IP-адреса сервера, IP обратного вызова, способа оплаты и версии системы, но эти поля будут заполняться только в новых записях — восполнить их в старых записях задним числом невозможно.",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "Этот идентификатор отправляется в платежный backend при создании заказа. Для Alipay используйте alipay, для WeChat Pay — wxpay, для Stripe — stripe. Пользовательские значения должны поддерживаться вашим платежным провайдером.",
"This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "Этот экземпляр использует автоматическое имя хоста. Задайте стабильное уникальное значение NODE_NAME для управления несколькими экземплярами.",
"This may cause cache failures.": "Это может привести к сбоям кэша.",
......@@ -4423,7 +4431,6 @@
"This page has not been created yet.": "Эта страница еще не создана.",
"This plan does not allow balance redemption": "Этот план не разрешает оплату балансом",
"This project must be used in compliance with the": "Этот проект должен использоваться в соответствии с",
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "Эта историческая запись была создана до появления функции аудита и не содержит данных аудита. Текущая версия уже поддерживает запись IP-адреса сервера, IP обратного вызова, способа оплаты и версии системы, но эти поля будут заполняться только в новых записях — восполнить их в старых записях задним числом невозможно.",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "Это удалит {{count}} неуспешных моделей из этого канала. Действие необратимо.",
"This site currently has {{count}} models enabled": "На этом сайте сейчас включено моделей: {{count}}",
"This tier catches any request that did not match earlier tiers.": "Этот уровень обрабатывает все запросы, которые не совпали с предыдущими уровнями.",
......@@ -4639,6 +4646,7 @@
"Unbind": "Отвязать",
"Unbind failed": "Не удалось отвязать",
"Unbound {{provider}}": "{{provider}} отвязан",
"Underflow": "Переполнение снизу",
"Underground": "Подземелье",
"Understand how user groups, token groups, ratios, and special rules work together.": "Поймите, как вместе работают группы пользователей, группы токенов, коэффициенты и специальные правила.",
"Understand image inputs alongside text": "Понимать изображения наряду с текстом",
......
......@@ -807,6 +807,7 @@
"Choose the default charts, range, and time granularity for model analytics.": "Chọn biểu đồ, khoảng thời gian và độ chi tiết thời gian mặc định cho phân tích mô hình.",
"Choose where to fetch upstream metadata.": "Chọn nơi để tìm nạp siêu dữ liệu thượng nguồn.",
"Choose which charts are selected by default when opening model analytics.": "Chọn biểu đồ được chọn mặc định khi mở phân tích mô hình.",
"Clamped to": "Giới hạn thành",
"Classic (Legacy Frontend)": "Cổ điển (Frontend cũ)",
"Claude": "Claude",
"Claude CLI Header Passthrough": "Chuyển tiếp header Claude CLI",
......@@ -2284,6 +2285,7 @@
"Internal Notes": "Ghi chú nội bộ",
"Internal notes (not shown to users)": "Ghi chú nội bộ (không hiển thị cho người dùng)",
"Internal Server Error!": "Lỗi máy chủ nội bộ!",
"Invalid (NaN)": "Không hợp lệ (NaN)",
"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 code": "Mã không hợp lệ",
......@@ -2364,6 +2366,7 @@
"Key Summary": "Tóm tắt khóa",
"Key Update Mode": "Chế độ cập nhật khóa",
"Keys, OAuth credentials, and multi-key update behavior.": "Khóa, thông tin xác thực OAuth và hành vi cập nhật nhiều khóa.",
"Kind": "Loại",
"Kling": "Kling",
"Knowledge Base ID *": "Mã số Cơ sở kiến thức *",
"Knowledge cutoff": "Mốc dữ liệu",
......@@ -3089,6 +3092,7 @@
"Order Payment Method": "Phương thức thanh toán đơn hàng",
"org-...": "org-...",
"Original Model": "Nguyên mẫu",
"Original value": "Giá trị gốc",
"Other": "Khác",
"Other channels": "Kênh khác",
"Other groups": "Nhóm khác",
......@@ -3106,6 +3110,7 @@
"Output Tokens": "Token đầu ra",
"Overage limited": "Đã giới hạn vượt mức",
"overall": "tổng",
"Overflow": "Tràn trên",
"Overflow items": "Mục vượt giới hạn",
"Overnight range": "Khoảng qua nửa đêm",
"override": "ghi đè",
......@@ -3492,6 +3497,7 @@
"Quota": "Hạn ngạch",
"Quota ({{currency}})": "Hạn mức ({{currency}})",
"Quota adjusted successfully": "Điều chỉnh hạn mức thành công",
"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 Distribution": "Phân bổ hạn ngạch",
"Quota given to invited users": "Hạn mức được cấp cho người dùng được mời",
......@@ -3501,6 +3507,7 @@
"Quota Per Unit": "Định mức mỗi đơn vị",
"Quota reminder (tokens)": "Nhắc nhở hạn mức (token)",
"Quota Reset": "Đặt lại hạn mức",
"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 Types": "Các loại hạn ngạch",
"Quota Warning Threshold": "Ngưỡng cảnh báo hạn mức",
......@@ -4411,6 +4418,7 @@
"This FAQ entry will be removed from the list.": "Mục FAQ này sẽ bị xóa khỏi danh sách.",
"This feature is experimental. Configuration format and behavior may change.": "Tính năng này đang ở giai đoạn thử nghiệm. Định dạng cấu hình và hành vi có thể thay đổi.",
"This feature requires server-side WeChat configuration": "Tính năng này yêu cầu cấu hình WeChat phía máy chủ",
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "Bản ghi lịch sử này được tạo trước khi tính năng thông tin kiểm toán ra đời nên thiếu dữ liệu kiểm toán. Phiên bản hiện tại đã hỗ trợ ghi lại IP máy chủ, IP gọi lại, phương thức thanh toán và phiên bản hệ thống, nhưng các trường này chỉ được ghi cho các bản ghi mới về sau — không thể bổ sung hồi tố cho bản ghi cũ.",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "Mã định danh này được gửi tới backend thanh toán khi tạo đơn hàng. Dùng alipay cho Alipay, wxpay cho WeChat Pay, stripe cho Stripe. Giá trị tùy chỉnh phải được nhà cung cấp thanh toán hỗ trợ.",
"This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "Phiên bản này đang dùng hostname tự động. Hãy đặt NODE_NAME thành một giá trị ổn định và duy nhất để quản lý nhiều phiên bản.",
"This may cause cache failures.": "Điều này có thể gây ra lỗi bộ nhớ đệm.",
......@@ -4423,7 +4431,6 @@
"This page has not been created yet.": "Trang này chưa được tạo.",
"This plan does not allow balance redemption": "Gói này không cho phép thanh toán bằng số dư",
"This project must be used in compliance with the": "Dự án này phải được sử dụng tuân thủ theo",
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "Bản ghi lịch sử này được tạo trước khi tính năng thông tin kiểm toán ra đời nên thiếu dữ liệu kiểm toán. Phiên bản hiện tại đã hỗ trợ ghi lại IP máy chủ, IP gọi lại, phương thức thanh toán và phiên bản hệ thống, nhưng các trường này chỉ được ghi cho các bản ghi mới về sau — không thể bổ sung hồi tố cho bản ghi cũ.",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "Thao tác này sẽ xóa {{count}} mô hình thất bại khỏi kênh này. Không thể hoàn tác.",
"This site currently has {{count}} models enabled": "Trang này hiện đã bật {{count}} mô hình",
"This tier catches any request that did not match earlier tiers.": "Tầng này bắt mọi yêu cầu không khớp với các tầng trước.",
......@@ -4639,6 +4646,7 @@
"Unbind": "Hủy liên kết",
"Unbind failed": "Hủy liên kết thất bại",
"Unbound {{provider}}": "Đã hủy liên kết {{provider}}",
"Underflow": "Tràn dưới",
"Underground": "Underground",
"Understand how user groups, token groups, ratios, and special rules work together.": "Hiểu cách nhóm người dùng, nhóm token, tỷ lệ và quy tắc đặc biệt hoạt động cùng nhau.",
"Understand image inputs alongside text": "Hiểu hình ảnh cùng với văn bản",
......
......@@ -807,6 +807,7 @@
"Choose the default charts, range, and time granularity for model analytics.": "选择模型调用分析的默认图表、范围和时间粒度。",
"Choose where to fetch upstream metadata.": "选择从何处获取上游元数据。",
"Choose which charts are selected by default when opening model analytics.": "选择打开模型调用分析时默认选中的图表。",
"Clamped to": "钳制为",
"Classic (Legacy Frontend)": "经典前端",
"Claude": "Claude",
"Claude CLI Header Passthrough": "Claude CLI 请求头透传",
......@@ -2284,6 +2285,7 @@
"Internal Notes": "内部备注",
"Internal notes (not shown to users)": "内部备注(不显示给用户)",
"Internal Server Error!": "内部服务器错误!",
"Invalid (NaN)": "无效 (NaN)",
"Invalid chat link. Please contact the administrator.": "无效的聊天链接。请联系管理员。",
"Invalid chat link. Please contact your administrator.": "无效的聊天链接。请联系您的管理员。",
"Invalid code": "无效代码",
......@@ -2364,6 +2366,7 @@
"Key Summary": "Key 摘要",
"Key Update Mode": "密钥更新模式",
"Keys, OAuth credentials, and multi-key update behavior.": "管理密钥、OAuth 凭据和多密钥更新行为。",
"Kind": "类型",
"Kling": "Kling",
"Knowledge Base ID *": "知识库 ID *",
"Knowledge cutoff": "知识截止",
......@@ -3089,6 +3092,7 @@
"Order Payment Method": "订单支付方式",
"org-...": "org-...",
"Original Model": "原始模型",
"Original value": "原始值",
"Other": "其他",
"Other channels": "其他渠道",
"Other groups": "其他分组",
......@@ -3106,6 +3110,7 @@
"Output Tokens": "输出 Token",
"Overage limited": "超额受限",
"overall": "总体",
"Overflow": "上溢",
"Overflow items": "超出项",
"Overnight range": "跨日范围",
"override": "覆盖",
......@@ -3492,6 +3497,7 @@
"Quota": "额度",
"Quota ({{currency}})": "额度 ({{currency}})",
"Quota adjusted successfully": "调整额度成功",
"Quota clamped": "额度已钳制",
"Quota consumed before charging users": "向用户收费前消耗的配额",
"Quota Distribution": "消耗分布",
"Quota given to invited users": "授予被邀请用户的配额",
......@@ -3501,6 +3507,7 @@
"Quota Per Unit": "每单位配额",
"Quota reminder (tokens)": "配额提醒(token)",
"Quota Reset": "额度重置",
"Quota saturation protection triggered": "额度饱和保护已触发",
"Quota Settings": "额度设置",
"Quota Types": "配额类型",
"Quota Warning Threshold": "配额警告阈值",
......@@ -4411,6 +4418,7 @@
"This FAQ entry will be removed from the list.": "此 FAQ 条目将从列表中移除。",
"This feature is experimental. Configuration format and behavior may change.": "此功能为实验性功能。配置格式和行为可能会发生变化。",
"This feature requires server-side WeChat configuration": "此功能需要服务器端微信配置",
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "该条历史记录缺少审计字段。当前版本已支持记录服务器 IP、回调 IP、支付方式与系统版本等审计信息;这些字段仅会写入后续新产生的记录,历史记录无法自动补齐。",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "创建订单时会把这个标识提交给支付后端。支付宝填 alipay,微信填 wxpay,Stripe 填 stripe。自定义值必须是支付服务支持的标识。",
"This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "该实例正在使用自动主机名。请设置稳定且唯一的 NODE_NAME,以便进行多实例管理。",
"This may cause cache failures.": "这可能导致缓存故障。",
......@@ -4423,7 +4431,6 @@
"This page has not been created yet.": "此页面尚未创建。",
"This plan does not allow balance redemption": "该套餐不允许使用余额兑换",
"This project must be used in compliance with the": "此项目的使用必须遵守",
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "该条历史记录缺少审计字段。当前版本已支持记录服务器 IP、回调 IP、支付方式与系统版本等审计信息;这些字段仅会写入后续新产生的记录,历史记录无法自动补齐。",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "此操作将从该渠道移除 {{count}} 个测试失败的模型,且无法撤销。",
"This site currently has {{count}} models enabled": "本站当前已启用模型,总计 {{count}} 个",
"This tier catches any request that did not match earlier tiers.": "此阶梯会兜底处理未匹配前面阶梯的请求。",
......@@ -4639,6 +4646,7 @@
"Unbind": "解绑",
"Unbind failed": "解绑失败",
"Unbound {{provider}}": "已解绑 {{provider}}",
"Underflow": "下溢",
"Underground": "暗夜",
"Understand how user groups, token groups, ratios, and special rules work together.": "了解用户分组、令牌分组、倍率和特殊规则如何共同生效。",
"Understand image inputs alongside text": "在文本之外理解图像输入",
......
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