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:
CaIon
2026-07-26 20:05:15 +08:00
parent 3e1e728279
commit 2d23cdf291
65 changed files with 3210 additions and 431 deletions
+146 -111
View File
@@ -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
View File
@@ -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")
}
-86
View File
@@ -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,
}
}