Files
sub2api/backend/internal/service/gemini_image_output_accounting.go
T
li b6eb6c1efa fix(gemini): 原生生图按上游实际回吐的图片张数计费
/v1beta/models/{model}:generateContent 与 Anthropic→Gemini 兼容路径的
ImageCount 只由 isImageGenerationModel(originalModel) 决定,而该白名单是
按 Google 官方模型名精确/前缀匹配写死的(antigravity_image_test.go 里
明确断言 my-gemini-3-pro-image-test 这类自定义名返回 false)。

GeminiMessagesCompatService 服务的却主要是 API Key + 自定义模型映射的账号:
客户端请求名和 GetMappedModel 后的上游名都可能是站长自取的别名,白名单必然
判不出来 → ImageCount=0 → calculateRecordUsageCost 里 `if result.ImageCount > 0`
的按次计费分支整条不触发 → 生图请求全部记 $0(issue #5358)。

改为优先按上游响应里真实的 inlineData 图片 part 计数:
- 新增请求级计数器,挂在 gin.Context 上,与既有的
  upstreamResponseModelObserver 同一批调用点取解包后的响应体;
- 取「单个 payload 内的最大值」而非累加:Gemini 兼容上游的 SSE 分片可能是
  累积式的(computeGeminiTextDelta 正是为此存在),逐 chunk 累加会把同一张图
  重复计费。max 保证累积式流与非流式都得到真实张数,增量式多图流最差退化到 1,
  与改动前同值,不构成回退;
- 每次 Forward 开头重置计数器,避免 failover 复用同一个 gin.Context 时
  把失败账号已回吐的图叠加到成功账号账单上;
- 响应里数不出图时(fileData 引用式回图等)退回原有模型名启发式,并额外认
  映射后的上游模型名,与 shouldSkipCodexPlanGatedImageModelCooldown 同时取
  requestedModel / modelKey 的口径一致。

inlineData / inline_data 两种字段风格都认(官方 SDK 与部分中转回 snake_case),
只统计带 base64 数据且 MIME 为图片的 part。

Fixes #5358
2026-08-08 21:36:18 +08:00

133 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"strings"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
)
// geminiImageOutputCounterKey 是请求级内联图片计数器挂在 gin.Context 上的键。
const geminiImageOutputCounterKey = "gemini_image_output_counter"
// geminiImageOutputCounter 记录一次转发里 Gemini 上游真正回吐的内联图片数量。
//
// 取「单个 payload 内的最大值」而不是累加:Gemini 兼容上游的 SSE 分片可能是
// 累积式的(同一份内容在后续 chunk 里重复整段回来——本文件同目录的
// computeGeminiTextDelta 就是为此存在的),逐 chunk 累加会把同一张图算很多次。
// 计费上宁可少算不可多算,所以用 max 兜底:
// - 非流式:整份响应体只观测一次,max 即真实张数;
// - 累积式流:最后一个 chunk 含全部图片,max 仍是真实张数;
// - 增量式流且多图分散在不同 chunk:会低估到 1,与改动前的模型名启发式同值,
// 不构成回退。
type geminiImageOutputCounter struct {
count int
}
// beginGeminiImageOutputObservation 在每次 Forward 开头重置计数器。
// failover 会拿同一个 gin.Context 重跑转发,不重置就会把上一个账号的图数带进来。
func beginGeminiImageOutputObservation(c *gin.Context) *geminiImageOutputCounter {
if c == nil {
return nil
}
counter := &geminiImageOutputCounter{}
c.Set(geminiImageOutputCounterKey, counter)
return counter
}
func geminiImageOutputCounterFromContext(c *gin.Context) *geminiImageOutputCounter {
if c == nil {
return nil
}
value, ok := c.Get(geminiImageOutputCounterKey)
if !ok {
return nil
}
counter, _ := value.(*geminiImageOutputCounter)
return counter
}
// observeGeminiImageOutputs 观测一段上游响应(整份或单个 chunk)里的内联图片。
// 调用点与 upstreamResponseModelObserver.ObserveGemini 一一对应——那里拿得到
// 解包后的上游响应体,这里需要的是同一份字节。
func observeGeminiImageOutputs(c *gin.Context, payload []byte) {
counter := geminiImageOutputCounterFromContext(c)
if counter == nil {
return
}
if count := countGeminiInlineImageOutputs(payload); count > counter.count {
counter.count = count
}
}
func observedGeminiImageOutputs(c *gin.Context) int {
counter := geminiImageOutputCounterFromContext(c)
if counter == nil {
return 0
}
return counter.count
}
// resolveGeminiImageCount 决定本次请求按几张图计费。
//
// 优先用上游真正返回的内联图片数:走 GeminiMessagesCompatService 的账号多是
// API Key + 自定义模型映射,客户端请求名和上游模型名都可能是站长自取的别名
// (issue #5358 里的 nana-banana-2),isImageGenerationModel 的白名单必然判不出,
// 于是 ImageCount=0,calculateRecordUsageCost 整条按次计费分支不触发,
// 生图请求全部记 $0。
//
// 只有响应里数不出图时(例如上游用 fileData 引用而非 inlineData 回图,或聚合
// 函数丢掉了图片 part)才退回既有的模型名启发式,保证老行为不回退;这里额外
// 也认映射后的上游模型名,与 shouldSkipCodexPlanGatedImageModelCooldown 对
// requestedModel / modelKey 双取的口径一致。
func resolveGeminiImageCount(c *gin.Context, originalModel, mappedModel string) int {
if observed := observedGeminiImageOutputs(c); observed > 0 {
return observed
}
if isImageGenerationModel(originalModel) || isImageGenerationModel(mappedModel) {
return 1
}
return 0
}
// countGeminiInlineImageOutputs 统计一段 Gemini 响应 JSON 里的内联图片 part。
// Gemini REST 回 camelCase 的 inlineData,官方 SDK 与部分中转会回 snake_case
// 的 inline_data,两种都要认。
func countGeminiInlineImageOutputs(payload []byte) int {
if len(payload) == 0 || !gjson.ValidBytes(payload) {
return 0
}
count := 0
gjson.GetBytes(payload, "candidates").ForEach(func(_, candidate gjson.Result) bool {
candidate.Get("content.parts").ForEach(func(_, part gjson.Result) bool {
if geminiPartIsInlineImage(part) {
count++
}
return true
})
return true
})
return count
}
func geminiPartIsInlineImage(part gjson.Result) bool {
inline := part.Get("inlineData")
if !inline.Exists() {
inline = part.Get("inline_data")
}
if !inline.Exists() {
return false
}
mimeType := inline.Get("mimeType")
if !mimeType.Exists() {
mimeType = inline.Get("mime_type")
}
if !isGeminiInlineImageMIMEType(strings.ToLower(strings.TrimSpace(mimeType.String()))) {
return false
}
// 只认真的带上了 base64 数据的 part,空壳 part 不计费。
return strings.TrimSpace(inline.Get("data").String()) != ""
}