Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
|
|
@ -0,0 +1,900 @@
|
|||
// 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
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,129 @@
|
|||
// Package gemini provides request translation functionality for Gemini to Antigravity API compatibility.
|
||||
// It handles parsing and transforming Gemini API requests into Antigravity API format,
|
||||
// extracting model information, system instructions, message contents, and tool declarations.
|
||||
// The package performs JSON data transformation to ensure compatibility
|
||||
// between Gemini API format and Antigravity API's expected format.
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// ConvertAntigravityResponseToGemini 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 response data from the request
|
||||
// 2. Handles alternative response formats
|
||||
// 3. Processes array responses by extracting individual response objects
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request, used for cancellation and timeout handling
|
||||
// - 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
|
||||
// - param: A pointer to a parameter object for the conversion (unused in current implementation)
|
||||
//
|
||||
// Returns:
|
||||
// - [][]byte: The transformed response data in Gemini API format.
|
||||
func ConvertAntigravityResponseToGemini(ctx context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) [][]byte {
|
||||
if bytes.HasPrefix(rawJSON, []byte("data:")) {
|
||||
rawJSON = bytes.TrimSpace(rawJSON[5:])
|
||||
}
|
||||
|
||||
if alt, ok := ctx.Value("alt").(string); ok {
|
||||
var chunk []byte
|
||||
if alt == "" {
|
||||
responseResult := gjson.GetBytes(rawJSON, "response")
|
||||
if responseResult.Exists() {
|
||||
chunk = []byte(responseResult.Raw)
|
||||
chunk = restoreUsageMetadata(chunk)
|
||||
chunk = restoreGeminiFunctionNames(chunk, originalRequestRawJSON)
|
||||
}
|
||||
} else {
|
||||
chunkTemplate := []byte("[]")
|
||||
responseResult := gjson.ParseBytes(chunk)
|
||||
if responseResult.IsArray() {
|
||||
responseResultItems := responseResult.Array()
|
||||
for i := 0; i < len(responseResultItems); i++ {
|
||||
responseResultItem := responseResultItems[i]
|
||||
if responseResultItem.Get("response").Exists() {
|
||||
chunkTemplate, _ = sjson.SetRawBytes(chunkTemplate, "-1", []byte(responseResultItem.Get("response").Raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
chunk = chunkTemplate
|
||||
}
|
||||
return [][]byte{chunk}
|
||||
}
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
// ConvertAntigravityResponseToGeminiNonStream converts a non-streaming Antigravity request to a non-streaming Gemini response.
|
||||
// This function processes the complete Antigravity request and transforms it into a single Gemini-compatible
|
||||
// JSON response. It extracts the response data from the request and returns it in the expected format.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request, used for cancellation and timeout handling
|
||||
// - modelName: The name of the model being used for the response (unused in current implementation)
|
||||
// - rawJSON: The raw JSON request data from the Antigravity API
|
||||
// - param: A pointer to a parameter object for the conversion (unused in current implementation)
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: A Gemini-compatible JSON response containing the response data.
|
||||
func ConvertAntigravityResponseToGeminiNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
|
||||
responseResult := gjson.GetBytes(rawJSON, "response")
|
||||
if responseResult.Exists() {
|
||||
chunk := restoreUsageMetadata([]byte(responseResult.Raw))
|
||||
return restoreGeminiFunctionNames(chunk, originalRequestRawJSON)
|
||||
}
|
||||
return restoreGeminiFunctionNames(rawJSON, originalRequestRawJSON)
|
||||
}
|
||||
|
||||
func restoreGeminiFunctionNames(chunk, originalRequestRawJSON []byte) []byte {
|
||||
nameMap := util.DisambiguatedToolNameMap(originalRequestRawJSON)
|
||||
if len(nameMap) == 0 {
|
||||
return chunk
|
||||
}
|
||||
candidates := gjson.GetBytes(chunk, "candidates")
|
||||
for candidateIndex, candidate := range candidates.Array() {
|
||||
for partIndex, part := range candidate.Get("content.parts").Array() {
|
||||
for _, field := range []string{"functionCall", "functionResponse", "function_call", "function_response"} {
|
||||
nameResult := part.Get(field + ".name")
|
||||
name := nameResult.String()
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
restoredName := util.RestoreSanitizedToolName(nameMap, name)
|
||||
if nameResult.Type == gjson.String && restoredName == name {
|
||||
continue
|
||||
}
|
||||
path := fmt.Sprintf("candidates.%d.content.parts.%d.%s.name", candidateIndex, partIndex, field)
|
||||
chunk, _ = sjson.SetBytes(chunk, path, restoredName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return chunk
|
||||
}
|
||||
|
||||
func GeminiTokenCount(ctx context.Context, count int64) []byte {
|
||||
return translatorcommon.GeminiTokenCountJSON(count)
|
||||
}
|
||||
|
||||
// restoreUsageMetadata renames cpaUsageMetadata back to usageMetadata.
|
||||
// The executor renames usageMetadata to cpaUsageMetadata in non-terminal chunks
|
||||
// to preserve usage data while hiding it from clients that don't expect it.
|
||||
// When returning standard Gemini API format, we must restore the original name.
|
||||
func restoreUsageMetadata(chunk []byte) []byte {
|
||||
if cpaUsage := gjson.GetBytes(chunk, "cpaUsageMetadata"); cpaUsage.Exists() {
|
||||
chunk, _ = sjson.SetRawBytes(chunk, "usageMetadata", []byte(cpaUsage.Raw))
|
||||
chunk, _ = sjson.DeleteBytes(chunk, "cpaUsageMetadata")
|
||||
}
|
||||
return chunk
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestRestoreUsageMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "cpaUsageMetadata renamed to usageMetadata",
|
||||
input: []byte(`{"modelVersion":"gemini-3-pro","cpaUsageMetadata":{"promptTokenCount":100,"candidatesTokenCount":200}}`),
|
||||
expected: `{"modelVersion":"gemini-3-pro","usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":200}}`,
|
||||
},
|
||||
{
|
||||
name: "no cpaUsageMetadata unchanged",
|
||||
input: []byte(`{"modelVersion":"gemini-3-pro","usageMetadata":{"promptTokenCount":100}}`),
|
||||
expected: `{"modelVersion":"gemini-3-pro","usageMetadata":{"promptTokenCount":100}}`,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: []byte(`{}`),
|
||||
expected: `{}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := restoreUsageMetadata(tt.input)
|
||||
if string(result) != tt.expected {
|
||||
t.Errorf("restoreUsageMetadata() = %s, want %s", string(result), tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAntigravityResponseToGeminiNonStream(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "cpaUsageMetadata restored in response",
|
||||
input: []byte(`{"response":{"modelVersion":"gemini-3-pro","cpaUsageMetadata":{"promptTokenCount":100}}}`),
|
||||
expected: `{"modelVersion":"gemini-3-pro","usageMetadata":{"promptTokenCount":100}}`,
|
||||
},
|
||||
{
|
||||
name: "usageMetadata preserved",
|
||||
input: []byte(`{"response":{"modelVersion":"gemini-3-pro","usageMetadata":{"promptTokenCount":100}}}`),
|
||||
expected: `{"modelVersion":"gemini-3-pro","usageMetadata":{"promptTokenCount":100}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ConvertAntigravityResponseToGeminiNonStream(context.Background(), "", nil, nil, tt.input, nil)
|
||||
if string(result) != tt.expected {
|
||||
t.Errorf("ConvertAntigravityResponseToGeminiNonStream() = %s, want %s", string(result), tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAntigravityResponseToGeminiNonStreamRestoresDisambiguatedName(t *testing.T) {
|
||||
first := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build"
|
||||
second := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build_logs"
|
||||
original := []byte(`{"tools":[{"functionDeclarations":[{"name":"` + first + `"},{"name":"` + second + `"}]}]}`)
|
||||
mapped := util.SanitizedFunctionNameMap(original)[second]
|
||||
raw := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"` + mapped + `","args":{}}}]}}]}}`)
|
||||
|
||||
out := ConvertAntigravityResponseToGeminiNonStream(context.Background(), "", original, nil, raw, nil)
|
||||
if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.name").String(); got != second {
|
||||
t.Fatalf("functionCall.name = %q, want %q. Output: %s", got, second, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAntigravityResponseToGeminiStream(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), "alt", "")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "cpaUsageMetadata restored in streaming response",
|
||||
input: []byte(`data: {"response":{"modelVersion":"gemini-3-pro","cpaUsageMetadata":{"promptTokenCount":100}}}`),
|
||||
expected: `{"modelVersion":"gemini-3-pro","usageMetadata":{"promptTokenCount":100}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
results := ConvertAntigravityResponseToGemini(ctx, "", nil, nil, tt.input, nil)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
if string(results[0]) != tt.expected {
|
||||
t.Errorf("ConvertAntigravityResponseToGemini() = %s, want %s", string(results[0]), tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
20
backend/internal/translator/antigravity/gemini/init.go
Normal file
20
backend/internal/translator/antigravity/gemini/init.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
Gemini,
|
||||
Antigravity,
|
||||
ConvertGeminiRequestToAntigravity,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertAntigravityResponseToGemini,
|
||||
NonStream: ConvertAntigravityResponseToGeminiNonStream,
|
||||
TokenCount: GeminiTokenCount,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestRewriteGeminiFunctionNamesReusesNormalizedPayload(t *testing.T) {
|
||||
input := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","args":{}}}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","response":{"result":"ok"}}}]}],"toolConfig":{"functionCallingConfig":{"allowedFunctionNames":["lookup"]}}}}`)
|
||||
|
||||
output := rewriteGeminiFunctionNames(input, nil)
|
||||
|
||||
if &output[0] != &input[0] {
|
||||
t.Fatal("normalized function names caused a payload copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveEmptyGeminiFunctionToolsReusesNormalizedPayload(t *testing.T) {
|
||||
input := []byte(`{"request":{"tools":[{"functionDeclarations":[{"name":"lookup"}]}]}}`)
|
||||
|
||||
output := removeEmptyGeminiFunctionTools(input)
|
||||
|
||||
if &output[0] != &input[0] {
|
||||
t.Fatal("non-empty tools caused a payload copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveEmptyGeminiFunctionToolsDeletesEmptyArray(t *testing.T) {
|
||||
input := []byte(`{"request":{"tools":[]}}`)
|
||||
|
||||
output := removeEmptyGeminiFunctionTools(input)
|
||||
|
||||
if gjson.GetBytes(output, "request.tools").Exists() {
|
||||
t.Fatalf("empty tools should be removed: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteGeminiFunctionNamesNormalizesNonStringNames(t *testing.T) {
|
||||
input := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":true,"args":{}}}]}],"toolConfig":{"functionCallingConfig":{"allowedFunctionNames":[true]}}}}`)
|
||||
|
||||
output := rewriteGeminiFunctionNames(input, nil)
|
||||
|
||||
if name := gjson.GetBytes(output, "request.contents.0.parts.0.functionCall.name"); name.Type != gjson.String || name.String() != "true" {
|
||||
t.Fatalf("functionCall.name = %s, want string true", name.Raw)
|
||||
}
|
||||
if name := gjson.GetBytes(output, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0"); name.Type != gjson.String || name.String() != "true" {
|
||||
t.Fatalf("allowedFunctionNames.0 = %s, want string true", name.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixCLIToolResponseReusesHistoryWithoutFunctionResponses(t *testing.T) {
|
||||
input := []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hello"}]},{"role":"model","parts":[{"text":"world"}]}]}}`)
|
||||
|
||||
output, errFix := fixCLIToolResponse(input)
|
||||
if errFix != nil {
|
||||
t.Fatalf("fixCLIToolResponse returned an error: %v", errFix)
|
||||
}
|
||||
if string(output) != string(input) {
|
||||
t.Fatalf("history changed:\n got: %s\nwant: %s", output, input)
|
||||
}
|
||||
if &output[0] != &input[0] {
|
||||
t.Fatal("history without function responses caused a payload copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixCLIToolResponsePreservesObjectNormalization(t *testing.T) {
|
||||
input := []byte(`{"request":{"contents":{"first":{"role":"user","parts":[{"text":"hello"}]}}}}`)
|
||||
|
||||
output, errFix := fixCLIToolResponse(input)
|
||||
if errFix != nil {
|
||||
t.Fatalf("fixCLIToolResponse returned an error: %v", errFix)
|
||||
}
|
||||
if !gjson.GetBytes(output, "request.contents").IsArray() {
|
||||
t.Fatalf("contents should be normalized to an array: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertGeminiRequestToAntigravityBoundsLargePayloadCopies keeps the number of
|
||||
// full-payload copies bounded for large inline data. The assertions run directly in
|
||||
// the test (not inside testing.Benchmark) so a regression fails loudly instead of
|
||||
// being swallowed by a discarded benchmark result.
|
||||
func TestConvertGeminiRequestToAntigravityBoundsLargePayloadCopies(t *testing.T) {
|
||||
const inlineDataSize = 4 << 20
|
||||
input := []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"image/png","data":"` +
|
||||
strings.Repeat("A", inlineDataSize) + `"}},{"text":"describe"}]}]}`)
|
||||
|
||||
var before, after runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&before)
|
||||
output := ConvertGeminiRequestToAntigravity("gemini-3-flash", input, false)
|
||||
runtime.ReadMemStats(&after)
|
||||
|
||||
if got := gjson.GetBytes(output, "request.contents.0.parts.0.inlineData.data").String(); len(got) != inlineDataSize {
|
||||
t.Fatalf("inline data length = %d, want %d", len(got), inlineDataSize)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "model").String(); got != "gemini-3-flash" {
|
||||
t.Fatalf("model = %q, want gemini-3-flash", got)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "request.safetySettings"); !got.IsArray() {
|
||||
t.Fatalf("request.safetySettings = %s, want array", got.Raw)
|
||||
}
|
||||
|
||||
// Wrapping the request in the Antigravity envelope and setting the model each
|
||||
// allocate one payload-sized buffer; everything beyond that is a regression.
|
||||
const allowedCopies = 3
|
||||
if allocated := after.TotalAlloc - before.TotalAlloc; allocated > allowedCopies*inlineDataSize {
|
||||
t.Fatalf("conversion allocated %d bytes for a %d byte payload, want at most %d",
|
||||
allocated, inlineDataSize, allowedCopies*inlineDataSize)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue