mappers_usage_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. package dto
  2. import (
  3. "encoding/json"
  4. "testing"
  5. "github.com/Wei-Shaw/sub2api/internal/service"
  6. "github.com/stretchr/testify/require"
  7. )
  8. func TestUsageLogFromService_IncludesOpenAIWSMode(t *testing.T) {
  9. t.Parallel()
  10. wsLog := &service.UsageLog{
  11. RequestID: "req_1",
  12. Model: "gpt-5.3-codex",
  13. OpenAIWSMode: true,
  14. }
  15. httpLog := &service.UsageLog{
  16. RequestID: "resp_1",
  17. Model: "gpt-5.3-codex",
  18. OpenAIWSMode: false,
  19. }
  20. require.True(t, UsageLogFromService(wsLog).OpenAIWSMode)
  21. require.False(t, UsageLogFromService(httpLog).OpenAIWSMode)
  22. require.True(t, UsageLogFromServiceAdmin(wsLog).OpenAIWSMode)
  23. require.False(t, UsageLogFromServiceAdmin(httpLog).OpenAIWSMode)
  24. }
  25. func TestUsageLogFromService_PreservesNativeCompactionAndStream(t *testing.T) {
  26. t.Parallel()
  27. log := &service.UsageLog{
  28. RequestID: "resp_compaction",
  29. Model: "gpt-5.6-sol",
  30. RequestType: service.RequestTypeStream,
  31. Stream: true,
  32. NativeCompactionV2: true,
  33. }
  34. userDTO := UsageLogFromService(log)
  35. adminDTO := UsageLogFromServiceAdmin(log)
  36. require.Equal(t, "stream", userDTO.RequestType)
  37. require.True(t, userDTO.Stream)
  38. require.True(t, userDTO.NativeCompactionV2)
  39. require.Equal(t, "stream", adminDTO.RequestType)
  40. require.True(t, adminDTO.Stream)
  41. require.True(t, adminDTO.NativeCompactionV2)
  42. }
  43. func TestUsageLogFromService_PrefersRequestTypeForLegacyFields(t *testing.T) {
  44. t.Parallel()
  45. log := &service.UsageLog{
  46. RequestID: "req_2",
  47. Model: "gpt-5.3-codex",
  48. RequestType: service.RequestTypeWSV2,
  49. Stream: false,
  50. OpenAIWSMode: false,
  51. }
  52. userDTO := UsageLogFromService(log)
  53. adminDTO := UsageLogFromServiceAdmin(log)
  54. require.Equal(t, "ws_v2", userDTO.RequestType)
  55. require.True(t, userDTO.Stream)
  56. require.True(t, userDTO.OpenAIWSMode)
  57. require.Equal(t, "ws_v2", adminDTO.RequestType)
  58. require.True(t, adminDTO.Stream)
  59. require.True(t, adminDTO.OpenAIWSMode)
  60. }
  61. func TestUsageCleanupTaskFromService_RequestTypeMapping(t *testing.T) {
  62. t.Parallel()
  63. requestType := int16(service.RequestTypeStream)
  64. task := &service.UsageCleanupTask{
  65. ID: 1,
  66. Status: service.UsageCleanupStatusPending,
  67. Filters: service.UsageCleanupFilters{
  68. RequestType: &requestType,
  69. },
  70. }
  71. dtoTask := UsageCleanupTaskFromService(task)
  72. require.NotNil(t, dtoTask)
  73. require.NotNil(t, dtoTask.Filters.RequestType)
  74. require.Equal(t, "stream", *dtoTask.Filters.RequestType)
  75. }
  76. func TestRequestTypeStringPtrNil(t *testing.T) {
  77. t.Parallel()
  78. require.Nil(t, requestTypeStringPtr(nil))
  79. }
  80. func TestUsageLogFromService_IncludesServiceTierForUserAndAdmin(t *testing.T) {
  81. t.Parallel()
  82. serviceTier := "priority"
  83. inboundEndpoint := "/v1/chat/completions"
  84. upstreamEndpoint := "/v1/responses"
  85. log := &service.UsageLog{
  86. RequestID: "req_3",
  87. Model: "gpt-5.4",
  88. ServiceTier: &serviceTier,
  89. InboundEndpoint: &inboundEndpoint,
  90. UpstreamEndpoint: &upstreamEndpoint,
  91. AccountRateMultiplier: f64Ptr(1.5),
  92. }
  93. userDTO := UsageLogFromService(log)
  94. adminDTO := UsageLogFromServiceAdmin(log)
  95. require.NotNil(t, userDTO.ServiceTier)
  96. require.Equal(t, serviceTier, *userDTO.ServiceTier)
  97. require.NotNil(t, userDTO.InboundEndpoint)
  98. require.Equal(t, inboundEndpoint, *userDTO.InboundEndpoint)
  99. require.Nil(t, userDTO.UpstreamEndpoint)
  100. require.NotNil(t, adminDTO.ServiceTier)
  101. require.Equal(t, serviceTier, *adminDTO.ServiceTier)
  102. require.NotNil(t, adminDTO.InboundEndpoint)
  103. require.Equal(t, inboundEndpoint, *adminDTO.InboundEndpoint)
  104. require.NotNil(t, adminDTO.UpstreamEndpoint)
  105. require.Equal(t, upstreamEndpoint, *adminDTO.UpstreamEndpoint)
  106. require.NotNil(t, adminDTO.AccountRateMultiplier)
  107. require.InDelta(t, 1.5, *adminDTO.AccountRateMultiplier, 1e-12)
  108. }
  109. func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *testing.T) {
  110. t.Parallel()
  111. upstreamModel := "claude-sonnet-4-20250514"
  112. upstreamResponseModel := "claude-sonnet-4-20250513"
  113. upstreamModelMismatch := true
  114. log := &service.UsageLog{
  115. RequestID: "req_4",
  116. Model: upstreamModel,
  117. RequestedModel: "claude-sonnet-4",
  118. UpstreamModel: &upstreamModel,
  119. UpstreamResponseModel: &upstreamResponseModel,
  120. UpstreamModelMismatch: &upstreamModelMismatch,
  121. }
  122. userDTO := UsageLogFromService(log)
  123. adminDTO := UsageLogFromServiceAdmin(log)
  124. require.Equal(t, "claude-sonnet-4", userDTO.Model)
  125. require.Equal(t, "claude-sonnet-4", adminDTO.Model)
  126. userJSON, err := json.Marshal(userDTO)
  127. require.NoError(t, err)
  128. require.NotContains(t, string(userJSON), "upstream_model")
  129. require.NotContains(t, string(userJSON), "upstream_response_model")
  130. require.NotContains(t, string(userJSON), "upstream_model_mismatch")
  131. adminJSON, err := json.Marshal(adminDTO)
  132. require.NoError(t, err)
  133. require.Contains(t, string(adminJSON), `"upstream_model":"claude-sonnet-4-20250514"`)
  134. require.Contains(t, string(adminJSON), `"upstream_response_model":"claude-sonnet-4-20250513"`)
  135. require.Contains(t, string(adminJSON), `"upstream_model_mismatch":true`)
  136. }
  137. func TestUsageLogFromService_KeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) {
  138. t.Parallel()
  139. ipAddress := "203.0.113.10"
  140. accountRateMultiplier := 1.5
  141. accountStatsCost := 0.21
  142. log := &service.UsageLog{
  143. RequestID: "req_user_visible_billing",
  144. Model: "gpt-5.4",
  145. InputCost: 0.01,
  146. OutputCost: 0.02,
  147. CacheCreationCost: 0.03,
  148. CacheReadCost: 0.04,
  149. TotalCost: 0.10,
  150. ActualCost: 0.08,
  151. RateMultiplier: 0.8,
  152. IPAddress: &ipAddress,
  153. AccountRateMultiplier: &accountRateMultiplier,
  154. AccountStatsCost: &accountStatsCost,
  155. }
  156. userDTO := UsageLogFromService(log)
  157. require.Equal(t, 0.01, userDTO.InputCost)
  158. require.Equal(t, 0.02, userDTO.OutputCost)
  159. require.Equal(t, 0.03, userDTO.CacheCreationCost)
  160. require.Equal(t, 0.04, userDTO.CacheReadCost)
  161. require.Equal(t, 0.10, userDTO.TotalCost)
  162. require.Equal(t, 0.08, userDTO.ActualCost)
  163. require.Equal(t, 0.8, userDTO.RateMultiplier)
  164. require.NotNil(t, userDTO.IPAddress)
  165. require.Equal(t, ipAddress, *userDTO.IPAddress)
  166. userJSON, err := json.Marshal(userDTO)
  167. require.NoError(t, err)
  168. require.NotContains(t, string(userJSON), "account_rate_multiplier")
  169. require.NotContains(t, string(userJSON), "account_stats_cost")
  170. require.NotContains(t, string(userJSON), "account_cost")
  171. }
  172. func TestUsageLogFromService_UsersSeeRequestedReasoningEffortOnly(t *testing.T) {
  173. t.Parallel()
  174. requested := "max"
  175. forwarded := "xhigh"
  176. log := &service.UsageLog{
  177. RequestID: "req_effort",
  178. Model: "gpt-5.4",
  179. ReasoningEffort: &forwarded,
  180. RequestedReasoningEffort: &requested,
  181. }
  182. userDTO := UsageLogFromService(log)
  183. adminDTO := UsageLogFromServiceAdmin(log)
  184. require.NotNil(t, userDTO.ReasoningEffort)
  185. require.Equal(t, requested, *userDTO.ReasoningEffort)
  186. require.NotNil(t, adminDTO.ReasoningEffort)
  187. require.Equal(t, requested, *adminDTO.ReasoningEffort)
  188. require.NotNil(t, adminDTO.UpstreamReasoningEffort)
  189. require.Equal(t, forwarded, *adminDTO.UpstreamReasoningEffort)
  190. userJSON, err := json.Marshal(userDTO)
  191. require.NoError(t, err)
  192. require.Contains(t, string(userJSON), `"reasoning_effort":"max"`)
  193. require.NotContains(t, string(userJSON), "upstream_reasoning_effort")
  194. require.NotContains(t, string(userJSON), "requested_reasoning_effort")
  195. adminJSON, err := json.Marshal(adminDTO)
  196. require.NoError(t, err)
  197. require.Contains(t, string(adminJSON), `"reasoning_effort":"max"`)
  198. require.Contains(t, string(adminJSON), `"upstream_reasoning_effort":"xhigh"`)
  199. }
  200. func TestUsageLogFromService_OmitsUpstreamReasoningEffortWhenUnmapped(t *testing.T) {
  201. t.Parallel()
  202. effort := "high"
  203. log := &service.UsageLog{
  204. RequestID: "req_effort_same",
  205. Model: "gpt-5.4",
  206. ReasoningEffort: &effort,
  207. RequestedReasoningEffort: &effort,
  208. }
  209. adminDTO := UsageLogFromServiceAdmin(log)
  210. require.NotNil(t, adminDTO.ReasoningEffort)
  211. require.Equal(t, effort, *adminDTO.ReasoningEffort)
  212. require.Nil(t, adminDTO.UpstreamReasoningEffort)
  213. adminJSON, err := json.Marshal(adminDTO)
  214. require.NoError(t, err)
  215. require.NotContains(t, string(adminJSON), "upstream_reasoning_effort")
  216. }
  217. func TestUsageLogFromService_FallsBackToLegacyModelWhenRequestedModelMissing(t *testing.T) {
  218. t.Parallel()
  219. log := &service.UsageLog{
  220. RequestID: "req_legacy",
  221. Model: "claude-3",
  222. }
  223. userDTO := UsageLogFromService(log)
  224. adminDTO := UsageLogFromServiceAdmin(log)
  225. require.Equal(t, "claude-3", userDTO.Model)
  226. require.Equal(t, "claude-3", adminDTO.Model)
  227. }
  228. func TestUsageLogFromService_IncludesImageBillingMetadataForUserAndAdmin(t *testing.T) {
  229. t.Parallel()
  230. imageSize := "4K"
  231. inputSize := "1024x1024"
  232. outputSize := "3840x2160"
  233. source := "output"
  234. log := &service.UsageLog{
  235. RequestID: "req_image_metadata",
  236. Model: "gpt-image-2",
  237. ImageCount: 2,
  238. ImageSize: &imageSize,
  239. ImageInputSize: &inputSize,
  240. ImageOutputSize: &outputSize,
  241. ImageSizeSource: &source,
  242. ImageSizeBreakdown: map[string]int{"4K": 2},
  243. }
  244. userDTO := UsageLogFromService(log)
  245. adminDTO := UsageLogFromServiceAdmin(log)
  246. for _, got := range []*UsageLog{userDTO, &adminDTO.UsageLog} {
  247. require.Equal(t, 2, got.ImageCount)
  248. require.NotNil(t, got.ImageSize)
  249. require.Equal(t, imageSize, *got.ImageSize)
  250. require.NotNil(t, got.ImageInputSize)
  251. require.Equal(t, inputSize, *got.ImageInputSize)
  252. require.NotNil(t, got.ImageOutputSize)
  253. require.Equal(t, outputSize, *got.ImageOutputSize)
  254. require.NotNil(t, got.ImageSizeSource)
  255. require.Equal(t, source, *got.ImageSizeSource)
  256. require.Equal(t, map[string]int{"4K": 2}, got.ImageSizeBreakdown)
  257. }
  258. }
  259. func TestUsageLogFromService_PreservesHistoricalMissingImageSize(t *testing.T) {
  260. t.Parallel()
  261. log := &service.UsageLog{
  262. RequestID: "req_legacy_image_missing_size",
  263. Model: "gpt-image-2",
  264. ImageCount: 1,
  265. ImageSize: nil,
  266. }
  267. dto := UsageLogFromService(log)
  268. require.Equal(t, 1, dto.ImageCount)
  269. require.Nil(t, dto.ImageSize)
  270. require.Nil(t, dto.ImageInputSize)
  271. require.Nil(t, dto.ImageOutputSize)
  272. require.Nil(t, dto.ImageSizeSource)
  273. require.Nil(t, dto.ImageSizeBreakdown)
  274. body, err := json.Marshal(dto)
  275. require.NoError(t, err)
  276. require.Contains(t, string(body), `"image_size":null`)
  277. require.NotContains(t, string(body), `"image_size":"2K"`)
  278. }
  279. func f64Ptr(value float64) *float64 {
  280. return &value
  281. }