900 lines
30 KiB
Go
900 lines
30 KiB
Go
// Package gemini provides request translation functionality for Antigravity to Gemini API compatibility.
|
|
// It handles parsing and transforming Antigravity API requests into Gemini API format,
|
|
// extracting model information, system instructions, message contents, and tool declarations.
|
|
// The package performs JSON data transformation to ensure compatibility
|
|
// between Antigravity API format and Gemini API's expected format.
|
|
package gemini
|
|
|
|
import (
|
|
"encoding/json"
|
|
"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"
|
|
)
|
|
|
|
// ConvertGeminiRequestToAntigravity parses and transforms a Antigravity API request into Gemini API format.
|
|
// It extracts the model name, system instruction, message contents, and tool declarations
|
|
// from the raw JSON request and returns them in the format expected by the Gemini API.
|
|
// The function performs the following transformations:
|
|
// 1. Extracts the model information from the request
|
|
// 2. Restructures the JSON to match Gemini API format
|
|
// 3. Converts system instructions to the expected format
|
|
// 4. Fixes CLI tool response format and grouping
|
|
//
|
|
// Parameters:
|
|
// - modelName: The name of the model to use for the request (unused in current implementation)
|
|
// - rawJSON: The raw JSON request data from the Antigravity API
|
|
// - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)
|
|
//
|
|
// Returns:
|
|
// - []byte: The transformed request data in Gemini API format
|
|
func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte {
|
|
rawJSON := inputRawJSON
|
|
functionNameMap := util.SanitizedFunctionNameMap(inputRawJSON)
|
|
// Keep the envelope in []byte form. Round-tripping through string copies the
|
|
// entire request, which dominates allocations for large inline data. Fill the
|
|
// small envelope fields first so the payload is only spliced in once.
|
|
envelope, _ := sjson.SetBytes([]byte(`{"project":"","request":{},"model":""}`), "model", modelName)
|
|
rawJSON, _ = sjson.SetRawBytes(envelope, "request", rawJSON)
|
|
if util.GetGJSONBytesNoCopy(rawJSON, "request.model").Exists() {
|
|
rawJSON, _ = sjson.DeleteBytes(rawJSON, "request.model")
|
|
}
|
|
|
|
fixedJSON, errFixCLIToolResponse := fixCLIToolResponse(rawJSON)
|
|
if errFixCLIToolResponse != nil {
|
|
return []byte{}
|
|
}
|
|
rawJSON = fixedJSON
|
|
|
|
if systemInstructionResult := util.GetGJSONBytesNoCopy(rawJSON, "request.system_instruction"); systemInstructionResult.Exists() {
|
|
rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.systemInstruction", []byte(systemInstructionResult.Raw))
|
|
rawJSON, _ = sjson.DeleteBytes(rawJSON, "request.system_instruction")
|
|
}
|
|
|
|
// Normalize roles in request.contents: default to valid values if missing/invalid.
|
|
// The contents array is only materialized when a role actually changes; copying
|
|
// every content up front duplicates the whole payload for large inline data.
|
|
contents := util.GetGJSONBytesNoCopy(rawJSON, "request.contents")
|
|
if contents.IsArray() && geminiContentRolesNeedNormalization(contents) {
|
|
contentItems := translatorcommon.NewRawArrayItems(contents.Get("#").Int())
|
|
previousRole := ""
|
|
contents.ForEach(func(_, value gjson.Result) bool {
|
|
role := value.Get("role").String()
|
|
content := []byte(value.Raw)
|
|
if role != "user" && role != "model" {
|
|
if previousRole == "" || previousRole == "model" {
|
|
role = "user"
|
|
} else {
|
|
role = "model"
|
|
}
|
|
content, _ = sjson.SetBytes(content, "role", role)
|
|
}
|
|
previousRole = role
|
|
contentItems = append(contentItems, content)
|
|
return true
|
|
})
|
|
rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.contents", translatorcommon.JoinRawArray(contentItems))
|
|
}
|
|
|
|
toolsResult := util.GetGJSONBytesNoCopy(rawJSON, "request.tools")
|
|
if toolsResult.IsArray() {
|
|
seenFunctionNames := make(map[string]struct{})
|
|
toolsChanged := false
|
|
var toolItems [][]byte
|
|
toolsResult.ForEach(func(toolIndex, tool gjson.Result) bool {
|
|
toolJSON := []byte(tool.Raw)
|
|
toolChanged := false
|
|
for _, key := range []string{"functionDeclarations", "function_declarations"} {
|
|
declarations := tool.Get(key)
|
|
if !declarations.IsArray() {
|
|
continue
|
|
}
|
|
|
|
declarationsChanged := false
|
|
var declarationItems [][]byte
|
|
declarations.ForEach(func(_, declaration gjson.Result) bool {
|
|
nameResult := declaration.Get("name")
|
|
originalName := nameResult.String()
|
|
mappedName := util.MapSanitizedFunctionName(functionNameMap, originalName)
|
|
if mappedName != "" {
|
|
if _, exists := seenFunctionNames[mappedName]; exists {
|
|
declarationsChanged = true
|
|
return true
|
|
}
|
|
seenFunctionNames[mappedName] = struct{}{}
|
|
}
|
|
|
|
declarationJSON := []byte(declaration.Raw)
|
|
if nameResult.Type != gjson.String || mappedName != originalName {
|
|
declarationJSON, _ = sjson.SetBytes(declarationJSON, "name", mappedName)
|
|
declarationsChanged = true
|
|
}
|
|
if parameters := declaration.Get("parameters"); parameters.Exists() {
|
|
declarationJSON, _ = sjson.SetRawBytes(declarationJSON, "parametersJsonSchema", []byte(parameters.Raw))
|
|
declarationJSON, _ = sjson.DeleteBytes(declarationJSON, "parameters")
|
|
declarationsChanged = true
|
|
}
|
|
declarationItems = append(declarationItems, declarationJSON)
|
|
return true
|
|
})
|
|
if declarationsChanged {
|
|
var errSet error
|
|
toolJSON, errSet = sjson.SetRawBytes(toolJSON, key, translatorcommon.JoinRawArray(declarationItems))
|
|
if errSet != nil {
|
|
log.Warnf("failed to normalize function declarations in tool %d: %v", toolIndex.Int(), errSet)
|
|
} else {
|
|
toolChanged = true
|
|
}
|
|
}
|
|
}
|
|
toolsChanged = toolsChanged || toolChanged
|
|
toolItems = append(toolItems, toolJSON)
|
|
return true
|
|
})
|
|
if toolsChanged {
|
|
rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.tools", translatorcommon.JoinRawArray(toolItems))
|
|
}
|
|
rawJSON = removeEmptyGeminiFunctionTools(rawJSON)
|
|
}
|
|
rawJSON = rewriteGeminiFunctionNames(rawJSON, functionNameMap)
|
|
|
|
if strings.Contains(strings.ToLower(modelName), "claude") {
|
|
rawJSON = SanitizeAntigravityClaudeGeminiRequestSignatures(modelName, rawJSON)
|
|
} else {
|
|
rawJSON = signature.SanitizeGeminiRequestThoughtSignatures(rawJSON, "request.contents")
|
|
}
|
|
|
|
return common.AttachDefaultSafetySettings(rawJSON, "request.safetySettings")
|
|
}
|
|
|
|
// geminiContentRolesNeedNormalization reports whether any content role is missing
|
|
// or invalid and therefore requires rebuilding the contents array.
|
|
func geminiContentRolesNeedNormalization(contents gjson.Result) bool {
|
|
needsNormalization := false
|
|
contents.ForEach(func(_, value gjson.Result) bool {
|
|
role := value.Get("role").String()
|
|
if role != "user" && role != "model" {
|
|
needsNormalization = true
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
return needsNormalization
|
|
}
|
|
|
|
func removeEmptyGeminiFunctionTools(rawJSON []byte) []byte {
|
|
tools := util.GetGJSONBytesNoCopy(rawJSON, "request.tools")
|
|
if tools.IsArray() && len(tools.Array()) == 0 {
|
|
rawJSON, _ = sjson.DeleteBytes(rawJSON, "request.tools")
|
|
return rawJSON
|
|
}
|
|
changed := false
|
|
var cleanedTools [][]byte
|
|
for _, tool := range tools.Array() {
|
|
toolJSON := []byte(tool.Raw)
|
|
if tool.IsObject() {
|
|
for _, key := range []string{"functionDeclarations", "function_declarations"} {
|
|
if declarations := tool.Get(key); declarations.IsArray() && len(declarations.Array()) == 0 {
|
|
toolJSON, _ = sjson.DeleteBytes(toolJSON, key)
|
|
changed = true
|
|
}
|
|
}
|
|
if len(util.ParseGJSONBytesNoCopy(toolJSON).Map()) == 0 {
|
|
changed = true
|
|
continue
|
|
}
|
|
}
|
|
cleanedTools = append(cleanedTools, toolJSON)
|
|
}
|
|
if !changed {
|
|
return rawJSON
|
|
}
|
|
if len(cleanedTools) == 0 {
|
|
rawJSON, _ = sjson.DeleteBytes(rawJSON, "request.tools")
|
|
return rawJSON
|
|
}
|
|
rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.tools", translatorcommon.JoinRawArray(cleanedTools))
|
|
return rawJSON
|
|
}
|
|
|
|
// geminiFunctionNameFields lists the part fields that can carry a function name.
|
|
var geminiFunctionNameFields = []string{"functionCall", "functionResponse", "function_call", "function_response"}
|
|
|
|
// geminiFunctionNamesNeedRewrite reports whether any part carries a function name
|
|
// that must be remapped or coerced to a string.
|
|
func geminiFunctionNamesNeedRewrite(contents gjson.Result, functionNameMap map[string]string) bool {
|
|
needsRewrite := false
|
|
contents.ForEach(func(_, content gjson.Result) bool {
|
|
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
|
for _, field := range geminiFunctionNameFields {
|
|
nameResult := part.Get(field + ".name")
|
|
name := nameResult.String()
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if nameResult.Type == gjson.String && util.MapSanitizedFunctionName(functionNameMap, name) == name {
|
|
continue
|
|
}
|
|
needsRewrite = true
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
return !needsRewrite
|
|
})
|
|
return needsRewrite
|
|
}
|
|
|
|
func rewriteGeminiFunctionNames(rawJSON []byte, functionNameMap map[string]string) []byte {
|
|
contents := util.GetGJSONBytesNoCopy(rawJSON, "request.contents")
|
|
canBatchContents := contents.IsArray()
|
|
if canBatchContents {
|
|
contents.ForEach(func(_, content gjson.Result) bool {
|
|
parts := content.Get("parts")
|
|
if parts.Exists() && !parts.IsArray() {
|
|
canBatchContents = false
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
// Rebuilding the contents array copies every content and part, so only pay for
|
|
// it once a name actually needs rewriting.
|
|
if canBatchContents && geminiFunctionNamesNeedRewrite(contents, functionNameMap) {
|
|
contentItems := translatorcommon.NewRawArrayItems(contents.Get("#").Int())
|
|
contents.ForEach(func(_, content gjson.Result) bool {
|
|
contentJSON := []byte(content.Raw)
|
|
partsChanged := false
|
|
partItems := make([][]byte, 0, 4)
|
|
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
|
partJSON := []byte(part.Raw)
|
|
for _, field := range geminiFunctionNameFields {
|
|
nameResult := part.Get(field + ".name")
|
|
name := nameResult.String()
|
|
if name == "" {
|
|
continue
|
|
}
|
|
mappedName := util.MapSanitizedFunctionName(functionNameMap, name)
|
|
if nameResult.Type == gjson.String && mappedName == name {
|
|
continue
|
|
}
|
|
partJSON, _ = sjson.SetBytes(partJSON, field+".name", mappedName)
|
|
partsChanged = true
|
|
}
|
|
partItems = append(partItems, partJSON)
|
|
return true
|
|
})
|
|
if partsChanged {
|
|
contentJSON, _ = sjson.SetRawBytes(contentJSON, "parts", translatorcommon.JoinRawArray(partItems))
|
|
}
|
|
contentItems = append(contentItems, contentJSON)
|
|
return true
|
|
})
|
|
rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.contents", translatorcommon.JoinRawArray(contentItems))
|
|
} else if !canBatchContents {
|
|
for contentIndex, content := range contents.Array() {
|
|
for partIndex, part := range content.Get("parts").Array() {
|
|
for _, field := range geminiFunctionNameFields {
|
|
nameResult := part.Get(field + ".name")
|
|
name := nameResult.String()
|
|
if name == "" {
|
|
continue
|
|
}
|
|
mappedName := util.MapSanitizedFunctionName(functionNameMap, name)
|
|
if nameResult.Type == gjson.String && mappedName == name {
|
|
continue
|
|
}
|
|
path := fmt.Sprintf("request.contents.%d.parts.%d.%s.name", contentIndex, partIndex, field)
|
|
rawJSON, _ = sjson.SetBytes(rawJSON, path, mappedName)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, allowedPath := range []string{
|
|
"request.toolConfig.functionCallingConfig.allowedFunctionNames",
|
|
"request.tool_config.function_calling_config.allowed_function_names",
|
|
} {
|
|
allowedNames := util.GetGJSONBytesNoCopy(rawJSON, allowedPath)
|
|
if allowedNames.IsArray() {
|
|
namesChanged := false
|
|
nameItems := make([][]byte, 0, 4)
|
|
allowedNames.ForEach(func(_, name gjson.Result) bool {
|
|
mappedName := util.MapSanitizedFunctionName(functionNameMap, name.String())
|
|
namesChanged = namesChanged || name.Type != gjson.String || mappedName != name.String()
|
|
mappedNameJSON, _ := json.Marshal(mappedName)
|
|
nameItems = append(nameItems, mappedNameJSON)
|
|
return true
|
|
})
|
|
if namesChanged {
|
|
rawJSON, _ = sjson.SetRawBytes(rawJSON, allowedPath, translatorcommon.JoinRawArray(nameItems))
|
|
}
|
|
} else {
|
|
for index, name := range allowedNames.Array() {
|
|
mappedName := util.MapSanitizedFunctionName(functionNameMap, name.String())
|
|
if name.Type == gjson.String && mappedName == name.String() {
|
|
continue
|
|
}
|
|
path := fmt.Sprintf("%s.%d", allowedPath, index)
|
|
rawJSON, _ = sjson.SetBytes(rawJSON, path, mappedName)
|
|
}
|
|
}
|
|
}
|
|
return rawJSON
|
|
}
|
|
|
|
func SanitizeAntigravityClaudeGeminiRequestSignatures(modelName string, rawJSON []byte) []byte {
|
|
contents := util.GetGJSONBytesNoCopy(rawJSON, "request.contents")
|
|
if !contents.IsArray() {
|
|
return rawJSON
|
|
}
|
|
|
|
contentsArray := contents.Array()
|
|
changed := false
|
|
rewrittenContents := make([][]byte, 0, len(contentsArray))
|
|
|
|
for contentIndex, content := range contentsArray {
|
|
parts := content.Get("parts")
|
|
if !parts.IsArray() {
|
|
rewrittenContents = append(rewrittenContents, []byte(content.Raw))
|
|
continue
|
|
}
|
|
|
|
isModelTurn := content.Get("role").String() == "model"
|
|
partsArray := parts.Array()
|
|
contentChanged := false
|
|
rewrittenParts := make([][]byte, 0, len(partsArray))
|
|
|
|
for partIndex, partResult := range partsArray {
|
|
var part map[string]any
|
|
decoder := json.NewDecoder(strings.NewReader(partResult.Raw))
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(&part); err != nil {
|
|
rewrittenParts = append(rewrittenParts, []byte(partResult.Raw))
|
|
continue
|
|
}
|
|
|
|
rawSignature, hasStringSignature := antigravityClaudeGeminiPartThoughtSignature(part)
|
|
hasSignatureKey := hasStringSignature || antigravityClaudeGeminiPartHasThoughtSignatureKey(part) || antigravityClaudeGeminiPartHasThoughtSignatureKeyInRaw(partResult.Raw)
|
|
|
|
if hasFunctionResponsePart(part) {
|
|
if hasSignatureKey {
|
|
changed = true
|
|
contentChanged = true
|
|
deleteAntigravityClaudeGeminiPartThoughtSignatureFields(part)
|
|
logAntigravityClaudeGeminiSignatureSanitize(modelName, "drop_signature", "functionResponse parts cannot replay Claude thinking signatures", contentIndex, partIndex, rawSignature)
|
|
partBytes, _ := json.Marshal(part)
|
|
rewrittenParts = append(rewrittenParts, partBytes)
|
|
} else {
|
|
rewrittenParts = append(rewrittenParts, []byte(partResult.Raw))
|
|
}
|
|
continue
|
|
}
|
|
|
|
if !isModelTurn {
|
|
if hasSignatureKey {
|
|
changed = true
|
|
contentChanged = true
|
|
deleteAntigravityClaudeGeminiPartThoughtSignatureFields(part)
|
|
logAntigravityClaudeGeminiSignatureSanitize(modelName, "drop_signature", "non-model parts cannot replay Claude thinking signatures", contentIndex, partIndex, rawSignature)
|
|
partBytes, _ := json.Marshal(part)
|
|
rewrittenParts = append(rewrittenParts, partBytes)
|
|
} else {
|
|
rewrittenParts = append(rewrittenParts, []byte(partResult.Raw))
|
|
}
|
|
continue
|
|
}
|
|
|
|
if part["thought"] == true {
|
|
normalized, compatible := signature.CompatibleAntigravityClaudeThinkingSignature(rawSignature)
|
|
if !compatible {
|
|
changed = true
|
|
contentChanged = true
|
|
logAntigravityClaudeGeminiSignatureSanitize(modelName, "drop_thinking_block", "missing_or_incompatible_signature", contentIndex, partIndex, rawSignature)
|
|
continue
|
|
}
|
|
text, _ := part["text"].(string)
|
|
if strings.TrimSpace(text) == "" {
|
|
changed = true
|
|
contentChanged = true
|
|
logAntigravityClaudeGeminiSignatureSanitize(modelName, "drop_thinking_block", "empty_thinking_text", contentIndex, partIndex, rawSignature)
|
|
continue
|
|
}
|
|
if normalized != rawSignature {
|
|
changed = true
|
|
contentChanged = true
|
|
logAntigravityClaudeGeminiSignatureSanitize(modelName, "normalize_signature", "compatible_claude_signature", contentIndex, partIndex, rawSignature)
|
|
}
|
|
deleteAntigravityClaudeGeminiPartThoughtSignatureFields(part)
|
|
part["thoughtSignature"] = normalized
|
|
partBytes, _ := json.Marshal(part)
|
|
rewrittenParts = append(rewrittenParts, partBytes)
|
|
continue
|
|
}
|
|
|
|
if hasSignatureKey {
|
|
changed = true
|
|
contentChanged = true
|
|
deleteAntigravityClaudeGeminiPartThoughtSignatureFields(part)
|
|
logAntigravityClaudeGeminiSignatureSanitize(modelName, "drop_signature", "non-thinking parts should not carry Claude thinking signatures", contentIndex, partIndex, rawSignature)
|
|
partBytes, _ := json.Marshal(part)
|
|
rewrittenParts = append(rewrittenParts, partBytes)
|
|
} else {
|
|
rewrittenParts = append(rewrittenParts, []byte(partResult.Raw))
|
|
}
|
|
}
|
|
|
|
if len(rewrittenParts) == 0 {
|
|
changed = true
|
|
continue
|
|
}
|
|
if contentChanged || len(rewrittenParts) != len(partsArray) {
|
|
contentBytes := []byte(content.Raw)
|
|
contentBytes, _ = sjson.SetRawBytes(contentBytes, "parts", translatorcommon.JoinRawArray(rewrittenParts))
|
|
rewrittenContents = append(rewrittenContents, contentBytes)
|
|
} else {
|
|
rewrittenContents = append(rewrittenContents, []byte(content.Raw))
|
|
}
|
|
}
|
|
|
|
if !changed {
|
|
return rawJSON
|
|
}
|
|
out, errSet := sjson.SetRawBytes(rawJSON, "request.contents", translatorcommon.JoinRawArray(rewrittenContents))
|
|
if errSet != nil {
|
|
return rawJSON
|
|
}
|
|
return out
|
|
}
|
|
|
|
func antigravityClaudeGeminiPartHasThoughtSignatureKeyInRaw(raw string) bool {
|
|
dec := json.NewDecoder(strings.NewReader(raw))
|
|
dec.UseNumber()
|
|
var stack []bool
|
|
expectKey := false
|
|
|
|
for {
|
|
t, err := dec.Token()
|
|
if err != nil {
|
|
break
|
|
}
|
|
|
|
switch v := t.(type) {
|
|
case json.Delim:
|
|
switch v {
|
|
case '{':
|
|
stack = append(stack, true)
|
|
expectKey = true
|
|
case '}':
|
|
if len(stack) > 0 {
|
|
stack = stack[:len(stack)-1]
|
|
}
|
|
if len(stack) > 0 && stack[len(stack)-1] {
|
|
expectKey = true
|
|
} else {
|
|
expectKey = false
|
|
}
|
|
case '[':
|
|
stack = append(stack, false)
|
|
expectKey = false
|
|
case ']':
|
|
if len(stack) > 0 {
|
|
stack = stack[:len(stack)-1]
|
|
}
|
|
if len(stack) > 0 && stack[len(stack)-1] {
|
|
expectKey = true
|
|
} else {
|
|
expectKey = false
|
|
}
|
|
}
|
|
case string:
|
|
if expectKey && len(stack) > 0 && stack[len(stack)-1] {
|
|
if v == "thoughtSignature" || v == "thought_signature" {
|
|
return true
|
|
}
|
|
expectKey = false
|
|
} else {
|
|
if len(stack) > 0 && stack[len(stack)-1] {
|
|
expectKey = true
|
|
}
|
|
}
|
|
default:
|
|
if len(stack) > 0 && stack[len(stack)-1] {
|
|
expectKey = true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func antigravityClaudeGeminiPartHasThoughtSignatureKey(part map[string]any) bool {
|
|
for _, path := range [][]string{
|
|
{"thoughtSignature"},
|
|
{"thought_signature"},
|
|
{"functionCall", "thoughtSignature"},
|
|
{"functionCall", "thought_signature"},
|
|
{"functionResponse", "thoughtSignature"},
|
|
{"functionResponse", "thought_signature"},
|
|
{"extra_content", "google", "thought_signature"},
|
|
} {
|
|
if hasKeyAtPath(part, path...) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasKeyAtPath(value map[string]any, path ...string) bool {
|
|
var current any = value
|
|
for _, key := range path {
|
|
m, ok := current.(map[string]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if _, exists := m[key]; !exists {
|
|
return false
|
|
}
|
|
current = m[key]
|
|
}
|
|
return true
|
|
}
|
|
|
|
func antigravityClaudeGeminiPartThoughtSignature(part map[string]any) (string, bool) {
|
|
for _, path := range [][]string{
|
|
{"thoughtSignature"},
|
|
{"thought_signature"},
|
|
{"functionCall", "thoughtSignature"},
|
|
{"functionCall", "thought_signature"},
|
|
{"functionResponse", "thoughtSignature"},
|
|
{"functionResponse", "thought_signature"},
|
|
{"extra_content", "google", "thought_signature"},
|
|
} {
|
|
if value, ok := stringAtPath(part, path...); ok {
|
|
return value, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func deleteAntigravityClaudeGeminiPartThoughtSignatureFields(part map[string]any) {
|
|
for _, path := range [][]string{
|
|
{"thoughtSignature"},
|
|
{"thought_signature"},
|
|
{"functionCall", "thoughtSignature"},
|
|
{"functionCall", "thought_signature"},
|
|
{"functionResponse", "thoughtSignature"},
|
|
{"functionResponse", "thought_signature"},
|
|
{"extra_content", "google", "thought_signature"},
|
|
} {
|
|
deleteAtPath(part, path...)
|
|
}
|
|
}
|
|
|
|
func hasFunctionResponsePart(part map[string]any) bool {
|
|
if _, ok := part["functionResponse"]; ok {
|
|
return true
|
|
}
|
|
_, ok := part["function_response"]
|
|
return ok
|
|
}
|
|
|
|
func stringAtPath(value map[string]any, path ...string) (string, bool) {
|
|
var current any = value
|
|
for _, key := range path {
|
|
m, ok := current.(map[string]any)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
current, ok = m[key]
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
}
|
|
s, ok := current.(string)
|
|
return s, ok
|
|
}
|
|
|
|
func deleteAtPath(value map[string]any, path ...string) {
|
|
if len(path) == 0 {
|
|
return
|
|
}
|
|
current := value
|
|
for _, key := range path[:len(path)-1] {
|
|
next, ok := current[key].(map[string]any)
|
|
if !ok {
|
|
return
|
|
}
|
|
current = next
|
|
}
|
|
delete(current, path[len(path)-1])
|
|
}
|
|
|
|
func logAntigravityClaudeGeminiSignatureSanitize(modelName, action, reason string, contentIndex, partIndex int, rawSignature string) {
|
|
fields := log.Fields{
|
|
"component": "signature_sanitizer",
|
|
"translator": "antigravity_gemini",
|
|
"target_provider": string(signature.SignatureProviderClaude),
|
|
"action": action,
|
|
"reason": reason,
|
|
"model": modelName,
|
|
"content_index": contentIndex,
|
|
"part_index": partIndex,
|
|
"has_signature": strings.TrimSpace(rawSignature) != "",
|
|
"signature_length": len(strings.TrimSpace(rawSignature)),
|
|
"detected_provider": string(signature.DetectSignatureProviderForBlock(rawSignature, signature.SignatureBlockKindClaudeThinking)),
|
|
}
|
|
log.WithFields(fields).Debug("antigravity gemini translator: sanitized Claude target thoughtSignature before upstream")
|
|
}
|
|
|
|
// FunctionCallGroup represents a group of function calls and their responses
|
|
type FunctionCallGroup struct {
|
|
ResponsesNeeded int
|
|
CallNames []string // ordered function call names for backfilling empty response names
|
|
}
|
|
|
|
func normalizeAntigravityInlineDataPart(part gjson.Result) ([]byte, bool) {
|
|
inline := part.Get("inlineData")
|
|
if !inline.Exists() {
|
|
inline = part.Get("inline_data")
|
|
}
|
|
if !inline.Exists() {
|
|
return nil, false
|
|
}
|
|
data := inline.Get("data").String()
|
|
if data == "" {
|
|
return nil, false
|
|
}
|
|
mimeType := inline.Get("mimeType").String()
|
|
if mimeType == "" {
|
|
mimeType = inline.Get("mime_type").String()
|
|
}
|
|
if mimeType == "" {
|
|
// Cloud Code Assist ignores inlineData without mimeType.
|
|
mimeType = "image/png"
|
|
}
|
|
out := []byte(`{"inlineData":{"mimeType":"","data":""}}`)
|
|
out, _ = sjson.SetBytes(out, "inlineData.mimeType", mimeType)
|
|
out, _ = sjson.SetBytes(out, "inlineData.data", data)
|
|
return out, true
|
|
}
|
|
|
|
func attachInlineDataToFunctionResponse(response gjson.Result, images [][]byte) gjson.Result {
|
|
if len(images) == 0 {
|
|
return response
|
|
}
|
|
target := []byte(response.Raw)
|
|
for _, img := range images {
|
|
target, _ = sjson.SetRawBytes(target, "functionResponse.parts.-1", img)
|
|
}
|
|
return gjson.ParseBytes(target)
|
|
}
|
|
|
|
// collectFunctionResponsesWithSiblingInlineData keeps functionResponse parts and
|
|
// moves sibling inline_data/inlineData onto the nearest preceding functionResponse.
|
|
// Leading images before the first functionResponse attach to that first response.
|
|
func collectFunctionResponsesWithSiblingInlineData(parts gjson.Result) []gjson.Result {
|
|
responses := make([]gjson.Result, 0)
|
|
leadingImages := make([][]byte, 0)
|
|
current := -1
|
|
parts.ForEach(func(_, part gjson.Result) bool {
|
|
if part.Get("functionResponse").Exists() {
|
|
responses = append(responses, part)
|
|
current = len(responses) - 1
|
|
if len(leadingImages) > 0 {
|
|
responses[current] = attachInlineDataToFunctionResponse(responses[current], leadingImages)
|
|
leadingImages = nil
|
|
}
|
|
return true
|
|
}
|
|
imagePart, ok := normalizeAntigravityInlineDataPart(part)
|
|
if !ok {
|
|
return true
|
|
}
|
|
if current >= 0 {
|
|
responses[current] = attachInlineDataToFunctionResponse(responses[current], [][]byte{imagePart})
|
|
return true
|
|
}
|
|
leadingImages = append(leadingImages, imagePart)
|
|
return true
|
|
})
|
|
return responses
|
|
}
|
|
|
|
// parseFunctionResponseRaw attempts to normalize a function response part into a JSON object string.
|
|
// Falls back to a minimal "functionResponse" object when parsing fails.
|
|
// fallbackName is used when the response's own name is empty.
|
|
func parseFunctionResponseRaw(response gjson.Result, fallbackName string) string {
|
|
if response.IsObject() && gjson.Valid(response.Raw) {
|
|
raw := response.Raw
|
|
name := response.Get("functionResponse.name").String()
|
|
if strings.TrimSpace(name) == "" && fallbackName != "" {
|
|
updated, _ := sjson.SetBytes([]byte(raw), "functionResponse.name", fallbackName)
|
|
raw = string(updated)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
log.Debugf("parse function response failed, using fallback")
|
|
funcResp := response.Get("functionResponse")
|
|
if funcResp.Exists() {
|
|
fr := []byte(`{"functionResponse":{"name":"","response":{"result":""}}}`)
|
|
name := funcResp.Get("name").String()
|
|
if strings.TrimSpace(name) == "" {
|
|
name = fallbackName
|
|
}
|
|
fr, _ = sjson.SetBytes(fr, "functionResponse.name", name)
|
|
fr, _ = sjson.SetBytes(fr, "functionResponse.response.result", funcResp.Get("response").String())
|
|
if id := funcResp.Get("id").String(); id != "" {
|
|
fr, _ = sjson.SetBytes(fr, "functionResponse.id", id)
|
|
}
|
|
return string(fr)
|
|
}
|
|
|
|
useName := fallbackName
|
|
if useName == "" {
|
|
useName = "unknown"
|
|
}
|
|
fr := []byte(`{"functionResponse":{"name":"","response":{"result":""}}}`)
|
|
fr, _ = sjson.SetBytes(fr, "functionResponse.name", useName)
|
|
fr, _ = sjson.SetBytes(fr, "functionResponse.response.result", response.String())
|
|
return string(fr)
|
|
}
|
|
|
|
// fixCLIToolResponse performs sophisticated tool response format conversion and grouping.
|
|
// This function transforms the CLI tool response format by intelligently grouping function calls
|
|
// with their corresponding responses, ensuring proper conversation flow and API compatibility.
|
|
// It converts from a linear format (1.json) to a grouped format (2.json) where function calls
|
|
// and their responses are properly associated and structured.
|
|
//
|
|
// Parameters:
|
|
// - input: The input JSON string to be processed
|
|
//
|
|
// Returns:
|
|
// - string: The processed JSON string with grouped function calls and responses
|
|
// - error: An error if the processing fails
|
|
func fixCLIToolResponse(input []byte) ([]byte, error) {
|
|
// Parse the input JSON to extract the conversation structure.
|
|
// The parsed result references input directly; input must not be mutated
|
|
// while the result and its raw slices are still in use.
|
|
parsed := util.ParseGJSONBytesNoCopy(input)
|
|
|
|
// Extract the contents array which contains the conversation messages
|
|
contents := parsed.Get("request.contents")
|
|
if !contents.Exists() {
|
|
// log.Debugf(input)
|
|
return input, fmt.Errorf("contents not found in input")
|
|
}
|
|
|
|
needsGrouping := false
|
|
allContentsAreObjects := true
|
|
contents.ForEach(func(_, content gjson.Result) bool {
|
|
if !content.IsObject() {
|
|
allContentsAreObjects = false
|
|
return true
|
|
}
|
|
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
|
if part.Get("functionResponse").Exists() {
|
|
needsGrouping = true
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
return !needsGrouping
|
|
})
|
|
if contents.IsArray() && allContentsAreObjects && !needsGrouping {
|
|
return input, nil
|
|
}
|
|
|
|
// Initialize data structures for processing and grouping
|
|
contentItems := translatorcommon.NewRawArrayItems(contents.Get("#").Int())
|
|
var pendingGroups []*FunctionCallGroup // Groups awaiting completion with responses
|
|
var collectedResponses []gjson.Result // Standalone responses to be matched
|
|
appendFunctionResponses := func(responses []gjson.Result, callNames []string) {
|
|
partItems := make([][]byte, 0, len(responses))
|
|
for responseIndex, response := range responses {
|
|
partRaw := parseFunctionResponseRaw(response, callNames[responseIndex])
|
|
if partRaw != "" {
|
|
partItems = append(partItems, []byte(partRaw))
|
|
}
|
|
}
|
|
if len(partItems) > 0 {
|
|
functionResponseContent := []byte(`{"parts":[],"role":"function"}`)
|
|
functionResponseContent, _ = sjson.SetRawBytes(functionResponseContent, "parts", translatorcommon.JoinRawArray(partItems))
|
|
contentItems = append(contentItems, functionResponseContent)
|
|
}
|
|
}
|
|
|
|
// Process each content object in the conversation
|
|
// This iterates through messages and groups function calls with their responses
|
|
contents.ForEach(func(key, value gjson.Result) bool {
|
|
role := value.Get("role").String()
|
|
parts := value.Get("parts")
|
|
|
|
// Collect function responses and attach sibling inlineData to the nearest one.
|
|
responsePartsInThisContent := collectFunctionResponsesWithSiblingInlineData(parts)
|
|
|
|
// If this content has function responses, collect them
|
|
if len(responsePartsInThisContent) > 0 {
|
|
collectedResponses = append(collectedResponses, responsePartsInThisContent...)
|
|
|
|
// Check if pending groups can be satisfied (FIFO: oldest group first)
|
|
for len(pendingGroups) > 0 && len(collectedResponses) >= pendingGroups[0].ResponsesNeeded {
|
|
group := pendingGroups[0]
|
|
pendingGroups = pendingGroups[1:]
|
|
|
|
// Take the needed responses for this group
|
|
groupResponses := collectedResponses[:group.ResponsesNeeded]
|
|
collectedResponses = collectedResponses[group.ResponsesNeeded:]
|
|
|
|
appendFunctionResponses(groupResponses, group.CallNames)
|
|
}
|
|
|
|
return true // Skip adding this content, responses are merged
|
|
}
|
|
|
|
// If this is a model with function calls, create a new group
|
|
if role == "model" {
|
|
var callNames []string
|
|
parts.ForEach(func(_, part gjson.Result) bool {
|
|
if part.Get("functionCall").Exists() {
|
|
callNames = append(callNames, part.Get("functionCall.name").String())
|
|
}
|
|
return true
|
|
})
|
|
|
|
if len(callNames) > 0 {
|
|
// Add the model content
|
|
if !value.IsObject() {
|
|
log.Warnf("failed to parse model content")
|
|
return true
|
|
}
|
|
contentItems = append(contentItems, []byte(value.Raw))
|
|
|
|
// Create a new group for tracking responses
|
|
group := &FunctionCallGroup{
|
|
ResponsesNeeded: len(callNames),
|
|
CallNames: callNames,
|
|
}
|
|
pendingGroups = append(pendingGroups, group)
|
|
} else {
|
|
// Regular model content without function calls
|
|
if !value.IsObject() {
|
|
log.Warnf("failed to parse content")
|
|
return true
|
|
}
|
|
contentItems = append(contentItems, []byte(value.Raw))
|
|
}
|
|
} else {
|
|
// Non-model content (user, etc.)
|
|
if !value.IsObject() {
|
|
log.Warnf("failed to parse content")
|
|
return true
|
|
}
|
|
contentItems = append(contentItems, []byte(value.Raw))
|
|
}
|
|
|
|
return true
|
|
})
|
|
|
|
// Handle any remaining pending groups with remaining responses
|
|
for _, group := range pendingGroups {
|
|
if len(collectedResponses) >= group.ResponsesNeeded {
|
|
groupResponses := collectedResponses[:group.ResponsesNeeded]
|
|
collectedResponses = collectedResponses[group.ResponsesNeeded:]
|
|
|
|
appendFunctionResponses(groupResponses, group.CallNames)
|
|
}
|
|
}
|
|
|
|
// Update the original JSON with the new contents
|
|
result, _ := sjson.SetRawBytes(input, "request.contents", translatorcommon.JoinRawArray(contentItems))
|
|
|
|
return result, nil
|
|
}
|