mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-08 16:56:23 +00:00
Merge pull request #6132 from wucm667/fix/issue-6125-anthropic-cache-ttl
fix: prevent duplicate Anthropic cache TTL billing
This commit is contained in:
6 files changed
+168
-26
No files matched your search
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -1370,16 +1371,43 @@ func (s *BillingService) computeTokenBreakdown(
|
|||||||
// multiplier 用于长上下文等场景下的整体价格缩放(普通调用传 1.0 即可)。
|
// multiplier 用于长上下文等场景下的整体价格缩放(普通调用传 1.0 即可)。
|
||||||
func (s *BillingService) computeCacheCreationCost(pricing *ModelPricing, tokens UsageTokens, price, multiplier float64) float64 {
|
func (s *BillingService) computeCacheCreationCost(pricing *ModelPricing, tokens UsageTokens, price, multiplier float64) float64 {
|
||||||
if pricing.SupportsCacheBreakdown && (pricing.CacheCreation5mPrice > 0 || pricing.CacheCreation1hPrice > 0) {
|
if pricing.SupportsCacheBreakdown && (pricing.CacheCreation5mPrice > 0 || pricing.CacheCreation1hPrice > 0) {
|
||||||
if tokens.CacheCreation5mTokens == 0 && tokens.CacheCreation1hTokens == 0 && tokens.CacheCreationTokens > 0 {
|
cacheCreation5mTokens, cacheCreation1hTokens := normalizeCacheCreationBreakdown(tokens)
|
||||||
|
if cacheCreation5mTokens == 0 && cacheCreation1hTokens == 0 && tokens.CacheCreationTokens > 0 {
|
||||||
// API 未返回 ephemeral 明细,回退到全部按 5m 单价计费
|
// API 未返回 ephemeral 明细,回退到全部按 5m 单价计费
|
||||||
return float64(tokens.CacheCreationTokens) * pricing.CacheCreation5mPrice * multiplier
|
return float64(tokens.CacheCreationTokens) * pricing.CacheCreation5mPrice * multiplier
|
||||||
}
|
}
|
||||||
return float64(tokens.CacheCreation5mTokens)*pricing.CacheCreation5mPrice*multiplier +
|
return float64(cacheCreation5mTokens)*pricing.CacheCreation5mPrice*multiplier +
|
||||||
float64(tokens.CacheCreation1hTokens)*pricing.CacheCreation1hPrice*multiplier
|
float64(cacheCreation1hTokens)*pricing.CacheCreation1hPrice*multiplier
|
||||||
}
|
}
|
||||||
return float64(tokens.CacheCreationTokens) * price * multiplier
|
return float64(tokens.CacheCreationTokens) * price * multiplier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeCacheCreationBreakdown caps contradictory 5m/1h details at an explicitly
|
||||||
|
// positive aggregate while retaining their reported ratio as closely as integer tokens allow.
|
||||||
|
func normalizeCacheCreationBreakdown(tokens UsageTokens) (int, int) {
|
||||||
|
cacheCreation5mTokens := tokens.CacheCreation5mTokens
|
||||||
|
cacheCreation1hTokens := tokens.CacheCreation1hTokens
|
||||||
|
aggregate := tokens.CacheCreationTokens
|
||||||
|
if cacheCreation5mTokens < 0 {
|
||||||
|
cacheCreation5mTokens = 0
|
||||||
|
}
|
||||||
|
if cacheCreation1hTokens < 0 {
|
||||||
|
cacheCreation1hTokens = 0
|
||||||
|
}
|
||||||
|
if aggregate <= 0 || (cacheCreation5mTokens <= aggregate && cacheCreation1hTokens <= aggregate-cacheCreation5mTokens) {
|
||||||
|
return cacheCreation5mTokens, cacheCreation1hTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
detailTotal := float64(cacheCreation5mTokens) + float64(cacheCreation1hTokens)
|
||||||
|
normalized5mTokens := math.Round(float64(aggregate) * float64(cacheCreation5mTokens) / detailTotal)
|
||||||
|
if normalized5mTokens >= float64(aggregate) {
|
||||||
|
cacheCreation5mTokens = aggregate
|
||||||
|
} else {
|
||||||
|
cacheCreation5mTokens = int(normalized5mTokens)
|
||||||
|
}
|
||||||
|
return cacheCreation5mTokens, aggregate - cacheCreation5mTokens
|
||||||
|
}
|
||||||
|
|
||||||
// calculatePerRequestCost 按次/图片计费
|
// calculatePerRequestCost 按次/图片计费
|
||||||
func (s *BillingService) calculatePerRequestCost(resolved *ResolvedPricing, input CostInput) (*CostBreakdown, error) {
|
func (s *BillingService) calculatePerRequestCost(resolved *ResolvedPricing, input CostInput) (*CostBreakdown, error) {
|
||||||
units := input.UsageUnits
|
units := input.UsageUnits
|
||||||
|
|||||||
@@ -1384,6 +1384,122 @@ func TestCalculateCost_SupportsCacheBreakdown(t *testing.T) {
|
|||||||
require.InDelta(t, expected5m+expected1h, cost.CacheCreationCost, 1e-10)
|
require.InDelta(t, expected5m+expected1h, cost.CacheCreationCost, 1e-10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestComputeCacheCreationCost_CapsContradictoryBreakdownAtAggregate(t *testing.T) {
|
||||||
|
svc := &BillingService{}
|
||||||
|
pricing := &ModelPricing{
|
||||||
|
SupportsCacheBreakdown: true,
|
||||||
|
CacheCreation5mPrice: 1,
|
||||||
|
CacheCreation1hPrice: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens := UsageTokens{
|
||||||
|
CacheCreationTokens: 463184,
|
||||||
|
CacheCreation5mTokens: 463184,
|
||||||
|
CacheCreation1hTokens: 463184,
|
||||||
|
}
|
||||||
|
|
||||||
|
cost := svc.computeCacheCreationCost(pricing, tokens, 0, 1)
|
||||||
|
require.Equal(t, float64(tokens.CacheCreationTokens), cost,
|
||||||
|
"billed cache-creation token equivalent must not exceed the positive aggregate")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeCacheCreationBreakdown_BillingSafetyInvariant(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tokens UsageTokens
|
||||||
|
want5m int
|
||||||
|
want1h int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "preserves ratio when capping",
|
||||||
|
tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: 90, CacheCreation1hTokens: 60},
|
||||||
|
want5m: 60,
|
||||||
|
want1h: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "details below aggregate unchanged",
|
||||||
|
tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: 30, CacheCreation1hTokens: 60},
|
||||||
|
want5m: 30,
|
||||||
|
want1h: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absent 5m detail unchanged",
|
||||||
|
tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation1hTokens: 60},
|
||||||
|
want5m: 0,
|
||||||
|
want1h: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absent 1h detail unchanged",
|
||||||
|
tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: 30},
|
||||||
|
want5m: 30,
|
||||||
|
want1h: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative detail clamped",
|
||||||
|
tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -50, CacheCreation1hTokens: 60},
|
||||||
|
want5m: 0,
|
||||||
|
want1h: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative detail cannot hide oversized positive detail",
|
||||||
|
tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -50, CacheCreation1hTokens: 150},
|
||||||
|
want5m: 0,
|
||||||
|
want1h: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "integer boundary details capped without overflow",
|
||||||
|
tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: int(^uint(0) >> 1), CacheCreation1hTokens: int(^uint(0) >> 1)},
|
||||||
|
want5m: 50,
|
||||||
|
want1h: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "integer boundary aggregate avoids float conversion overflow",
|
||||||
|
tokens: UsageTokens{CacheCreationTokens: int(^uint(0) >> 1), CacheCreation5mTokens: int(^uint(0) >> 1), CacheCreation1hTokens: 1},
|
||||||
|
want5m: int(^uint(0) >> 1),
|
||||||
|
want1h: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero aggregate unchanged",
|
||||||
|
tokens: UsageTokens{CacheCreation5mTokens: 90, CacheCreation1hTokens: 60},
|
||||||
|
want5m: 90,
|
||||||
|
want1h: 60,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got5m, got1h := normalizeCacheCreationBreakdown(tt.tokens)
|
||||||
|
require.Equal(t, tt.want5m, got5m)
|
||||||
|
require.Equal(t, tt.want1h, got1h)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeCacheCreationCost_PreservesZeroDetailFallback(t *testing.T) {
|
||||||
|
svc := &BillingService{}
|
||||||
|
pricing := &ModelPricing{
|
||||||
|
SupportsCacheBreakdown: true,
|
||||||
|
CacheCreation5mPrice: 4e-6,
|
||||||
|
CacheCreation1hPrice: 5e-6,
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tokens UsageTokens
|
||||||
|
}{
|
||||||
|
{name: "zero details", tokens: UsageTokens{CacheCreationTokens: 100}},
|
||||||
|
{name: "one negative detail", tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -25}},
|
||||||
|
{name: "both negative details", tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -25, CacheCreation1hTokens: -75}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cost := svc.computeCacheCreationCost(pricing, tt.tokens, 0, 1)
|
||||||
|
require.InDelta(t, 100*4e-6, cost, 1e-12)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCalculateCost_LargeTokenCount(t *testing.T) {
|
func TestCalculateCost_LargeTokenCount(t *testing.T) {
|
||||||
svc := newTestBillingService()
|
svc := newTestBillingService()
|
||||||
|
|
||||||
|
|||||||
@@ -1295,21 +1295,20 @@ func TestGatewayService_ParseSSEUsagePassthrough_MessageStartFallbacks(t *testin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGatewayService_ParseSSEUsagePassthrough_MessageDeltaSelectiveOverwrite(t *testing.T) {
|
func TestGatewayService_ParseSSEUsagePassthrough_MessageDeltaSelectiveOverwrite(t *testing.T) {
|
||||||
usage := &ClaudeUsage{
|
usage := &ClaudeUsage{}
|
||||||
InputTokens: 10,
|
start := `{"type":"message_start","message":{"usage":{"input_tokens":10,"cache_creation_input_tokens":463184,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":463184}}}}`
|
||||||
CacheCreation5mTokens: 2,
|
parseSSEUsagePassthrough(start, usage)
|
||||||
CacheCreation1hTokens: 6,
|
|
||||||
}
|
data := `{"type":"message_delta","usage":{"input_tokens":0,"output_tokens":5,"cache_creation_input_tokens":463184,"cache_read_input_tokens":0,"cached_tokens":11,"cache_creation":{"ephemeral_5m_input_tokens":463184,"ephemeral_1h_input_tokens":0}}}`
|
||||||
data := `{"type":"message_delta","usage":{"input_tokens":0,"output_tokens":5,"cache_creation_input_tokens":8,"cache_read_input_tokens":0,"cached_tokens":11,"cache_creation":{"ephemeral_5m_input_tokens":1,"ephemeral_1h_input_tokens":0}}}`
|
|
||||||
|
|
||||||
parseSSEUsagePassthrough(data, usage)
|
parseSSEUsagePassthrough(data, usage)
|
||||||
|
|
||||||
require.Equal(t, 10, usage.InputTokens, "message_delta 中 0 值不应覆盖已有 input_tokens")
|
require.Equal(t, 10, usage.InputTokens, "message_delta 中 0 值不应覆盖已有 input_tokens")
|
||||||
require.Equal(t, 5, usage.OutputTokens)
|
require.Equal(t, 5, usage.OutputTokens)
|
||||||
require.Equal(t, 8, usage.CacheCreationInputTokens)
|
require.Equal(t, 463184, usage.CacheCreationInputTokens)
|
||||||
require.Equal(t, 11, usage.CacheReadInputTokens, "cache_read_input_tokens 为空时应回退到 cached_tokens")
|
require.Equal(t, 11, usage.CacheReadInputTokens, "cache_read_input_tokens 为空时应回退到 cached_tokens")
|
||||||
require.Equal(t, 1, usage.CacheCreation5mTokens)
|
require.Equal(t, 463184, usage.CacheCreation5mTokens)
|
||||||
require.Equal(t, 6, usage.CacheCreation1hTokens, "message_delta 中 0 值不应覆盖已有 1h 明细")
|
require.Equal(t, 0, usage.CacheCreation1hTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGatewayService_ParseSSEUsagePassthrough_NoopCases(t *testing.T) {
|
func TestGatewayService_ParseSSEUsagePassthrough_NoopCases(t *testing.T) {
|
||||||
|
|||||||
@@ -669,10 +669,10 @@ func parseSSEUsagePassthrough(data string, usage *ClaudeUsage) {
|
|||||||
|
|
||||||
cc5m := deltaUsage.Get("cache_creation.ephemeral_5m_input_tokens")
|
cc5m := deltaUsage.Get("cache_creation.ephemeral_5m_input_tokens")
|
||||||
cc1h := deltaUsage.Get("cache_creation.ephemeral_1h_input_tokens")
|
cc1h := deltaUsage.Get("cache_creation.ephemeral_1h_input_tokens")
|
||||||
if cc5m.Exists() && cc5m.Int() > 0 {
|
if cc5m.Exists() {
|
||||||
usage.CacheCreation5mTokens = int(cc5m.Int())
|
usage.CacheCreation5mTokens = int(cc5m.Int())
|
||||||
}
|
}
|
||||||
if cc1h.Exists() && cc1h.Int() > 0 {
|
if cc1h.Exists() {
|
||||||
usage.CacheCreation1hTokens = int(cc1h.Int())
|
usage.CacheCreation1hTokens = int(cc1h.Int())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,20 +82,19 @@ func TestParseSSEUsage_DeltaOverwritesWithNonZero(t *testing.T) {
|
|||||||
require.Equal(t, 60, usage.CacheReadInputTokens)
|
require.Equal(t, 60, usage.CacheReadInputTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSSEUsage_DeltaDoesNotResetCacheCreationBreakdown(t *testing.T) {
|
func TestParseSSEUsage_DeltaAuthoritativelyUpdatesCacheCreationBreakdown(t *testing.T) {
|
||||||
svc := newMinimalGatewayService()
|
svc := newMinimalGatewayService()
|
||||||
usage := &ClaudeUsage{}
|
usage := &ClaudeUsage{}
|
||||||
|
|
||||||
// 先在 message_start 中写入非零 5m/1h 明细
|
svc.parseSSEUsage(`{"type":"message_start","message":{"usage":{"cache_creation_input_tokens":463184,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":463184}}}}`, usage)
|
||||||
svc.parseSSEUsage(`{"type":"message_start","message":{"usage":{"input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":30,"ephemeral_1h_input_tokens":70}}}}`, usage)
|
require.Equal(t, 463184, usage.CacheCreationInputTokens)
|
||||||
require.Equal(t, 30, usage.CacheCreation5mTokens)
|
require.Equal(t, 0, usage.CacheCreation5mTokens)
|
||||||
require.Equal(t, 70, usage.CacheCreation1hTokens)
|
require.Equal(t, 463184, usage.CacheCreation1hTokens)
|
||||||
|
|
||||||
// 后续 delta 带默认 0,不应覆盖已有非零值
|
svc.parseSSEUsage(`{"type":"message_delta","usage":{"cache_creation_input_tokens":463184,"cache_creation":{"ephemeral_5m_input_tokens":463184,"ephemeral_1h_input_tokens":0}}}`, usage)
|
||||||
svc.parseSSEUsage(`{"type":"message_delta","usage":{"output_tokens":12,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0}}}`, usage)
|
require.Equal(t, 463184, usage.CacheCreationInputTokens)
|
||||||
require.Equal(t, 30, usage.CacheCreation5mTokens, "delta 的 0 值不应重置 5m 明细")
|
require.Equal(t, 463184, usage.CacheCreation5mTokens)
|
||||||
require.Equal(t, 70, usage.CacheCreation1hTokens, "delta 的 0 值不应重置 1h 明细")
|
require.Equal(t, 0, usage.CacheCreation1hTokens)
|
||||||
require.Equal(t, 12, usage.OutputTokens)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSSEUsage_InvalidJSON(t *testing.T) {
|
func TestParseSSEUsage_InvalidJSON(t *testing.T) {
|
||||||
|
|||||||
@@ -1237,11 +1237,11 @@ func (s *GatewayService) extractSSEUsagePatch(event map[string]any) *sseUsagePat
|
|||||||
patch.hasCacheReadInput = true
|
patch.hasCacheReadInput = true
|
||||||
}
|
}
|
||||||
if cc, ok := usageObj["cache_creation"].(map[string]any); ok {
|
if cc, ok := usageObj["cache_creation"].(map[string]any); ok {
|
||||||
if v, exists := parseSSEUsageInt(cc["ephemeral_5m_input_tokens"]); exists && v > 0 {
|
if v, exists := parseSSEUsageInt(cc["ephemeral_5m_input_tokens"]); exists {
|
||||||
patch.cacheCreation5mTokens = v
|
patch.cacheCreation5mTokens = v
|
||||||
patch.hasCacheCreation5m = true
|
patch.hasCacheCreation5m = true
|
||||||
}
|
}
|
||||||
if v, exists := parseSSEUsageInt(cc["ephemeral_1h_input_tokens"]); exists && v > 0 {
|
if v, exists := parseSSEUsageInt(cc["ephemeral_1h_input_tokens"]); exists {
|
||||||
patch.cacheCreation1hTokens = v
|
patch.cacheCreation1hTokens = v
|
||||||
patch.hasCacheCreation1h = true
|
patch.hasCacheCreation1h = true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user