From d7da9485ebdc9040bd7e49f5c1903eae20f40628 Mon Sep 17 00:00:00 2001 From: majiayu000 <1835304752@qq.com> Date: Sat, 21 Mar 2026 21:04:45 +0800 Subject: [PATCH 1/2] fix: recognize non-standard cache tokens from OpenAI-compatible channels - Change CachedCreationTokens json tag from "-" to "cache_creation_input_tokens" so providers like Qwen can deserialize this field naturally from prompt_tokens_details. - Add default case in applyUsagePostProcessing to handle generic OpenAI-compatible channels: extract cached_tokens (StepFun) and cache_creation_input_tokens from response body when not already populated via standard fields. - Add extractCacheCreationTokensFromBody helper following the existing extractCachedTokensFromBody pattern. Closes #3309 Signed-off-by: majiayu000 <1835304752@qq.com> --- dto/openai_response.go | 2 +- relay/channel/openai/relay-openai.go | 36 ++++++++ relay/channel/openai/relay_openai_test.go | 103 ++++++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 relay/channel/openai/relay_openai_test.go diff --git a/dto/openai_response.go b/dto/openai_response.go index 1378c4f6a95c..f4afed8e47d8 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -251,7 +251,7 @@ type OpenAIVideoResponse struct { type InputTokenDetails struct { CachedTokens int `json:"cached_tokens"` - CachedCreationTokens int `json:"-"` + CachedCreationTokens int `json:"cache_creation_input_tokens"` TextTokens int `json:"text_tokens"` AudioTokens int `json:"audio_tokens"` ImageTokens int `json:"image_tokens"` diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index a4de16112956..a5a32bac40b1 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -627,6 +627,19 @@ func applyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, res usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens } } + default: + if usage.PromptTokensDetails.CachedTokens == 0 { + if usage.PromptCacheHitTokens > 0 { + usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens + } else if cachedTokens, ok := extractCachedTokensFromBody(responseBody); ok { + usage.PromptTokensDetails.CachedTokens = cachedTokens + } + } + if usage.PromptTokensDetails.CachedCreationTokens == 0 { + if cacheCreationTokens, ok := extractCacheCreationTokensFromBody(responseBody); ok { + usage.PromptTokensDetails.CachedCreationTokens = cacheCreationTokens + } + } } } @@ -661,6 +674,29 @@ func extractCachedTokensFromBody(body []byte) (int, bool) { return 0, false } +func extractCacheCreationTokensFromBody(body []byte) (int, bool) { + if len(body) == 0 { + return 0, false + } + + var payload struct { + Usage struct { + PromptTokensDetails struct { + CacheCreationInputTokens *int `json:"cache_creation_input_tokens"` + } `json:"prompt_tokens_details"` + } `json:"usage"` + } + + if err := common.Unmarshal(body, &payload); err != nil { + return 0, false + } + + if payload.Usage.PromptTokensDetails.CacheCreationInputTokens != nil { + return *payload.Usage.PromptTokensDetails.CacheCreationInputTokens, true + } + return 0, false +} + // extractMoonshotCachedTokensFromBody 从Moonshot的非标准位置提取cached_tokens // Moonshot的流式响应格式: {"choices":[{"usage":{"cached_tokens":111}}]} func extractMoonshotCachedTokensFromBody(body []byte) (int, bool) { diff --git a/relay/channel/openai/relay_openai_test.go b/relay/channel/openai/relay_openai_test.go new file mode 100644 index 000000000000..ee96be29cb50 --- /dev/null +++ b/relay/channel/openai/relay_openai_test.go @@ -0,0 +1,103 @@ +package openai + +import ( + "testing" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" +) + +func newRelayInfo(channelType int) *relaycommon.RelayInfo { + return &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ChannelType: channelType}, + } +} + +func TestApplyUsagePostProcessing_DefaultCase_PromptCacheHitTokens(t *testing.T) { + info := newRelayInfo(0) + usage := &dto.Usage{ + PromptCacheHitTokens: 200, + } + applyUsagePostProcessing(info, usage, nil) + if usage.PromptTokensDetails.CachedTokens != 200 { + t.Errorf("CachedTokens = %d, want 200", usage.PromptTokensDetails.CachedTokens) + } +} + +func TestApplyUsagePostProcessing_DefaultCase_StepFunCachedTokens(t *testing.T) { + body := []byte(`{"usage":{"cached_tokens":150}}`) + info := newRelayInfo(0) + usage := &dto.Usage{} + applyUsagePostProcessing(info, usage, body) + if usage.PromptTokensDetails.CachedTokens != 150 { + t.Errorf("CachedTokens = %d, want 150", usage.PromptTokensDetails.CachedTokens) + } +} + +func TestApplyUsagePostProcessing_DefaultCase_CacheCreationTokens(t *testing.T) { + body := []byte(`{"usage":{"prompt_tokens_details":{"cache_creation_input_tokens":300}}}`) + info := newRelayInfo(0) + usage := &dto.Usage{} + applyUsagePostProcessing(info, usage, body) + if usage.PromptTokensDetails.CachedCreationTokens != 300 { + t.Errorf("CachedCreationTokens = %d, want 300", usage.PromptTokensDetails.CachedCreationTokens) + } +} + +func TestApplyUsagePostProcessing_DefaultCase_AlreadyPopulated(t *testing.T) { + body := []byte(`{"usage":{"cached_tokens":999,"prompt_tokens_details":{"cache_creation_input_tokens":999}}}`) + info := newRelayInfo(0) + usage := &dto.Usage{ + PromptTokensDetails: dto.InputTokenDetails{ + CachedTokens: 100, + CachedCreationTokens: 50, + }, + } + applyUsagePostProcessing(info, usage, body) + if usage.PromptTokensDetails.CachedTokens != 100 { + t.Errorf("CachedTokens = %d, want 100 (should not overwrite)", usage.PromptTokensDetails.CachedTokens) + } + if usage.PromptTokensDetails.CachedCreationTokens != 50 { + t.Errorf("CachedCreationTokens = %d, want 50 (should not overwrite)", usage.PromptTokensDetails.CachedCreationTokens) + } +} + +func TestApplyUsagePostProcessing_DeepSeek_Unaffected(t *testing.T) { + info := newRelayInfo(constant.ChannelTypeDeepSeek) + usage := &dto.Usage{ + PromptCacheHitTokens: 500, + } + applyUsagePostProcessing(info, usage, nil) + if usage.PromptTokensDetails.CachedTokens != 500 { + t.Errorf("CachedTokens = %d, want 500", usage.PromptTokensDetails.CachedTokens) + } +} + +func TestExtractCacheCreationTokensFromBody(t *testing.T) { + body := []byte(`{"usage":{"prompt_tokens_details":{"cache_creation_input_tokens":42}}}`) + tokens, ok := extractCacheCreationTokensFromBody(body) + if !ok { + t.Fatal("expected ok=true") + } + if tokens != 42 { + t.Errorf("tokens = %d, want 42", tokens) + } +} + +func TestExtractCacheCreationTokensFromBody_Empty(t *testing.T) { + tokens, ok := extractCacheCreationTokensFromBody(nil) + if ok { + t.Errorf("expected ok=false for nil body, got tokens=%d", tokens) + } + + tokens, ok = extractCacheCreationTokensFromBody([]byte(`{}`)) + if ok { + t.Errorf("expected ok=false for empty JSON, got tokens=%d", tokens) + } + + tokens, ok = extractCacheCreationTokensFromBody([]byte(`invalid`)) + if ok { + t.Errorf("expected ok=false for invalid JSON, got tokens=%d", tokens) + } +} From c825318950f9fa8b5961a805c1d2fbfc1724f4ac Mon Sep 17 00:00:00 2001 From: majiayu000 <1835304752@qq.com> Date: Sat, 21 Mar 2026 21:46:16 +0800 Subject: [PATCH 2/2] test: add priority verification test for PromptCacheHitTokens over body cached_tokens Signed-off-by: majiayu000 <1835304752@qq.com> --- relay/channel/openai/relay_openai_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/relay/channel/openai/relay_openai_test.go b/relay/channel/openai/relay_openai_test.go index ee96be29cb50..20ea72d23abb 100644 --- a/relay/channel/openai/relay_openai_test.go +++ b/relay/channel/openai/relay_openai_test.go @@ -35,6 +35,18 @@ func TestApplyUsagePostProcessing_DefaultCase_StepFunCachedTokens(t *testing.T) } } +func TestApplyUsagePostProcessing_DefaultCase_PrefersPromptCacheHitTokens(t *testing.T) { + body := []byte(`{"usage":{"cached_tokens":100}}`) + info := newRelayInfo(0) + usage := &dto.Usage{ + PromptCacheHitTokens: 200, + } + applyUsagePostProcessing(info, usage, body) + if usage.PromptTokensDetails.CachedTokens != 200 { + t.Errorf("CachedTokens = %d, want 200 (should prefer PromptCacheHitTokens over body)", usage.PromptTokensDetails.CachedTokens) + } +} + func TestApplyUsagePostProcessing_DefaultCase_CacheCreationTokens(t *testing.T) { body := []byte(`{"usage":{"prompt_tokens_details":{"cache_creation_input_tokens":300}}}`) info := newRelayInfo(0)