Merge pull request #6137 from yan9651688/codex/fix-cn-anthropic-usage-billing

fix(billing): normalize CN Anthropic usage tokens
This commit is contained in:
Wesley Liddick
2026-08-24 14:09:45 +08:00
committed by GitHub
5 changed files with 369 additions and 10 deletions
+14
View File
@@ -155,12 +155,26 @@ func AnthropicStopReasonString(p *string) string {
return *p
}
// AnthropicPromptTokensDetails holds OpenAI-compatible prompt token details
// occasionally included by Anthropic-compatible providers.
type AnthropicPromptTokensDetails struct {
CachedTokens int `json:"cached_tokens,omitempty"`
}
// AnthropicUsage holds token counts in Anthropic format.
type AnthropicUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
// Anthropic-compatible providers can also expose their native OpenAI-style
// total/cache fields. Preserve them so callers can normalize provider totals
// into Anthropic's mutually-exclusive billing buckets.
PromptTokens int `json:"prompt_tokens,omitempty"`
CachedTokens int `json:"cached_tokens,omitempty"`
PromptTokensDetails *AnthropicPromptTokensDetails `json:"prompt_tokens_details,omitempty"`
PromptCacheHitTokens *int `json:"prompt_cache_hit_tokens,omitempty"`
PromptCacheMissTokens *int `json:"prompt_cache_miss_tokens,omitempty"`
}
// ---------------------------------------------------------------------------
@@ -698,6 +698,72 @@ func parseSSEUsagePassthrough(data string, usage *ClaudeUsage) {
usage.CacheCreationInputTokens = int(total)
}
}
// Kimi's Anthropic-compatible stream uses input_tokens with two meanings:
// message_start reports total prompt input, while message_delta reports only
// uncached input. prompt_tokens remains the total in both events. Normalize
// to ClaudeUsage's mutually-exclusive buckets so downstream billing does not
// subtract cache tokens from an already-uncached value.
usageNode := parsed.Get("usage")
if parsed.Get("type").String() == "message_start" {
usageNode = parsed.Get("message.usage")
}
normalizeAnthropicCompatiblePromptUsage(usageNode, usage)
}
// normalizeAnthropicCompatiblePromptUsage converts provider-native OpenAI-style
// prompt/cache fields into Claude's mutually-exclusive usage buckets. Native
// Anthropic responses do not expose these aliases and are left alone.
func normalizeAnthropicCompatiblePromptUsage(usageNode gjson.Result, usage *ClaudeUsage) bool {
if usage == nil || !usageNode.Exists() {
return false
}
promptTokens := usageNode.Get("prompt_tokens")
promptCacheHitTokens := usageNode.Get("prompt_cache_hit_tokens")
promptCacheMissTokens := usageNode.Get("prompt_cache_miss_tokens")
if (!promptTokens.Exists() || promptTokens.Int() <= 0) &&
!promptCacheHitTokens.Exists() && !promptCacheMissTokens.Exists() {
return false
}
cacheReadTokens := usage.CacheReadInputTokens
if v := usageNode.Get("cache_read_input_tokens"); v.Exists() {
cacheReadTokens = int(v.Int())
}
if cacheReadTokens == 0 {
if v := usageNode.Get("cached_tokens"); v.Exists() {
cacheReadTokens = int(v.Int())
}
}
if cacheReadTokens == 0 {
if v := usageNode.Get("prompt_tokens_details.cached_tokens"); v.Exists() {
cacheReadTokens = int(v.Int())
}
}
if cacheReadTokens == 0 && promptCacheHitTokens.Exists() {
cacheReadTokens = max(int(promptCacheHitTokens.Int()), 0)
}
cacheCreationTokens := usage.CacheCreationInputTokens
if v := usageNode.Get("cache_creation_input_tokens"); v.Exists() {
cacheCreationTokens = int(v.Int())
}
if cacheCreationTokens == 0 {
cc5m := usageNode.Get("cache_creation.ephemeral_5m_input_tokens").Int()
cc1h := usageNode.Get("cache_creation.ephemeral_1h_input_tokens").Int()
if cc5m > 0 || cc1h > 0 {
cacheCreationTokens = int(cc5m + cc1h)
}
}
if promptCacheMissTokens.Exists() {
usage.InputTokens = max(int(promptCacheMissTokens.Int()), 0)
} else {
usage.InputTokens = max(int(promptTokens.Int())-cacheReadTokens-cacheCreationTokens, 0)
}
usage.CacheReadInputTokens = cacheReadTokens
usage.CacheCreationInputTokens = cacheCreationTokens
return true
}
func parseClaudeUsageFromResponseBody(body []byte) *ClaudeUsage {
@@ -731,6 +797,7 @@ func parseClaudeUsageFromResponseBody(body []byte) *ClaudeUsage {
usage.CacheReadInputTokens = int(cached)
}
}
normalizeAnthropicCompatiblePromptUsage(usageNode, usage)
return usage
}
@@ -285,18 +285,46 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) {
if dst == nil {
return
}
if src.InputTokens > 0 {
dst.InputTokens = src.InputTokens
// Some Anthropic-compatible providers retain OpenAI-style prompt/cache
// fields. Prefer those authoritative totals or hit/miss buckets over the
// overloaded input_tokens field. This covers Kimi's changing stream
// semantics as well as GLM/DeepSeek cache aliases.
if src.PromptTokens > 0 || src.PromptCacheHitTokens != nil || src.PromptCacheMissTokens != nil {
cacheReadTokens := src.CacheReadInputTokens
if cacheReadTokens == 0 && src.CachedTokens > 0 {
cacheReadTokens = src.CachedTokens
}
if cacheReadTokens == 0 && src.PromptTokensDetails != nil && src.PromptTokensDetails.CachedTokens > 0 {
cacheReadTokens = src.PromptTokensDetails.CachedTokens
}
if cacheReadTokens == 0 && src.PromptCacheHitTokens != nil {
cacheReadTokens = max(*src.PromptCacheHitTokens, 0)
}
if src.PromptCacheMissTokens != nil {
dst.InputTokens = max(*src.PromptCacheMissTokens, 0)
} else {
dst.InputTokens = max(src.PromptTokens-cacheReadTokens-src.CacheCreationInputTokens, 0)
}
dst.CacheReadInputTokens = cacheReadTokens
dst.CacheCreationInputTokens = src.CacheCreationInputTokens
} else {
if src.InputTokens > 0 {
dst.InputTokens = src.InputTokens
}
if src.CacheReadInputTokens > 0 {
dst.CacheReadInputTokens = src.CacheReadInputTokens
} else if src.CachedTokens > 0 {
dst.CacheReadInputTokens = src.CachedTokens
}
if src.CacheCreationInputTokens > 0 {
dst.CacheCreationInputTokens = src.CacheCreationInputTokens
}
}
if src.OutputTokens > 0 {
dst.OutputTokens = src.OutputTokens
}
if src.CacheReadInputTokens > 0 {
dst.CacheReadInputTokens = src.CacheReadInputTokens
}
if src.CacheCreationInputTokens > 0 {
dst.CacheCreationInputTokens = src.CacheCreationInputTokens
}
}
// parseAnthropicSSEField parses an SSE field line in the form "field:value" or "field: value".
@@ -0,0 +1,248 @@
package service
import (
"encoding/json"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/stretchr/testify/require"
)
func TestParseSSEUsagePassthroughNormalizesKimiPromptUsage(t *testing.T) {
usage := &ClaudeUsage{}
parseSSEUsagePassthrough(`{"type":"message_start","message":{"usage":{"input_tokens":173306,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"prompt_tokens":173306,"cached_tokens":0}}}`, usage)
require.Equal(t, 173306, usage.InputTokens)
require.Zero(t, usage.CacheReadInputTokens)
parseSSEUsagePassthrough(`{"type":"message_delta","usage":{"input_tokens":250,"cache_creation_input_tokens":0,"cache_read_input_tokens":173056,"output_tokens":166,"prompt_tokens":173306,"cached_tokens":173056}}`, usage)
require.Equal(t, 250, usage.InputTokens, "Kimi message_delta input_tokens is already the uncached bucket")
require.Equal(t, 173056, usage.CacheReadInputTokens)
require.Equal(t, 166, usage.OutputTokens)
}
func TestParseSSEUsagePassthroughKimiFullyCachedInputReplacesStartTotal(t *testing.T) {
usage := &ClaudeUsage{}
parseSSEUsagePassthrough(`{"type":"message_start","message":{"usage":{"input_tokens":173306,"prompt_tokens":173306}}}`, usage)
parseSSEUsagePassthrough(`{"type":"message_delta","usage":{"input_tokens":0,"cache_read_input_tokens":173306,"output_tokens":8,"prompt_tokens":173306,"cached_tokens":173306}}`, usage)
require.Zero(t, usage.InputTokens, "an explicit zero uncached bucket must not retain message_start's total")
require.Equal(t, 173306, usage.CacheReadInputTokens)
}
func TestParseClaudeUsageFromResponseBodyNormalizesCNProviderAliases(t *testing.T) {
tests := []struct {
name string
body string
wantInput int
wantCacheRead int
wantOutput int
}{
{
name: "Kimi top-level cached_tokens",
body: `{"usage":{"input_tokens":173306,"output_tokens":166,"cache_read_input_tokens":173056,"prompt_tokens":173306,"cached_tokens":173056}}`,
wantInput: 250,
wantCacheRead: 173056,
wantOutput: 166,
},
{
name: "GLM nested prompt cache details",
body: `{"usage":{"input_tokens":1200,"output_tokens":300,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}}`,
wantInput: 400,
wantCacheRead: 800,
wantOutput: 300,
},
{
name: "DeepSeek prompt cache hit and miss buckets",
body: `{"usage":{"input_tokens":1200,"output_tokens":300,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}}`,
wantInput: 400,
wantCacheRead: 800,
wantOutput: 300,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
usage := parseClaudeUsageFromResponseBody([]byte(tt.body))
require.Equal(t, tt.wantInput, usage.InputTokens)
require.Equal(t, tt.wantCacheRead, usage.CacheReadInputTokens)
require.Equal(t, tt.wantOutput, usage.OutputTokens)
})
}
}
func TestParseSSEUsagePassthroughNormalizesGLMAndDeepSeekAliases(t *testing.T) {
tests := []struct {
name string
data string
wantInput int
wantCacheRead int
}{
{
name: "GLM",
data: `{"type":"message_delta","usage":{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}}`,
wantInput: 400,
wantCacheRead: 800,
},
{
name: "DeepSeek",
data: `{"type":"message_delta","usage":{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}}`,
wantInput: 400,
wantCacheRead: 800,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
usage := &ClaudeUsage{}
parseSSEUsagePassthrough(tt.data, usage)
require.Equal(t, tt.wantInput, usage.InputTokens)
require.Equal(t, tt.wantCacheRead, usage.CacheReadInputTokens)
require.Equal(t, 30, usage.OutputTokens)
})
}
}
func TestMergeAnthropicUsageNormalizesKimiStreamForOpenAIBilling(t *testing.T) {
var start apicompat.AnthropicStreamEvent
require.NoError(t, json.Unmarshal([]byte(`{"type":"message_start","message":{"usage":{"input_tokens":173306,"prompt_tokens":173306,"cached_tokens":0}}}`), &start))
var delta apicompat.AnthropicStreamEvent
require.NoError(t, json.Unmarshal([]byte(`{"type":"message_delta","usage":{"input_tokens":250,"cache_read_input_tokens":173056,"output_tokens":166,"prompt_tokens":173306,"cached_tokens":173056}}`), &delta))
usage := &ClaudeUsage{}
mergeAnthropicUsage(usage, start.Message.Usage)
mergeAnthropicUsage(usage, *delta.Usage)
require.Equal(t, 250, usage.InputTokens)
require.Equal(t, 173056, usage.CacheReadInputTokens)
openAIUsage := claudeUsageToOpenAIUsage(usage)
require.Equal(t, 173306, openAIUsage.InputTokens, "OpenAI gateway expects an inclusive input total")
require.Equal(t, 250, openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens)
require.Equal(t, 166, openAIUsage.OutputTokens)
}
func TestMergeAnthropicUsageNormalizesGLMAndDeepSeekAliases(t *testing.T) {
tests := []struct {
name string
raw string
}{
{
name: "GLM",
raw: `{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}`,
},
{
name: "DeepSeek",
raw: `{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var src apicompat.AnthropicUsage
require.NoError(t, json.Unmarshal([]byte(tt.raw), &src))
usage := &ClaudeUsage{}
mergeAnthropicUsage(usage, src)
require.Equal(t, 400, usage.InputTokens)
require.Equal(t, 800, usage.CacheReadInputTokens)
openAIUsage := claudeUsageToOpenAIUsage(usage)
require.Equal(t, 1200, openAIUsage.InputTokens)
require.Equal(t, 400, openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens)
})
}
}
func TestClaudeUsageToOpenAIUsagePreservesCNProviderNativeAnthropicBuckets(t *testing.T) {
tests := []struct {
name string
usage ClaudeUsage
wantTotal int
wantUncached int
}{
{
name: "GLM",
usage: ClaudeUsage{
InputTokens: 2,
OutputTokens: 302,
CacheCreationInputTokens: 733,
CacheReadInputTokens: 376156,
},
wantTotal: 376891,
wantUncached: 2,
},
{
name: "DeepSeek",
usage: ClaudeUsage{
InputTokens: 400,
OutputTokens: 30,
CacheReadInputTokens: 800,
},
wantTotal: 1200,
wantUncached: 400,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
openAIUsage := claudeUsageToOpenAIUsage(&tt.usage)
require.Equal(t, tt.wantTotal, openAIUsage.InputTokens)
require.Equal(t, tt.wantUncached, openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens)
require.Equal(t, tt.usage.CacheReadInputTokens, openAIUsage.CacheReadInputTokens)
require.Equal(t, tt.usage.CacheCreationInputTokens, openAIUsage.CacheCreationInputTokens)
})
}
}
func TestCNProviderAnthropicUsageBillsUncachedInput(t *testing.T) {
tests := []struct {
name string
model string
body string
wantInput int
}{
{
name: "Kimi",
model: "k3",
body: `{"usage":{"input_tokens":173306,"output_tokens":166,"prompt_tokens":173306,"cached_tokens":173056}}`,
wantInput: 250,
},
{
name: "GLM",
model: "glm-5.2",
body: `{"usage":{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}}`,
wantInput: 400,
},
{
name: "DeepSeek",
model: "deepseek-v4-flash",
body: `{"usage":{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}}`,
wantInput: 400,
},
}
billing := NewBillingService(&config.Config{}, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
claudeUsage := parseClaudeUsageFromResponseBody([]byte(tt.body))
openAIUsage := claudeUsageToOpenAIUsage(claudeUsage)
uncachedInput := max(openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens, 0)
require.Equal(t, tt.wantInput, uncachedInput)
cost, err := billing.CalculateCost(tt.model, UsageTokens{
InputTokens: uncachedInput,
OutputTokens: openAIUsage.OutputTokens,
CacheCreationTokens: openAIUsage.CacheCreationInputTokens,
CacheReadTokens: openAIUsage.CacheReadInputTokens,
}, 1)
require.NoError(t, err)
require.Positive(t, cost.InputCost, "uncached input must contribute to the final charge")
pricing, err := billing.GetModelPricing(tt.model)
require.NoError(t, err)
require.InDelta(t, float64(tt.wantInput)*pricing.InputPricePerToken, cost.InputCost, 1e-12)
})
}
}
@@ -530,13 +530,15 @@ func (s *OpenAIGatewayService) nativeAnthropicStreamResult(
}
// claudeUsageToOpenAIUsage 把 Anthropic 格式 usage 映射到 OpenAI 网关统一的
// 用量结构(字段一一对应)。
// 用量结构。Anthropic 的 input_tokens 不含缓存读写,而 OpenAI 网关内部
// 约定 InputTokens 是包含缓存明细的总输入;这里必须先合并,RecordUsage
// 才能准确拆回互斥的计费桶。
func claudeUsageToOpenAIUsage(u *ClaudeUsage) OpenAIUsage {
if u == nil {
return OpenAIUsage{}
}
return OpenAIUsage{
InputTokens: u.InputTokens,
InputTokens: u.InputTokens + u.CacheCreationInputTokens + u.CacheReadInputTokens,
OutputTokens: u.OutputTokens,
CacheCreationInputTokens: u.CacheCreationInputTokens,
CacheReadInputTokens: u.CacheReadInputTokens,