| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248 |
- 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)
- })
- }
- }
|