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.
320 lines
12 KiB
Go
320 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/constant"
|
|
"github.com/QuantumNous/new-api/dto"
|
|
"github.com/QuantumNous/new-api/logger"
|
|
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
|
"github.com/QuantumNous/new-api/types"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// attachQuotaSaturationToOther nests a quota saturation marker under
|
|
// other.admin_info.quota_saturation. Nesting under admin_info makes it
|
|
// admin-only for free, since model.formatUserLogs strips the whole admin_info
|
|
// object for non-admin viewers. Creates admin_info if absent. No-op when the
|
|
// clamp is nil (the common case: no saturation happened).
|
|
func attachQuotaSaturationToOther(other map[string]interface{}, clamp *common.QuotaClamp) {
|
|
if clamp == nil || other == nil {
|
|
return
|
|
}
|
|
adminInfo, ok := other["admin_info"].(map[string]interface{})
|
|
if !ok || adminInfo == nil {
|
|
adminInfo = map[string]interface{}{}
|
|
other["admin_info"] = adminInfo
|
|
}
|
|
adminInfo["quota_saturation"] = clamp.AuditMap()
|
|
}
|
|
|
|
// attachQuotaSaturation records the request's quota clamp (if any) onto the
|
|
// consume log's other.admin_info and emits a request-correlated backend audit
|
|
// line. Called right before RecordConsumeLog on the text/audio/wss paths.
|
|
func attachQuotaSaturation(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
|
if relayInfo == nil {
|
|
return
|
|
}
|
|
clamp := relayInfo.QuotaClamp
|
|
if clamp == nil {
|
|
return
|
|
}
|
|
attachQuotaSaturationToOther(other, clamp)
|
|
logger.LogWarn(ctx, fmt.Sprintf("quota saturation on consume log: op=%s kind=%s original=%g clamped=%d user=%d model=%s",
|
|
clamp.Op, clamp.Kind, clamp.Original, clamp.Clamped, relayInfo.UserId, relayInfo.OriginModelName))
|
|
}
|
|
|
|
func appendRequestPath(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
|
if other == nil {
|
|
return
|
|
}
|
|
if ctx != nil && ctx.Request != nil && ctx.Request.URL != nil {
|
|
if path := ctx.Request.URL.Path; path != "" {
|
|
other["request_path"] = path
|
|
return
|
|
}
|
|
}
|
|
if relayInfo != nil && relayInfo.RequestURLPath != "" {
|
|
path := relayInfo.RequestURLPath
|
|
if idx := strings.Index(path, "?"); idx != -1 {
|
|
path = path[:idx]
|
|
}
|
|
other["request_path"] = path
|
|
}
|
|
}
|
|
|
|
func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
|
|
cacheTokens int, cacheRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} {
|
|
other := make(map[string]interface{})
|
|
other["model_ratio"] = modelRatio
|
|
other["group_ratio"] = groupRatio
|
|
other["completion_ratio"] = completionRatio
|
|
other["cache_tokens"] = cacheTokens
|
|
other["cache_ratio"] = cacheRatio
|
|
other["model_price"] = modelPrice
|
|
other["user_group_ratio"] = userGroupRatio
|
|
other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli())
|
|
if relayInfo.ReasoningEffort != "" {
|
|
other["reasoning_effort"] = relayInfo.ReasoningEffort
|
|
}
|
|
if relayInfo.IsModelMapped {
|
|
other["is_model_mapped"] = true
|
|
other["upstream_model_name"] = relayInfo.UpstreamModelName
|
|
}
|
|
|
|
isSystemPromptOverwritten := common.GetContextKeyBool(ctx, constant.ContextKeySystemPromptOverride)
|
|
if isSystemPromptOverwritten {
|
|
other["is_system_prompt_overwritten"] = true
|
|
}
|
|
|
|
adminInfo := make(map[string]interface{})
|
|
adminInfo["use_channel"] = ctx.GetStringSlice("use_channel")
|
|
isMultiKey := common.GetContextKeyBool(ctx, constant.ContextKeyChannelIsMultiKey)
|
|
if isMultiKey {
|
|
adminInfo["is_multi_key"] = true
|
|
adminInfo["multi_key_index"] = common.GetContextKeyInt(ctx, constant.ContextKeyChannelMultiKeyIndex)
|
|
}
|
|
|
|
isLocalCountTokens := common.GetContextKeyBool(ctx, constant.ContextKeyLocalCountTokens)
|
|
if isLocalCountTokens {
|
|
adminInfo["local_count_tokens"] = isLocalCountTokens
|
|
}
|
|
|
|
AppendChannelAffinityAdminInfo(ctx, adminInfo)
|
|
|
|
other["admin_info"] = adminInfo
|
|
appendRequestPath(ctx, relayInfo, other)
|
|
appendRequestConversionChain(relayInfo, other)
|
|
appendFinalRequestFormat(relayInfo, other)
|
|
appendBillingInfo(relayInfo, other)
|
|
appendParamOverrideInfo(relayInfo, other)
|
|
appendStreamStatus(relayInfo, other)
|
|
return other
|
|
}
|
|
|
|
func appendParamOverrideInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
|
if relayInfo == nil || other == nil || len(relayInfo.ParamOverrideAudit) == 0 {
|
|
return
|
|
}
|
|
other["po"] = relayInfo.ParamOverrideAudit
|
|
}
|
|
|
|
func appendStreamStatus(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
|
if relayInfo == nil || other == nil || !relayInfo.IsStream || relayInfo.StreamStatus == nil {
|
|
return
|
|
}
|
|
ss := relayInfo.StreamStatus
|
|
status := "ok"
|
|
if !ss.IsNormalEnd() || ss.HasErrors() {
|
|
status = "error"
|
|
}
|
|
streamInfo := map[string]interface{}{
|
|
"status": status,
|
|
"end_reason": string(ss.EndReason),
|
|
}
|
|
if ss.EndError != nil {
|
|
streamInfo["end_error"] = ss.EndError.Error()
|
|
}
|
|
if ss.ErrorCount > 0 {
|
|
streamInfo["error_count"] = ss.ErrorCount
|
|
messages := make([]string, 0, len(ss.Errors))
|
|
for _, e := range ss.Errors {
|
|
messages = append(messages, e.Message)
|
|
}
|
|
streamInfo["errors"] = messages
|
|
}
|
|
other["stream_status"] = streamInfo
|
|
}
|
|
|
|
func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
|
if relayInfo == nil || other == nil {
|
|
return
|
|
}
|
|
// billing_source: "wallet" or "subscription"
|
|
if relayInfo.BillingSource != "" {
|
|
other["billing_source"] = relayInfo.BillingSource
|
|
}
|
|
if relayInfo.UserSetting.BillingPreference != "" {
|
|
other["billing_preference"] = relayInfo.UserSetting.BillingPreference
|
|
}
|
|
if relayInfo.BillingSource == "subscription" {
|
|
if relayInfo.SubscriptionId != 0 {
|
|
other["subscription_id"] = relayInfo.SubscriptionId
|
|
}
|
|
if relayInfo.SubscriptionPreConsumed > 0 {
|
|
other["subscription_pre_consumed"] = relayInfo.SubscriptionPreConsumed
|
|
}
|
|
// post_delta: settlement delta applied after actual usage is known (can be negative for refund)
|
|
if relayInfo.SubscriptionPostDelta != 0 {
|
|
other["subscription_post_delta"] = relayInfo.SubscriptionPostDelta
|
|
}
|
|
if relayInfo.SubscriptionPlanId != 0 {
|
|
other["subscription_plan_id"] = relayInfo.SubscriptionPlanId
|
|
}
|
|
if relayInfo.SubscriptionPlanTitle != "" {
|
|
other["subscription_plan_title"] = relayInfo.SubscriptionPlanTitle
|
|
}
|
|
// Compute "this request" subscription consumed + remaining
|
|
consumed := relayInfo.SubscriptionPreConsumed + relayInfo.SubscriptionPostDelta
|
|
usedFinal := relayInfo.SubscriptionAmountUsedAfterPreConsume + relayInfo.SubscriptionPostDelta
|
|
if consumed < 0 {
|
|
consumed = 0
|
|
}
|
|
if usedFinal < 0 {
|
|
usedFinal = 0
|
|
}
|
|
if relayInfo.SubscriptionAmountTotal > 0 {
|
|
remain := relayInfo.SubscriptionAmountTotal - usedFinal
|
|
if remain < 0 {
|
|
remain = 0
|
|
}
|
|
other["subscription_total"] = relayInfo.SubscriptionAmountTotal
|
|
other["subscription_used"] = usedFinal
|
|
other["subscription_remain"] = remain
|
|
}
|
|
if consumed > 0 {
|
|
other["subscription_consumed"] = consumed
|
|
}
|
|
// Wallet quota is not deducted when billed from subscription.
|
|
other["wallet_quota_deducted"] = 0
|
|
}
|
|
}
|
|
|
|
func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
|
if relayInfo == nil || other == nil {
|
|
return
|
|
}
|
|
if len(relayInfo.RequestConversionChain) == 0 {
|
|
return
|
|
}
|
|
chain := make([]string, 0, len(relayInfo.RequestConversionChain))
|
|
for _, f := range relayInfo.RequestConversionChain {
|
|
switch f {
|
|
case types.RelayFormatOpenAI:
|
|
chain = append(chain, "OpenAI Compatible")
|
|
case types.RelayFormatClaude:
|
|
chain = append(chain, "Claude Messages")
|
|
case types.RelayFormatGemini:
|
|
chain = append(chain, "Google Gemini")
|
|
case types.RelayFormatOpenAIResponses:
|
|
chain = append(chain, "OpenAI Responses")
|
|
default:
|
|
chain = append(chain, string(f))
|
|
}
|
|
}
|
|
if len(chain) == 0 {
|
|
return
|
|
}
|
|
other["request_conversion"] = chain
|
|
}
|
|
|
|
func appendFinalRequestFormat(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
|
if relayInfo == nil || other == nil {
|
|
return
|
|
}
|
|
if relayInfo.GetFinalRequestRelayFormat() == types.RelayFormatClaude {
|
|
// claude indicates the final upstream request format is Claude Messages.
|
|
// Frontend log rendering uses this to keep the original Claude input display.
|
|
other["claude"] = true
|
|
}
|
|
}
|
|
|
|
func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} {
|
|
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
|
|
info["ws"] = true
|
|
info["audio_input"] = usage.InputTokenDetails.AudioTokens
|
|
info["audio_output"] = usage.OutputTokenDetails.AudioTokens
|
|
info["text_input"] = usage.InputTokenDetails.TextTokens
|
|
info["text_output"] = usage.OutputTokenDetails.TextTokens
|
|
info["audio_ratio"] = audioRatio
|
|
info["audio_completion_ratio"] = audioCompletionRatio
|
|
return info
|
|
}
|
|
|
|
func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} {
|
|
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
|
|
info["audio"] = true
|
|
info["audio_input"] = usage.PromptTokensDetails.AudioTokens
|
|
info["audio_output"] = usage.CompletionTokenDetails.AudioTokens
|
|
info["text_input"] = usage.PromptTokensDetails.TextTokens
|
|
info["text_output"] = usage.CompletionTokenDetails.TextTokens
|
|
info["audio_ratio"] = audioRatio
|
|
info["audio_completion_ratio"] = audioCompletionRatio
|
|
return info
|
|
}
|
|
|
|
func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
|
|
cacheTokens int, cacheRatio float64,
|
|
cacheCreationTokens int, cacheCreationRatio float64,
|
|
cacheCreationTokens5m int, cacheCreationRatio5m float64,
|
|
cacheCreationTokens1h int, cacheCreationRatio1h float64,
|
|
modelPrice float64, userGroupRatio float64) map[string]interface{} {
|
|
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, userGroupRatio)
|
|
info["claude"] = true
|
|
info["cache_creation_tokens"] = cacheCreationTokens
|
|
info["cache_creation_ratio"] = cacheCreationRatio
|
|
if cacheCreationTokens5m != 0 {
|
|
info["cache_creation_tokens_5m"] = cacheCreationTokens5m
|
|
info["cache_creation_ratio_5m"] = cacheCreationRatio5m
|
|
}
|
|
if cacheCreationTokens1h != 0 {
|
|
info["cache_creation_tokens_1h"] = cacheCreationTokens1h
|
|
info["cache_creation_ratio_1h"] = cacheCreationRatio1h
|
|
}
|
|
return info
|
|
}
|
|
|
|
func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData types.PriceData) map[string]interface{} {
|
|
other := make(map[string]interface{})
|
|
other["model_price"] = priceData.ModelPrice
|
|
other["group_ratio"] = priceData.GroupRatioInfo.GroupRatio
|
|
if priceData.GroupRatioInfo.HasSpecialRatio {
|
|
other["user_group_ratio"] = priceData.GroupRatioInfo.GroupSpecialRatio
|
|
}
|
|
appendRequestPath(nil, relayInfo, other)
|
|
return other
|
|
}
|
|
|
|
// InjectTieredBillingInfo overlays tiered billing fields onto an existing
|
|
// module-specific other map. Call this after GenerateTextOtherInfo /
|
|
// GenerateClaudeOtherInfo / etc. when the request used tiered_expr billing.
|
|
func InjectTieredBillingInfo(other map[string]interface{}, relayInfo *relaycommon.RelayInfo, result *billingexpr.TieredResult) {
|
|
if relayInfo == nil || other == nil {
|
|
return
|
|
}
|
|
snap := relayInfo.TieredBillingSnapshot
|
|
if snap == nil {
|
|
return
|
|
}
|
|
other["billing_mode"] = "tiered_expr"
|
|
other["expr_b64"] = base64.StdEncoding.EncodeToString([]byte(snap.ExprString))
|
|
if result != nil {
|
|
other["matched_tier"] = result.MatchedTier
|
|
}
|
|
}
|