mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-01 16:29:29 +00:00
249 lines
8.8 KiB
Go
249 lines
8.8 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|