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 ...@@ -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. - 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. - 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. - 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. - 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. - 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. - 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 package common
import "math" import (
"fmt"
"math"
// QuotaFromFloat converts a computed quota value to int with saturation. "github.com/shopspring/decimal"
// 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 // Quota conversions are centralized here so every billing path shares one
// (user/token/log) are 32-bit integers in the database. // saturation + logging policy. Quota columns (user/token/log) are 32-bit
func QuotaFromFloat(value float64) int { // integers in the database, so an oversized product must clamp to the int32
if math.IsNaN(value) { // range instead of wrapping around and turning a charge into a credit.
return 0 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 map[string]interface{}{
return math.MaxInt32 "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 ( ...@@ -4,19 +4,105 @@ import (
"math" "math"
"testing" "testing"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert" "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 // TestQuotaFromFloat guards the billing invariant that oversized quota
// products (e.g. price multiplied by a huge user-supplied count) saturate // 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) { func TestQuotaFromFloat(t *testing.T) {
assert.Equal(t, 42, QuotaFromFloat(42.4)) assert.Equal(t, 42, QuotaFromFloat(42.4))
assert.Equal(t, -42, QuotaFromFloat(-42.4)) assert.Equal(t, 42, QuotaFromFloat(42.9))
// 2000 quota per call * n=18446744073686646784 overflows int64. assert.Equal(t, -42, QuotaFromFloat(-42.9))
assert.Equal(t, math.MaxInt32, QuotaFromFloat(2000*1.8446744073686647e19)) assert.Equal(t, MaxQuota, QuotaFromFloat(overflowingProduct))
assert.Equal(t, math.MinInt32, QuotaFromFloat(-2000*1.8446744073686647e19)) assert.Equal(t, MinQuota, QuotaFromFloat(-overflowingProduct))
assert.Equal(t, math.MaxInt32, QuotaFromFloat(math.Inf(1))) assert.Equal(t, MaxQuota, QuotaFromFloat(math.Inf(1)))
assert.Equal(t, math.MinInt32, QuotaFromFloat(math.Inf(-1))) assert.Equal(t, MinQuota, QuotaFromFloat(math.Inf(-1)))
assert.Equal(t, 0, QuotaFromFloat(math.NaN())) 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 ...@@ -179,7 +179,11 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
} }
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio(饱和转换,防止溢出成负数) // 计算实际应扣费额度: 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 preConsumedQuota := task.Quota
...@@ -205,7 +209,12 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha ...@@ -205,7 +209,12 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,补扣费 %s", logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,补扣费 %s",
modelRatio, finalGroupRatio, taskResult.TotalTokens, modelRatio, finalGroupRatio, taskResult.TotalTokens,
logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(quotaDelta)) 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 { } else if quotaDelta < 0 {
// 需要退还多扣的费用 // 需要退还多扣的费用
...@@ -226,7 +235,12 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha ...@@ -226,7 +235,12 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,退还 %s", logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,退还 %s",
modelRatio, finalGroupRatio, taskResult.TotalTokens, modelRatio, finalGroupRatio, taskResult.TotalTokens,
logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(refundQuota)) 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 { } else {
// quotaDelta == 0, 预扣费刚好准确 // 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) { ...@@ -291,12 +291,9 @@ func TestQuotaRound(t *testing.T) {
{999.4999, 999}, {999.4999, 999},
{999.5, 1000}, {999.5, 1000},
{1e9 + 0.5, 1e9 + 1}, {1e9 + 0.5, 1e9 + 1},
// Oversized expression results must saturate at int32 bounds // Oversized expression results saturate at int32 (delegated to
// instead of wrapping into a negative charge. // common.QuotaRound); full saturation coverage lives in common.
{3.6893488147419103e19, math.MaxInt32}, {3.6893488147419103e19, math.MaxInt32},
{-3.6893488147419103e19, math.MinInt32},
{math.Inf(1), math.MaxInt32},
{math.NaN(), 0},
} }
for _, tt := range tests { for _, tt := range tests {
got := billingexpr.QuotaRound(tt.in) got := billingexpr.QuotaRound(tt.in)
......
package billingexpr package billingexpr
import "math" import "github.com/QuantumNous/new-api/common"
// QuotaRound converts a float64 quota value to int using half-away-from-zero // QuotaRound converts a float64 quota value to int using half-away-from-zero
// rounding. Every tiered billing path (pre-consume, settlement, breakdown // rounding with int32 saturation. Every tiered billing path (pre-consume,
// validation, log fields) MUST use this function to avoid +-1 discrepancies. // 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 // It delegates to common.QuotaRound so all quota rounding/conversion shares
// the database, and an oversized expression result must never wrap around // one saturation + logging policy (see common/quota_math.go).
// and turn a charge into a credit.
func QuotaRound(f float64) int { func QuotaRound(f float64) int {
r := math.Round(f) return common.QuotaRound(f)
if math.IsNaN(r) {
return 0
}
if r >= math.MaxInt32 {
return math.MaxInt32
}
if r <= math.MinInt32 {
return math.MinInt32
}
return int(r)
} }
package billingexpr package billingexpr
import "github.com/QuantumNous/new-api/common"
// quotaConversion converts raw expression output to quota based on the // quotaConversion converts raw expression output to quota based on the
// expression version. This is the central dispatch point for future versions // expression version. This is the central dispatch point for future versions
// that may use a different conversion formula. // that may use a different conversion formula.
...@@ -23,7 +25,7 @@ func ComputeTieredQuotaWithRequest(snap *BillingSnapshot, params TokenParams, re ...@@ -23,7 +25,7 @@ func ComputeTieredQuotaWithRequest(snap *BillingSnapshot, params TokenParams, re
} }
quotaBeforeGroup := quotaConversion(cost, snap) quotaBeforeGroup := quotaConversion(cost, snap)
afterGroup := QuotaRound(quotaBeforeGroup * snap.GroupRatio) afterGroup, clamp := common.QuotaRoundChecked(quotaBeforeGroup * snap.GroupRatio)
crossed := trace.MatchedTier != snap.EstimatedTier crossed := trace.MatchedTier != snap.EstimatedTier
return TieredResult{ return TieredResult{
...@@ -31,5 +33,6 @@ func ComputeTieredQuotaWithRequest(snap *BillingSnapshot, params TokenParams, re ...@@ -31,5 +33,6 @@ func ComputeTieredQuotaWithRequest(snap *BillingSnapshot, params TokenParams, re
ActualQuotaAfterGroup: afterGroup, ActualQuotaAfterGroup: afterGroup,
MatchedTier: trace.MatchedTier, MatchedTier: trace.MatchedTier,
CrossedTier: crossed, CrossedTier: crossed,
Clamp: clamp,
}, nil }, 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 ...@@ -3,6 +3,8 @@ package billingexpr
import ( import (
"crypto/sha256" "crypto/sha256"
"fmt" "fmt"
"github.com/QuantumNous/new-api/common"
) )
type RequestInput struct { type RequestInput struct {
...@@ -57,6 +59,11 @@ type TieredResult struct { ...@@ -57,6 +59,11 @@ type TieredResult struct {
ActualQuotaAfterGroup int `json:"actual_quota_after_group"` ActualQuotaAfterGroup int `json:"actual_quota_after_group"`
MatchedTier string `json:"matched_tier"` MatchedTier string `json:"matched_tier"`
CrossedTier bool `json:"crossed_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. // 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 ...@@ -105,7 +105,7 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
} else if duration > 0 { } else if duration > 0 {
// 计算 token: ceil(duration) / 60.0 * 1000,即每分钟 1000 tokens。 // 计算 token: ceil(duration) / 60.0 * 1000,即每分钟 1000 tokens。
// duration 解析自上游返回的音频元数据,饱和转换防止 int 回绕。 // 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.CompletionTokens = completionTokens
usage.CompletionTokenDetails.AudioTokens = completionTokens usage.CompletionTokenDetails.AudioTokens = completionTokens
} }
......
...@@ -163,6 +163,11 @@ type RelayInfo struct { ...@@ -163,6 +163,11 @@ type RelayInfo struct {
PriceData types.PriceData 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 // TieredBillingSnapshot is a frozen snapshot of tiered billing rules
// captured at pre-consume time. Non-nil only when billing mode is "tiered_expr". // captured at pre-consume time. Non-nil only when billing mode is "tiered_expr".
TieredBillingSnapshot *billingexpr.BillingSnapshot TieredBillingSnapshot *billingexpr.BillingSnapshot
......
...@@ -202,7 +202,9 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe ...@@ -202,7 +202,9 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe
quotaWithRatios *= ra quotaWithRatios *= ra
} }
} }
info.PriceData.Quota = common.QuotaFromFloat(quotaWithRatios) quota, clamp := common.QuotaFromFloatChecked(quotaWithRatios)
info.PriceData.Quota = quota
noteTaskQuotaClamp(info, clamp)
} }
// 7. 预扣费(仅首次 — 重试时 info.Billing 已存在,跳过) // 7. 预扣费(仅首次 — 重试时 info.Billing 已存在,跳过)
...@@ -278,7 +280,21 @@ func recalcQuotaFromRatios(info *relaycommon.RelayInfo, ratios map[string]float6 ...@@ -278,7 +280,21 @@ func recalcQuotaFromRatios(info *relaycommon.RelayInfo, ratios map[string]float6
result *= ra 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){ var fetchRespBuilders = map[int]func(c *gin.Context) (respBody []byte, taskResp *dto.TaskError){
......
...@@ -2,11 +2,13 @@ package service ...@@ -2,11 +2,13 @@ package service
import ( import (
"encoding/base64" "encoding/base64"
"fmt"
"strings" "strings"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/pkg/billingexpr" "github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common" relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/types"
...@@ -14,6 +16,39 @@ import ( ...@@ -14,6 +16,39 @@ import (
"github.com/gin-gonic/gin" "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{}) { func appendRequestPath(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
if other == nil { if other == nil {
return return
......
...@@ -47,14 +47,14 @@ func hasCustomModelRatio(modelName string, currentRatio float64) bool { ...@@ -47,14 +47,14 @@ func hasCustomModelRatio(modelName string, currentRatio float64) bool {
return currentRatio != defaultRatio return currentRatio != defaultRatio
} }
func calculateAudioQuota(info QuotaInfo) int { func calculateAudioQuota(info QuotaInfo) (int, *common.QuotaClamp) {
if info.UsePrice { if info.UsePrice {
modelPrice := decimal.NewFromFloat(info.ModelPrice) modelPrice := decimal.NewFromFloat(info.ModelPrice)
quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
groupRatio := decimal.NewFromFloat(info.GroupRatio) groupRatio := decimal.NewFromFloat(info.GroupRatio)
quota := modelPrice.Mul(quotaPerUnit).Mul(groupRatio) quota := modelPrice.Mul(quotaPerUnit).Mul(groupRatio)
return decimalToQuota(quota) return common.QuotaFromDecimalChecked(quota)
} }
completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(info.ModelName)) completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(info.ModelName))
...@@ -83,7 +83,7 @@ func calculateAudioQuota(info QuotaInfo) int { ...@@ -83,7 +83,7 @@ func calculateAudioQuota(info QuotaInfo) int {
quota = decimal.NewFromInt(1) quota = decimal.NewFromInt(1)
} }
return decimalToQuota(quota) return common.QuotaFromDecimalChecked(quota)
} }
func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage) error { 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 ...@@ -136,7 +136,8 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
GroupRatio: actualGroupRatio, GroupRatio: actualGroupRatio,
} }
quota := calculateAudioQuota(quotaInfo) quota, clamp := calculateAudioQuota(quotaInfo)
noteQuotaClamp(relayInfo, clamp)
if userQuota < quota { if userQuota < quota {
return fmt.Errorf("user quota is not enough, user quota: %s, need quota: %s", logger.FormatQuota(userQuota), logger.FormatQuota(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 ...@@ -199,7 +200,8 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
GroupRatio: groupRatio, GroupRatio: groupRatio,
} }
quota := calculateAudioQuota(quotaInfo) quota, clamp := calculateAudioQuota(quotaInfo)
noteQuotaClamp(relayInfo, clamp)
if tieredOk { if tieredOk {
quota = tieredQuota quota = tieredQuota
} }
...@@ -239,6 +241,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod ...@@ -239,6 +241,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
if tieredResult != nil { if tieredResult != nil {
InjectTieredBillingInfo(other, relayInfo, tieredResult) InjectTieredBillingInfo(other, relayInfo, tieredResult)
} }
attachQuotaSaturation(ctx, relayInfo, other)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId, ChannelId: relayInfo.ChannelId,
PromptTokens: usage.InputTokens, PromptTokens: usage.InputTokens,
...@@ -320,7 +323,8 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u ...@@ -320,7 +323,8 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
GroupRatio: groupRatio, GroupRatio: groupRatio,
} }
quota := calculateAudioQuota(quotaInfo) quota, clamp := calculateAudioQuota(quotaInfo)
noteQuotaClamp(relayInfo, clamp)
if tieredOk { if tieredOk {
quota = tieredQuota quota = tieredQuota
} }
...@@ -360,6 +364,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u ...@@ -360,6 +364,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
if tieredResult != nil { if tieredResult != nil {
InjectTieredBillingInfo(other, relayInfo, tieredResult) InjectTieredBillingInfo(other, relayInfo, tieredResult)
} }
attachQuotaSaturation(ctx, relayInfo, other)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId, ChannelId: relayInfo.ChannelId,
PromptTokens: usage.PromptTokens, 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) { ...@@ -50,6 +50,7 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
other["is_model_mapped"] = true other["is_model_mapped"] = true
other["upstream_model_name"] = info.UpstreamModelName other["upstream_model_name"] = info.UpstreamModelName
} }
attachQuotaSaturation(c, info, other)
model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{ model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{
ChannelId: info.ChannelId, ChannelId: info.ChannelId,
ModelName: info.OriginModelName, ModelName: info.OriginModelName,
...@@ -184,7 +185,8 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) { ...@@ -184,7 +185,8 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) {
// RecalculateTaskQuota 通用的异步差额结算。 // RecalculateTaskQuota 通用的异步差额结算。
// actualQuota 是任务完成后的实际应扣额度,与预扣额度 (task.Quota) 做差额结算。 // actualQuota 是任务完成后的实际应扣额度,与预扣额度 (task.Quota) 做差额结算。
// reason 用于日志记录(例如 "token重算" 或 "adaptor调整")。 // 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 { if actualQuota <= 0 {
return return
} }
...@@ -234,6 +236,9 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int ...@@ -234,6 +236,9 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
other["task_id"] = task.TaskID other["task_id"] = task.TaskID
other["pre_consumed_quota"] = preConsumedQuota other["pre_consumed_quota"] = preConsumedQuota
other["actual_quota"] = actualQuota other["actual_quota"] = actualQuota
for _, clamp := range clamps {
attachQuotaSaturationToOther(other, clamp)
}
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId, UserId: task.UserId,
LogType: logType, LogType: logType,
...@@ -298,8 +303,8 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo ...@@ -298,8 +303,8 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo
} }
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio * otherMultiplier(饱和转换,防止溢出成负数) // 计算实际应扣费额度: 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) 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 ...@@ -138,6 +138,18 @@ func calculateTextToolCallSurcharge(ctx *gin.Context, relayInfo *relaycommon.Rel
return surcharge 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 { func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaSummary, tieredQuota int, tieredResult *billingexpr.TieredResult) int {
if summary.ToolCallSurchargeQuota.IsZero() { if summary.ToolCallSurchargeQuota.IsZero() {
return tieredQuota return tieredQuota
...@@ -145,13 +157,17 @@ func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaS ...@@ -145,13 +157,17 @@ func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaS
if tieredResult != nil { if tieredResult != nil {
if snap := relayInfo.TieredBillingSnapshot; snap != 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)). Mul(decimal.NewFromFloat(snap.GroupRatio)).
Add(summary.ToolCallSurchargeQuota)) 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 { func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) textQuotaSummary {
...@@ -285,7 +301,9 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf ...@@ -285,7 +301,9 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf
if !ratio.IsZero() && quotaCalculateDecimal.LessThanOrEqual(decimal.Zero) { if !ratio.IsZero() && quotaCalculateDecimal.LessThanOrEqual(decimal.Zero) {
quotaCalculateDecimal = decimal.NewFromInt(1) quotaCalculateDecimal = decimal.NewFromInt(1)
} }
summary.Quota = decimalToQuota(quotaCalculateDecimal) quota, clamp := common.QuotaFromDecimalChecked(quotaCalculateDecimal)
summary.Quota = quota
noteQuotaClamp(relayInfo, clamp)
} else { } else {
quotaCalculateDecimal := dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio) quotaCalculateDecimal := dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
quotaCalculateDecimal = quotaCalculateDecimal.Add(summary.ToolCallSurchargeQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(summary.ToolCallSurchargeQuota)
...@@ -295,7 +313,9 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf ...@@ -295,7 +313,9 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf
quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(otherRatio)) 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 { if summary.TotalTokens == 0 {
...@@ -307,14 +327,6 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf ...@@ -307,14 +327,6 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf
return summary 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 { func usageSemanticFromUsage(relayInfo *relaycommon.RelayInfo, usage *dto.Usage) string {
if usage != nil && usage.UsageSemantic != "" { if usage != nil && usage.UsageSemantic != "" {
return usage.UsageSemantic return usage.UsageSemantic
...@@ -465,6 +477,8 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us ...@@ -465,6 +477,8 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
InjectTieredBillingInfo(other, relayInfo, tieredResult) InjectTieredBillingInfo(other, relayInfo, tieredResult)
} }
attachQuotaSaturation(ctx, relayInfo, other)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId, ChannelId: relayInfo.ChannelId,
PromptTokens: summary.PromptTokens, PromptTokens: summary.PromptTokens,
......
...@@ -6,6 +6,7 @@ import ( ...@@ -6,6 +6,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/pkg/billingexpr" "github.com/QuantumNous/new-api/pkg/billingexpr"
...@@ -13,22 +14,9 @@ import ( ...@@ -13,22 +14,9 @@ import (
"github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/require" "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) { func TestCalculateTextQuotaSummaryUnifiedForClaudeSemantic(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
w := httptest.NewRecorder() w := httptest.NewRecorder()
...@@ -453,3 +441,52 @@ func TestComposeTieredTextQuotaErrorFallbackUsesPreConsumedQuota(t *testing.T) { ...@@ -453,3 +441,52 @@ func TestComposeTieredTextQuotaErrorFallbackUsesPreConsumedQuota(t *testing.T) {
require.Equal(t, int64(12500), summary.ToolCallSurchargeQuota.Round(0).IntPart()) require.Equal(t, int64(12500), summary.ToolCallSurchargeQuota.Round(0).IntPart())
require.Equal(t, 14500, quota) 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 ...@@ -112,5 +112,10 @@ func TryTieredSettle(relayInfo *relaycommon.RelayInfo, params billingexpr.TokenP
return true, quota, nil 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 return true, tr.ActualQuotaAfterGroup, &tr
} }
...@@ -208,14 +208,13 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela ...@@ -208,14 +208,13 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela
if err != nil { if err != nil {
return 0, fmt.Errorf("error getting audio duration: %v", err) return 0, fmt.Errorf("error getting audio duration: %v", err)
} }
// 一分钟 1000 token,与 $price / minute 对齐。 // duration 来自用户上传文件的元数据,可被伪造成天文数字或负数。
// duration 来自用户上传文件的元数据,可被伪造成天文数字, // 负值会让 token 估算变成负数(低估预扣费),先钳到 0 再转换。
// 必须饱和转换防止 int 回绕成负数 token。 if duration < 0 {
audioTokens := common.QuotaFromFloat(math.Round(math.Ceil(duration) / 60.0 * 1000)) duration = 0
if audioTokens < 0 {
audioTokens = 0
} }
totalAudioToken += audioTokens // 一分钟 1000 token,与 $price / minute 对齐。
totalAudioToken += common.QuotaRound(math.Ceil(duration) / 60.0 * 1000)
} }
return totalAudioToken, nil return totalAudioToken, nil
} }
......
package service package service
import ( import (
"math"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/operation_setting"
) )
...@@ -49,7 +47,7 @@ func ComputeToolCallQuota(usage ToolCallUsage, groupRatio float64) ToolCallResul ...@@ -49,7 +47,7 @@ func ComputeToolCallQuota(usage ToolCallUsage, groupRatio float64) ToolCallResul
return return
} }
totalPrice := pricePer1K * float64(count) / 1000 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{ items = append(items, ToolCallItem{
Name: toolName, Name: toolName,
CallCount: count, CallCount: count,
...@@ -70,7 +68,7 @@ func ComputeToolCallQuota(usage ToolCallUsage, groupRatio float64) ToolCallResul ...@@ -70,7 +68,7 @@ func ComputeToolCallQuota(usage ToolCallUsage, groupRatio float64) ToolCallResul
if usage.ImageGenerationCall { if usage.ImageGenerationCall {
price := operation_setting.GetGPTImage1PriceOnceCall(usage.ImageGenerationQuality, usage.ImageGenerationSize) 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{ items = append(items, ToolCallItem{
Name: "image_generation", Name: "image_generation",
CallCount: 1, CallCount: 1,
......
...@@ -106,6 +106,23 @@ function splitQuotaDisplay(value: string): { prefix: string; amount: string } { ...@@ -106,6 +106,23 @@ function splitQuotaDisplay(value: string): { prefix: string; amount: string } {
function buildDetailSegments( function buildDetailSegments(
log: UsageLog, log: UsageLog,
other: LogOtherData | null, 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 t: (key: string, opts?: Record<string, unknown>) => string
): DetailSegment[] { ): DetailSegment[] {
// Audit (type=3) and login (type=7) logs: render localized content from the // Audit (type=3) and login (type=7) logs: render localized content from the
...@@ -817,7 +834,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -817,7 +834,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
const log = row.original const log = row.original
const other = parseLogOther(log.other) const other = parseLogOther(log.other)
const segments = buildDetailSegments(log, other, t) const segments = buildDetailSegments(log, other, t, isAdmin)
const primary = segments[0] const primary = segments[0]
const hasMore = segments.length > 1 const hasMore = segments.length > 1
......
...@@ -143,6 +143,15 @@ function formatRatio(ratio: number | undefined): string { ...@@ -143,6 +143,15 @@ function formatRatio(ratio: number | undefined): string {
return ratio.toFixed(4) 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: { function BillingBreakdown(props: {
log: UsageLog log: UsageLog
other: LogOtherData other: LogOtherData
...@@ -704,6 +713,41 @@ export function DetailsDialog(props: DetailsDialogProps) { ...@@ -704,6 +713,41 @@ export function DetailsDialog(props: DetailsDialogProps) {
</DetailSection> </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) */} {/* Reject reason (admin only) */}
{props.isAdmin && other?.reject_reason && ( {props.isAdmin && other?.reject_reason && (
<DetailSection <DetailSection
......
...@@ -38,6 +38,7 @@ import { ...@@ -38,6 +38,7 @@ import {
LOG_TYPE_ENUM, LOG_TYPE_ENUM,
} from '../constants' } from '../constants'
import { useColumnsByCategory } from '../lib/columns' import { useColumnsByCategory } from '../lib/columns'
import { parseLogOther } from '../lib/format'
import { fetchLogsByCategory } from '../lib/utils' import { fetchLogsByCategory } from '../lib/utils'
import type { LogCategory } from '../types' import type { LogCategory } from '../types'
import { CommonLogsFilterBar } from './common-logs-filter-bar' import { CommonLogsFilterBar } from './common-logs-filter-bar'
...@@ -51,6 +52,10 @@ const logTypeRowTint: Record<number, string> = { ...@@ -51,6 +52,10 @@ const logTypeRowTint: Record<number, string> = {
[LOG_TYPE_ENUM.REFUND]: 'bg-blue-50/30 dark:bg-blue-950/15', [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( function getColumnVisibilityStorageKey(
logCategory: LogCategory, logCategory: LogCategory,
isAdmin: boolean isAdmin: boolean
...@@ -204,8 +209,16 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { ...@@ -204,8 +209,16 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
const logType = (row.original as Record<string, unknown>).type as const logType = (row.original as Record<string, unknown>).type as
| number | number
| undefined | undefined
const tintClass = let tintClass =
isCommon && logType != null ? (logTypeRowTint[logType] ?? '') : '' 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 ( return (
<DataTableRow <DataTableRow
......
...@@ -111,6 +111,15 @@ export interface LogOtherData { ...@@ -111,6 +111,15 @@ export interface LogOtherData {
admin_id?: number | string admin_id?: number | string
admin_role?: number admin_role?: number
auth_method?: 'session' | 'access_token' | string 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). // Language-independent operation descriptor (audit/login logs).
// Frontend renders localized content from action + params via i18n templates. // Frontend renders localized content from action + params via i18n templates.
......
...@@ -807,6 +807,7 @@ ...@@ -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 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 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.", "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)", "Classic (Legacy Frontend)": "Classic (Legacy Frontend)",
"Claude": "Claude", "Claude": "Claude",
"Claude CLI Header Passthrough": "Claude CLI Header Passthrough", "Claude CLI Header Passthrough": "Claude CLI Header Passthrough",
...@@ -2284,6 +2285,7 @@ ...@@ -2284,6 +2285,7 @@
"Internal Notes": "Internal Notes", "Internal Notes": "Internal Notes",
"Internal notes (not shown to users)": "Internal notes (not shown to users)", "Internal notes (not shown to users)": "Internal notes (not shown to users)",
"Internal Server Error!": "Internal Server Error!", "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 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",
...@@ -2364,6 +2366,7 @@ ...@@ -2364,6 +2366,7 @@
"Key Summary": "Key Summary", "Key Summary": "Key Summary",
"Key Update Mode": "Key Update Mode", "Key Update Mode": "Key Update Mode",
"Keys, OAuth credentials, and multi-key update behavior.": "Keys, OAuth credentials, and multi-key update behavior.", "Keys, OAuth credentials, and multi-key update behavior.": "Keys, OAuth credentials, and multi-key update behavior.",
"Kind": "Kind",
"Kling": "Kling", "Kling": "Kling",
"Knowledge Base ID *": "Knowledge Base ID *", "Knowledge Base ID *": "Knowledge Base ID *",
"Knowledge cutoff": "Knowledge cutoff", "Knowledge cutoff": "Knowledge cutoff",
...@@ -3089,6 +3092,7 @@ ...@@ -3089,6 +3092,7 @@
"Order Payment Method": "Order Payment Method", "Order Payment Method": "Order Payment Method",
"org-...": "org-...", "org-...": "org-...",
"Original Model": "Original Model", "Original Model": "Original Model",
"Original value": "Original value",
"Other": "Other", "Other": "Other",
"Other channels": "Other channels", "Other channels": "Other channels",
"Other groups": "Other groups", "Other groups": "Other groups",
...@@ -3106,6 +3110,7 @@ ...@@ -3106,6 +3110,7 @@
"Output Tokens": "Output Tokens", "Output Tokens": "Output Tokens",
"Overage limited": "Overage limited", "Overage limited": "Overage limited",
"overall": "overall", "overall": "overall",
"Overflow": "Overflow",
"Overflow items": "Overflow items", "Overflow items": "Overflow items",
"Overnight range": "Overnight range", "Overnight range": "Overnight range",
"override": "override", "override": "override",
...@@ -3492,6 +3497,7 @@ ...@@ -3492,6 +3497,7 @@
"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 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",
"Quota given to invited users": "Quota given to invited users", "Quota given to invited users": "Quota given to invited users",
...@@ -3501,6 +3507,7 @@ ...@@ -3501,6 +3507,7 @@
"Quota Per Unit": "Quota Per Unit", "Quota Per Unit": "Quota Per Unit",
"Quota reminder (tokens)": "Quota reminder (tokens)", "Quota reminder (tokens)": "Quota reminder (tokens)",
"Quota Reset": "Quota Reset", "Quota Reset": "Quota Reset",
"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 Warning Threshold": "Quota Warning Threshold", "Quota Warning Threshold": "Quota Warning Threshold",
...@@ -4411,6 +4418,7 @@ ...@@ -4411,6 +4418,7 @@
"This FAQ entry will be removed from the list.": "This FAQ entry will be removed from the list.", "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 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 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 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 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.", "This may cause cache failures.": "This may cause cache failures.",
...@@ -4423,7 +4431,6 @@ ...@@ -4423,7 +4431,6 @@
"This page has not been created yet.": "This page has not been created yet.", "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 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 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 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 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.", "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 @@ ...@@ -4639,6 +4646,7 @@
"Unbind": "Unbind", "Unbind": "Unbind",
"Unbind failed": "Unbind failed", "Unbind failed": "Unbind failed",
"Unbound {{provider}}": "Unbound {{provider}}", "Unbound {{provider}}": "Unbound {{provider}}",
"Underflow": "Underflow",
"Underground": "Underground", "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 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", "Understand image inputs alongside text": "Understand image inputs alongside text",
......
...@@ -807,6 +807,7 @@ ...@@ -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 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 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.", "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)", "Classic (Legacy Frontend)": "Classique (Ancien frontend)",
"Claude": "Claude", "Claude": "Claude",
"Claude CLI Header Passthrough": "Passthrough en-tête Claude CLI", "Claude CLI Header Passthrough": "Passthrough en-tête Claude CLI",
...@@ -2284,6 +2285,7 @@ ...@@ -2284,6 +2285,7 @@
"Internal Notes": "Notes internes", "Internal Notes": "Notes internes",
"Internal notes (not shown to users)": "Notes internes (non visibles par les utilisateurs)", "Internal notes (not shown to users)": "Notes internes (non visibles par les utilisateurs)",
"Internal Server Error!": "Erreur interne du serveur !", "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 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",
...@@ -2364,6 +2366,7 @@ ...@@ -2364,6 +2366,7 @@
"Key Summary": "Résumé de clé", "Key Summary": "Résumé de clé",
"Key Update Mode": "Mode de mise à jour de la 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.", "Keys, OAuth credentials, and multi-key update behavior.": "Clés, identifiants OAuth et comportement de mise à jour multi-clés.",
"Kind": "Type",
"Kling": "Kling", "Kling": "Kling",
"Knowledge Base ID *": "ID de la base de connaissances *", "Knowledge Base ID *": "ID de la base de connaissances *",
"Knowledge cutoff": "Date de coupure des connaissances", "Knowledge cutoff": "Date de coupure des connaissances",
...@@ -3089,6 +3092,7 @@ ...@@ -3089,6 +3092,7 @@
"Order Payment Method": "Moyen de paiement (commande)", "Order Payment Method": "Moyen de paiement (commande)",
"org-...": "org-...", "org-...": "org-...",
"Original Model": "Modèle Original", "Original Model": "Modèle Original",
"Original value": "Valeur d'origine",
"Other": "Autre", "Other": "Autre",
"Other channels": "Autres canaux", "Other channels": "Autres canaux",
"Other groups": "Autres groupes", "Other groups": "Autres groupes",
...@@ -3106,6 +3110,7 @@ ...@@ -3106,6 +3110,7 @@
"Output Tokens": "Tokens de sortie", "Output Tokens": "Tokens de sortie",
"Overage limited": "Dépassement limité", "Overage limited": "Dépassement limité",
"overall": "global", "overall": "global",
"Overflow": "Débordement",
"Overflow items": "Éléments excédentaires", "Overflow items": "Éléments excédentaires",
"Overnight range": "Plage nocturne", "Overnight range": "Plage nocturne",
"override": "remplacer", "override": "remplacer",
...@@ -3492,6 +3497,7 @@ ...@@ -3492,6 +3497,7 @@
"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 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",
"Quota given to invited users": "Quota attribué aux utilisateurs invités", "Quota given to invited users": "Quota attribué aux utilisateurs invités",
...@@ -3501,6 +3507,7 @@ ...@@ -3501,6 +3507,7 @@
"Quota Per Unit": "Quota par unité", "Quota Per Unit": "Quota par unité",
"Quota reminder (tokens)": "Rappel de quota (jetons)", "Quota reminder (tokens)": "Rappel de quota (jetons)",
"Quota Reset": "Réinitialisation du quota", "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 Settings": "Paramètres de quota",
"Quota Types": "Types de quotas", "Quota Types": "Types de quotas",
"Quota Warning Threshold": "Seuil d'avertissement de quota", "Quota Warning Threshold": "Seuil d'avertissement de quota",
...@@ -4411,6 +4418,7 @@ ...@@ -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 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 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 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 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 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.", "This may cause cache failures.": "Cela peut provoquer des échecs de cache.",
...@@ -4423,7 +4431,6 @@ ...@@ -4423,7 +4431,6 @@
"This page has not been created yet.": "Cette page n'a pas encore été créée.", "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 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 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 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 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.", "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 @@ ...@@ -4639,6 +4646,7 @@
"Unbind": "Dissocier", "Unbind": "Dissocier",
"Unbind failed": "Échec de la dissociation", "Unbind failed": "Échec de la dissociation",
"Unbound {{provider}}": "{{provider}} dissocié", "Unbound {{provider}}": "{{provider}} dissocié",
"Underflow": "Sous-dépassement",
"Underground": "Underground", "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 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", "Understand image inputs alongside text": "Comprendre des entrées image en complément du texte",
......
...@@ -807,6 +807,7 @@ ...@@ -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": "制限後の値",
"Classic (Legacy Frontend)": "クラシック(旧フロントエンド)", "Classic (Legacy Frontend)": "クラシック(旧フロントエンド)",
"Claude": "Claude", "Claude": "Claude",
"Claude CLI Header Passthrough": "Claude CLI ヘッダーパススルー", "Claude CLI Header Passthrough": "Claude CLI ヘッダーパススルー",
...@@ -2284,6 +2285,7 @@ ...@@ -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)": "無効 (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 @@ ...@@ -2364,6 +2366,7 @@
"Key Summary": "キー概要", "Key Summary": "キー概要",
"Key Update Mode": "キー更新モード", "Key Update Mode": "キー更新モード",
"Keys, OAuth credentials, and multi-key update behavior.": "キー、OAuth 認証情報、マルチキー更新動作を管理します。", "Keys, OAuth credentials, and multi-key update behavior.": "キー、OAuth 認証情報、マルチキー更新動作を管理します。",
"Kind": "種類",
"Kling": "Kling", "Kling": "Kling",
"Knowledge Base ID *": "ナレッジベースID *", "Knowledge Base ID *": "ナレッジベースID *",
"Knowledge cutoff": "知識のカットオフ", "Knowledge cutoff": "知識のカットオフ",
...@@ -3089,6 +3092,7 @@ ...@@ -3089,6 +3092,7 @@
"Order Payment Method": "注文の支払い方法", "Order Payment Method": "注文の支払い方法",
"org-...": "org-...", "org-...": "org-...",
"Original Model": "オリジナルモデル", "Original Model": "オリジナルモデル",
"Original value": "元の値",
"Other": "その他", "Other": "その他",
"Other channels": "その他のチャネル", "Other channels": "その他のチャネル",
"Other groups": "その他のグループ", "Other groups": "その他のグループ",
...@@ -3106,6 +3110,7 @@ ...@@ -3106,6 +3110,7 @@
"Output Tokens": "出力トークン", "Output Tokens": "出力トークン",
"Overage limited": "超過利用制限中", "Overage limited": "超過利用制限中",
"overall": "全体", "overall": "全体",
"Overflow": "オーバーフロー",
"Overflow items": "超過項目", "Overflow items": "超過項目",
"Overnight range": "日跨ぎ範囲", "Overnight range": "日跨ぎ範囲",
"override": "上書き", "override": "上書き",
...@@ -3492,6 +3497,7 @@ ...@@ -3492,6 +3497,7 @@
"Quota": "クォータ", "Quota": "クォータ",
"Quota ({{currency}})": "クォータ ({{currency}})", "Quota ({{currency}})": "クォータ ({{currency}})",
"Quota adjusted successfully": "クォータの調整に成功しました", "Quota adjusted successfully": "クォータの調整に成功しました",
"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 @@ ...@@ -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 Settings": "クォータ設定", "Quota Settings": "クォータ設定",
"Quota Types": "クォータタイプ", "Quota Types": "クォータタイプ",
"Quota Warning Threshold": "クォータ警告しきい値", "Quota Warning Threshold": "クォータ警告しきい値",
...@@ -4411,6 +4418,7 @@ ...@@ -4411,6 +4418,7 @@
"This FAQ entry will be removed from the list.": "この FAQ 項目はリストから削除されます。", "This FAQ entry will be removed from the list.": "この FAQ 項目はリストから削除されます。",
"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": "この機能にはサーバー側のWeChat設定が必要です", "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 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 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.": "これによりキャッシュ障害が発生する可能性があります。", "This may cause cache failures.": "これによりキャッシュ障害が発生する可能性があります。",
...@@ -4423,7 +4431,6 @@ ...@@ -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.": "このレコードは監査情報の記録に対応する前の履歴データのため、監査情報がありません。現在のバージョンではサーバーIP、コールバックIP、支払い方法、システムバージョンなどの監査情報を記録できますが、これらは今後新しく作成されるレコードにのみ適用され、過去のレコードを遡って補完することはできません。",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "この操作はこのチャンネルから失敗した {{count}} 個のモデルを削除します。元に戻せません。", "This removes {{count}} failed models from this channel. This action cannot be undone.": "この操作はこのチャンネルから失敗した {{count}} 個のモデルを削除します。元に戻せません。",
"This site currently has {{count}} models enabled": "このサイトでは現在 {{count}} 個のモデルが有効です", "This site currently has {{count}} models enabled": "このサイトでは現在 {{count}} 個のモデルが有効です",
"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 @@ ...@@ -4639,6 +4646,7 @@
"Unbind": "連携解除", "Unbind": "連携解除",
"Unbind failed": "連携解除に失敗しました", "Unbind failed": "連携解除に失敗しました",
"Unbound {{provider}}": "{{provider}}の連携を解除しました", "Unbound {{provider}}": "{{provider}}の連携を解除しました",
"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 @@ ...@@ -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": "Ограничено до",
"Classic (Legacy Frontend)": "Классический (Старый интерфейс)", "Classic (Legacy Frontend)": "Классический (Старый интерфейс)",
"Claude": "Клод", "Claude": "Клод",
"Claude CLI Header Passthrough": "Проброс заголовков Claude CLI", "Claude CLI Header Passthrough": "Проброс заголовков Claude CLI",
...@@ -2284,6 +2285,7 @@ ...@@ -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)": "Недопустимо (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 @@ ...@@ -2364,6 +2366,7 @@
"Key Summary": "Сводка ключа", "Key Summary": "Сводка ключа",
"Key Update Mode": "Режим обновления ключа", "Key Update Mode": "Режим обновления ключа",
"Keys, OAuth credentials, and multi-key update behavior.": "Ключи, учетные данные OAuth и поведение обновления нескольких ключей.", "Keys, OAuth credentials, and multi-key update behavior.": "Ключи, учетные данные OAuth и поведение обновления нескольких ключей.",
"Kind": "Тип",
"Kling": "Kling", "Kling": "Kling",
"Knowledge Base ID *": "ID базы знаний *", "Knowledge Base ID *": "ID базы знаний *",
"Knowledge cutoff": "Дата актуальности данных", "Knowledge cutoff": "Дата актуальности данных",
...@@ -3089,6 +3092,7 @@ ...@@ -3089,6 +3092,7 @@
"Order Payment Method": "Способ оплаты (заказа)", "Order Payment Method": "Способ оплаты (заказа)",
"org-...": "орг-...", "org-...": "орг-...",
"Original Model": "Оригинальная модель", "Original Model": "Оригинальная модель",
"Original value": "Исходное значение",
"Other": "Другое", "Other": "Другое",
"Other channels": "Другие каналы", "Other channels": "Другие каналы",
"Other groups": "Другие группы", "Other groups": "Другие группы",
...@@ -3106,6 +3110,7 @@ ...@@ -3106,6 +3110,7 @@
"Output Tokens": "Выходные токены", "Output Tokens": "Выходные токены",
"Overage limited": "Ограничение перерасхода", "Overage limited": "Ограничение перерасхода",
"overall": "всего", "overall": "всего",
"Overflow": "Переполнение",
"Overflow items": "Элементы сверх лимита", "Overflow items": "Элементы сверх лимита",
"Overnight range": "Диапазон через полночь", "Overnight range": "Диапазон через полночь",
"override": "переопределить", "override": "переопределить",
...@@ -3492,6 +3497,7 @@ ...@@ -3492,6 +3497,7 @@
"Quota": "Квота", "Quota": "Квота",
"Quota ({{currency}})": "Квота ({{currency}})", "Quota ({{currency}})": "Квота ({{currency}})",
"Quota adjusted successfully": "Квота успешно изменена", "Quota adjusted successfully": "Квота успешно изменена",
"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 @@ ...@@ -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 Settings": "Настройки квоты", "Quota Settings": "Настройки квоты",
"Quota Types": "Типы квот", "Quota Types": "Типы квот",
"Quota Warning Threshold": "Порог предупреждения о квоте", "Quota Warning Threshold": "Порог предупреждения о квоте",
...@@ -4411,6 +4418,7 @@ ...@@ -4411,6 +4418,7 @@
"This FAQ entry will be removed from the list.": "Эта запись FAQ будет удалена из списка.", "This FAQ entry will be removed from the list.": "Эта запись FAQ будет удалена из списка.",
"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": "Эта функция требует серверной конфигурации WeChat", "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 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 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.": "Это может привести к сбоям кэша.", "This may cause cache failures.": "Это может привести к сбоям кэша.",
...@@ -4423,7 +4431,6 @@ ...@@ -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.": "Эта историческая запись была создана до появления функции аудита и не содержит данных аудита. Текущая версия уже поддерживает запись IP-адреса сервера, IP обратного вызова, способа оплаты и версии системы, но эти поля будут заполняться только в новых записях — восполнить их в старых записях задним числом невозможно.",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "Это удалит {{count}} неуспешных моделей из этого канала. Действие необратимо.", "This removes {{count}} failed models from this channel. This action cannot be undone.": "Это удалит {{count}} неуспешных моделей из этого канала. Действие необратимо.",
"This site currently has {{count}} models enabled": "На этом сайте сейчас включено моделей: {{count}}", "This site currently has {{count}} models enabled": "На этом сайте сейчас включено моделей: {{count}}",
"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 @@ ...@@ -4639,6 +4646,7 @@
"Unbind": "Отвязать", "Unbind": "Отвязать",
"Unbind failed": "Не удалось отвязать", "Unbind failed": "Не удалось отвязать",
"Unbound {{provider}}": "{{provider}} отвязан", "Unbound {{provider}}": "{{provider}} отвязан",
"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 @@ ...@@ -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 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 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.", "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ũ)", "Classic (Legacy Frontend)": "Cổ điển (Frontend cũ)",
"Claude": "Claude", "Claude": "Claude",
"Claude CLI Header Passthrough": "Chuyển tiếp header Claude CLI", "Claude CLI Header Passthrough": "Chuyển tiếp header Claude CLI",
...@@ -2284,6 +2285,7 @@ ...@@ -2284,6 +2285,7 @@
"Internal Notes": "Ghi chú nội bộ", "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 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ộ!", "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 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ệ",
...@@ -2364,6 +2366,7 @@ ...@@ -2364,6 +2366,7 @@
"Key Summary": "Tóm tắt khóa", "Key Summary": "Tóm tắt khóa",
"Key Update Mode": "Chế độ cập nhậ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.", "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", "Kling": "Kling",
"Knowledge Base ID *": "Mã số Cơ sở kiến thức *", "Knowledge Base ID *": "Mã số Cơ sở kiến thức *",
"Knowledge cutoff": "Mốc dữ liệu", "Knowledge cutoff": "Mốc dữ liệu",
...@@ -3089,6 +3092,7 @@ ...@@ -3089,6 +3092,7 @@
"Order Payment Method": "Phương thức thanh toán đơn hàng", "Order Payment Method": "Phương thức thanh toán đơn hàng",
"org-...": "org-...", "org-...": "org-...",
"Original Model": "Nguyên mẫu", "Original Model": "Nguyên mẫu",
"Original value": "Giá trị gốc",
"Other": "Khác", "Other": "Khác",
"Other channels": "Kênh khác", "Other channels": "Kênh khác",
"Other groups": "Nhóm khác", "Other groups": "Nhóm khác",
...@@ -3106,6 +3110,7 @@ ...@@ -3106,6 +3110,7 @@
"Output Tokens": "Token đầu ra", "Output Tokens": "Token đầu ra",
"Overage limited": "Đã giới hạn vượt mức", "Overage limited": "Đã giới hạn vượt mức",
"overall": "tổng", "overall": "tổng",
"Overflow": "Tràn trên",
"Overflow items": "Mục vượt giới hạn", "Overflow items": "Mục vượt giới hạn",
"Overnight range": "Khoảng qua nửa đêm", "Overnight range": "Khoảng qua nửa đêm",
"override": "ghi đè", "override": "ghi đè",
...@@ -3492,6 +3497,7 @@ ...@@ -3492,6 +3497,7 @@
"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 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",
"Quota given to invited users": "Hạn mức được cấp cho người dùng được mời", "Quota given to invited users": "Hạn mức được cấp cho người dùng được mời",
...@@ -3501,6 +3507,7 @@ ...@@ -3501,6 +3507,7 @@
"Quota Per Unit": "Định mức mỗi đơn vị", "Quota Per Unit": "Định mức mỗi đơn vị",
"Quota reminder (tokens)": "Nhắc nhở hạn mức (token)", "Quota reminder (tokens)": "Nhắc nhở hạn mức (token)",
"Quota Reset": "Đặt lại hạn mức", "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 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 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",
...@@ -4411,6 +4418,7 @@ ...@@ -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 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 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 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 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 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.", "This may cause cache failures.": "Điều này có thể gây ra lỗi bộ nhớ đệm.",
...@@ -4423,7 +4431,6 @@ ...@@ -4423,7 +4431,6 @@
"This page has not been created yet.": "Trang này chưa được tạo.", "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 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 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 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 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.", "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 @@ ...@@ -4639,6 +4646,7 @@
"Unbind": "Hủy liên kết", "Unbind": "Hủy liên kết",
"Unbind failed": "Hủy liên kết thất bại", "Unbind failed": "Hủy liên kết thất bại",
"Unbound {{provider}}": "Đã hủy liên kết {{provider}}", "Unbound {{provider}}": "Đã hủy liên kết {{provider}}",
"Underflow": "Tràn dưới",
"Underground": "Underground", "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 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", "Understand image inputs alongside text": "Hiểu hình ảnh cùng với văn bản",
......
...@@ -807,6 +807,7 @@ ...@@ -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": "钳制为",
"Classic (Legacy Frontend)": "经典前端", "Classic (Legacy Frontend)": "经典前端",
"Claude": "Claude", "Claude": "Claude",
"Claude CLI Header Passthrough": "Claude CLI 请求头透传", "Claude CLI Header Passthrough": "Claude CLI 请求头透传",
...@@ -2284,6 +2285,7 @@ ...@@ -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)": "无效 (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 @@ ...@@ -2364,6 +2366,7 @@
"Key Summary": "Key 摘要", "Key Summary": "Key 摘要",
"Key Update Mode": "密钥更新模式", "Key Update Mode": "密钥更新模式",
"Keys, OAuth credentials, and multi-key update behavior.": "管理密钥、OAuth 凭据和多密钥更新行为。", "Keys, OAuth credentials, and multi-key update behavior.": "管理密钥、OAuth 凭据和多密钥更新行为。",
"Kind": "类型",
"Kling": "Kling", "Kling": "Kling",
"Knowledge Base ID *": "知识库 ID *", "Knowledge Base ID *": "知识库 ID *",
"Knowledge cutoff": "知识截止", "Knowledge cutoff": "知识截止",
...@@ -3089,6 +3092,7 @@ ...@@ -3089,6 +3092,7 @@
"Order Payment Method": "订单支付方式", "Order Payment Method": "订单支付方式",
"org-...": "org-...", "org-...": "org-...",
"Original Model": "原始模型", "Original Model": "原始模型",
"Original value": "原始值",
"Other": "其他", "Other": "其他",
"Other channels": "其他渠道", "Other channels": "其他渠道",
"Other groups": "其他分组", "Other groups": "其他分组",
...@@ -3106,6 +3110,7 @@ ...@@ -3106,6 +3110,7 @@
"Output Tokens": "输出 Token", "Output Tokens": "输出 Token",
"Overage limited": "超额受限", "Overage limited": "超额受限",
"overall": "总体", "overall": "总体",
"Overflow": "上溢",
"Overflow items": "超出项", "Overflow items": "超出项",
"Overnight range": "跨日范围", "Overnight range": "跨日范围",
"override": "覆盖", "override": "覆盖",
...@@ -3492,6 +3497,7 @@ ...@@ -3492,6 +3497,7 @@
"Quota": "额度", "Quota": "额度",
"Quota ({{currency}})": "额度 ({{currency}})", "Quota ({{currency}})": "额度 ({{currency}})",
"Quota adjusted successfully": "调整额度成功", "Quota adjusted successfully": "调整额度成功",
"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 @@ ...@@ -3501,6 +3507,7 @@
"Quota Per Unit": "每单位配额", "Quota Per Unit": "每单位配额",
"Quota reminder (tokens)": "配额提醒(token)", "Quota reminder (tokens)": "配额提醒(token)",
"Quota Reset": "额度重置", "Quota Reset": "额度重置",
"Quota saturation protection triggered": "额度饱和保护已触发",
"Quota Settings": "额度设置", "Quota Settings": "额度设置",
"Quota Types": "配额类型", "Quota Types": "配额类型",
"Quota Warning Threshold": "配额警告阈值", "Quota Warning Threshold": "配额警告阈值",
...@@ -4411,6 +4418,7 @@ ...@@ -4411,6 +4418,7 @@
"This FAQ entry will be removed from the list.": "此 FAQ 条目将从列表中移除。", "This FAQ entry will be removed from the list.": "此 FAQ 条目将从列表中移除。",
"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.": "该条历史记录缺少审计字段。当前版本已支持记录服务器 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 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 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.": "这可能导致缓存故障。", "This may cause cache failures.": "这可能导致缓存故障。",
...@@ -4423,7 +4431,6 @@ ...@@ -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.": "该条历史记录缺少审计字段。当前版本已支持记录服务器 IP、回调 IP、支付方式与系统版本等审计信息;这些字段仅会写入后续新产生的记录,历史记录无法自动补齐。",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "此操作将从该渠道移除 {{count}} 个测试失败的模型,且无法撤销。", "This removes {{count}} failed models from this channel. This action cannot be undone.": "此操作将从该渠道移除 {{count}} 个测试失败的模型,且无法撤销。",
"This site currently has {{count}} models enabled": "本站当前已启用模型,总计 {{count}} 个", "This site currently has {{count}} models enabled": "本站当前已启用模型,总计 {{count}} 个",
"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 @@ ...@@ -4639,6 +4646,7 @@
"Unbind": "解绑", "Unbind": "解绑",
"Unbind failed": "解绑失败", "Unbind failed": "解绑失败",
"Unbound {{provider}}": "已解绑 {{provider}}", "Unbound {{provider}}": "已解绑 {{provider}}",
"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": "在文本之外理解图像输入",
......
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