kimi_anthropic_usage_test.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. package service
  2. import (
  3. "encoding/json"
  4. "testing"
  5. "github.com/Wei-Shaw/sub2api/internal/config"
  6. "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
  7. "github.com/stretchr/testify/require"
  8. )
  9. func TestParseSSEUsagePassthroughNormalizesKimiPromptUsage(t *testing.T) {
  10. usage := &ClaudeUsage{}
  11. 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)
  12. require.Equal(t, 173306, usage.InputTokens)
  13. require.Zero(t, usage.CacheReadInputTokens)
  14. 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)
  15. require.Equal(t, 250, usage.InputTokens, "Kimi message_delta input_tokens is already the uncached bucket")
  16. require.Equal(t, 173056, usage.CacheReadInputTokens)
  17. require.Equal(t, 166, usage.OutputTokens)
  18. }
  19. func TestParseSSEUsagePassthroughKimiFullyCachedInputReplacesStartTotal(t *testing.T) {
  20. usage := &ClaudeUsage{}
  21. parseSSEUsagePassthrough(`{"type":"message_start","message":{"usage":{"input_tokens":173306,"prompt_tokens":173306}}}`, usage)
  22. parseSSEUsagePassthrough(`{"type":"message_delta","usage":{"input_tokens":0,"cache_read_input_tokens":173306,"output_tokens":8,"prompt_tokens":173306,"cached_tokens":173306}}`, usage)
  23. require.Zero(t, usage.InputTokens, "an explicit zero uncached bucket must not retain message_start's total")
  24. require.Equal(t, 173306, usage.CacheReadInputTokens)
  25. }
  26. func TestParseClaudeUsageFromResponseBodyNormalizesCNProviderAliases(t *testing.T) {
  27. tests := []struct {
  28. name string
  29. body string
  30. wantInput int
  31. wantCacheRead int
  32. wantOutput int
  33. }{
  34. {
  35. name: "Kimi top-level cached_tokens",
  36. body: `{"usage":{"input_tokens":173306,"output_tokens":166,"cache_read_input_tokens":173056,"prompt_tokens":173306,"cached_tokens":173056}}`,
  37. wantInput: 250,
  38. wantCacheRead: 173056,
  39. wantOutput: 166,
  40. },
  41. {
  42. name: "GLM nested prompt cache details",
  43. body: `{"usage":{"input_tokens":1200,"output_tokens":300,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}}`,
  44. wantInput: 400,
  45. wantCacheRead: 800,
  46. wantOutput: 300,
  47. },
  48. {
  49. name: "DeepSeek prompt cache hit and miss buckets",
  50. body: `{"usage":{"input_tokens":1200,"output_tokens":300,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}}`,
  51. wantInput: 400,
  52. wantCacheRead: 800,
  53. wantOutput: 300,
  54. },
  55. }
  56. for _, tt := range tests {
  57. t.Run(tt.name, func(t *testing.T) {
  58. usage := parseClaudeUsageFromResponseBody([]byte(tt.body))
  59. require.Equal(t, tt.wantInput, usage.InputTokens)
  60. require.Equal(t, tt.wantCacheRead, usage.CacheReadInputTokens)
  61. require.Equal(t, tt.wantOutput, usage.OutputTokens)
  62. })
  63. }
  64. }
  65. func TestParseSSEUsagePassthroughNormalizesGLMAndDeepSeekAliases(t *testing.T) {
  66. tests := []struct {
  67. name string
  68. data string
  69. wantInput int
  70. wantCacheRead int
  71. }{
  72. {
  73. name: "GLM",
  74. data: `{"type":"message_delta","usage":{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}}`,
  75. wantInput: 400,
  76. wantCacheRead: 800,
  77. },
  78. {
  79. name: "DeepSeek",
  80. data: `{"type":"message_delta","usage":{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}}`,
  81. wantInput: 400,
  82. wantCacheRead: 800,
  83. },
  84. }
  85. for _, tt := range tests {
  86. t.Run(tt.name, func(t *testing.T) {
  87. usage := &ClaudeUsage{}
  88. parseSSEUsagePassthrough(tt.data, usage)
  89. require.Equal(t, tt.wantInput, usage.InputTokens)
  90. require.Equal(t, tt.wantCacheRead, usage.CacheReadInputTokens)
  91. require.Equal(t, 30, usage.OutputTokens)
  92. })
  93. }
  94. }
  95. func TestMergeAnthropicUsageNormalizesKimiStreamForOpenAIBilling(t *testing.T) {
  96. var start apicompat.AnthropicStreamEvent
  97. require.NoError(t, json.Unmarshal([]byte(`{"type":"message_start","message":{"usage":{"input_tokens":173306,"prompt_tokens":173306,"cached_tokens":0}}}`), &start))
  98. var delta apicompat.AnthropicStreamEvent
  99. 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))
  100. usage := &ClaudeUsage{}
  101. mergeAnthropicUsage(usage, start.Message.Usage)
  102. mergeAnthropicUsage(usage, *delta.Usage)
  103. require.Equal(t, 250, usage.InputTokens)
  104. require.Equal(t, 173056, usage.CacheReadInputTokens)
  105. openAIUsage := claudeUsageToOpenAIUsage(usage)
  106. require.Equal(t, 173306, openAIUsage.InputTokens, "OpenAI gateway expects an inclusive input total")
  107. require.Equal(t, 250, openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens)
  108. require.Equal(t, 166, openAIUsage.OutputTokens)
  109. }
  110. func TestMergeAnthropicUsageNormalizesGLMAndDeepSeekAliases(t *testing.T) {
  111. tests := []struct {
  112. name string
  113. raw string
  114. }{
  115. {
  116. name: "GLM",
  117. raw: `{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}`,
  118. },
  119. {
  120. name: "DeepSeek",
  121. raw: `{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}`,
  122. },
  123. }
  124. for _, tt := range tests {
  125. t.Run(tt.name, func(t *testing.T) {
  126. var src apicompat.AnthropicUsage
  127. require.NoError(t, json.Unmarshal([]byte(tt.raw), &src))
  128. usage := &ClaudeUsage{}
  129. mergeAnthropicUsage(usage, src)
  130. require.Equal(t, 400, usage.InputTokens)
  131. require.Equal(t, 800, usage.CacheReadInputTokens)
  132. openAIUsage := claudeUsageToOpenAIUsage(usage)
  133. require.Equal(t, 1200, openAIUsage.InputTokens)
  134. require.Equal(t, 400, openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens)
  135. })
  136. }
  137. }
  138. func TestClaudeUsageToOpenAIUsagePreservesCNProviderNativeAnthropicBuckets(t *testing.T) {
  139. tests := []struct {
  140. name string
  141. usage ClaudeUsage
  142. wantTotal int
  143. wantUncached int
  144. }{
  145. {
  146. name: "GLM",
  147. usage: ClaudeUsage{
  148. InputTokens: 2,
  149. OutputTokens: 302,
  150. CacheCreationInputTokens: 733,
  151. CacheReadInputTokens: 376156,
  152. },
  153. wantTotal: 376891,
  154. wantUncached: 2,
  155. },
  156. {
  157. name: "DeepSeek",
  158. usage: ClaudeUsage{
  159. InputTokens: 400,
  160. OutputTokens: 30,
  161. CacheReadInputTokens: 800,
  162. },
  163. wantTotal: 1200,
  164. wantUncached: 400,
  165. },
  166. }
  167. for _, tt := range tests {
  168. t.Run(tt.name, func(t *testing.T) {
  169. openAIUsage := claudeUsageToOpenAIUsage(&tt.usage)
  170. require.Equal(t, tt.wantTotal, openAIUsage.InputTokens)
  171. require.Equal(t, tt.wantUncached, openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens)
  172. require.Equal(t, tt.usage.CacheReadInputTokens, openAIUsage.CacheReadInputTokens)
  173. require.Equal(t, tt.usage.CacheCreationInputTokens, openAIUsage.CacheCreationInputTokens)
  174. })
  175. }
  176. }
  177. func TestCNProviderAnthropicUsageBillsUncachedInput(t *testing.T) {
  178. tests := []struct {
  179. name string
  180. model string
  181. body string
  182. wantInput int
  183. }{
  184. {
  185. name: "Kimi",
  186. model: "k3",
  187. body: `{"usage":{"input_tokens":173306,"output_tokens":166,"prompt_tokens":173306,"cached_tokens":173056}}`,
  188. wantInput: 250,
  189. },
  190. {
  191. name: "GLM",
  192. model: "glm-5.2",
  193. body: `{"usage":{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}}`,
  194. wantInput: 400,
  195. },
  196. {
  197. name: "DeepSeek",
  198. model: "deepseek-v4-flash",
  199. body: `{"usage":{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}}`,
  200. wantInput: 400,
  201. },
  202. }
  203. billing := NewBillingService(&config.Config{}, nil)
  204. for _, tt := range tests {
  205. t.Run(tt.name, func(t *testing.T) {
  206. claudeUsage := parseClaudeUsageFromResponseBody([]byte(tt.body))
  207. openAIUsage := claudeUsageToOpenAIUsage(claudeUsage)
  208. uncachedInput := max(openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens, 0)
  209. require.Equal(t, tt.wantInput, uncachedInput)
  210. cost, err := billing.CalculateCost(tt.model, UsageTokens{
  211. InputTokens: uncachedInput,
  212. OutputTokens: openAIUsage.OutputTokens,
  213. CacheCreationTokens: openAIUsage.CacheCreationInputTokens,
  214. CacheReadTokens: openAIUsage.CacheReadInputTokens,
  215. }, 1)
  216. require.NoError(t, err)
  217. require.Positive(t, cost.InputCost, "uncached input must contribute to the final charge")
  218. pricing, err := billing.GetModelPricing(tt.model)
  219. require.NoError(t, err)
  220. require.InDelta(t, float64(tt.wantInput)*pricing.InputPricePerToken, cost.InputCost, 1e-12)
  221. })
  222. }
  223. }