Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
555 lines
22 KiB
Go
555 lines
22 KiB
Go
package service
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/QuantumNous/new-api/common"
|
||
"github.com/QuantumNous/new-api/constant"
|
||
"github.com/QuantumNous/new-api/logger"
|
||
"github.com/QuantumNous/new-api/model"
|
||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||
"github.com/QuantumNous/new-api/relaykit/types"
|
||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/shopspring/decimal"
|
||
)
|
||
|
||
// ToolSurchargeItem is one billable tool-call line for consume logs.
|
||
type ToolSurchargeItem struct {
|
||
Name string `json:"name"`
|
||
Count int `json:"count"`
|
||
Price float64 `json:"price"`
|
||
}
|
||
|
||
func appendToolSurchargeLogInfo(other *model.LogOther, items []ToolSurchargeItem) {
|
||
if len(items) == 0 {
|
||
return
|
||
}
|
||
other.SetPublic("tool_surcharges", items)
|
||
}
|
||
|
||
type textQuotaSummary struct {
|
||
PromptTokens int
|
||
CompletionTokens int
|
||
TotalTokens int
|
||
CacheTokens int
|
||
CacheCreationTokens int
|
||
CacheCreationTokens5m int
|
||
CacheCreationTokens1h int
|
||
ImageTokens int
|
||
AudioTokens int
|
||
ModelName string
|
||
TokenName string
|
||
UseTimeSeconds int64
|
||
CompletionRatio float64
|
||
CacheRatio float64
|
||
ImageRatio float64
|
||
ModelRatio float64
|
||
GroupRatio float64
|
||
ModelPrice float64
|
||
CacheCreationRatio float64
|
||
CacheCreationRatio5m float64
|
||
CacheCreationRatio1h float64
|
||
Quota int
|
||
IsClaudeUsageSemantic bool
|
||
UsageSemantic string
|
||
AudioInputPrice float64
|
||
ToolSurchargeItems []ToolSurchargeItem
|
||
ToolCallSurchargeQuota decimal.Decimal
|
||
FixedPriceBilling bool
|
||
}
|
||
|
||
// hasBillableUsage reports whether this request should incur any charge.
|
||
// A request can carry zero tokens yet still be billable via a tool-call
|
||
// surcharge (e.g. /v1/alpha/search returns no usage but bills one web_search
|
||
// call), so token count alone is not sufficient to decide.
|
||
func (s *textQuotaSummary) hasBillableUsage() bool {
|
||
return s.FixedPriceBilling || s.TotalTokens > 0 || !s.ToolCallSurchargeQuota.IsZero()
|
||
}
|
||
|
||
func cacheWriteTokensTotal(summary textQuotaSummary) int {
|
||
if summary.CacheCreationTokens5m > 0 || summary.CacheCreationTokens1h > 0 {
|
||
splitCacheWriteTokens := summary.CacheCreationTokens5m + summary.CacheCreationTokens1h
|
||
if summary.CacheCreationTokens > splitCacheWriteTokens {
|
||
return summary.CacheCreationTokens
|
||
}
|
||
return splitCacheWriteTokens
|
||
}
|
||
return summary.CacheCreationTokens
|
||
}
|
||
|
||
func isLegacyClaudeDerivedOpenAIUsage(relayInfo *relaycommon.RelayInfo, usage *dto.Usage) bool {
|
||
if relayInfo == nil || usage == nil {
|
||
return false
|
||
}
|
||
if relayInfo.GetFinalRequestRelayFormat() == types.RelayFormatClaude {
|
||
return false
|
||
}
|
||
if usage.UsageSource != "" || usage.UsageSemantic != "" {
|
||
return false
|
||
}
|
||
return usage.ClaudeCacheCreation5mTokens > 0 || usage.ClaudeCacheCreation1hTokens > 0
|
||
}
|
||
|
||
func collectToolSurchargeItem(items []ToolSurchargeItem, name string, count int, modelName string) []ToolSurchargeItem {
|
||
if count <= 0 {
|
||
return items
|
||
}
|
||
price := operation_setting.GetToolPriceForModel(name, modelName)
|
||
if price <= 0 || math.IsNaN(price) || math.IsInf(price, 0) {
|
||
return items
|
||
}
|
||
return append(items, ToolSurchargeItem{
|
||
Name: name,
|
||
Count: count,
|
||
Price: price,
|
||
})
|
||
}
|
||
|
||
func mergeToolSurchargeItems(items []ToolSurchargeItem) []ToolSurchargeItem {
|
||
if len(items) == 0 {
|
||
return nil
|
||
}
|
||
sort.Slice(items, func(i, j int) bool {
|
||
if items[i].Name == items[j].Name {
|
||
return items[i].Price < items[j].Price
|
||
}
|
||
return items[i].Name < items[j].Name
|
||
})
|
||
|
||
merged := items[:0]
|
||
for _, item := range items {
|
||
lastIndex := len(merged) - 1
|
||
if lastIndex >= 0 &&
|
||
merged[lastIndex].Name == item.Name &&
|
||
merged[lastIndex].Price == item.Price {
|
||
if item.Count > math.MaxInt-merged[lastIndex].Count {
|
||
common.SysError("tool surcharge call count overflow for " + item.Name)
|
||
merged[lastIndex].Count = math.MaxInt
|
||
} else {
|
||
merged[lastIndex].Count += item.Count
|
||
}
|
||
continue
|
||
}
|
||
merged = append(merged, item)
|
||
}
|
||
return merged
|
||
}
|
||
|
||
func calculateTextToolCallSurcharge(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, summary *textQuotaSummary) decimal.Decimal {
|
||
dGroupRatio := decimal.NewFromFloat(summary.GroupRatio)
|
||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||
|
||
var items []ToolSurchargeItem
|
||
|
||
if relayInfo.ResponsesUsageInfo != nil {
|
||
for name, tool := range relayInfo.ResponsesUsageInfo.BuiltInTools {
|
||
if tool == nil {
|
||
continue
|
||
}
|
||
items = collectToolSurchargeItem(items, name, tool.CallCount, summary.ModelName)
|
||
}
|
||
}
|
||
if relayInfo.RelayMode != relayconstant.RelayModeResponses &&
|
||
strings.HasSuffix(summary.ModelName, "search-preview") {
|
||
items = collectToolSurchargeItem(items, dto.BuildInToolWebSearchPreview, 1, summary.ModelName)
|
||
}
|
||
|
||
items = collectToolSurchargeItem(
|
||
items,
|
||
dto.BuildInToolWebSearch,
|
||
ctx.GetInt("claude_web_search_requests"),
|
||
summary.ModelName,
|
||
)
|
||
|
||
if ctx.GetBool("gemini_google_search_call") {
|
||
items = collectToolSurchargeItem(items, dto.BuildInToolGoogleSearch, 1, summary.ModelName)
|
||
}
|
||
|
||
summary.ToolSurchargeItems = mergeToolSurchargeItems(items)
|
||
var surcharge decimal.Decimal
|
||
for _, item := range summary.ToolSurchargeItems {
|
||
surcharge = surcharge.Add(decimal.NewFromFloat(item.Price).
|
||
Mul(decimal.NewFromInt(int64(item.Count))).
|
||
Div(decimal.NewFromInt(1000)).
|
||
Mul(dGroupRatio).
|
||
Mul(dQuotaPerUnit))
|
||
}
|
||
|
||
return surcharge
|
||
}
|
||
|
||
// noteQuotaClamp records the first quota saturation event onto relayInfo so it
|
||
// can later be attached to the consume/task log for admin auditing. First
|
||
// non-nil clamp wins (a single request may hit multiple conversions).
|
||
func noteQuotaClamp(relayInfo *relaycommon.RelayInfo, clamp *common.QuotaClamp) {
|
||
if clamp == nil || relayInfo == nil {
|
||
return
|
||
}
|
||
if relayInfo.QuotaClamp == nil {
|
||
relayInfo.QuotaClamp = clamp
|
||
}
|
||
}
|
||
|
||
func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaSummary, tieredQuota int, tieredResult *billingexpr.TieredResult) int {
|
||
if summary.ToolCallSurchargeQuota.IsZero() {
|
||
return tieredQuota
|
||
}
|
||
|
||
if tieredResult != nil {
|
||
if snap := relayInfo.TieredBillingSnapshot; snap != nil {
|
||
quota, clamp := common.QuotaFromDecimalChecked(decimal.NewFromFloat(tieredResult.ActualQuotaBeforeGroup).
|
||
Mul(decimal.NewFromFloat(snap.GroupRatio)).
|
||
Add(summary.ToolCallSurchargeQuota))
|
||
noteQuotaClamp(relayInfo, clamp)
|
||
return quota
|
||
}
|
||
}
|
||
|
||
// Saturate the final sum, not just the surcharge: tieredQuota can be near
|
||
// MaxQuota and adding the surcharge could push the total past the
|
||
// single-request quota policy bound.
|
||
total, clamp := common.QuotaFromDecimalChecked(
|
||
decimal.NewFromInt(int64(tieredQuota)).Add(summary.ToolCallSurchargeQuota),
|
||
)
|
||
noteQuotaClamp(relayInfo, clamp)
|
||
return total
|
||
}
|
||
|
||
// calculateTextQuotaSummary expects a usage already remapped by
|
||
// effectiveBillingUsage; PostTextConsumeQuota performs that remap once and shares
|
||
// the result with tiered billing, affinity observation and logging.
|
||
func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) textQuotaSummary {
|
||
summary := textQuotaSummary{
|
||
ModelName: relayInfo.GetBillingModelName(),
|
||
TokenName: ctx.GetString("token_name"),
|
||
UseTimeSeconds: time.Now().Unix() - relayInfo.StartTime.Unix(),
|
||
CompletionRatio: relayInfo.PriceData.CompletionRatio,
|
||
CacheRatio: relayInfo.PriceData.CacheRatio,
|
||
ImageRatio: relayInfo.PriceData.ImageRatio,
|
||
ModelRatio: relayInfo.PriceData.ModelRatio,
|
||
GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio,
|
||
ModelPrice: relayInfo.PriceData.ModelPrice,
|
||
CacheCreationRatio: relayInfo.PriceData.CacheCreationRatio,
|
||
CacheCreationRatio5m: relayInfo.PriceData.CacheCreation5mRatio,
|
||
CacheCreationRatio1h: relayInfo.PriceData.CacheCreation1hRatio,
|
||
UsageSemantic: usageSemanticFromUsage(relayInfo, usage),
|
||
}
|
||
summary.IsClaudeUsageSemantic = summary.UsageSemantic == "anthropic"
|
||
|
||
if usage == nil {
|
||
usage = &dto.Usage{
|
||
PromptTokens: relayInfo.GetEstimatePromptTokens(),
|
||
CompletionTokens: 0,
|
||
TotalTokens: relayInfo.GetEstimatePromptTokens(),
|
||
}
|
||
}
|
||
|
||
summary.PromptTokens = usage.PromptTokens
|
||
summary.CompletionTokens = usage.CompletionTokens
|
||
summary.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||
summary.CacheTokens = usage.PromptTokensDetails.CachedTokens
|
||
summary.CacheCreationTokens = usage.PromptTokensDetails.CacheCreationTokensTotal()
|
||
summary.CacheCreationTokens5m = usage.ClaudeCacheCreation5mTokens
|
||
summary.CacheCreationTokens1h = usage.ClaudeCacheCreation1hTokens
|
||
summary.ImageTokens = usage.PromptTokensDetails.ImageTokens
|
||
summary.AudioTokens = usage.PromptTokensDetails.AudioTokens
|
||
legacyClaudeDerived := isLegacyClaudeDerivedOpenAIUsage(relayInfo, usage)
|
||
isOpenRouterClaudeBilling := relayInfo.ChannelMeta != nil &&
|
||
relayInfo.ChannelType == constant.ChannelTypeOpenRouter &&
|
||
summary.IsClaudeUsageSemantic
|
||
|
||
if isOpenRouterClaudeBilling {
|
||
summary.PromptTokens -= summary.CacheTokens
|
||
isUsingCustomSettings := relayInfo.PriceData.UsePrice || hasCustomModelRatio(summary.ModelName, relayInfo.PriceData.ModelRatio)
|
||
if summary.CacheCreationTokens == 0 && relayInfo.PriceData.CacheCreationRatio != 1 && usage.Cost != 0 && !isUsingCustomSettings {
|
||
maybeCacheCreationTokens := CalcOpenRouterCacheCreateTokens(*usage, relayInfo.PriceData)
|
||
if maybeCacheCreationTokens >= 0 && summary.PromptTokens >= maybeCacheCreationTokens {
|
||
summary.CacheCreationTokens = maybeCacheCreationTokens
|
||
}
|
||
}
|
||
summary.PromptTokens -= summary.CacheCreationTokens
|
||
}
|
||
|
||
dPromptTokens := decimal.NewFromInt(int64(summary.PromptTokens))
|
||
dCacheTokens := decimal.NewFromInt(int64(summary.CacheTokens))
|
||
dImageTokens := decimal.NewFromInt(int64(summary.ImageTokens))
|
||
dAudioTokens := decimal.NewFromInt(int64(summary.AudioTokens))
|
||
dCompletionTokens := decimal.NewFromInt(int64(summary.CompletionTokens))
|
||
dCachedCreationTokens := decimal.NewFromInt(int64(summary.CacheCreationTokens))
|
||
dCompletionRatio := decimal.NewFromFloat(summary.CompletionRatio)
|
||
dCacheRatio := decimal.NewFromFloat(summary.CacheRatio)
|
||
dImageRatio := decimal.NewFromFloat(summary.ImageRatio)
|
||
dModelRatio := decimal.NewFromFloat(summary.ModelRatio)
|
||
dGroupRatio := decimal.NewFromFloat(summary.GroupRatio)
|
||
dModelPrice := decimal.NewFromFloat(summary.ModelPrice)
|
||
dCacheCreationRatio := decimal.NewFromFloat(summary.CacheCreationRatio)
|
||
dCacheCreationRatio5m := decimal.NewFromFloat(summary.CacheCreationRatio5m)
|
||
dCacheCreationRatio1h := decimal.NewFromFloat(summary.CacheCreationRatio1h)
|
||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||
|
||
ratio := dModelRatio.Mul(dGroupRatio)
|
||
summary.ToolCallSurchargeQuota = calculateTextToolCallSurcharge(ctx, relayInfo, &summary)
|
||
|
||
var audioInputQuota decimal.Decimal
|
||
if !relayInfo.PriceData.UsePrice {
|
||
baseTokens := dPromptTokens
|
||
|
||
var cachedTokensWithRatio decimal.Decimal
|
||
if !dCacheTokens.IsZero() {
|
||
if !summary.IsClaudeUsageSemantic && !legacyClaudeDerived {
|
||
baseTokens = baseTokens.Sub(dCacheTokens)
|
||
}
|
||
cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
|
||
}
|
||
|
||
var cachedCreationTokensWithRatio decimal.Decimal
|
||
hasSplitCacheCreationTokens := summary.CacheCreationTokens5m > 0 || summary.CacheCreationTokens1h > 0
|
||
if !dCachedCreationTokens.IsZero() || hasSplitCacheCreationTokens {
|
||
if !summary.IsClaudeUsageSemantic && !legacyClaudeDerived {
|
||
baseTokens = baseTokens.Sub(dCachedCreationTokens)
|
||
cachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCacheCreationRatio)
|
||
} else {
|
||
remaining := max(summary.CacheCreationTokens-summary.CacheCreationTokens5m-summary.CacheCreationTokens1h, 0)
|
||
cachedCreationTokensWithRatio = decimal.NewFromInt(int64(remaining)).Mul(dCacheCreationRatio)
|
||
cachedCreationTokensWithRatio = cachedCreationTokensWithRatio.Add(decimal.NewFromInt(int64(summary.CacheCreationTokens5m)).Mul(dCacheCreationRatio5m))
|
||
cachedCreationTokensWithRatio = cachedCreationTokensWithRatio.Add(decimal.NewFromInt(int64(summary.CacheCreationTokens1h)).Mul(dCacheCreationRatio1h))
|
||
}
|
||
}
|
||
|
||
var imageTokensWithRatio decimal.Decimal
|
||
if !dImageTokens.IsZero() {
|
||
baseTokens = baseTokens.Sub(dImageTokens)
|
||
imageTokensWithRatio = dImageTokens.Mul(dImageRatio)
|
||
}
|
||
|
||
if !dAudioTokens.IsZero() {
|
||
summary.AudioInputPrice = operation_setting.GetGeminiInputAudioPricePerMillionTokens(summary.ModelName)
|
||
if summary.AudioInputPrice > 0 {
|
||
baseTokens = baseTokens.Sub(dAudioTokens)
|
||
audioInputQuota = decimal.NewFromFloat(summary.AudioInputPrice).
|
||
Div(decimal.NewFromInt(1000000)).Mul(dAudioTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit)
|
||
}
|
||
}
|
||
|
||
// OpenAI cache-write usage reports unadjusted prefix counts, so
|
||
// cached_tokens + cache_write_tokens can exceed prompt_tokens and the
|
||
// remainder can go negative. Clamp at zero so overlap never turns into
|
||
// a negative base charge.
|
||
if baseTokens.IsNegative() {
|
||
baseTokens = decimal.Zero
|
||
}
|
||
|
||
promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio).Add(cachedCreationTokensWithRatio)
|
||
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
|
||
quotaCalculateDecimal := promptQuota.Add(completionQuota).Mul(ratio)
|
||
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
|
||
quotaCalculateDecimal = relayInfo.PriceData.ApplyOtherRatiosToDecimal(quotaCalculateDecimal)
|
||
quotaCalculateDecimal = quotaCalculateDecimal.Add(summary.ToolCallSurchargeQuota)
|
||
|
||
if !ratio.IsZero() && quotaCalculateDecimal.LessThanOrEqual(decimal.Zero) {
|
||
quotaCalculateDecimal = decimal.NewFromInt(1)
|
||
}
|
||
quota, clamp := common.QuotaFromDecimalChecked(quotaCalculateDecimal)
|
||
summary.Quota = quota
|
||
noteQuotaClamp(relayInfo, clamp)
|
||
} else {
|
||
quotaCalculateDecimal := dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
|
||
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
|
||
quotaCalculateDecimal = relayInfo.PriceData.ApplyOtherRatiosToDecimal(quotaCalculateDecimal)
|
||
quotaCalculateDecimal = quotaCalculateDecimal.Add(summary.ToolCallSurchargeQuota)
|
||
quota, clamp := common.QuotaFromDecimalChecked(quotaCalculateDecimal)
|
||
summary.Quota = quota
|
||
noteQuotaClamp(relayInfo, clamp)
|
||
}
|
||
|
||
if !summary.hasBillableUsage() {
|
||
summary.Quota = 0
|
||
} else if !ratio.IsZero() && summary.Quota == 0 {
|
||
summary.Quota = 1
|
||
}
|
||
|
||
return summary
|
||
}
|
||
|
||
func usageSemanticFromUsage(relayInfo *relaycommon.RelayInfo, usage *dto.Usage) string {
|
||
if usage != nil && usage.UsageSemantic != "" {
|
||
return usage.UsageSemantic
|
||
}
|
||
if relayInfo != nil && relayInfo.GetFinalRequestRelayFormat() == types.RelayFormatClaude {
|
||
return "anthropic"
|
||
}
|
||
return "openai"
|
||
}
|
||
|
||
func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent []string) {
|
||
originUsage := usage
|
||
billingUsage := effectiveBillingUsage(usage)
|
||
if usage == nil {
|
||
extraContent = append(extraContent, "上游无计费信息")
|
||
}
|
||
if originUsage != nil && !ctx.GetBool("attempt_checkpoint") {
|
||
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, billingUsage, relayInfo.GetFinalRequestRelayFormat())
|
||
}
|
||
|
||
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
|
||
summary := calculateTextQuotaSummary(ctx, relayInfo, billingUsage)
|
||
|
||
var tieredResult *billingexpr.TieredResult
|
||
var tieredTokens billingexpr.TokenParams
|
||
tieredBillingApplied := false
|
||
snap := relayInfo.TieredBillingSnapshot
|
||
// Providers normally estimate missing usage before settlement. Preserve the
|
||
// same prompt estimate when a fixed-price expression reaches us without it;
|
||
// its conditions must still run and may select a token-priced fallback.
|
||
if billingUsage == nil && snap != nil && billingexpr.UsesFixedPricingByHash(snap.ExprString, snap.ExprHash) {
|
||
billingUsage = &dto.Usage{PromptTokens: summary.PromptTokens, CompletionTokens: summary.CompletionTokens, TotalTokens: summary.TotalTokens}
|
||
}
|
||
if billingUsage != nil {
|
||
var tieredUsedVars map[string]bool
|
||
if snap := relayInfo.TieredBillingSnapshot; snap != nil {
|
||
tieredUsedVars = billingexpr.UsedVarsByHash(snap.ExprString, snap.ExprHash)
|
||
}
|
||
tieredTokens = BuildTieredTokenParams(billingUsage, summary.IsClaudeUsageSemantic, tieredUsedVars)
|
||
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, tieredTokens)
|
||
if tieredOk {
|
||
tieredBillingApplied = true
|
||
tieredResult = tieredRes
|
||
summary.Quota = composeTieredTextQuota(relayInfo, summary, tieredQuota, tieredRes)
|
||
summary.FixedPriceBilling = isFixedPriceSettlement(relayInfo, tieredRes)
|
||
if summary.FixedPriceBilling {
|
||
summary.AudioInputPrice = 0
|
||
}
|
||
}
|
||
}
|
||
|
||
for _, item := range summary.ToolSurchargeItems {
|
||
q := decimal.NewFromFloat(item.Price).
|
||
Mul(decimal.NewFromInt(int64(item.Count))).
|
||
Div(decimal.NewFromInt(1000)).
|
||
Mul(decimal.NewFromFloat(summary.GroupRatio)).
|
||
Mul(decimal.NewFromFloat(common.QuotaPerUnit))
|
||
extraContent = append(extraContent, fmt.Sprintf(
|
||
"%s 调用 %d 次,调用花费 %s",
|
||
item.Name,
|
||
item.Count,
|
||
logger.LogQuota(common.QuotaFromDecimal(q)),
|
||
))
|
||
}
|
||
if summary.AudioInputPrice > 0 && summary.AudioTokens > 0 {
|
||
q := decimal.NewFromFloat(summary.AudioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(decimal.NewFromInt(int64(summary.AudioTokens))).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit))
|
||
extraContent = append(extraContent, fmt.Sprintf("Audio Input 花费 %s", logger.LogQuota(common.QuotaFromDecimal(q))))
|
||
}
|
||
|
||
if !summary.hasBillableUsage() {
|
||
extraContent = append(extraContent, "上游没有返回计费信息,无法扣费(可能是上游超时)")
|
||
logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, summary.ModelName, relayInfo.FinalPreConsumedQuota))
|
||
} else if relayInfo.DurableAttemptID == 0 {
|
||
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, summary.Quota)
|
||
model.UpdateChannelUsedQuota(relayInfo.ChannelId, summary.Quota)
|
||
}
|
||
|
||
if relayInfo.DurableAttemptID == 0 {
|
||
if err := SettleBilling(ctx, relayInfo, summary.Quota); err != nil {
|
||
logger.LogError(ctx, "error settling billing: "+err.Error())
|
||
}
|
||
}
|
||
|
||
logModel := summary.ModelName
|
||
if strings.HasPrefix(logModel, "gpt-4-gizmo") {
|
||
logModel = "gpt-4-gizmo-*"
|
||
extraContent = append(extraContent, fmt.Sprintf("模型 %s", summary.ModelName))
|
||
}
|
||
if strings.HasPrefix(logModel, "gpt-4o-gizmo") {
|
||
logModel = "gpt-4o-gizmo-*"
|
||
extraContent = append(extraContent, fmt.Sprintf("模型 %s", summary.ModelName))
|
||
}
|
||
|
||
logContent := strings.Join(extraContent, ", ")
|
||
var other *model.LogOther
|
||
if summary.IsClaudeUsageSemantic {
|
||
other = GenerateClaudeOtherInfo(ctx, relayInfo,
|
||
summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio,
|
||
summary.CacheTokens, summary.CacheRatio,
|
||
summary.CacheCreationTokens, summary.CacheCreationRatio,
|
||
summary.CacheCreationTokens5m, summary.CacheCreationRatio5m,
|
||
summary.CacheCreationTokens1h, summary.CacheCreationRatio1h,
|
||
summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
|
||
other.SetPublic("usage_semantic", "anthropic")
|
||
} else {
|
||
other = GenerateTextOtherInfo(ctx, relayInfo, summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio, summary.CacheTokens, summary.CacheRatio, summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
|
||
}
|
||
appendUsageBillingPathForLog(other, common.GetContextKeyBool(ctx, constant.ContextKeyLocalCountTokens), originUsage)
|
||
if adminRejectReason != "" {
|
||
other.SetAdmin("reject_reason", adminRejectReason)
|
||
}
|
||
if summary.ImageTokens != 0 {
|
||
other.SetPublic("image", true)
|
||
other.SetPublic("image_ratio", summary.ImageRatio)
|
||
other.SetPublic("image_output", summary.ImageTokens)
|
||
}
|
||
appendToolSurchargeLogInfo(other, summary.ToolSurchargeItems)
|
||
if summary.AudioInputPrice > 0 && summary.AudioTokens > 0 {
|
||
other.SetPublic("audio_input_seperate_price", true)
|
||
other.SetPublic("audio_input_token_count", summary.AudioTokens)
|
||
other.SetPublic("audio_input_price", summary.AudioInputPrice)
|
||
}
|
||
if summary.CacheCreationTokens > 0 {
|
||
other.SetPublic("cache_creation_tokens", summary.CacheCreationTokens)
|
||
other.SetPublic("cache_creation_ratio", summary.CacheCreationRatio)
|
||
}
|
||
if summary.CacheCreationTokens5m > 0 {
|
||
other.SetPublic("cache_creation_tokens_5m", summary.CacheCreationTokens5m)
|
||
other.SetPublic("cache_creation_ratio_5m", summary.CacheCreationRatio5m)
|
||
}
|
||
if summary.CacheCreationTokens1h > 0 {
|
||
other.SetPublic("cache_creation_tokens_1h", summary.CacheCreationTokens1h)
|
||
other.SetPublic("cache_creation_ratio_1h", summary.CacheCreationRatio1h)
|
||
}
|
||
cacheWriteTokens := cacheWriteTokensTotal(summary)
|
||
if cacheWriteTokens > 0 {
|
||
// cache_write_tokens: normalized cache creation total for UI display.
|
||
// If split 5m/1h values are present, this is their sum; otherwise it falls back
|
||
// to cache_creation_tokens.
|
||
other.SetPublic("cache_write_tokens", cacheWriteTokens)
|
||
}
|
||
if relayInfo.GetFinalRequestRelayFormat() != types.RelayFormatClaude && billingUsage != nil && billingUsage.UsageSource != "" && billingUsage.InputTokens > 0 {
|
||
// input_tokens_total: explicit normalized total input used by the usage log UI.
|
||
// Only write this field when upstream/current conversion has already provided a
|
||
// reliable total input value and tagged the usage source. Do not infer it from
|
||
// prompt/cache fields here, otherwise old upstream payloads may be double-counted.
|
||
other.SetPublic("input_tokens_total", billingUsage.InputTokens)
|
||
}
|
||
if tieredBillingApplied {
|
||
InjectTieredBillingInfo(other, relayInfo, tieredResult)
|
||
|
||
}
|
||
|
||
attachQuotaSaturation(ctx, relayInfo, other)
|
||
|
||
logParams := model.RecordConsumeLogParams{
|
||
ChannelId: relayInfo.ChannelId,
|
||
PromptTokens: summary.PromptTokens,
|
||
CompletionTokens: summary.CompletionTokens,
|
||
ModelName: logModel,
|
||
TokenName: summary.TokenName,
|
||
Quota: summary.Quota,
|
||
Content: logContent,
|
||
TokenId: relayInfo.TokenId,
|
||
UseTimeSeconds: int(summary.UseTimeSeconds),
|
||
IsStream: relayInfo.IsStream,
|
||
Group: relayInfo.UsingGroup,
|
||
Other: other,
|
||
}
|
||
if relayInfo.DurableAttemptID != 0 { recordDurableAttempt(ctx,relayInfo,billingUsage,logParams) } else { model.RecordConsumeLog(ctx,relayInfo.UserId,logParams) }
|
||
relayInfo.PerformanceOutputTokens = int64(summary.CompletionTokens)
|
||
}
|