vibe-proxy/backend/internal/translator/gemini/gemini/gemini_gemini_request.go
2026-08-24 00:10:41 +02:00

310 lines
10 KiB
Go

// Package gemini provides in-provider request normalization for Gemini API.
// It ensures incoming v1beta requests meet minimal schema requirements
// expected by Google's Generative Language API.
package gemini
import (
"fmt"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertGeminiRequestToGemini normalizes Gemini v1beta requests.
// - Adds a default role for each content if missing or invalid.
// The first message defaults to "user", then alternates user/model when needed.
//
// It keeps the payload otherwise unchanged.
func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) []byte {
rawJSON := inputRawJSON
// Fast path: if no contents field, only attach safety settings
contents := util.GetGJSONBytesNoCopy(rawJSON, "contents")
if !contents.Exists() {
return common.AttachDefaultSafetySettings(rawJSON, "safetySettings")
}
toolsResult := gjson.GetBytes(rawJSON, "tools")
if toolsResult.Exists() && toolsResult.IsArray() {
var toolItems [][]byte
toolsChanged := false
toolsResult.ForEach(func(_, toolResult gjson.Result) bool {
tool := []byte(toolResult.Raw)
toolChanged := false
if declarations := toolResult.Get("functionDeclarations"); declarations.Exists() {
tool, _ = sjson.SetRawBytes(tool, "function_declarations", []byte(declarations.Raw))
tool, _ = sjson.DeleteBytes(tool, "functionDeclarations")
toolChanged = true
}
declarations := gjson.GetBytes(tool, "function_declarations")
if declarations.IsArray() {
var declarationItems [][]byte
declarationsChanged := false
declarations.ForEach(func(_, declarationResult gjson.Result) bool {
declaration := []byte(declarationResult.Raw)
if parameters := declarationResult.Get("parameters"); parameters.Exists() {
declaration, _ = sjson.SetRawBytes(declaration, "parametersJsonSchema", []byte(parameters.Raw))
declaration, _ = sjson.DeleteBytes(declaration, "parameters")
declarationsChanged = true
}
declarationItems = append(declarationItems, declaration)
return true
})
if declarationsChanged {
tool, _ = sjson.SetRawBytes(tool, "function_declarations", translatorcommon.JoinRawArray(declarationItems))
toolChanged = true
}
}
toolsChanged = toolsChanged || toolChanged
toolItems = append(toolItems, tool)
return true
})
if toolsChanged {
rawJSON, _ = sjson.SetRawBytes(rawJSON, "tools", translatorcommon.JoinRawArray(toolItems))
}
}
// Walk contents and fix roles
out := rawJSON
prevRole := ""
if contents.IsArray() {
rolesChanged := false
contents.ForEach(func(_, value gjson.Result) bool {
role := value.Get("role").String()
if role != "user" && role != "model" {
role = nextGeminiRole(prevRole)
rolesChanged = true
}
prevRole = role
return true
})
if rolesChanged {
prevRole = ""
contentItems := translatorcommon.NewRawArrayItems(contents.Get("#").Int())
contents.ForEach(func(_, value gjson.Result) bool {
role := value.Get("role").String()
item := []byte(value.Raw)
if role != "user" && role != "model" {
role = nextGeminiRole(prevRole)
item, _ = sjson.SetBytes(item, "role", role)
}
prevRole = role
contentItems = append(contentItems, item)
return true
})
out, _ = sjson.SetRawBytes(out, "contents", translatorcommon.JoinRawArray(contentItems))
}
} else {
idx := 0
contents.ForEach(func(_ gjson.Result, value gjson.Result) bool {
role := value.Get("role").String()
if role != "user" && role != "model" {
role = nextGeminiRole(prevRole)
out, _ = sjson.SetBytes(out, fmt.Sprintf("contents.%d.role", idx), role)
}
prevRole = role
idx++
return true
})
}
out = signature.SanitizeGeminiRequestThoughtSignatures(out, "contents")
if gjson.GetBytes(rawJSON, "generationConfig.responseSchema").Exists() {
strJson, _ := util.RenameKey(string(out), "generationConfig.responseSchema", "generationConfig.responseJsonSchema")
out = []byte(strJson)
}
// Backfill empty functionResponse.name from the preceding functionCall.name.
// Some clients send function responses with empty names; the Gemini API rejects these.
out = backfillEmptyFunctionResponseNames(out)
out = common.AttachDefaultSafetySettings(out, "safetySettings")
return out
}
// backfillEmptyFunctionResponseNames walks the contents array and for each
// model turn containing functionCall parts, records the call names in order.
// For the immediately following user/function turn containing functionResponse
// parts, any empty name is replaced with the corresponding call name.
func backfillEmptyFunctionResponseNames(data []byte) []byte {
contents := util.GetGJSONBytesNoCopy(data, "contents")
if !contents.Exists() {
return data
}
canBatch := contents.IsArray()
if canBatch {
contents.ForEach(func(_, content gjson.Result) bool {
parts := content.Get("parts")
if parts.Exists() && !parts.IsArray() {
canBatch = false
return false
}
return true
})
}
if !canBatch {
return backfillEmptyFunctionResponseNamesLegacy(data, contents)
}
needsBackfill, excessResponseIndexes := geminiFunctionResponseNamesNeedBackfill(contents)
if !needsBackfill {
for _, contentIndex := range excessResponseIndexes {
log.Debugf("more function responses than calls at contents[%d], skipping name backfill", contentIndex)
}
return data
}
changed := false
contentItems := translatorcommon.NewRawArrayItems(contents.Get("#").Int())
var pendingCallNames []string
contents.ForEach(func(contentIdx, content gjson.Result) bool {
role := content.Get("role").String()
contentRaw := []byte(content.Raw)
// Collect functionCall names from model turns.
if role == "model" {
var names []string
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
if part.Get("functionCall").Exists() {
names = append(names, part.Get("functionCall.name").String())
}
return true
})
pendingCallNames = names
contentItems = append(contentItems, contentRaw)
return true
}
// Backfill empty functionResponse names from pending call names.
if len(pendingCallNames) > 0 {
responseIndex := 0
partsChanged := false
partItems := make([][]byte, 0, 4)
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
partRaw := []byte(part.Raw)
if part.Get("functionResponse").Exists() {
name := part.Get("functionResponse.name").String()
if strings.TrimSpace(name) == "" {
if responseIndex < len(pendingCallNames) {
partRaw, _ = sjson.SetBytes(partRaw, "functionResponse.name", pendingCallNames[responseIndex])
partsChanged = true
} else {
log.Debugf("more function responses than calls at contents[%d], skipping name backfill", contentIdx.Int())
}
}
responseIndex++
}
partItems = append(partItems, partRaw)
return true
})
if partsChanged {
contentRaw, _ = sjson.SetRawBytes(contentRaw, "parts", translatorcommon.JoinRawArray(partItems))
changed = true
}
pendingCallNames = nil
}
contentItems = append(contentItems, contentRaw)
return true
})
if !changed {
return data
}
out, errSetContents := sjson.SetRawBytes(data, "contents", translatorcommon.JoinRawArray(contentItems))
if errSetContents != nil {
return data
}
return out
}
func geminiFunctionResponseNamesNeedBackfill(contents gjson.Result) (bool, []int64) {
var pendingCallNames []string
var excessResponseIndexes []int64
needsBackfill := false
contents.ForEach(func(contentIdx, content gjson.Result) bool {
if content.Get("role").String() == "model" {
var names []string
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
if part.Get("functionCall").Exists() {
names = append(names, part.Get("functionCall.name").String())
}
return true
})
pendingCallNames = names
return true
}
if len(pendingCallNames) == 0 {
return true
}
responseIndex := 0
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
if part.Get("functionResponse").Exists() {
if strings.TrimSpace(part.Get("functionResponse.name").String()) == "" {
if responseIndex < len(pendingCallNames) {
needsBackfill = true
return false
}
excessResponseIndexes = append(excessResponseIndexes, contentIdx.Int())
}
responseIndex++
}
return true
})
pendingCallNames = nil
return !needsBackfill
})
return needsBackfill, excessResponseIndexes
}
func backfillEmptyFunctionResponseNamesLegacy(data []byte, contents gjson.Result) []byte {
out := data
var pendingCallNames []string
contents.ForEach(func(contentIdx, content gjson.Result) bool {
if content.Get("role").String() == "model" {
var names []string
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
if part.Get("functionCall").Exists() {
names = append(names, part.Get("functionCall.name").String())
}
return true
})
pendingCallNames = names
return true
}
if len(pendingCallNames) > 0 {
responseIndex := 0
content.Get("parts").ForEach(func(partIdx, part gjson.Result) bool {
if part.Get("functionResponse").Exists() {
if strings.TrimSpace(part.Get("functionResponse.name").String()) == "" {
if responseIndex < len(pendingCallNames) {
path := fmt.Sprintf("contents.%d.parts.%d.functionResponse.name", contentIdx.Int(), partIdx.Int())
out, _ = sjson.SetBytes(out, path, pendingCallNames[responseIndex])
} else {
log.Debugf("more function responses than calls at contents[%d], skipping name backfill", contentIdx.Int())
}
}
responseIndex++
}
return true
})
pendingCallNames = nil
}
return true
})
return out
}
func nextGeminiRole(previousRole string) string {
if previousRole == "" || previousRole == "model" {
return "user"
}
return "model"
}