gemini_image_output_accounting_test.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. //go:build unit
  2. package service
  3. import (
  4. "io"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "testing"
  9. "github.com/gin-gonic/gin"
  10. "github.com/stretchr/testify/require"
  11. )
  12. const geminiTestPNG = "iVBORw0KGgoAAAANSUhEUg=="
  13. func newGeminiImageTestContext(t *testing.T) *gin.Context {
  14. t.Helper()
  15. gin.SetMode(gin.TestMode)
  16. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  17. c.Request = httptest.NewRequest(http.MethodPost,
  18. "/v1beta/models/nana-banana-2:generateContent", strings.NewReader("{}"))
  19. return c
  20. }
  21. func geminiImageResponse(parts string) string {
  22. return `{"candidates":[{"content":{"role":"model","parts":[` + parts + `]},"finishReason":"STOP"}]}`
  23. }
  24. func TestCountGeminiInlineImageOutputs(t *testing.T) {
  25. cases := []struct {
  26. name string
  27. payload string
  28. want int
  29. }{
  30. {
  31. name: "camelCase inlineData",
  32. payload: geminiImageResponse(`{"inlineData":{"mimeType":"image/png","data":"` + geminiTestPNG + `"}}`),
  33. want: 1,
  34. },
  35. {
  36. // 官方 SDK 与部分中转把字段回成 snake_case。
  37. name: "snake_case inline_data",
  38. payload: geminiImageResponse(`{"inline_data":{"mime_type":"image/png","data":"` + geminiTestPNG + `"}}`),
  39. want: 1,
  40. },
  41. {
  42. name: "text and image mixed",
  43. payload: geminiImageResponse(`{"text":"here you go"},` +
  44. `{"inlineData":{"mimeType":"image/jpeg","data":"` + geminiTestPNG + `"}}`),
  45. want: 1,
  46. },
  47. {
  48. name: "multiple images",
  49. payload: geminiImageResponse(`{"inlineData":{"mimeType":"image/png","data":"` + geminiTestPNG + `"}},` +
  50. `{"inlineData":{"mimeType":"image/webp","data":"` + geminiTestPNG + `"}}`),
  51. want: 2,
  52. },
  53. {
  54. name: "uppercase mime type",
  55. payload: geminiImageResponse(`{"inlineData":{"mimeType":"IMAGE/PNG","data":"` + geminiTestPNG + `"}}`),
  56. want: 1,
  57. },
  58. {
  59. name: "text only",
  60. payload: geminiImageResponse(`{"text":"no image here"}`),
  61. want: 0,
  62. },
  63. {
  64. // 非图片的内联附件(例如音频)不能按图片计费。
  65. name: "non image mime type",
  66. payload: geminiImageResponse(`{"inlineData":{"mimeType":"audio/mpeg","data":"` + geminiTestPNG + `"}}`),
  67. want: 0,
  68. },
  69. {
  70. name: "empty data is not billable",
  71. payload: geminiImageResponse(`{"inlineData":{"mimeType":"image/png","data":""}}`),
  72. want: 0,
  73. },
  74. {name: "empty payload", payload: "", want: 0},
  75. {name: "invalid json", payload: "not-json", want: 0},
  76. {name: "error response", payload: `{"error":{"code":429,"message":"quota"}}`, want: 0},
  77. }
  78. for _, tc := range cases {
  79. t.Run(tc.name, func(t *testing.T) {
  80. require.Equal(t, tc.want, countGeminiInlineImageOutputs([]byte(tc.payload)))
  81. })
  82. }
  83. }
  84. // 累积式 SSE 会把同一张图在后续 chunk 里整段重发,逐 chunk 累加会重复计费。
  85. // 计数器取单个 payload 内的最大值,正是为了挡住这一点。
  86. func TestObserveGeminiImageOutputs_CumulativeChunksDoNotDoubleCount(t *testing.T) {
  87. c := newGeminiImageTestContext(t)
  88. beginGeminiImageOutputObservation(c)
  89. oneImage := geminiImageResponse(`{"inlineData":{"mimeType":"image/png","data":"` + geminiTestPNG + `"}}`)
  90. for range 4 {
  91. observeGeminiImageOutputs(c, []byte(oneImage))
  92. }
  93. require.Equal(t, 1, observedGeminiImageOutputs(c))
  94. }
  95. func TestObserveGeminiImageOutputs_KeepsLargestChunk(t *testing.T) {
  96. c := newGeminiImageTestContext(t)
  97. beginGeminiImageOutputObservation(c)
  98. observeGeminiImageOutputs(c, []byte(geminiImageResponse(`{"text":"working"}`)))
  99. observeGeminiImageOutputs(c, []byte(geminiImageResponse(
  100. `{"inlineData":{"mimeType":"image/png","data":"`+geminiTestPNG+`"}},`+
  101. `{"inlineData":{"mimeType":"image/png","data":"`+geminiTestPNG+`"}}`)))
  102. // 收尾 chunk 只带 usageMetadata,不能把已数到的张数抹掉。
  103. observeGeminiImageOutputs(c, []byte(`{"usageMetadata":{"promptTokenCount":9}}`))
  104. require.Equal(t, 2, observedGeminiImageOutputs(c))
  105. }
  106. // failover 会拿同一个 gin.Context 重跑 Forward,计数器必须按次重置,
  107. // 否则失败账号已经回吐的图会被叠加到成功账号的账单上。
  108. func TestBeginGeminiImageOutputObservation_ResetsPerForward(t *testing.T) {
  109. c := newGeminiImageTestContext(t)
  110. oneImage := []byte(geminiImageResponse(`{"inlineData":{"mimeType":"image/png","data":"` + geminiTestPNG + `"}}`))
  111. beginGeminiImageOutputObservation(c)
  112. observeGeminiImageOutputs(c, oneImage)
  113. require.Equal(t, 1, observedGeminiImageOutputs(c))
  114. beginGeminiImageOutputObservation(c)
  115. require.Equal(t, 0, observedGeminiImageOutputs(c))
  116. observeGeminiImageOutputs(c, oneImage)
  117. require.Equal(t, 1, observedGeminiImageOutputs(c))
  118. }
  119. // issue #5358:自定义模型名(客户端名与上游映射名都不在白名单里)走 Gemini 原生
  120. // generateContent 生图,改动前 ImageCount 恒为 0,calculateRecordUsageCost 的按次
  121. // 计费分支整条不触发,四次生图全部记 $0。
  122. func TestResolveGeminiImageCount(t *testing.T) {
  123. oneImage := []byte(geminiImageResponse(`{"inlineData":{"mimeType":"image/png","data":"` + geminiTestPNG + `"}}`))
  124. textOnly := []byte(geminiImageResponse(`{"text":"hello"}`))
  125. t.Run("custom model name bills by observed images", func(t *testing.T) {
  126. c := newGeminiImageTestContext(t)
  127. beginGeminiImageOutputObservation(c)
  128. observeGeminiImageOutputs(c, oneImage)
  129. require.False(t, isImageGenerationModel("nana-banana-2"), "前置条件:白名单判不出自定义名")
  130. require.Equal(t, 1, resolveGeminiImageCount(c, "nana-banana-2", "nana-banana-2"))
  131. })
  132. t.Run("falls back to requested model name", func(t *testing.T) {
  133. c := newGeminiImageTestContext(t)
  134. beginGeminiImageOutputObservation(c)
  135. observeGeminiImageOutputs(c, textOnly)
  136. require.Equal(t, 1, resolveGeminiImageCount(c, "gemini-3-pro-image-preview", "gemini-3-pro-image-preview"))
  137. })
  138. t.Run("falls back to mapped upstream model name", func(t *testing.T) {
  139. c := newGeminiImageTestContext(t)
  140. beginGeminiImageOutputObservation(c)
  141. observeGeminiImageOutputs(c, textOnly)
  142. require.Equal(t, 1, resolveGeminiImageCount(c, "my-image-alias", "gemini-2.5-flash-image"))
  143. })
  144. t.Run("text model stays unbilled", func(t *testing.T) {
  145. c := newGeminiImageTestContext(t)
  146. beginGeminiImageOutputObservation(c)
  147. observeGeminiImageOutputs(c, textOnly)
  148. require.Equal(t, 0, resolveGeminiImageCount(c, "gemini-2.5-pro", "gemini-2.5-pro"))
  149. })
  150. t.Run("no counter on context degrades to name heuristic", func(t *testing.T) {
  151. c := newGeminiImageTestContext(t)
  152. require.Equal(t, 0, resolveGeminiImageCount(c, "nana-banana-2", "nana-banana-2"))
  153. require.Equal(t, 1, resolveGeminiImageCount(c, "gemini-3-pro-image", "gemini-3-pro-image"))
  154. })
  155. }
  156. // 端到端守住接线:/v1beta/models/{model}:generateContent 的非流式响应体
  157. // 必须真的喂进计数器,否则上面的单测全绿而线上依然记 $0。
  158. func TestHandleNativeNonStreamingResponse_FeedsImageCounter(t *testing.T) {
  159. c := newGeminiImageTestContext(t)
  160. beginGeminiImageOutputObservation(c)
  161. body := geminiImageResponse(`{"inlineData":{"mimeType":"image/png","data":"` + geminiTestPNG + `"}}`)
  162. resp := &http.Response{
  163. StatusCode: http.StatusOK,
  164. Header: http.Header{"Content-Type": []string{"application/json"}},
  165. Body: io.NopCloser(strings.NewReader(body)),
  166. }
  167. svc := &GeminiMessagesCompatService{}
  168. usage, err := svc.handleNativeNonStreamingResponse(c, resp, false)
  169. require.NoError(t, err)
  170. require.NotNil(t, usage)
  171. require.Equal(t, 1, observedGeminiImageOutputs(c))
  172. require.Equal(t, 1, resolveGeminiImageCount(c, "nana-banana-2", "nana-banana-2"))
  173. }