Files
new-api/relay/channel/gemini/relay-gemini.go
T
Calcium-Ion bbd97446c2 fix(relay): follow-up billing integrity and conversion completions (#7170)
Deferred follow-ups from the relaykit-tools review cycle, verified by
live end-to-end billing tests:

- billing: normalize Gemini modality keys consistently between stream
  merge and settlement (case/whitespace variants no longer drop
  independent audio/image pricing) and sum duplicate modality entries
  on both paths
- billing: sync legacy flat Claude cache-creation fields from the
  CacheCreation sub-object (including zeroing) and fall back to flat
  fields only when the snapshot never carried a sub-object, closing a
  stale 1h-cache overcharge path in cascaded deployments
- relay: move Chat-to-Claude and Chat-to-Gemini stream conversion state
  from gin.Context onto RelayInfo and reset it with SendResponseCount in
  InitChannelMeta, so channel retries start clean while per-request
  state (stream error collection, conversion diagnostics, channel
  chain, billing accumulators) survives
- relay: Claude channel now serves Gemini-format clients (request via
  registry conversion, response and stream composed through the Chat
  pivot), removing the last unimplemented conversion direction
- relaykit: recognize legacy pseudo tool names (googleSearch,
  codeExecution, urlContext) in the toolconv decode stage and drop the
  string-matching bypass in the Chat-to-Gemini converter; native Gemini
  tool output is restored and non-Gemini targets follow standard loss
  diagnostics
- relaykit: attach upstream Gemini usage (with billing_usage sidecar)
  to intermediate stream chunks so converted Claude streams report
  upstream truth from message_start, and preserve the sidecar through
  Claude stream usage merges; billing settlement unchanged
- billing: clamp negative Total-Prompt completion derivation, OR the
  Estimated flag across cross-dialect snapshot replacement, and fill
  canonical OpenAI prompt details via field-wise merge
2026-09-03 10:40:05 +08:00

612 lines
21 KiB
Go

package gemini
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relay/channel/openai"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
func buildUsageFromGeminiMetadata(metadata *dto.GeminiUsageMetadata, fallbackPromptTokens int) dto.Usage {
usage := relayconvert.UsageFromGeminiMetadata(metadata, fallbackPromptTokens)
if usage == nil {
return dto.Usage{}
}
return *usage
}
func attachEstimatedGeminiBillingUsage(usage *dto.Usage) *dto.Usage {
if usage != nil && usage.BillingUsage == nil {
usage.BillingUsage = dto.NewEstimatedGeminiChatBillingUsage(usage)
}
return usage
}
// patchGeminiZeroCompletionUsage estimates completion tokens locally when upstream
// usageMetadata was billable but reported zero completion tokens even though output
// content was actually received. Typical case: the client aborts a stream before the
// final chunk that carries candidatesTokenCount, leaving prompt-only metadata; without
// this patch the output side would settle at zero quota.
func patchGeminiZeroCompletionUsage(c *gin.Context, info *relaycommon.RelayInfo, usage *dto.Usage, responseText string, imageCount int) {
if usage == nil || usage.CompletionTokens > 0 {
return
}
if responseText == "" && imageCount == 0 {
return
}
estimated := service.ResponseText2Usage(c, responseText, info.UpstreamModelName, usage.PromptTokens)
usage.CompletionTokens = estimated.CompletionTokens
if imageCount != 0 && usage.CompletionTokens == 0 {
usage.CompletionTokens = imageCount * 1400
}
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
// Settlement prefers BillingUsage, so fill the missing completion in the
// original upstream dialect without discarding cache or modality details.
if usage.BillingUsage != nil {
usage.BillingUsage = dto.CloneBillingUsageWithEstimatedCompletion(usage.BillingUsage, usage.CompletionTokens)
} else {
usage.BillingUsage = dto.NewEstimatedGeminiChatBillingUsage(usage)
}
}
func geminiResponseUsageText(response *dto.GeminiChatResponse) string {
if response == nil {
return ""
}
var text strings.Builder
for _, candidate := range response.Candidates {
for _, part := range candidate.Content.Parts {
if part.Text != "" {
text.WriteString(part.Text)
}
}
}
return text.String()
}
func markGeminiGoogleSearchCall(c *gin.Context, response *dto.GeminiChatResponse) {
if c == nil || response == nil {
return
}
for _, candidate := range response.Candidates {
if candidate.GroundingMetadata != nil && len(candidate.GroundingMetadata.WebSearchQueries) > 0 {
c.Set("gemini_google_search_call", true)
return
}
}
}
func countGeminiBillableFunctionCalls(info *relaycommon.RelayInfo, response *dto.GeminiChatResponse) {
if info == nil || response == nil {
return
}
for _, candidate := range response.Candidates {
for _, part := range candidate.Content.Parts {
if part.FunctionCall == nil {
continue
}
if part.FunctionCall.WillContinue != nil && *part.FunctionCall.WillContinue {
continue
}
info.CountBillableToolCall(dto.BuildInCallFunctionCall, part.FunctionCall.FunctionName)
}
}
}
func buildUsageFromGeminiResponse(c *gin.Context, info *relaycommon.RelayInfo, response *dto.GeminiChatResponse) dto.Usage {
metadata := response.GetUsageMetadata()
if dto.HasGeminiUsageMetadataTokens(metadata) {
usage := buildUsageFromGeminiMetadata(metadata, info.GetEstimatePromptTokens())
patchGeminiZeroCompletionUsage(c, info, &usage, geminiResponseUsageText(response), geminiResponseInlineImageCount(response))
return usage
}
usage := service.ResponseText2Usage(c, geminiResponseUsageText(response), info.UpstreamModelName, info.GetEstimatePromptTokens())
attachEstimatedGeminiBillingUsage(usage)
return *usage
}
func geminiResponseInlineImageCount(response *dto.GeminiChatResponse) int {
if response == nil {
return 0
}
count := 0
for _, candidate := range response.Candidates {
for _, part := range candidate.Content.Parts {
if part.InlineData != nil && part.InlineData.MimeType != "" {
count++
}
}
}
return count
}
func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse) *dto.OpenAITextResponse {
return relayconvert.ResponseGeminiChat2OpenAI(helper.GetResponseID(c), common.GetTimestamp(), response)
}
func streamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) {
return relayconvert.StreamResponseGeminiChat2OpenAI(geminiResponse)
}
func handleStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.ChatCompletionsStreamResponse) error {
streamData, err := common.Marshal(resp)
if err != nil {
return fmt.Errorf("failed to marshal stream response: %w", err)
}
err = openai.HandleStreamFormat(c, info, string(streamData), info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent)
if err != nil {
return fmt.Errorf("failed to handle stream format: %w", err)
}
return nil
}
func handleFinalStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.ChatCompletionsStreamResponse) error {
streamData, err := common.Marshal(resp)
if err != nil {
return fmt.Errorf("failed to marshal stream response: %w", err)
}
openai.HandleFinalResponse(c, info, string(streamData), resp.Id, resp.Created, resp.Model, resp.GetSystemFingerprint(), resp.Usage, false)
return nil
}
func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response, callback func(data string, geminiResponse *dto.GeminiChatResponse) bool) (*dto.Usage, *types.NewAPIError) {
var usage = &dto.Usage{}
var imageCount int
var hasBillableUsageMetadata bool
var streamErr error
var accumulatedUsageMetadata *dto.GeminiUsageMetadata
responseText := strings.Builder{}
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var geminiResponse dto.GeminiChatResponse
if err := common.UnmarshalJsonStr(data, &geminiResponse); err != nil {
streamErr = fmt.Errorf("unmarshal Gemini stream response: %w", err)
sr.Stop(streamErr)
return
}
if len(geminiResponse.Candidates) == 0 && geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason))
}
markGeminiGoogleSearchCall(c, &geminiResponse)
countGeminiBillableFunctionCalls(info, &geminiResponse)
// 统计图片数量
for _, candidate := range geminiResponse.Candidates {
for _, part := range candidate.Content.Parts {
if part.InlineData != nil && part.InlineData.MimeType != "" {
imageCount++
}
if part.Text != "" {
responseText.WriteString(part.Text)
}
}
}
// 更新使用量统计
if metadata := geminiResponse.GetUsageMetadata(); dto.HasGeminiUsageMetadataTokens(metadata) {
accumulatedUsageMetadata = dto.MergeGeminiUsageMetadataNonZero(accumulatedUsageMetadata, metadata)
mappedUsage := buildUsageFromGeminiMetadata(accumulatedUsageMetadata, info.GetEstimatePromptTokens())
*usage = mappedUsage
hasBillableUsageMetadata = true
}
if !callback(data, &geminiResponse) {
if isGeminiDownstreamStop(c, info) {
sr.Stop(nil)
return
}
streamErr = errors.New("Gemini stream callback stopped")
sr.Stop(streamErr)
}
})
if !hasBillableUsageMetadata {
if info.ReceivedResponseCount > 0 {
usage = service.ResponseText2Usage(c, responseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
} else {
usage = &dto.Usage{}
}
if imageCount != 0 && usage.CompletionTokens == 0 {
usage.CompletionTokens = imageCount * 1400
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
common.SetContextKey(c, constant.ContextKeyLocalCountTokens, true)
}
attachEstimatedGeminiBillingUsage(usage)
} else {
patchGeminiZeroCompletionUsage(c, info, usage, responseText.String(), imageCount)
}
if streamErr != nil {
return usage, types.NewOpenAIError(streamErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if info.StreamStatus != nil && !info.StreamStatus.IsNormalEnd() {
logger.LogWarn(c, fmt.Sprintf("Gemini stream ended unexpectedly: %s", info.StreamStatus.Summary()))
}
return usage, nil
}
func isGeminiDownstreamStop(c *gin.Context, info *relaycommon.RelayInfo) bool {
if c != nil && c.Request != nil && c.Request.Context().Err() != nil {
return true
}
return info != nil && info.StreamStatus != nil &&
info.StreamStatus.EndReason == relaycommon.StreamEndReasonClientGone
}
func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
id := helper.GetResponseID(c)
createAt := common.GetTimestamp()
finishReason := constant.FinishReasonStop
toolCallIndexByChoice := make(map[int]map[string]int)
nextToolCallIndexByChoice := make(map[int]int)
usage, err := geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool {
response, isStop := streamResponseGeminiChat2OpenAI(geminiResponse)
response.Id = id
response.Created = createAt
response.Model = info.UpstreamModelName
if response.IsToolCall() {
finishReason = constant.FinishReasonToolCalls
if info.RelayFormat == types.RelayFormatClaude {
for choiceIdx := range response.Choices {
response.Choices[choiceIdx].FinishReason = nil
}
}
}
for choiceIdx := range response.Choices {
choiceKey := response.Choices[choiceIdx].Index
for toolIdx := range response.Choices[choiceIdx].Delta.ToolCalls {
tool := &response.Choices[choiceIdx].Delta.ToolCalls[toolIdx]
if tool.ID == "" {
continue
}
m := toolCallIndexByChoice[choiceKey]
if m == nil {
m = make(map[string]int)
toolCallIndexByChoice[choiceKey] = m
}
if idx, ok := m[tool.ID]; ok {
tool.SetIndex(idx)
continue
}
idx := nextToolCallIndexByChoice[choiceKey]
nextToolCallIndexByChoice[choiceKey] = idx + 1
m[tool.ID] = idx
tool.SetIndex(idx)
}
}
logger.LogDebug(c, "info.SendResponseCount = %d", info.SendResponseCount)
if info.SendResponseCount == 0 {
// send first response
emptyResponse := helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil)
// Claude message_start is emitted from this first OpenAI chunk.
// Carry upstream usage when the current Gemini frame provided it.
emptyResponse.Usage = response.Usage
if response.IsToolCall() {
if len(emptyResponse.Choices) > 0 && len(response.Choices) > 0 {
toolCalls := response.Choices[0].Delta.ToolCalls
copiedToolCalls := make([]dto.ToolCallResponse, len(toolCalls))
for idx := range toolCalls {
copiedToolCalls[idx] = toolCalls[idx]
copiedToolCalls[idx].Function.Arguments = ""
}
emptyResponse.Choices[0].Delta.ToolCalls = copiedToolCalls
}
finishReason = constant.FinishReasonToolCalls
err := handleStream(c, info, emptyResponse)
if err != nil {
logger.LogError(c, err.Error())
}
response.ClearToolCalls()
if response.IsFinished() {
response.Choices[0].FinishReason = nil
}
} else {
err := handleStream(c, info, emptyResponse)
if err != nil {
logger.LogError(c, err.Error())
}
}
}
err := handleStream(c, info, response)
if err != nil {
logger.LogError(c, err.Error())
}
if isStop {
if info.RelayFormat != types.RelayFormatClaude {
_ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, finishReason))
}
}
return true
})
if err != nil {
return usage, err
}
response := helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage)
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil && !info.ClaudeConvertInfo.Done {
response = helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, finishReason)
response.Usage = usage
}
handleErr := handleFinalStream(c, info, response)
if handleErr != nil {
common.SysLog("send final response failed: " + handleErr.Error())
}
return usage, nil
}
func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
service.CloseResponseBodyGracefully(resp)
logger.LogDebug(c, "Gemini response body: %s", responseBody)
var geminiResponse dto.GeminiChatResponse
err = common.Unmarshal(responseBody, &geminiResponse)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
markGeminiGoogleSearchCall(c, &geminiResponse)
countGeminiBillableFunctionCalls(info, &geminiResponse)
if len(geminiResponse.Candidates) == 0 {
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
var newAPIError *types.NewAPIError
if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason))
newAPIError = types.NewOpenAIError(
errors.New("request blocked by Gemini API: "+*geminiResponse.PromptFeedback.BlockReason),
types.ErrorCodePromptBlocked,
http.StatusBadRequest,
)
} else {
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, "gemini_empty_candidates")
newAPIError = types.NewOpenAIError(
errors.New("empty response from Gemini API"),
types.ErrorCodeEmptyResponse,
http.StatusInternalServerError,
)
}
service.ResetStatusCode(newAPIError, c.GetString("status_code_mapping"))
switch info.RelayFormat {
case types.RelayFormatClaude:
c.JSON(newAPIError.StatusCode, gin.H{
"type": "error",
"error": newAPIError.ToClaudeError(),
})
default:
c.JSON(newAPIError.StatusCode, gin.H{
"error": newAPIError.ToOpenAIError(),
})
}
return &usage, nil
}
fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse)
fullTextResponse.Model = info.UpstreamModelName
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
fullTextResponse.Usage = usage
switch info.RelayFormat {
case types.RelayFormatOpenAI:
responseBody, err = common.Marshal(fullTextResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
case types.RelayFormatClaude:
convertResult, err := service.ConvertResponse(c, info, types.RelayFormatClaude, fullTextResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
claudeRespStr, err := common.Marshal(convertResult.Value)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
responseBody = claudeRespStr
case types.RelayFormatGemini:
break
}
service.IOCopyBytesGracefully(c, resp, responseBody)
return &usage, nil
}
func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
defer service.CloseResponseBodyGracefully(resp)
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
var geminiResponse dto.GeminiBatchEmbeddingResponse
if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil {
return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
// convert to openai format response
openAIResponse := dto.OpenAIEmbeddingResponse{
Object: "list",
Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(geminiResponse.Embeddings)),
Model: info.UpstreamModelName,
}
for i, embedding := range geminiResponse.Embeddings {
openAIResponse.Data = append(openAIResponse.Data, dto.OpenAIEmbeddingResponseItem{
Object: "embedding",
Embedding: embedding.Values,
Index: i,
})
}
// calculate usage
// https://ai.google.dev/gemini-api/docs/pricing?hl=zh-cn#text-embedding-004
// Google has not yet clarified how embedding models will be billed
// refer to openai billing method to use input tokens billing
// https://platform.openai.com/docs/guides/embeddings#what-are-embeddings
usage := service.ResponseText2Usage(c, "", info.UpstreamModelName, info.GetEstimatePromptTokens())
openAIResponse.Usage = *usage
jsonResponse, jsonErr := common.Marshal(openAIResponse)
if jsonErr != nil {
return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
service.IOCopyBytesGracefully(c, resp, jsonResponse)
return usage, nil
}
func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
_ = resp.Body.Close()
var geminiResponse dto.GeminiImageResponse
if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil {
return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if len(geminiResponse.Predictions) == 0 {
return nil, types.NewOpenAIError(errors.New("no images generated"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
// convert to openai format response
openAIResponse := dto.ImageResponse{
Created: common.GetTimestamp(),
Data: make([]dto.ImageData, 0, len(geminiResponse.Predictions)),
}
for _, prediction := range geminiResponse.Predictions {
if prediction.RaiFilteredReason != "" {
continue // skip filtered image
}
openAIResponse.Data = append(openAIResponse.Data, dto.ImageData{
B64Json: prediction.BytesBase64Encoded,
})
}
jsonResponse, jsonErr := common.Marshal(openAIResponse)
if jsonErr != nil {
return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody)
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, _ = c.Writer.Write(jsonResponse)
// https://github.com/google-gemini/cookbook/blob/719a27d752aac33f39de18a8d3cb42a70874917e/quickstarts/Counting_Tokens.ipynb
// each image has fixed 258 tokens
const imageTokens = 258
generatedImages := len(openAIResponse.Data)
usage := &dto.Usage{
PromptTokens: imageTokens * generatedImages, // each generated image has fixed 258 tokens
CompletionTokens: 0, // image generation does not calculate completion tokens
TotalTokens: imageTokens * generatedImages,
}
return usage, nil
}
type GeminiModelsResponse struct {
Models []dto.GeminiModel `json:"models"`
NextPageToken string `json:"nextPageToken"`
}
func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) {
client, err := service.GetHttpClientWithProxy(proxyURL)
if err != nil {
return nil, fmt.Errorf("创建HTTP客户端失败: %v", err)
}
allModels := make([]string, 0)
nextPageToken := ""
maxPages := 100 // Safety limit to prevent infinite loops
for page := 0; page < maxPages; page++ {
url := fmt.Sprintf("%s/v1beta/models", baseURL)
if nextPageToken != "" {
url = fmt.Sprintf("%s?pageToken=%s", url, nextPageToken)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
request, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
cancel()
return nil, fmt.Errorf("创建请求失败: %v", err)
}
request.Header.Set("x-goog-api-key", apiKey)
response, err := client.Do(request)
if err != nil {
cancel()
return nil, fmt.Errorf("请求失败: %v", err)
}
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
response.Body.Close()
cancel()
return nil, fmt.Errorf("服务器返回错误 %d: %s", response.StatusCode, string(body))
}
body, err := io.ReadAll(response.Body)
response.Body.Close()
cancel()
if err != nil {
return nil, fmt.Errorf("读取响应失败: %v", err)
}
var modelsResponse GeminiModelsResponse
if err = common.Unmarshal(body, &modelsResponse); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
for _, model := range modelsResponse.Models {
modelNameValue, ok := model.Name.(string)
if !ok {
continue
}
modelName := strings.TrimPrefix(modelNameValue, "models/")
allModels = append(allModels, modelName)
}
nextPageToken = modelsResponse.NextPageToken
if nextPageToken == "" {
break
}
}
return allModels, nil
}