feat: configurable tool pricing, Sub2API channel, and alpha search billing
Add admin-configurable tool-call prices with cross-provider surcharge settlement, Sub2API channel support, /v1/alpha/search relay, and usage-log surcharge UI.
This commit is contained in:
+146
-111
@@ -2,6 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
@@ -21,40 +24,56 @@ import (
|
||||
"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 map[string]interface{}, items []ToolSurchargeItem) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
other["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
|
||||
WebSearchPrice float64
|
||||
WebSearchCallCount int
|
||||
ClaudeWebSearchPrice float64
|
||||
ClaudeWebSearchCallCount int
|
||||
FileSearchPrice float64
|
||||
FileSearchCallCount int
|
||||
AudioInputPrice float64
|
||||
ImageGenerationCallPrice float64
|
||||
ToolCallSurchargeQuota decimal.Decimal
|
||||
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
|
||||
}
|
||||
|
||||
// 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.TotalTokens > 0 || !s.ToolCallSurchargeQuota.IsZero()
|
||||
}
|
||||
|
||||
func cacheWriteTokensTotal(summary textQuotaSummary) int {
|
||||
@@ -81,60 +100,91 @@ func isLegacyClaudeDerivedOpenAIUsage(relayInfo *relaycommon.RelayInfo, usage *d
|
||||
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
|
||||
|
||||
if relayInfo.ResponsesUsageInfo != nil {
|
||||
if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool.CallCount > 0 {
|
||||
summary.WebSearchCallCount = webSearchTool.CallCount
|
||||
summary.WebSearchPrice = operation_setting.GetToolPriceForModel("web_search_preview", summary.ModelName)
|
||||
surcharge = surcharge.Add(decimal.NewFromFloat(summary.WebSearchPrice).
|
||||
Mul(decimal.NewFromInt(int64(webSearchTool.CallCount))).
|
||||
Div(decimal.NewFromInt(1000)).
|
||||
Mul(dGroupRatio).
|
||||
Mul(dQuotaPerUnit))
|
||||
}
|
||||
} else if strings.HasSuffix(summary.ModelName, "search-preview") {
|
||||
summary.WebSearchCallCount = 1
|
||||
summary.WebSearchPrice = operation_setting.GetToolPriceForModel("web_search_preview", summary.ModelName)
|
||||
surcharge = surcharge.Add(decimal.NewFromFloat(summary.WebSearchPrice).
|
||||
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))
|
||||
}
|
||||
|
||||
summary.ClaudeWebSearchCallCount = ctx.GetInt("claude_web_search_requests")
|
||||
if summary.ClaudeWebSearchCallCount > 0 {
|
||||
summary.ClaudeWebSearchPrice = operation_setting.GetToolPrice("web_search")
|
||||
surcharge = surcharge.Add(decimal.NewFromFloat(summary.ClaudeWebSearchPrice).
|
||||
Div(decimal.NewFromInt(1000)).
|
||||
Mul(dGroupRatio).
|
||||
Mul(dQuotaPerUnit).
|
||||
Mul(decimal.NewFromInt(int64(summary.ClaudeWebSearchCallCount))))
|
||||
}
|
||||
|
||||
if relayInfo.ResponsesUsageInfo != nil {
|
||||
if fileSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolFileSearch]; exists && fileSearchTool.CallCount > 0 {
|
||||
summary.FileSearchCallCount = fileSearchTool.CallCount
|
||||
summary.FileSearchPrice = operation_setting.GetToolPrice("file_search")
|
||||
surcharge = surcharge.Add(decimal.NewFromFloat(summary.FileSearchPrice).
|
||||
Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))).
|
||||
Div(decimal.NewFromInt(1000)).
|
||||
Mul(dGroupRatio).
|
||||
Mul(dQuotaPerUnit))
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.GetBool("image_generation_call") {
|
||||
summary.ImageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(ctx.GetString("image_generation_call_quality"), ctx.GetString("image_generation_call_size"))
|
||||
surcharge = surcharge.Add(decimal.NewFromFloat(summary.ImageGenerationCallPrice).
|
||||
Mul(dGroupRatio).
|
||||
Mul(dQuotaPerUnit))
|
||||
}
|
||||
|
||||
return surcharge
|
||||
}
|
||||
|
||||
@@ -305,9 +355,9 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf
|
||||
promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio).Add(cachedCreationTokensWithRatio)
|
||||
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
|
||||
quotaCalculateDecimal := promptQuota.Add(completionQuota).Mul(ratio)
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(summary.ToolCallSurchargeQuota)
|
||||
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)
|
||||
@@ -317,15 +367,15 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf
|
||||
noteQuotaClamp(relayInfo, clamp)
|
||||
} else {
|
||||
quotaCalculateDecimal := dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(summary.ToolCallSurchargeQuota)
|
||||
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.TotalTokens == 0 {
|
||||
if !summary.hasBillableUsage() {
|
||||
summary.Quota = 0
|
||||
} else if !ratio.IsZero() && summary.Quota == 0 {
|
||||
summary.Quota = 1
|
||||
@@ -372,23 +422,25 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
|
||||
}
|
||||
}
|
||||
|
||||
if summary.WebSearchCallCount > 0 {
|
||||
extraContent = append(extraContent, fmt.Sprintf("Web Search 调用 %d 次,调用花费 %s", summary.WebSearchCallCount, decimal.NewFromFloat(summary.WebSearchPrice).Mul(decimal.NewFromInt(int64(summary.WebSearchCallCount))).Div(decimal.NewFromInt(1000)).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String()))
|
||||
}
|
||||
if summary.ClaudeWebSearchCallCount > 0 {
|
||||
extraContent = append(extraContent, fmt.Sprintf("Claude Web Search 调用 %d 次,调用花费 %s", summary.ClaudeWebSearchCallCount, decimal.NewFromFloat(summary.ClaudeWebSearchPrice).Div(decimal.NewFromInt(1000)).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).Mul(decimal.NewFromInt(int64(summary.ClaudeWebSearchCallCount))).String()))
|
||||
}
|
||||
if summary.FileSearchCallCount > 0 {
|
||||
extraContent = append(extraContent, fmt.Sprintf("File Search 调用 %d 次,调用花费 %s", summary.FileSearchCallCount, decimal.NewFromFloat(summary.FileSearchPrice).Mul(decimal.NewFromInt(int64(summary.FileSearchCallCount))).Div(decimal.NewFromInt(1000)).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String()))
|
||||
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 {
|
||||
extraContent = append(extraContent, fmt.Sprintf("Audio Input 花费 %s", decimal.NewFromFloat(summary.AudioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(decimal.NewFromInt(int64(summary.AudioTokens))).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String()))
|
||||
}
|
||||
if summary.ImageGenerationCallPrice > 0 {
|
||||
extraContent = append(extraContent, fmt.Sprintf("Image Generation Call 花费 %s", decimal.NewFromFloat(summary.ImageGenerationCallPrice).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String()))
|
||||
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.TotalTokens == 0 {
|
||||
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 {
|
||||
@@ -433,29 +485,12 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
|
||||
other["image_ratio"] = summary.ImageRatio
|
||||
other["image_output"] = summary.ImageTokens
|
||||
}
|
||||
if summary.WebSearchCallCount > 0 {
|
||||
other["web_search"] = true
|
||||
other["web_search_call_count"] = summary.WebSearchCallCount
|
||||
other["web_search_price"] = summary.WebSearchPrice
|
||||
} else if summary.ClaudeWebSearchCallCount > 0 {
|
||||
other["web_search"] = true
|
||||
other["web_search_call_count"] = summary.ClaudeWebSearchCallCount
|
||||
other["web_search_price"] = summary.ClaudeWebSearchPrice
|
||||
}
|
||||
if summary.FileSearchCallCount > 0 {
|
||||
other["file_search"] = true
|
||||
other["file_search_call_count"] = summary.FileSearchCallCount
|
||||
other["file_search_price"] = summary.FileSearchPrice
|
||||
}
|
||||
appendToolSurchargeLogInfo(other, summary.ToolSurchargeItems)
|
||||
if summary.AudioInputPrice > 0 && summary.AudioTokens > 0 {
|
||||
other["audio_input_seperate_price"] = true
|
||||
other["audio_input_token_count"] = summary.AudioTokens
|
||||
other["audio_input_price"] = summary.AudioInputPrice
|
||||
}
|
||||
if summary.ImageGenerationCallPrice > 0 {
|
||||
other["image_generation_call"] = true
|
||||
other["image_generation_call_price"] = summary.ImageGenerationCallPrice
|
||||
}
|
||||
if summary.CacheCreationTokens > 0 {
|
||||
other["cache_creation_tokens"] = summary.CacheCreationTokens
|
||||
other["cache_creation_ratio"] = summary.CacheCreationRatio
|
||||
|
||||
+316
-5
@@ -11,9 +11,13 @@ import (
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"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/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -546,9 +550,12 @@ func TestComposeTieredTextQuotaKeepsToolCallSurcharges(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
ctx.Set("image_generation_call", true)
|
||||
ctx.Set("image_generation_call_quality", "low")
|
||||
ctx.Set("image_generation_call_size", "1024x1024")
|
||||
|
||||
// 11 $/1K => 0.011 per completed image output, matching the prior fixed low-tier charge.
|
||||
operation_setting.SetToolPriceForTest(dto.BuildInToolImageGeneration, 11.0)
|
||||
t.Cleanup(func() {
|
||||
operation_setting.DeleteToolPriceForTest(dto.BuildInToolImageGeneration)
|
||||
})
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
OriginModelName: "o1",
|
||||
@@ -559,12 +566,15 @@ func TestComposeTieredTextQuotaKeepsToolCallSurcharges(t *testing.T) {
|
||||
},
|
||||
ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{
|
||||
BuiltInTools: map[string]*relaycommon.BuildInToolInfo{
|
||||
dto.BuildInToolWebSearchPreview: &relaycommon.BuildInToolInfo{
|
||||
dto.BuildInToolWebSearchPreview: {
|
||||
CallCount: 1,
|
||||
},
|
||||
dto.BuildInToolFileSearch: &relaycommon.BuildInToolInfo{
|
||||
dto.BuildInToolFileSearch: {
|
||||
CallCount: 2,
|
||||
},
|
||||
dto.BuildInToolImageGeneration: {
|
||||
CallCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
|
||||
@@ -740,3 +750,304 @@ func TestCalculateTextQuotaSummaryFixedPriceAppliesImageCountOnceAndAllowsOverri
|
||||
summary = calculateTextQuotaSummary(ctx, relayInfo, usage)
|
||||
require.Equal(t, 120000, summary.Quota)
|
||||
}
|
||||
|
||||
func TestCalculateTextToolCallSurchargeGeneralizedBuiltInTools(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
|
||||
operation_setting.SetToolPriceForTest("my_fn", 5.0)
|
||||
t.Cleanup(func() {
|
||||
operation_setting.DeleteToolPriceForTest("my_fn")
|
||||
})
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
OriginModelName: "o1",
|
||||
ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{
|
||||
BuiltInTools: map[string]*relaycommon.BuildInToolInfo{
|
||||
dto.BuildInToolWebSearchPreview: {CallCount: 2},
|
||||
"my_fn": {CallCount: 3},
|
||||
"unpriced": {CallCount: 5},
|
||||
},
|
||||
},
|
||||
}
|
||||
summary := &textQuotaSummary{
|
||||
ModelName: "o1",
|
||||
GroupRatio: 1,
|
||||
}
|
||||
|
||||
surcharge := calculateTextToolCallSurcharge(ctx, relayInfo, summary)
|
||||
expected := decimal.NewFromFloat((10.0*2 + 5.0*3) / 1000).Mul(decimal.NewFromFloat(common.QuotaPerUnit))
|
||||
assert.True(t, expected.Equal(surcharge), "got %s want %s", surcharge, expected)
|
||||
require.Len(t, summary.ToolSurchargeItems, 2)
|
||||
assert.Equal(t, "my_fn", summary.ToolSurchargeItems[0].Name)
|
||||
assert.Equal(t, 3, summary.ToolSurchargeItems[0].Count)
|
||||
assert.Equal(t, 5.0, summary.ToolSurchargeItems[0].Price)
|
||||
assert.Equal(t, dto.BuildInToolWebSearchPreview, summary.ToolSurchargeItems[1].Name)
|
||||
assert.Equal(t, 2, summary.ToolSurchargeItems[1].Count)
|
||||
assert.Equal(t, 10.0, summary.ToolSurchargeItems[1].Price)
|
||||
}
|
||||
|
||||
func TestCalculateTextToolCallSurchargeKeepsSearchPreviewFallbackWithCustomFunctions(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
|
||||
operation_setting.SetToolPriceForTest("my_fn", 5)
|
||||
t.Cleanup(func() {
|
||||
operation_setting.DeleteToolPriceForTest("my_fn")
|
||||
})
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
RelayMode: relayconstant.RelayModeChatCompletions,
|
||||
OriginModelName: "gpt-4o-search-preview",
|
||||
ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{
|
||||
BuiltInTools: map[string]*relaycommon.BuildInToolInfo{
|
||||
"my_fn": {CallCount: 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
summary := &textQuotaSummary{
|
||||
ModelName: relayInfo.OriginModelName,
|
||||
GroupRatio: 1,
|
||||
}
|
||||
|
||||
surcharge := calculateTextToolCallSurcharge(ctx, relayInfo, summary)
|
||||
|
||||
require.Len(t, summary.ToolSurchargeItems, 2)
|
||||
assert.Equal(t, "my_fn", summary.ToolSurchargeItems[0].Name)
|
||||
assert.Equal(t, dto.BuildInToolWebSearchPreview, summary.ToolSurchargeItems[1].Name)
|
||||
expected := decimal.NewFromFloat((5.0 + 25.0) / 1000).
|
||||
Mul(decimal.NewFromFloat(common.QuotaPerUnit))
|
||||
assert.True(t, expected.Equal(surcharge), "got %s want %s", surcharge, expected)
|
||||
}
|
||||
|
||||
func TestCalculateTextToolCallSurchargeDoesNotInferSearchForResponses(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
RelayMode: relayconstant.RelayModeResponses,
|
||||
OriginModelName: "gpt-4o-search-preview",
|
||||
ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{
|
||||
BuiltInTools: map[string]*relaycommon.BuildInToolInfo{},
|
||||
},
|
||||
}
|
||||
summary := &textQuotaSummary{
|
||||
ModelName: relayInfo.OriginModelName,
|
||||
GroupRatio: 1,
|
||||
}
|
||||
|
||||
surcharge := calculateTextToolCallSurcharge(ctx, relayInfo, summary)
|
||||
|
||||
assert.True(t, surcharge.IsZero())
|
||||
assert.Empty(t, summary.ToolSurchargeItems)
|
||||
}
|
||||
|
||||
func TestCalculateTextToolCallSurchargeMergesSameNameAndPrice(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set("claude_web_search_requests", 3)
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
OriginModelName: "claude-3-7-sonnet",
|
||||
ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{
|
||||
BuiltInTools: map[string]*relaycommon.BuildInToolInfo{
|
||||
dto.BuildInToolWebSearch: {CallCount: 2},
|
||||
},
|
||||
},
|
||||
}
|
||||
summary := &textQuotaSummary{ModelName: relayInfo.OriginModelName, GroupRatio: 1}
|
||||
|
||||
surcharge := calculateTextToolCallSurcharge(ctx, relayInfo, summary)
|
||||
|
||||
require.Len(t, summary.ToolSurchargeItems, 1)
|
||||
assert.Equal(t, dto.BuildInToolWebSearch, summary.ToolSurchargeItems[0].Name)
|
||||
assert.Equal(t, 5, summary.ToolSurchargeItems[0].Count)
|
||||
assert.Equal(t, 10.0, summary.ToolSurchargeItems[0].Price)
|
||||
expected := decimal.NewFromFloat(10.0 * 5 / 1000).Mul(decimal.NewFromFloat(common.QuotaPerUnit))
|
||||
assert.True(t, expected.Equal(surcharge), "got %s want %s", surcharge, expected)
|
||||
}
|
||||
|
||||
func TestMergeToolSurchargeItemsSaturatesCountOverflow(t *testing.T) {
|
||||
items := []ToolSurchargeItem{
|
||||
{Name: "custom_fn", Count: math.MaxInt, Price: 5},
|
||||
{Name: "custom_fn", Count: 1, Price: 5},
|
||||
}
|
||||
|
||||
merged := mergeToolSurchargeItems(items)
|
||||
|
||||
require.Len(t, merged, 1)
|
||||
assert.Equal(t, math.MaxInt, merged[0].Count)
|
||||
}
|
||||
|
||||
// A zero-token request (e.g. /v1/alpha/search returns no usage) must still
|
||||
// bill a tool-call surcharge. Regression for the TotalTokens==0 gate zeroing
|
||||
// out the surcharge quota.
|
||||
func TestCalculateTextQuotaSummaryZeroTokensStillBillsToolSurcharge(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
OriginModelName: "o1",
|
||||
ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{
|
||||
BuiltInTools: map[string]*relaycommon.BuildInToolInfo{
|
||||
dto.BuildInToolWebSearchPreview: {CallCount: 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
relayInfo.PriceData.GroupRatioInfo.GroupRatio = 1
|
||||
|
||||
usage := &dto.Usage{} // zero tokens, mirrors alpha search
|
||||
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
|
||||
|
||||
require.Equal(t, 0, summary.TotalTokens)
|
||||
assert.False(t, summary.ToolCallSurchargeQuota.IsZero(), "surcharge should be computed")
|
||||
assert.Greater(t, summary.Quota, 0, "quota must not be zeroed out for a zero-token web search request")
|
||||
expected := common.QuotaFromDecimal(summary.ToolCallSurchargeQuota)
|
||||
assert.Equal(t, expected, summary.Quota)
|
||||
}
|
||||
|
||||
func TestCalculateTextQuotaSummaryDoesNotApplyRequestMultipliersToToolSurcharge(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
OriginModelName: "o1",
|
||||
PriceData: types.PriceData{
|
||||
ModelRatio: 1,
|
||||
CompletionRatio: 1,
|
||||
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
|
||||
},
|
||||
ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{
|
||||
BuiltInTools: map[string]*relaycommon.BuildInToolInfo{
|
||||
dto.BuildInToolWebSearchPreview: {CallCount: 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
relayInfo.PriceData.AddOtherRatio("n", 3)
|
||||
|
||||
summary := calculateTextQuotaSummary(ctx, relayInfo, &dto.Usage{})
|
||||
|
||||
expected := decimal.NewFromFloat(10.0 / 1000).Mul(decimal.NewFromFloat(common.QuotaPerUnit))
|
||||
assert.True(t, expected.Equal(summary.ToolCallSurchargeQuota))
|
||||
assert.Equal(t, common.QuotaFromDecimal(expected), summary.Quota)
|
||||
}
|
||||
|
||||
func TestCalculateTextToolCallSurchargeGeminiGoogleSearch(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set("gemini_google_search_call", true)
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{OriginModelName: "gemini-2.5-flash"}
|
||||
summary := &textQuotaSummary{ModelName: "gemini-2.5-flash", GroupRatio: 1}
|
||||
|
||||
surcharge := calculateTextToolCallSurcharge(ctx, relayInfo, summary)
|
||||
expected := decimal.NewFromFloat(14.0 / 1000).Mul(decimal.NewFromFloat(common.QuotaPerUnit))
|
||||
assert.True(t, expected.Equal(surcharge), "got %s want %s", surcharge, expected)
|
||||
require.Len(t, summary.ToolSurchargeItems, 1)
|
||||
assert.Equal(t, dto.BuildInToolGoogleSearch, summary.ToolSurchargeItems[0].Name)
|
||||
assert.Equal(t, 1, summary.ToolSurchargeItems[0].Count)
|
||||
assert.Equal(t, 14.0, summary.ToolSurchargeItems[0].Price)
|
||||
}
|
||||
|
||||
func TestCalculateTextToolCallSurchargeImageGenerationDefaultPrice(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
t.Cleanup(func() {
|
||||
operation_setting.DeleteToolPriceForTest(dto.BuildInToolImageGeneration)
|
||||
})
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gpt-5.1",
|
||||
ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{
|
||||
BuiltInTools: map[string]*relaycommon.BuildInToolInfo{
|
||||
dto.BuildInToolImageGeneration: {CallCount: 2},
|
||||
},
|
||||
},
|
||||
}
|
||||
summary := &textQuotaSummary{ModelName: "gpt-5.1", GroupRatio: 1.5}
|
||||
|
||||
surcharge := calculateTextToolCallSurcharge(ctx, relayInfo, summary)
|
||||
expected := decimal.NewFromFloat(150.0).
|
||||
Mul(decimal.NewFromInt(2)).
|
||||
Div(decimal.NewFromInt(1000)).
|
||||
Mul(decimal.NewFromFloat(1.5)).
|
||||
Mul(decimal.NewFromFloat(common.QuotaPerUnit))
|
||||
assert.True(t, expected.Equal(surcharge), "got %s want %s", surcharge, expected)
|
||||
require.Len(t, summary.ToolSurchargeItems, 1)
|
||||
assert.Equal(t, dto.BuildInToolImageGeneration, summary.ToolSurchargeItems[0].Name)
|
||||
assert.Equal(t, 2, summary.ToolSurchargeItems[0].Count)
|
||||
assert.Equal(t, 150.0, summary.ToolSurchargeItems[0].Price)
|
||||
}
|
||||
|
||||
func TestCalculateTextToolCallSurchargeImageGenerationExplicitZeroDisables(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
operation_setting.SetToolPriceForTest(dto.BuildInToolImageGeneration, 0)
|
||||
t.Cleanup(func() {
|
||||
operation_setting.DeleteToolPriceForTest(dto.BuildInToolImageGeneration)
|
||||
})
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gpt-5.1",
|
||||
ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{
|
||||
BuiltInTools: map[string]*relaycommon.BuildInToolInfo{
|
||||
dto.BuildInToolImageGeneration: {CallCount: 3},
|
||||
},
|
||||
},
|
||||
}
|
||||
summary := &textQuotaSummary{ModelName: "gpt-5.1", GroupRatio: 1}
|
||||
|
||||
surcharge := calculateTextToolCallSurcharge(ctx, relayInfo, summary)
|
||||
assert.True(t, surcharge.IsZero())
|
||||
assert.Empty(t, summary.ToolSurchargeItems)
|
||||
}
|
||||
|
||||
func TestCalculateTextQuotaSummaryImageGenerationUsesStructuredSurcharge(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
t.Cleanup(func() {
|
||||
operation_setting.DeleteToolPriceForTest(dto.BuildInToolImageGeneration)
|
||||
})
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gpt-5.1",
|
||||
ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{
|
||||
BuiltInTools: map[string]*relaycommon.BuildInToolInfo{
|
||||
dto.BuildInToolImageGeneration: {CallCount: 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
relayInfo.PriceData.GroupRatioInfo.GroupRatio = 1
|
||||
relayInfo.PriceData.ModelRatio = 1
|
||||
relayInfo.PriceData.CompletionRatio = 1
|
||||
|
||||
usage := &dto.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}
|
||||
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
|
||||
|
||||
require.Len(t, summary.ToolSurchargeItems, 1)
|
||||
assert.Equal(t, dto.BuildInToolImageGeneration, summary.ToolSurchargeItems[0].Name)
|
||||
assert.Equal(t, 1, summary.ToolSurchargeItems[0].Count)
|
||||
assert.Equal(t, 150.0, summary.ToolSurchargeItems[0].Price)
|
||||
|
||||
expectedSurcharge := decimal.NewFromFloat(150.0 / 1000).Mul(decimal.NewFromFloat(common.QuotaPerUnit))
|
||||
assert.True(t, expectedSurcharge.Equal(summary.ToolCallSurchargeQuota),
|
||||
"got %s want %s", summary.ToolCallSurchargeQuota, expectedSurcharge)
|
||||
assert.Greater(t, summary.Quota, 0)
|
||||
}
|
||||
|
||||
func TestAppendToolSurchargeLogInfoWritesOnlyStructuredFields(t *testing.T) {
|
||||
items := []ToolSurchargeItem{
|
||||
{Name: dto.BuildInToolWebSearch, Count: 2, Price: 10},
|
||||
{Name: dto.BuildInToolImageGeneration, Count: 1, Price: 150},
|
||||
}
|
||||
other := map[string]interface{}{}
|
||||
|
||||
appendToolSurchargeLogInfo(other, items)
|
||||
|
||||
assert.Equal(t, items, other["tool_surcharges"])
|
||||
assert.NotContains(t, other, "web_search")
|
||||
assert.NotContains(t, other, "web_search_call_count")
|
||||
assert.NotContains(t, other, "web_search_price")
|
||||
assert.NotContains(t, other, "file_search")
|
||||
assert.NotContains(t, other, "image_generation_call")
|
||||
assert.NotContains(t, other, "image_generation_call_price")
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
)
|
||||
|
||||
// ToolCallUsage captures all tool call counts from a single request.
|
||||
type ToolCallUsage struct {
|
||||
ModelName string
|
||||
WebSearchCalls int
|
||||
WebSearchToolName string // "web_search_preview", "web_search", etc.
|
||||
FileSearchCalls int
|
||||
ImageGenerationCall bool
|
||||
ImageGenerationQuality string
|
||||
ImageGenerationSize string
|
||||
}
|
||||
|
||||
// ToolCallItem represents a single billed tool usage line.
|
||||
type ToolCallItem struct {
|
||||
Name string `json:"name"`
|
||||
CallCount int `json:"call_count"`
|
||||
PricePer1K float64 `json:"price_per_1k"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
Quota int `json:"quota"`
|
||||
}
|
||||
|
||||
// ToolCallResult holds the aggregated tool call billing for a request.
|
||||
type ToolCallResult struct {
|
||||
TotalQuota int `json:"total_quota"`
|
||||
Items []ToolCallItem `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// ComputeToolCallQuota calculates the total quota for all tool calls in a
|
||||
// request. Tool prices are resolved via GetToolPriceForModel which supports
|
||||
// model-prefix overrides. groupRatio is applied.
|
||||
func ComputeToolCallQuota(usage ToolCallUsage, groupRatio float64) ToolCallResult {
|
||||
var items []ToolCallItem
|
||||
totalQuota := 0
|
||||
|
||||
addItem := func(toolName string, count int) {
|
||||
if count <= 0 {
|
||||
return
|
||||
}
|
||||
pricePer1K := operation_setting.GetToolPriceForModel(toolName, usage.ModelName)
|
||||
if pricePer1K <= 0 {
|
||||
return
|
||||
}
|
||||
totalPrice := pricePer1K * float64(count) / 1000
|
||||
quota := common.QuotaRound(totalPrice * common.QuotaPerUnit * groupRatio)
|
||||
items = append(items, ToolCallItem{
|
||||
Name: toolName,
|
||||
CallCount: count,
|
||||
PricePer1K: pricePer1K,
|
||||
TotalPrice: totalPrice,
|
||||
Quota: quota,
|
||||
})
|
||||
totalQuota += quota
|
||||
}
|
||||
|
||||
if usage.WebSearchCalls > 0 && usage.WebSearchToolName != "" {
|
||||
addItem(usage.WebSearchToolName, usage.WebSearchCalls)
|
||||
}
|
||||
|
||||
if usage.FileSearchCalls > 0 {
|
||||
addItem("file_search", usage.FileSearchCalls)
|
||||
}
|
||||
|
||||
if usage.ImageGenerationCall {
|
||||
price := operation_setting.GetGPTImage1PriceOnceCall(usage.ImageGenerationQuality, usage.ImageGenerationSize)
|
||||
quota := common.QuotaRound(price * common.QuotaPerUnit * groupRatio)
|
||||
items = append(items, ToolCallItem{
|
||||
Name: "image_generation",
|
||||
CallCount: 1,
|
||||
PricePer1K: price,
|
||||
TotalPrice: price,
|
||||
Quota: quota,
|
||||
})
|
||||
totalQuota += quota
|
||||
}
|
||||
|
||||
return ToolCallResult{
|
||||
TotalQuota: totalQuota,
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user