gemini_image_output_accounting.go 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. package service
  2. import (
  3. "strings"
  4. "github.com/gin-gonic/gin"
  5. "github.com/tidwall/gjson"
  6. )
  7. // geminiImageOutputCounterKey 是请求级内联图片计数器挂在 gin.Context 上的键。
  8. const geminiImageOutputCounterKey = "gemini_image_output_counter"
  9. // geminiImageOutputCounter 记录一次转发里 Gemini 上游真正回吐的内联图片数量。
  10. //
  11. // 取「单个 payload 内的最大值」而不是累加:Gemini 兼容上游的 SSE 分片可能是
  12. // 累积式的(同一份内容在后续 chunk 里重复整段回来——本文件同目录的
  13. // computeGeminiTextDelta 就是为此存在的),逐 chunk 累加会把同一张图算很多次。
  14. // 计费上宁可少算不可多算,所以用 max 兜底:
  15. // - 非流式:整份响应体只观测一次,max 即真实张数;
  16. // - 累积式流:最后一个 chunk 含全部图片,max 仍是真实张数;
  17. // - 增量式流且多图分散在不同 chunk:会低估到 1,与改动前的模型名启发式同值,
  18. // 不构成回退。
  19. type geminiImageOutputCounter struct {
  20. count int
  21. }
  22. // beginGeminiImageOutputObservation 在每次 Forward 开头重置计数器。
  23. // failover 会拿同一个 gin.Context 重跑转发,不重置就会把上一个账号的图数带进来。
  24. func beginGeminiImageOutputObservation(c *gin.Context) *geminiImageOutputCounter {
  25. if c == nil {
  26. return nil
  27. }
  28. counter := &geminiImageOutputCounter{}
  29. c.Set(geminiImageOutputCounterKey, counter)
  30. return counter
  31. }
  32. func geminiImageOutputCounterFromContext(c *gin.Context) *geminiImageOutputCounter {
  33. if c == nil {
  34. return nil
  35. }
  36. value, ok := c.Get(geminiImageOutputCounterKey)
  37. if !ok {
  38. return nil
  39. }
  40. counter, _ := value.(*geminiImageOutputCounter)
  41. return counter
  42. }
  43. // observeGeminiImageOutputs 观测一段上游响应(整份或单个 chunk)里的内联图片。
  44. // 调用点与 upstreamResponseModelObserver.ObserveGemini 一一对应——那里拿得到
  45. // 解包后的上游响应体,这里需要的是同一份字节。
  46. func observeGeminiImageOutputs(c *gin.Context, payload []byte) {
  47. counter := geminiImageOutputCounterFromContext(c)
  48. if counter == nil {
  49. return
  50. }
  51. if count := countGeminiInlineImageOutputs(payload); count > counter.count {
  52. counter.count = count
  53. }
  54. }
  55. func observedGeminiImageOutputs(c *gin.Context) int {
  56. counter := geminiImageOutputCounterFromContext(c)
  57. if counter == nil {
  58. return 0
  59. }
  60. return counter.count
  61. }
  62. // resolveGeminiImageCount 决定本次请求按几张图计费。
  63. //
  64. // 优先用上游真正返回的内联图片数:走 GeminiMessagesCompatService 的账号多是
  65. // API Key + 自定义模型映射,客户端请求名和上游模型名都可能是站长自取的别名
  66. // (issue #5358 里的 nana-banana-2),isImageGenerationModel 的白名单必然判不出,
  67. // 于是 ImageCount=0,calculateRecordUsageCost 整条按次计费分支不触发,
  68. // 生图请求全部记 $0。
  69. //
  70. // 只有响应里数不出图时(例如上游用 fileData 引用而非 inlineData 回图,或聚合
  71. // 函数丢掉了图片 part)才退回既有的模型名启发式,保证老行为不回退;这里额外
  72. // 也认映射后的上游模型名,与 shouldSkipCodexPlanGatedImageModelCooldown 对
  73. // requestedModel / modelKey 双取的口径一致。
  74. func resolveGeminiImageCount(c *gin.Context, originalModel, mappedModel string) int {
  75. if observed := observedGeminiImageOutputs(c); observed > 0 {
  76. return observed
  77. }
  78. if isImageGenerationModel(originalModel) || isImageGenerationModel(mappedModel) {
  79. return 1
  80. }
  81. return 0
  82. }
  83. // countGeminiInlineImageOutputs 统计一段 Gemini 响应 JSON 里的内联图片 part。
  84. // Gemini REST 回 camelCase 的 inlineData,官方 SDK 与部分中转会回 snake_case
  85. // 的 inline_data,两种都要认。
  86. func countGeminiInlineImageOutputs(payload []byte) int {
  87. if len(payload) == 0 || !gjson.ValidBytes(payload) {
  88. return 0
  89. }
  90. count := 0
  91. gjson.GetBytes(payload, "candidates").ForEach(func(_, candidate gjson.Result) bool {
  92. candidate.Get("content.parts").ForEach(func(_, part gjson.Result) bool {
  93. if geminiPartIsInlineImage(part) {
  94. count++
  95. }
  96. return true
  97. })
  98. return true
  99. })
  100. return count
  101. }
  102. func geminiPartIsInlineImage(part gjson.Result) bool {
  103. inline := part.Get("inlineData")
  104. if !inline.Exists() {
  105. inline = part.Get("inline_data")
  106. }
  107. if !inline.Exists() {
  108. return false
  109. }
  110. mimeType := inline.Get("mimeType")
  111. if !mimeType.Exists() {
  112. mimeType = inline.Get("mime_type")
  113. }
  114. if !isGeminiInlineImageMIMEType(strings.ToLower(strings.TrimSpace(mimeType.String()))) {
  115. return false
  116. }
  117. // 只认真的带上了 base64 数据的 part,空壳 part 不计费。
  118. return strings.TrimSpace(inline.Get("data").String()) != ""
  119. }