Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View file

@ -0,0 +1,940 @@
// Package claude provides request translation functionality for Claude Code API compatibility.
// This package handles the conversion of Claude Code API requests into Antigravity-compatible
// JSON format, transforming message contents, system instructions, and tool declarations
// into the format expected by Antigravity API clients. It performs JSON data transformation
// to ensure compatibility between Claude Code API format and Antigravity API's expected format.
package claude
import (
"context"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
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"
)
func resolveThinkingSignature(modelName, thinkingText, rawSignature string) string {
signature, errSignature := resolveThinkingSignatureRequired(context.Background(), modelName, thinkingText, rawSignature)
if errSignature != nil {
return ""
}
return signature
}
func resolveThinkingSignatureRequired(ctx context.Context, modelName, thinkingText, rawSignature string) (string, error) {
targetProvider := sigcompat.SignatureProviderFromModelName(modelName)
if targetProvider == sigcompat.SignatureProviderGemini {
innerSignature, _, targetKind, marked, okCarrier := decodeGeminiClaudeCarrierSignature(rawSignature)
if !okCarrier {
return "", nil
}
blockKind := sigcompat.SignatureBlockKindGeminiModelPart
if marked && targetKind == geminiClaudeCarrierFunction {
blockKind = sigcompat.SignatureBlockKindGeminiFunctionCall
}
return resolveProviderCompatibleSignature(targetProvider, innerSignature, blockKind), nil
}
if cache.SignatureCacheEnabled() {
return resolveCacheModeSignatureRequired(ctx, modelName, thinkingText, rawSignature)
}
if signature := resolveProviderCompatibleSignature(targetProvider, rawSignature, sigcompat.SignatureBlockKindUnknown); signature != "" {
return signature, nil
}
return resolveBypassModeSignatureForProvider(targetProvider, rawSignature), nil
}
func resolveCacheModeSignature(modelName, thinkingText, rawSignature string) string {
signature, errSignature := resolveCacheModeSignatureRequired(context.Background(), modelName, thinkingText, rawSignature)
if errSignature != nil {
return ""
}
return signature
}
func resolveCacheModeSignatureRequired(ctx context.Context, modelName, thinkingText, rawSignature string) (string, error) {
targetProvider := sigcompat.SignatureProviderFromModelName(modelName)
if thinkingText != "" {
cachedSig, errCachedSig := cache.GetCachedSignatureRequired(ctx, modelName, thinkingText)
if errCachedSig != nil {
return "", errCachedSig
}
if cachedSig != "" {
if targetProvider == sigcompat.SignatureProviderClaude {
signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(cachedSig)
if !ok {
return "", nil
}
return signature, nil
}
return cachedSig, nil
}
}
if rawSignature == "" {
return "", nil
}
clientSignature := ""
arrayClientSignatures := strings.SplitN(rawSignature, "#", 2)
if len(arrayClientSignatures) == 2 {
if cache.GetModelGroup(modelName) == arrayClientSignatures[0] {
clientSignature = arrayClientSignatures[1]
}
}
if cache.HasValidSignature(modelName, clientSignature) {
if targetProvider == sigcompat.SignatureProviderClaude {
signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(clientSignature)
if !ok {
return "", nil
}
return signature, nil
}
return clientSignature, nil
}
return "", nil
}
func RequireCachedThinkingSignatures(ctx context.Context, modelName string, rawJSON []byte) error {
if !cache.SignatureCacheEnabled() {
return nil
}
if sigcompat.SignatureProviderFromModelName(modelName) == sigcompat.SignatureProviderGemini {
return nil
}
messagesResult := gjson.GetBytes(rawJSON, "messages")
if !messagesResult.IsArray() {
return nil
}
for _, messageResult := range messagesResult.Array() {
contentsResult := messageResult.Get("content")
if !contentsResult.IsArray() {
continue
}
for _, contentResult := range contentsResult.Array() {
if contentResult.Get("type").String() != "thinking" {
continue
}
thinkingText := thinking.GetThinkingText(contentResult)
if thinkingText == "" {
continue
}
if _, errSignature := cache.GetCachedSignatureRequired(ctx, modelName, thinkingText); errSignature != nil {
return errSignature
}
}
}
return nil
}
func resolveBypassModeSignature(rawSignature string) string {
return resolveBypassModeSignatureForProvider(sigcompat.SignatureProviderClaude, rawSignature)
}
func resolveBypassModeSignatureForProvider(targetProvider sigcompat.SignatureProvider, rawSignature string) string {
if rawSignature == "" {
return ""
}
if targetProvider != sigcompat.SignatureProviderClaude && targetProvider != sigcompat.SignatureProviderUnknown {
return ""
}
if targetProvider == sigcompat.SignatureProviderClaude {
signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSignature)
if !ok {
return ""
}
return signature
}
normalized, err := normalizeClaudeBypassSignature(rawSignature)
if err != nil {
return ""
}
return normalized
}
func hasResolvedThinkingSignature(modelName, signature string) bool {
targetProvider := sigcompat.SignatureProviderFromModelName(modelName)
if targetProvider == sigcompat.SignatureProviderClaude {
_, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(signature)
return ok
}
if _, ok := sigcompat.CompatibleSignatureForProvider(targetProvider, signature); ok {
return true
}
if cache.SignatureCacheEnabled() {
return cache.HasValidSignature(modelName, signature)
}
return signature != ""
}
func resolveProviderCompatibleSignature(targetProvider sigcompat.SignatureProvider, rawSignature string, blockKind sigcompat.SignatureBlockKind) string {
if rawSignature == "" {
return ""
}
if targetProvider == sigcompat.SignatureProviderClaude {
signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSignature)
if !ok {
return ""
}
return signature
}
signature, ok := sigcompat.CompatibleSignatureForProviderBlock(targetProvider, rawSignature, blockKind)
if !ok {
return ""
}
return signature
}
func resolveToolUseThoughtSignature(modelName string, contentResult gjson.Result, allowSyntheticFallback bool) string {
targetProvider := sigcompat.SignatureProviderFromModelName(modelName)
if targetProvider == sigcompat.SignatureProviderGemini {
for _, path := range []string{
"signature",
"thought_signature",
"extra_content.google.thought_signature",
} {
if signatureResult := contentResult.Get(path); signatureResult.Exists() {
if signature := resolveProviderCompatibleSignature(targetProvider, signatureResult.String(), sigcompat.SignatureBlockKindGeminiFunctionCall); signature != "" {
return signature
}
}
}
if allowSyntheticFallback {
return sigcompat.GeminiSkipThoughtSignatureValidator
}
return ""
}
for _, path := range []string{
"signature",
"thought_signature",
"extra_content.google.thought_signature",
} {
if signatureResult := contentResult.Get(path); signatureResult.Exists() {
if signature := resolveProviderCompatibleSignature(targetProvider, signatureResult.String(), sigcompat.SignatureBlockKindUnknown); signature != "" {
return signature
}
}
}
if targetProvider == sigcompat.SignatureProviderClaude {
return ""
}
return sigcompat.GeminiSkipThoughtSignatureValidator
}
func firstToolUseSignatureField(contentResult gjson.Result) (string, string, bool) {
for _, path := range []string{
"signature",
"thought_signature",
"extra_content.google.thought_signature",
} {
signatureResult := contentResult.Get(path)
if signatureResult.Exists() {
return path, signatureResult.String(), true
}
}
return "", "", false
}
func logDroppedAntigravityThinkingSignature(modelName string, messageIndex, contentIndex int, thinkingText string, signatureResult gjson.Result) {
rawSignature := signatureResult.String()
fields := log.Fields{
"component": "signature_sanitizer",
"translator": "antigravity_claude",
"target_provider": string(sigcompat.SignatureProviderFromModelName(modelName)),
"action": "drop_thinking_block",
"reason": "missing_or_incompatible_signature",
"model": modelName,
"message_index": messageIndex,
"content_index": contentIndex,
"thinking_length": len(thinkingText),
"has_signature": signatureResult.Exists(),
"signature_length": len(strings.TrimSpace(rawSignature)),
}
if signatureResult.Exists() {
fields["detected_provider"] = string(sigcompat.DetectSignatureProviderForBlock(rawSignature, sigcompat.SignatureBlockKindClaudeThinking))
}
log.WithFields(fields).Debug("antigravity claude translator: dropped thinking block with incompatible signature")
}
func logDroppedAntigravityEmptyThinking(modelName string, messageIndex, contentIndex int) {
log.WithFields(log.Fields{
"component": "signature_sanitizer",
"translator": "antigravity_claude",
"target_provider": string(sigcompat.SignatureProviderFromModelName(modelName)),
"action": "drop_thinking_block",
"reason": "empty_thinking_text",
"model": modelName,
"message_index": messageIndex,
"content_index": contentIndex,
}).Debug("antigravity claude translator: dropped empty thinking block")
}
func logDroppedAntigravityToolUseSignature(modelName string, messageIndex, contentIndex int, contentResult gjson.Result) {
path, rawSignature, ok := firstToolUseSignatureField(contentResult)
if !ok {
return
}
log.WithFields(log.Fields{
"component": "signature_sanitizer",
"translator": "antigravity_claude",
"target_provider": string(sigcompat.SignatureProviderFromModelName(modelName)),
"action": "drop_tool_use_signature",
"reason": "missing_or_incompatible_signature",
"model": modelName,
"message_index": messageIndex,
"content_index": contentIndex,
"signature_path": path,
"signature_length": len(strings.TrimSpace(rawSignature)),
"detected_provider": string(sigcompat.DetectSignatureProviderForBlock(rawSignature, sigcompat.SignatureBlockKindUnknown)),
}).Debug("antigravity claude translator: dropped tool_use signature field")
}
// ConvertClaudeRequestToAntigravity parses and transforms a Claude Code API request into Antigravity 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 Antigravity API.
// The function performs the following transformations:
// 1. Extracts the model information from the request
// 2. Restructures the JSON to match Antigravity API format
// 3. Converts system instructions to the expected format
// 4. Maps message contents with proper role transformations
// 5. Handles tool declarations and tool choices
// 6. Maps generation configuration parameters
//
// Parameters:
// - modelName: The name of the model to use for the request
// - rawJSON: The raw JSON request data from the Claude Code API
// - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)
//
// Returns:
// - []byte: The transformed request data in Antigravity API format
func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte {
enableThoughtTranslate := true
rawJSON := inputRawJSON
if shouldBuildAntigravityWebSearchRequest(modelName, rawJSON) {
return buildAntigravityWebSearchRequest(modelName, rawJSON)
}
functionNameMap := util.SanitizedFunctionNameMap(rawJSON)
// system instruction
systemParts := make([][]byte, 0, 2)
systemResult := gjson.GetBytes(rawJSON, "system")
if systemResult.IsArray() {
systemResults := systemResult.Array()
for i := 0; i < len(systemResults); i++ {
systemPromptResult := systemResults[i]
systemTypePromptResult := systemPromptResult.Get("type")
if systemTypePromptResult.Type == gjson.String && systemTypePromptResult.String() == "text" {
systemPrompt := systemPromptResult.Get("text").String()
if util.IsClaudeCodeAttributionSystemText(systemPrompt) {
continue
}
partJSON := []byte(`{}`)
if systemPrompt != "" {
partJSON, _ = sjson.SetBytes(partJSON, "text", systemPrompt)
}
systemParts = append(systemParts, partJSON)
}
}
} else if systemResult.Type == gjson.String && !util.IsClaudeCodeAttributionSystemText(systemResult.String()) {
partJSON := []byte(`{"text":""}`)
partJSON, _ = sjson.SetBytes(partJSON, "text", systemResult.String())
systemParts = append(systemParts, partJSON)
}
// contents
contentItems := translatorcommon.NewRawArrayItems(gjson.GetBytes(rawJSON, "messages.#").Int())
// tool_use_id → tool_name lookup, populated incrementally during the main loop.
// Claude's tool_result references tool_use by ID; Gemini requires functionResponse.name.
toolNameByID := make(map[string]string)
messagesResult := gjson.GetBytes(rawJSON, "messages")
if messagesResult.IsArray() {
messageResults := messagesResult.Array()
numMessages := len(messageResults)
for i := 0; i < numMessages; i++ {
messageResult := messageResults[i]
roleResult := messageResult.Get("role")
if roleResult.Type != gjson.String {
continue
}
originalRole := roleResult.String()
role := originalRole
if role == "assistant" {
role = "model"
} else if role == "system" {
role = "user"
}
partItems := make([][]byte, 0, 4)
appendDetachedCarrier := func(signature string, _ bool) {
carrier := []byte(`{"text":"","thoughtSignature":""}`)
carrier, _ = sjson.SetBytes(carrier, "thoughtSignature", signature)
partItems = append(partItems, carrier)
}
pendingDetachedSignature := ""
pendingDetachedTargetKind := ""
clearPendingDetachedSignature := func() {
pendingDetachedSignature = ""
pendingDetachedTargetKind = ""
}
setPendingDetachedSignature := func(signature, targetKind string) {
if pendingDetachedSignature != "" {
appendDetachedCarrier(pendingDetachedSignature, true)
}
pendingDetachedSignature = signature
pendingDetachedTargetKind = targetKind
}
contentsResult := messageResult.Get("content")
if originalRole == "system" {
if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(contentsResult); ok {
partJSON := []byte(`{}`)
partJSON, _ = sjson.SetBytes(partJSON, "text", reminderText)
partItems = append(partItems, partJSON)
contentItems = append(contentItems, antigravityClaudeContent(role, partItems))
}
continue
}
if contentsResult.IsArray() {
contentResults := contentsResult.Array()
numContents := len(contentResults)
for j := 0; j < numContents; j++ {
contentResult := contentResults[j]
contentTypeResult := contentResult.Get("type")
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {
if originalRole != "assistant" {
continue
}
// Use GetThinkingText to handle wrapped thinking objects
thinkingText := thinking.GetThinkingText(contentResult)
signatureResult := contentResult.Get("signature")
signature := resolveThinkingSignature(modelName, thinkingText, signatureResult.String())
if signature != "" && pendingDetachedSignature != "" {
if pendingDetachedSignature != signature {
appendDetachedCarrier(pendingDetachedSignature, false)
}
clearPendingDetachedSignature()
}
signatureFromPendingCarrier := false
if signature == "" && thinkingText != "" && pendingDetachedSignature != "" {
if pendingDetachedTargetKind == "" || pendingDetachedTargetKind == geminiClaudeCarrierAny || pendingDetachedTargetKind == geminiClaudeCarrierText {
signature = pendingDetachedSignature
signatureFromPendingCarrier = true
} else {
appendDetachedCarrier(pendingDetachedSignature, true)
}
clearPendingDetachedSignature()
}
// Skip unsigned thinking blocks instead of converting them to text.
isUnsigned := !hasResolvedThinkingSignature(modelName, signature)
// If unsigned, skip entirely (don't convert to text)
// Claude requires assistant messages to start with thinking blocks when thinking is enabled
// Converting to text would break this requirement
if isUnsigned {
logDroppedAntigravityThinkingSignature(modelName, i, j, thinkingText, signatureResult)
enableThoughtTranslate = false
continue
}
nextAcceptsDetachedSignature := false
nextTargetKind := geminiClaudeCarrierAny
if j+1 < numContents {
switch contentResults[j+1].Get("type").String() {
case "text":
nextAcceptsDetachedSignature = true
nextTargetKind = geminiClaudeCarrierText
case "tool_use":
nextAcceptsDetachedSignature = true
nextTargetKind = geminiClaudeCarrierFunction
}
}
isGeminiSignature := sigcompat.SignatureProviderFromModelName(modelName) == sigcompat.SignatureProviderGemini
_, carrierDirection, carrierTargetKind, markedCarrier, validCarrier := decodeGeminiClaudeCarrierSignature(signatureResult.String())
// Gemini places the signature on the visible text/function part that
// follows hidden thought text. Keep the thought text, but defer its
// opaque signature to that native neighboring part.
if thinkingText != "" {
partJSON := []byte(`{}`)
partJSON, _ = sjson.SetBytes(partJSON, "thought", true)
partJSON, _ = sjson.SetBytes(partJSON, "text", thinkingText)
if signatureFromPendingCarrier {
partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)
} else if markedCarrier {
carrierTargetsNext := carrierTargetKind == geminiClaudeCarrierAny || carrierTargetKind == nextTargetKind
if validCarrier && carrierDirection == geminiClaudeCarrierStandalone && (carrierTargetKind == geminiClaudeCarrierText || carrierTargetKind == geminiClaudeCarrierAny) {
partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)
} else if validCarrier && carrierDirection == geminiClaudeCarrierNext && nextAcceptsDetachedSignature && carrierTargetsNext {
setPendingDetachedSignature(signature, carrierTargetKind)
}
} else if isGeminiSignature && nextAcceptsDetachedSignature {
setPendingDetachedSignature(signature, nextTargetKind)
} else if signature != "" {
partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)
}
partItems = append(partItems, partJSON)
continue
}
if !isGeminiSignature {
logDroppedAntigravityEmptyThinking(modelName, i, j)
continue
}
if markedCarrier && !validCarrier {
continue
}
if markedCarrier && carrierDirection == geminiClaudeCarrierNext {
if geminiClaudeCarrierMatchesAdjacent(contentResults, j, carrierDirection, carrierTargetKind) {
setPendingDetachedSignature(signature, carrierTargetKind)
}
continue
}
if markedCarrier && carrierDirection == geminiClaudeCarrierStandalone {
appendDetachedCarrier(signature, false)
continue
}
// Tagged trailing carriers bind backward even when another semantic
// block follows. Untagged legacy carriers retain adjacency behavior.
bindBackward := markedCarrier && carrierDirection == geminiClaudeCarrierPrevious
if bindBackward && !geminiClaudeCarrierMatchesAdjacent(contentResults, j, carrierDirection, carrierTargetKind) {
continue
}
if !bindBackward && nextAcceptsDetachedSignature {
setPendingDetachedSignature(signature, nextTargetKind)
continue
}
attached := false
foundSemanticPart := false
for partIndex := len(partItems) - 1; partIndex >= 0; partIndex-- {
part := gjson.ParseBytes(partItems[partIndex])
partTargetKind := ""
switch {
case part.Get("functionCall").Exists():
partTargetKind = geminiClaudeCarrierFunction
case part.Get("text").Exists() && part.Get("text").String() != "":
partTargetKind = geminiClaudeCarrierText
default:
continue
}
foundSemanticPart = true
if markedCarrier && carrierTargetKind != geminiClaudeCarrierAny && carrierTargetKind != partTargetKind {
break
}
partSignature := strings.TrimSpace(part.Get("thoughtSignature").String())
replaceFallback := bindBackward && partTargetKind == geminiClaudeCarrierFunction && partSignature == sigcompat.GeminiSkipThoughtSignatureValidator
if partSignature == "" || replaceFallback {
partItems[partIndex], _ = sjson.SetBytes(partItems[partIndex], "thoughtSignature", signature)
attached = true
}
break
}
if !attached && (foundSemanticPart || bindBackward) {
appendDetachedCarrier(signature, false)
} else if !attached {
setPendingDetachedSignature(signature, carrierTargetKind)
}
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
prompt := contentResult.Get("text").String()
// Skip empty text parts to avoid Gemini API error:
// "required oneof field 'data' must have one initialized field"
if prompt == "" {
continue
}
partJSON := []byte(`{}`)
partJSON, _ = sjson.SetBytes(partJSON, "text", prompt)
if pendingDetachedSignature != "" {
if pendingDetachedTargetKind == "" || pendingDetachedTargetKind == geminiClaudeCarrierAny || pendingDetachedTargetKind == geminiClaudeCarrierText {
partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", pendingDetachedSignature)
} else {
appendDetachedCarrier(pendingDetachedSignature, true)
}
clearPendingDetachedSignature()
}
partItems = append(partItems, partJSON)
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_use" {
// NOTE: Do NOT inject dummy thinking blocks here.
// Antigravity API validates signatures, so dummy values are rejected.
originalFunctionName := contentResult.Get("name").String()
functionName := util.MapSanitizedFunctionName(functionNameMap, originalFunctionName)
argsResult := contentResult.Get("input")
functionID := contentResult.Get("id").String()
if functionID != "" && originalFunctionName != "" {
toolNameByID[functionID] = originalFunctionName
}
// Preserve every present input as valid JSON for the function call.
var argsRaw string
if argsResult.IsObject() {
argsRaw = argsResult.Raw
} else if argsResult.Exists() {
switch argsResult.Type {
case gjson.String:
// Parse JSON-encoded object strings while preserving other strings as JSON strings.
parsed := gjson.Parse(argsResult.String())
if parsed.IsObject() {
argsRaw = parsed.Raw
} else {
argsRaw = argsResult.Raw
}
case gjson.Null:
argsRaw = `{}`
default:
argsRaw = argsResult.Raw
}
}
if argsRaw != "" {
partJSON := []byte(`{}`)
signature := resolveToolUseThoughtSignature(modelName, contentResult, true)
if pendingDetachedSignature != "" {
pendingMatchesTool := pendingDetachedTargetKind == "" || pendingDetachedTargetKind == geminiClaudeCarrierAny || pendingDetachedTargetKind == geminiClaudeCarrierFunction
if pendingMatchesTool && (signature == "" || signature == sigcompat.GeminiSkipThoughtSignatureValidator) {
signature = pendingDetachedSignature
} else {
appendDetachedCarrier(pendingDetachedSignature, true)
}
clearPendingDetachedSignature()
}
if signature != "" {
partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)
} else {
logDroppedAntigravityToolUseSignature(modelName, i, j, contentResult)
}
if functionID != "" {
partJSON, _ = sjson.SetBytes(partJSON, "functionCall.id", functionID)
}
partJSON, _ = sjson.SetBytes(partJSON, "functionCall.name", functionName)
partJSON, _ = sjson.SetRawBytes(partJSON, "functionCall.args", []byte(argsRaw))
partItems = append(partItems, partJSON)
}
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" {
toolCallID := contentResult.Get("tool_use_id").String()
if toolCallID != "" {
funcName, ok := toolNameByID[toolCallID]
if !ok {
// Fallback: derive a semantic name from the ID by stripping
// the last two dash-separated segments (e.g. "get_weather-call-123" → "get_weather").
// Only use the raw ID as a last resort when the heuristic produces an empty string.
parts := strings.Split(toolCallID, "-")
if len(parts) > 2 {
funcName = strings.Join(parts[:len(parts)-2], "-")
}
if funcName == "" {
funcName = toolCallID
}
log.Warnf("antigravity claude request: tool_result references unknown tool_use_id=%s, derived function name=%s", toolCallID, funcName)
}
functionResponseResult := contentResult.Get("content")
functionResponseJSON := []byte(`{}`)
functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, "id", toolCallID)
functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, "name", util.MapSanitizedFunctionName(functionNameMap, funcName))
responseData := ""
if functionResponseResult.Type == gjson.String {
responseData = functionResponseResult.String()
functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, "response.result", responseData)
} else if functionResponseResult.IsArray() {
frResults := functionResponseResult.Array()
nonImageItems := make([][]byte, 0, len(frResults))
imagePartItems := make([][]byte, 0, 2)
for _, fr := range frResults {
if fr.Get("type").String() == "image" && fr.Get("source.type").String() == "base64" {
inlineDataJSON := []byte(`{}`)
if mimeType := fr.Get("source.media_type").String(); mimeType != "" {
inlineDataJSON, _ = sjson.SetBytes(inlineDataJSON, "mimeType", mimeType)
}
if data := fr.Get("source.data").String(); data != "" {
inlineDataJSON, _ = sjson.SetBytes(inlineDataJSON, "data", data)
}
imagePartJSON := []byte(`{}`)
imagePartJSON, _ = sjson.SetRawBytes(imagePartJSON, "inlineData", inlineDataJSON)
imagePartItems = append(imagePartItems, imagePartJSON)
continue
}
nonImageItems = append(nonImageItems, []byte(fr.Raw))
}
if len(nonImageItems) == 1 {
functionResponseJSON, _ = sjson.SetRawBytes(functionResponseJSON, "response.result", nonImageItems[0])
} else if len(nonImageItems) > 1 {
functionResponseJSON, _ = sjson.SetRawBytes(functionResponseJSON, "response.result", translatorcommon.JoinRawArray(nonImageItems))
} else {
functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, "response.result", "")
}
// Place image data inside functionResponse.parts as inlineData
// instead of as sibling parts in the outer content, to avoid
// base64 data bloating the text context.
if len(imagePartItems) > 0 {
functionResponseJSON, _ = sjson.SetRawBytes(functionResponseJSON, "parts", translatorcommon.JoinRawArray(imagePartItems))
}
} else if functionResponseResult.IsObject() {
if functionResponseResult.Get("type").String() == "image" && functionResponseResult.Get("source.type").String() == "base64" {
inlineDataJSON := []byte(`{}`)
if mimeType := functionResponseResult.Get("source.media_type").String(); mimeType != "" {
inlineDataJSON, _ = sjson.SetBytes(inlineDataJSON, "mimeType", mimeType)
}
if data := functionResponseResult.Get("source.data").String(); data != "" {
inlineDataJSON, _ = sjson.SetBytes(inlineDataJSON, "data", data)
}
imagePartJSON := []byte(`{}`)
imagePartJSON, _ = sjson.SetRawBytes(imagePartJSON, "inlineData", inlineDataJSON)
functionResponseJSON, _ = sjson.SetRawBytes(functionResponseJSON, "parts", translatorcommon.JoinRawArray([][]byte{imagePartJSON}))
functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, "response.result", "")
} else {
functionResponseJSON, _ = sjson.SetRawBytes(functionResponseJSON, "response.result", []byte(functionResponseResult.Raw))
}
} else if functionResponseResult.Raw != "" {
functionResponseJSON, _ = sjson.SetRawBytes(functionResponseJSON, "response.result", []byte(functionResponseResult.Raw))
} else {
// Content field is missing entirely — .Raw is empty which
// causes sjson.SetRaw to produce invalid JSON (e.g. "result":}).
functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, "response.result", "")
}
partJSON := []byte(`{}`)
partJSON, _ = sjson.SetRawBytes(partJSON, "functionResponse", functionResponseJSON)
partItems = append(partItems, partJSON)
}
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "image" {
sourceResult := contentResult.Get("source")
if sourceResult.Get("type").String() == "base64" {
inlineDataJSON := []byte(`{}`)
if mimeType := sourceResult.Get("media_type").String(); mimeType != "" {
inlineDataJSON, _ = sjson.SetBytes(inlineDataJSON, "mimeType", mimeType)
}
if data := sourceResult.Get("data").String(); data != "" {
inlineDataJSON, _ = sjson.SetBytes(inlineDataJSON, "data", data)
}
partJSON := []byte(`{}`)
partJSON, _ = sjson.SetRawBytes(partJSON, "inlineData", inlineDataJSON)
partItems = append(partItems, partJSON)
}
}
}
if pendingDetachedSignature != "" {
appendDetachedCarrier(pendingDetachedSignature, false)
clearPendingDetachedSignature()
}
// Reorder model parts: thinking first, regular content second, function calls and trailing signature carriers last.
if len(partItems) == 0 {
continue
}
clientContentJSON := antigravityClaudeContent(role, partItems)
if role == "model" && len(partItems) > 1 {
var thinkingParts [][]byte
var regularParts [][]byte
var trailingParts [][]byte
needsReorder := false
previousCategory := -1
seenFunctionCall := false
for _, partJSON := range partItems {
part := gjson.ParseBytes(partJSON)
category := 1
isSignatureCarrier := part.Get("text").Exists() && part.Get("text").String() == "" && strings.TrimSpace(part.Get("thoughtSignature").String()) != ""
isFunctionTailCarrier := isSignatureCarrier && seenFunctionCall
if part.Get("thought").Bool() {
category = 0
thinkingParts = append(thinkingParts, partJSON)
} else if part.Get("functionCall").Exists() || isFunctionTailCarrier {
category = 2
trailingParts = append(trailingParts, partJSON)
seenFunctionCall = seenFunctionCall || part.Get("functionCall").Exists()
} else {
regularParts = append(regularParts, partJSON)
}
needsReorder = needsReorder || category < previousCategory
previousCategory = category
}
if needsReorder {
newParts := make([][]byte, 0, len(partItems))
newParts = append(newParts, thinkingParts...)
newParts = append(newParts, regularParts...)
newParts = append(newParts, trailingParts...)
clientContentJSON, _ = sjson.SetRawBytes(clientContentJSON, "parts", translatorcommon.JoinRawArray(newParts))
}
}
contentItems = append(contentItems, clientContentJSON)
} else if contentsResult.Type == gjson.String {
partJSON := []byte(`{}`)
if prompt := contentsResult.String(); prompt != "" {
partJSON, _ = sjson.SetBytes(partJSON, "text", prompt)
}
contentItems = append(contentItems, antigravityClaudeContent(role, [][]byte{partJSON}))
}
}
}
// tools
var toolsJSON []byte
toolDeclCount := 0
allowedToolKeys := []string{"name", "description", "behavior", "parameters", "parametersJsonSchema", "response", "responseJsonSchema"}
toolsResult := gjson.GetBytes(rawJSON, "tools")
if toolsResult.IsArray() {
var functionDeclarations [][]byte
toolsResults := toolsResult.Array()
for i := 0; i < len(toolsResults); i++ {
toolResult := toolsResults[i]
if isClaudeTypedWebSearchToolType(toolResult.Get("type").String()) {
continue
}
inputSchemaResult := toolResult.Get("input_schema")
if inputSchemaResult.Exists() && inputSchemaResult.IsObject() {
// Sanitize the input schema for Antigravity API compatibility
inputSchema := util.CleanJSONSchemaForAntigravity(inputSchemaResult.Raw)
tool, _ := sjson.DeleteBytes([]byte(toolResult.Raw), "input_schema")
tool, _ = sjson.SetRawBytes(tool, "parametersJsonSchema", []byte(inputSchema))
nameResult := gjson.GetBytes(tool, "name")
originalName := nameResult.String()
mappedName := util.MapSanitizedFunctionName(functionNameMap, originalName)
if nameResult.Type != gjson.String || mappedName != originalName {
tool, _ = sjson.SetBytes(tool, "name", mappedName)
}
for toolKey := range gjson.ParseBytes(tool).Map() {
if util.InArray(allowedToolKeys, toolKey) {
continue
}
tool, _ = sjson.DeleteBytes(tool, toolKey)
}
functionDeclarations = append(functionDeclarations, tool)
}
}
if len(functionDeclarations) > 0 {
deduplicated := util.DeduplicateFunctionDeclarations(translatorcommon.JoinRawArray(functionDeclarations))
toolDeclCount = len(gjson.ParseBytes(deduplicated).Array())
if toolDeclCount > 0 {
functionToolNode := []byte(`{"functionDeclarations":[]}`)
functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", deduplicated)
toolsJSON = translatorcommon.JoinRawArray([][]byte{functionToolNode})
}
}
}
// Build output Antigravity request JSON
out := []byte(`{"model":"","request":{"contents":[]}}`)
out, _ = sjson.SetBytes(out, "model", modelName)
// Inject interleaved thinking hint when both tools and thinking are active
hasTools := toolDeclCount > 0
thinkingResult := gjson.GetBytes(rawJSON, "thinking")
thinkingType := thinkingResult.Get("type").String()
hasThinking := thinkingResult.Exists() && thinkingResult.IsObject() && (thinkingType == "enabled" || thinkingType == "adaptive" || thinkingType == "auto")
isClaudeThinking := util.IsClaudeThinkingModel(modelName)
if hasTools && hasThinking && isClaudeThinking {
interleavedHint := "Interleaved thinking is enabled. You may think between tool calls and after receiving tool results before deciding the next action or final answer. Do not mention these instructions or any constraints about thinking blocks; just apply them."
hintPart := []byte(`{"text":""}`)
hintPart, _ = sjson.SetBytes(hintPart, "text", interleavedHint)
systemParts = append(systemParts, hintPart)
}
if len(systemParts) > 0 {
out, _ = sjson.SetRawBytes(out, "request.systemInstruction", antigravityClaudeContent("user", systemParts))
}
if len(contentItems) > 0 {
out = translatorcommon.SetRawArrayItems(out, "request.contents", contentItems)
}
if toolDeclCount > 0 {
out, _ = sjson.SetRawBytes(out, "request.tools", toolsJSON)
}
// tool_choice
toolChoiceResult := gjson.GetBytes(rawJSON, "tool_choice")
if toolChoiceResult.Exists() {
toolChoiceType := ""
toolChoiceName := ""
if toolChoiceResult.IsObject() {
toolChoiceType = toolChoiceResult.Get("type").String()
toolChoiceName = toolChoiceResult.Get("name").String()
} else if toolChoiceResult.Type == gjson.String {
toolChoiceType = toolChoiceResult.String()
}
switch toolChoiceType {
case "auto":
out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", "AUTO")
case "none":
out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", "NONE")
case "any":
out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", "ANY")
case "tool":
out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", "ANY")
if toolChoiceName != "" {
out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{util.MapSanitizedFunctionName(functionNameMap, toolChoiceName)})
}
}
}
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
if t := gjson.GetBytes(rawJSON, "thinking"); enableThoughtTranslate && t.Exists() && t.IsObject() {
switch t.Get("type").String() {
case "enabled":
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
budget := int(b.Int())
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
}
case "adaptive", "auto":
// For adaptive thinking:
// - If output_config.effort is explicitly present, pass through as thinkingLevel.
// - Otherwise, treat it as "enabled with target-model maximum" and emit high.
// ApplyThinking handles clamping to target model's supported levels.
effort := ""
if v := gjson.GetBytes(rawJSON, "output_config.effort"); v.Exists() && v.Type == gjson.String {
effort = strings.ToLower(strings.TrimSpace(v.String()))
}
if effort != "" {
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", effort)
} else {
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high")
}
}
}
if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "request.generationConfig.temperature", v.Num)
}
if v := gjson.GetBytes(rawJSON, "top_p"); v.Exists() && v.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "request.generationConfig.topP", v.Num)
}
if v := gjson.GetBytes(rawJSON, "top_k"); v.Exists() && v.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "request.generationConfig.topK", v.Num)
}
if v := gjson.GetBytes(rawJSON, "max_tokens"); v.Exists() && v.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "request.generationConfig.maxOutputTokens", v.Num)
}
out = common.AttachDefaultSafetySettings(out, "request.safetySettings")
if sigcompat.SignatureProviderFromModelName(modelName) == sigcompat.SignatureProviderGemini {
out = sigcompat.SanitizeGeminiRequestThoughtSignatures(out, "request.contents")
}
return out
}
func antigravityClaudeContent(role string, parts [][]byte) []byte {
content := []byte(`{"role":"","parts":[]}`)
content, _ = sjson.SetBytes(content, "role", role)
content, _ = sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(parts))
return content
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,765 @@
// Package claude provides response translation functionality for Claude Code API compatibility.
// This package handles the conversion of backend client responses into Claude Code-compatible
// Server-Sent Events (SSE) format, implementing a sophisticated state machine that manages
// different response types including text content, thinking processes, and function calls.
// The translation ensures proper sequencing of SSE events and maintains state across
// multiple response chunks to provide a seamless streaming experience.
package claude
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"strings"
"sync/atomic"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
sigcompat "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/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// decodeSignature decodes R... (2-layer Base64) to E... (1-layer Base64, Anthropic format).
// Returns empty string if decoding fails (skip invalid signatures).
func decodeSignature(signature string) string {
if signature == "" {
return signature
}
if strings.HasPrefix(signature, "R") {
decoded, err := base64.StdEncoding.DecodeString(signature)
if err != nil {
log.Warnf("antigravity claude response: failed to decode signature, skipping")
return ""
}
return string(decoded)
}
return signature
}
func formatGeminiClaudeCarrierValue(modelName, signature, direction, targetKind string) string {
if sigcompat.SignatureProviderFromModelName(modelName) == sigcompat.SignatureProviderGemini {
return encodeGeminiClaudeCarrierSignature(signature, direction, targetKind)
}
return formatClaudeSignatureValue(modelName, signature)
}
func formatClaudeSignatureValue(modelName, signature string) string {
// Gemini signatures are provider-native replay state. Keep them raw so an
// empty detached thinking block or tool_use block can round-trip through
// Claude Code and be recognized by the Gemini request translator.
if cache.GetModelGroup(modelName) == "gemini" {
return signature
}
if cache.SignatureCacheEnabled() {
return fmt.Sprintf("%s#%s", cache.GetModelGroup(modelName), signature)
}
if cache.GetModelGroup(modelName) == "claude" {
return decodeSignature(signature)
}
return signature
}
// Params holds parameters for response conversion and maintains state across streaming chunks.
// This structure tracks the current state of the response translation process to ensure
// proper sequencing of SSE events and transitions between different content types.
type Params struct {
HasFirstResponse bool // Indicates if the initial message_start event has been sent
ResponseType int // Current response type: 0=none, 1=content, 2=thinking, 3=function
ResponseIndex int // Index counter for content blocks in the streaming response
HasFinishReason bool // Tracks whether a finish reason has been observed
FinishReason string // The finish reason string returned by the provider
HasUsageMetadata bool // Tracks whether usage metadata has been observed
PromptTokenCount int64 // Cached prompt token count from usage metadata
CandidatesTokenCount int64 // Cached candidate token count from usage metadata
ThoughtsTokenCount int64 // Cached thinking token count from usage metadata
TotalTokenCount int64 // Cached total token count from usage metadata
CachedTokenCount int64 // Cached content token count (indicates prompt caching)
HasSentFinalEvents bool // Indicates if final content/message events have been sent
HasToolUse bool // Indicates if tool use was observed in the stream
HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output
HasSemanticContent bool
LastSemanticKind string
HasWebSearchTool bool
WebSearchRequests int64
WebSearchTextBuffer strings.Builder
// Signature caching support
CurrentThinkingText strings.Builder // Accumulates thinking text for signature caching
CurrentThinkingSigned bool // Tracks whether the active thinking block already has its terminal signature
// Reverse map: sanitized Gemini function name → original Claude tool name.
// Populated lazily on the first response chunk from the original request JSON.
ToolNameMap map[string]string
}
// toolUseIDCounter provides a process-wide unique counter for tool use identifiers.
var toolUseIDCounter uint64
func antigravityClaudeToolUseID(modelName string, functionCall gjson.Result, fallback string) string {
if sigcompat.SignatureProviderFromModelName(modelName) == sigcompat.SignatureProviderGemini {
if stableID := util.GeminiClaudeToolUseID(functionCall.Get("id").String(), functionCall.Get("name").String(), functionCall.Get("args").Raw); stableID != "" {
return stableID
}
}
return util.SanitizeClaudeToolID(fallback)
}
// ConvertAntigravityResponseToClaude performs sophisticated streaming response format conversion.
// This function implements a complex state machine that translates backend client responses
// into Claude Code-compatible Server-Sent Events (SSE) format. It manages different response types
// and handles state transitions between content blocks, thinking processes, and function calls.
//
// Response type states: 0=none, 1=content, 2=thinking, 3=function
// The function maintains state across multiple calls to ensure proper SSE event sequencing.
//
// 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 response from the Antigravity API
// - param: A pointer to a parameter object for maintaining state between calls
//
// Returns:
// - [][]byte: A slice of bytes, each containing a Claude Code-compatible SSE payload.
func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
if *param == nil {
*param = &Params{
HasFirstResponse: false,
ResponseType: 0,
ResponseIndex: 0,
ToolNameMap: util.DisambiguatedToolNameMap(originalRequestRawJSON),
}
}
modelName := gjson.GetBytes(requestRawJSON, "model").String()
params := (*param).(*Params)
if bytes.Equal(rawJSON, []byte("[DONE]")) {
output := make([]byte, 0, 256)
if params.HasFirstResponse && !params.HasContent {
output = translatorcommon.AppendSSEEventString(output, "content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, params.ResponseIndex), 3)
params.ResponseType = 1
params.HasContent = true
}
if params.HasContent {
appendFinalEvents(params, &output, true)
output = translatorcommon.AppendSSEEventString(output, "message_stop", `{"type":"message_stop"}`, 3)
return [][]byte{output}
}
return [][]byte{}
}
output := make([]byte, 0, 1024)
appendEvent := func(event, payload string) {
output = translatorcommon.AppendSSEEventString(output, event, payload, 3)
}
webSearchStreamMode := shouldTranslateWebSearchGrounding(originalRequestRawJSON, requestRawJSON)
appendThinkingSignature := func(signature, direction, targetKind string) {
if signature == "" || params.ResponseType != 2 {
return
}
if params.CurrentThinkingText.Len() > 0 {
cache.CacheSignatureBestEffort(ctx, modelName, params.CurrentThinkingText.String(), signature)
params.CurrentThinkingText.Reset()
}
sigValue := formatGeminiClaudeCarrierValue(modelName, signature, direction, targetKind)
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex)), "delta.signature", sigValue)
appendEvent("content_block_delta", string(data))
params.CurrentThinkingSigned = true
params.HasContent = true
}
closeCurrentBlock := func() {
if params.ResponseType == 0 {
return
}
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex))
params.ResponseIndex++
params.ResponseType = 0
params.CurrentThinkingSigned = false
}
startEmptyThinkingBlock := func() {
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, params.ResponseIndex))
params.ResponseType = 2
params.CurrentThinkingSigned = false
params.HasContent = true
}
appendCarrierSignature := func(signature, direction, targetKind string) {
if signature == "" || params.ResponseType != 2 {
return
}
sigValue := formatGeminiClaudeCarrierValue(modelName, signature, direction, targetKind)
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex)), "delta.signature", sigValue)
appendEvent("content_block_delta", string(data))
params.CurrentThinkingSigned = true
params.HasContent = true
}
appendPartSignature := func(signature, direction, targetKind string) bool {
if signature == "" {
return false
}
if params.ResponseType == 2 && !params.CurrentThinkingSigned {
appendThinkingSignature(signature, direction, targetKind)
return false
}
closeCurrentBlock()
startEmptyThinkingBlock()
appendCarrierSignature(signature, direction, targetKind)
return true
}
// Initialize the streaming session with a message_start event
// This is only sent for the very first response chunk to establish the streaming session
if !params.HasFirstResponse {
// Create the initial message structure with default values according to Claude Code API specification
// This follows the Claude Code API specification for streaming message initialization
messageStartTemplate := []byte(`{"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-3-5-sonnet-20241022", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 0, "output_tokens": 0}}}`)
// Use cpaUsageMetadata within the message_start event for Claude.
if promptTokenCount := gjson.GetBytes(rawJSON, "response.cpaUsageMetadata.promptTokenCount"); promptTokenCount.Exists() {
messageStartTemplate, _ = sjson.SetBytes(messageStartTemplate, "message.usage.input_tokens", promptTokenCount.Int())
}
if candidatesTokenCount := gjson.GetBytes(rawJSON, "response.cpaUsageMetadata.candidatesTokenCount"); candidatesTokenCount.Exists() && !webSearchStreamMode {
messageStartTemplate, _ = sjson.SetBytes(messageStartTemplate, "message.usage.output_tokens", candidatesTokenCount.Int())
}
// Override default values with actual response metadata if available from the Antigravity response
if modelVersionResult := gjson.GetBytes(rawJSON, "response.modelVersion"); modelVersionResult.Exists() {
messageStartTemplate, _ = sjson.SetBytes(messageStartTemplate, "message.model", modelVersionResult.String())
}
if responseIDResult := gjson.GetBytes(rawJSON, "response.responseId"); responseIDResult.Exists() {
messageStartTemplate, _ = sjson.SetBytes(messageStartTemplate, "message.id", responseIDResult.String())
}
appendEvent("message_start", string(messageStartTemplate))
params.HasFirstResponse = true
}
handledWebSearchGrounding := false
if webSearchStreamMode && !params.HasWebSearchTool {
root := gjson.ParseBytes(rawJSON)
if groundingMetadata := antigravityGroundingMetadata(root); groundingMetadata.Exists() {
toolUseID := newClaudeWebSearchToolUseID()
textContent := params.WebSearchTextBuffer.String() + antigravityTextContent(root)
params.WebSearchTextBuffer.Reset()
params.ResponseIndex = appendClaudeWebSearchStreamBlocks(appendEvent, params.ResponseIndex, toolUseID, textContent, groundingMetadata)
params.HasWebSearchTool = true
params.WebSearchRequests = 1
params.HasContent = true
params.ResponseType = 0
handledWebSearchGrounding = true
}
}
// Process the response parts array from the backend client
// Each part can contain text content, thinking content, or function calls
partsResult := gjson.GetBytes(rawJSON, "response.candidates.0.content.parts")
if partsResult.IsArray() && webSearchStreamMode && !params.HasWebSearchTool && !handledWebSearchGrounding {
appendWebSearchBufferedText(partsResult, &params.WebSearchTextBuffer)
} else if partsResult.IsArray() && !handledWebSearchGrounding {
partResults := partsResult.Array()
for i := 0; i < len(partResults); i++ {
partResult := partResults[i]
// Extract the different types of content from each part
partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall")
thoughtSignatureResult := partResult.Get("thoughtSignature")
if !thoughtSignatureResult.Exists() {
thoughtSignatureResult = partResult.Get("thought_signature")
}
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" && !functionCallResult.Exists()
if hasThoughtSignature && (!partTextResult.Exists() || partTextResult.String() == "") {
direction := geminiClaudeCarrierNext
targetKind := geminiClaudeCarrierAny
if params.HasSemanticContent {
direction = geminiClaudeCarrierPrevious
targetKind = params.LastSemanticKind
}
appendPartSignature(thoughtSignatureResult.String(), direction, targetKind)
continue
}
// Handle text content (both regular content and thinking)
if partTextResult.Exists() {
partText := partTextResult.String()
if partResult.Get("thought").Bool() {
if partText != "" {
params.HasSemanticContent = true
params.LastSemanticKind = geminiClaudeCarrierText
if params.ResponseType == 2 && params.CurrentThinkingSigned {
closeCurrentBlock()
}
if params.ResponseType == 2 {
params.CurrentThinkingText.WriteString(partText)
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex)), "delta.thinking", partText)
appendEvent("content_block_delta", string(data))
params.HasContent = true
} else {
if params.ResponseType != 0 {
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex))
params.ResponseIndex++
}
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, params.ResponseIndex))
params.CurrentThinkingSigned = false
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex)), "delta.thinking", partText)
appendEvent("content_block_delta", string(data))
params.ResponseType = 2
params.HasContent = true
params.CurrentThinkingText.Reset()
params.CurrentThinkingText.WriteString(partText)
}
}
if hasThoughtSignature {
appendThinkingSignature(thoughtSignatureResult.String(), geminiClaudeCarrierStandalone, geminiClaudeCarrierText)
}
} else {
signatureTargetsVisibleText := false
if hasThoughtSignature {
signatureTargetsVisibleText = appendPartSignature(thoughtSignatureResult.String(), geminiClaudeCarrierNext, geminiClaudeCarrierText)
}
finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason")
if partText != "" || !finishReasonResult.Exists() {
if params.ResponseType == 1 {
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex)), "delta.text", partText)
appendEvent("content_block_delta", string(data))
params.HasContent = true
} else {
if params.ResponseType != 0 {
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex))
params.ResponseIndex++
}
if partText != "" {
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, params.ResponseIndex))
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex)), "delta.text", partText)
appendEvent("content_block_delta", string(data))
params.ResponseType = 1
params.HasContent = true
}
}
}
if partText != "" {
params.HasSemanticContent = true
params.LastSemanticKind = geminiClaudeCarrierText
if signatureTargetsVisibleText {
closeCurrentBlock()
}
}
}
} else if functionCallResult.Exists() {
toolSignature := thoughtSignatureResult.String()
if cache.GetModelGroup(modelName) != "claude" {
appendPartSignature(toolSignature, geminiClaudeCarrierNext, geminiClaudeCarrierFunction)
}
// Handle function/tool calls from the AI model
// This processes tool usage requests and formats them for Claude Code API compatibility
params.HasToolUse = true
fcName := util.RestoreSanitizedToolName(params.ToolNameMap, functionCallResult.Get("name").String())
// Handle state transitions when switching to function calls
// Close any existing function call block first
if params.ResponseType == 3 {
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex))
params.ResponseIndex++
params.ResponseType = 0
}
// Special handling for thinking state transition
if params.ResponseType == 2 {
// output = output + "event: content_block_delta\n"
// output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, params.ResponseIndex)
// output = output + "\n\n\n"
}
// Close any other existing content block
if params.ResponseType != 0 {
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex))
params.ResponseIndex++
}
// Start a new tool use content block
// This creates the structure for a function call in Claude Code format
// Create the tool use block with unique ID and function details
data := []byte(fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`, params.ResponseIndex))
fallbackID := fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&toolUseIDCounter, 1))
data, _ = sjson.SetBytes(data, "content_block.id", antigravityClaudeToolUseID(modelName, functionCallResult, fallbackID))
data, _ = sjson.SetBytes(data, "content_block.name", fcName)
if cache.GetModelGroup(modelName) == "claude" && toolSignature != "" {
data, _ = sjson.SetBytes(data, "content_block.signature", formatClaudeSignatureValue(modelName, toolSignature))
}
appendEvent("content_block_start", string(data))
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
data, _ = sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", fcArgsResult.Raw)
appendEvent("content_block_delta", string(data))
}
params.ResponseType = 3
params.HasContent = true
params.HasSemanticContent = true
params.LastSemanticKind = geminiClaudeCarrierFunction
}
}
}
if finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason"); finishReasonResult.Exists() {
params.HasFinishReason = true
params.FinishReason = finishReasonResult.String()
}
if usageResult := gjson.GetBytes(rawJSON, "response.usageMetadata"); usageResult.Exists() {
params.HasUsageMetadata = true
params.CachedTokenCount = usageResult.Get("cachedContentTokenCount").Int()
params.PromptTokenCount = usageResult.Get("promptTokenCount").Int() - params.CachedTokenCount
params.CandidatesTokenCount = usageResult.Get("candidatesTokenCount").Int()
params.ThoughtsTokenCount = usageResult.Get("thoughtsTokenCount").Int()
params.TotalTokenCount = usageResult.Get("totalTokenCount").Int()
if params.CandidatesTokenCount == 0 && params.TotalTokenCount > 0 {
params.CandidatesTokenCount = params.TotalTokenCount - params.PromptTokenCount - params.ThoughtsTokenCount
if params.CandidatesTokenCount < 0 {
params.CandidatesTokenCount = 0
}
}
}
if webSearchStreamMode && !params.HasWebSearchTool && params.HasFinishReason && params.WebSearchTextBuffer.Len() > 0 {
appendBufferedWebSearchTextBlock(params, appendEvent)
}
if params.HasUsageMetadata && params.HasFinishReason {
appendFinalEvents(params, &output, false)
}
return [][]byte{output}
}
func appendWebSearchBufferedText(partsResult gjson.Result, buffer *strings.Builder) {
for _, partResult := range partsResult.Array() {
if partResult.Get("thought").Bool() || partResult.Get("functionCall").Exists() {
continue
}
if partTextResult := partResult.Get("text"); partTextResult.Exists() {
buffer.WriteString(partTextResult.String())
}
}
}
func appendBufferedWebSearchTextBlock(params *Params, appendEvent func(string, string)) {
text := params.WebSearchTextBuffer.String()
params.WebSearchTextBuffer.Reset()
if text == "" {
return
}
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, params.ResponseIndex))
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex)), "delta.text", text)
appendEvent("content_block_delta", string(data))
params.ResponseType = 1
params.HasContent = true
}
func appendFinalEvents(params *Params, output *[]byte, force bool) {
if params.HasSentFinalEvents {
return
}
if !params.HasUsageMetadata && !force {
return
}
// Only send final events if we have actually output content
if !params.HasContent {
return
}
if params.ResponseType != 0 {
*output = translatorcommon.AppendSSEEventString(*output, "content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex), 3)
params.ResponseType = 0
}
stopReason := resolveStopReason(params)
usageOutputTokens := params.CandidatesTokenCount + params.ThoughtsTokenCount
if usageOutputTokens == 0 && params.TotalTokenCount > 0 {
usageOutputTokens = params.TotalTokenCount - params.PromptTokenCount
if usageOutputTokens < 0 {
usageOutputTokens = 0
}
}
delta := []byte(fmt.Sprintf(`{"type":"message_delta","delta":{"stop_reason":"%s","stop_sequence":null},"usage":{"input_tokens":%d,"output_tokens":%d}}`, stopReason, params.PromptTokenCount, usageOutputTokens))
if params.WebSearchRequests > 0 {
delta, _ = sjson.SetBytes(delta, "usage.server_tool_use.web_search_requests", params.WebSearchRequests)
}
// Add cache_read_input_tokens if cached tokens are present (indicates prompt caching is working)
if params.CachedTokenCount > 0 {
var err error
delta, err = sjson.SetBytes(delta, "usage.cache_read_input_tokens", params.CachedTokenCount)
if err != nil {
log.Warnf("antigravity claude response: failed to set cache_read_input_tokens: %v", err)
}
}
*output = translatorcommon.AppendSSEEventString(*output, "message_delta", string(delta), 3)
params.HasSentFinalEvents = true
}
func resolveStopReason(params *Params) string {
if params.HasToolUse {
return "tool_use"
}
switch params.FinishReason {
case "MAX_TOKENS":
return "max_tokens"
case "STOP", "FINISH_REASON_UNSPECIFIED", "UNKNOWN":
return "end_turn"
}
return "end_turn"
}
// ConvertAntigravityResponseToClaudeNonStream converts a non-streaming Antigravity response to a non-streaming Claude response.
//
// Parameters:
// - ctx: The context for the request.
// - modelName: The name of the model.
// - rawJSON: The raw JSON response from the Antigravity API.
// - param: A pointer to a parameter object for the conversion.
//
// Returns:
// - []byte: A Claude-compatible JSON response.
func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
toolNameMap := util.DisambiguatedToolNameMap(originalRequestRawJSON)
modelName := gjson.GetBytes(requestRawJSON, "model").String()
root := gjson.ParseBytes(rawJSON)
promptTokens := root.Get("response.usageMetadata.promptTokenCount").Int()
candidateTokens := root.Get("response.usageMetadata.candidatesTokenCount").Int()
thoughtTokens := root.Get("response.usageMetadata.thoughtsTokenCount").Int()
totalTokens := root.Get("response.usageMetadata.totalTokenCount").Int()
cachedTokens := root.Get("response.usageMetadata.cachedContentTokenCount").Int()
outputTokens := candidateTokens + thoughtTokens
if outputTokens == 0 && totalTokens > 0 {
outputTokens = totalTokens - promptTokens
if outputTokens < 0 {
outputTokens = 0
}
}
responseJSON := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
responseJSON, _ = sjson.SetBytes(responseJSON, "id", root.Get("response.responseId").String())
responseJSON, _ = sjson.SetBytes(responseJSON, "model", root.Get("response.modelVersion").String())
responseJSON, _ = sjson.SetBytes(responseJSON, "usage.input_tokens", promptTokens)
responseJSON, _ = sjson.SetBytes(responseJSON, "usage.output_tokens", outputTokens)
// Add cache_read_input_tokens if cached tokens are present (indicates prompt caching is working)
if cachedTokens > 0 {
var err error
responseJSON, err = sjson.SetBytes(responseJSON, "usage.cache_read_input_tokens", cachedTokens)
if err != nil {
log.Warnf("antigravity claude response: failed to set cache_read_input_tokens: %v", err)
}
}
if shouldTranslateWebSearchGrounding(originalRequestRawJSON, requestRawJSON) {
if groundingMetadata := antigravityGroundingMetadata(root); groundingMetadata.Exists() {
toolUseID := newClaudeWebSearchToolUseID()
responseJSON, _ = sjson.SetRawBytes(responseJSON, "content", buildClaudeWebSearchContent(toolUseID, antigravityTextContent(root), groundingMetadata))
responseJSON, _ = sjson.SetBytes(responseJSON, "stop_reason", "end_turn")
responseJSON, _ = sjson.SetBytes(responseJSON, "usage.server_tool_use.web_search_requests", 1)
return responseJSON
}
}
var blocks [][]byte
parts := root.Get("response.candidates.0.content.parts")
textBuilder := strings.Builder{}
thinkingBuilder := strings.Builder{}
thinkingSignature := ""
thinkingSignatureDirection := geminiClaudeCarrierStandalone
thinkingSignatureTargetKind := geminiClaudeCarrierText
toolIDCounter := 0
hasToolCall := false
hasSemanticContent := false
lastSemanticKind := geminiClaudeCarrierAny
flushText := func() {
if textBuilder.Len() == 0 {
return
}
block := []byte(`{"type":"text","text":""}`)
block, _ = sjson.SetBytes(block, "text", textBuilder.String())
blocks = append(blocks, block)
textBuilder.Reset()
}
flushThinking := func() {
if thinkingBuilder.Len() == 0 && thinkingSignature == "" {
return
}
block := []byte(`{"type":"thinking","thinking":""}`)
block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
if thinkingSignature != "" {
sigValue := formatGeminiClaudeCarrierValue(modelName, thinkingSignature, thinkingSignatureDirection, thinkingSignatureTargetKind)
block, _ = sjson.SetBytes(block, "signature", sigValue)
}
blocks = append(blocks, block)
thinkingBuilder.Reset()
thinkingSignature = ""
thinkingSignatureDirection = geminiClaudeCarrierStandalone
thinkingSignatureTargetKind = geminiClaudeCarrierText
}
appendSignatureCarrier := func(signature, direction, targetKind string) {
if signature == "" {
return
}
carrier := []byte(`{"type":"thinking","thinking":"","signature":""}`)
carrier, _ = sjson.SetBytes(carrier, "signature", formatGeminiClaudeCarrierValue(modelName, signature, direction, targetKind))
blocks = append(blocks, carrier)
}
if parts.IsArray() {
for _, part := range parts.Array() {
sig := part.Get("thoughtSignature")
if !sig.Exists() {
sig = part.Get("thought_signature")
}
signature := ""
if sig.Exists() {
signature = sig.String()
}
if functionCall := part.Get("functionCall"); functionCall.Exists() {
signatureAttachedToThought := false
isClaudeTarget := cache.GetModelGroup(modelName) == "claude"
if !isClaudeTarget && signature != "" && thinkingBuilder.Len() > 0 && thinkingSignature == "" {
thinkingSignature = signature
thinkingSignatureDirection = geminiClaudeCarrierNext
thinkingSignatureTargetKind = geminiClaudeCarrierFunction
signatureAttachedToThought = true
}
flushThinking()
flushText()
hasToolCall = true
name := util.RestoreSanitizedToolName(toolNameMap, functionCall.Get("name").String())
toolIDCounter++
if !isClaudeTarget && signature != "" && !signatureAttachedToThought {
appendSignatureCarrier(signature, geminiClaudeCarrierNext, geminiClaudeCarrierFunction)
}
toolBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
toolBlock, _ = sjson.SetBytes(toolBlock, "id", antigravityClaudeToolUseID(modelName, functionCall, fmt.Sprintf("tool_%d", toolIDCounter)))
toolBlock, _ = sjson.SetBytes(toolBlock, "name", name)
if isClaudeTarget && signature != "" {
toolBlock, _ = sjson.SetBytes(toolBlock, "signature", formatClaudeSignatureValue(modelName, signature))
}
if args := functionCall.Get("args"); args.Exists() && args.Raw != "" && gjson.Valid(args.Raw) && args.IsObject() {
toolBlock, _ = sjson.SetRawBytes(toolBlock, "input", []byte(args.Raw))
}
blocks = append(blocks, toolBlock)
hasSemanticContent = true
lastSemanticKind = geminiClaudeCarrierFunction
continue
}
text := part.Get("text")
isThought := part.Get("thought").Bool()
if isThought {
flushText()
if thinkingSignature != "" {
flushThinking()
}
if text.Exists() && text.String() != "" {
thinkingBuilder.WriteString(text.String())
hasSemanticContent = true
lastSemanticKind = geminiClaudeCarrierText
}
if signature != "" {
if thinkingBuilder.Len() > 0 {
thinkingSignature = signature
thinkingSignatureDirection = geminiClaudeCarrierStandalone
thinkingSignatureTargetKind = geminiClaudeCarrierText
flushThinking()
} else if hasSemanticContent {
appendSignatureCarrier(signature, geminiClaudeCarrierPrevious, lastSemanticKind)
} else {
appendSignatureCarrier(signature, geminiClaudeCarrierNext, geminiClaudeCarrierAny)
}
}
continue
}
visibleSignatureCarrier := false
if signature != "" {
if thinkingBuilder.Len() > 0 && thinkingSignature == "" {
thinkingSignature = signature
thinkingSignatureDirection = geminiClaudeCarrierNext
thinkingSignatureTargetKind = geminiClaudeCarrierText
flushThinking()
} else {
flushThinking()
flushText()
if text.Exists() && text.String() != "" {
appendSignatureCarrier(signature, geminiClaudeCarrierNext, geminiClaudeCarrierText)
visibleSignatureCarrier = true
} else if hasSemanticContent {
appendSignatureCarrier(signature, geminiClaudeCarrierPrevious, lastSemanticKind)
} else {
appendSignatureCarrier(signature, geminiClaudeCarrierNext, geminiClaudeCarrierAny)
}
}
}
if text.Exists() && text.String() != "" {
flushThinking()
textBuilder.WriteString(text.String())
hasSemanticContent = true
lastSemanticKind = geminiClaudeCarrierText
if visibleSignatureCarrier {
flushText()
}
}
}
}
flushThinking()
flushText()
if len(blocks) > 0 {
responseJSON, _ = sjson.SetRawBytes(responseJSON, "content", translatorcommon.JoinRawArray(blocks))
}
stopReason := "end_turn"
if hasToolCall {
stopReason = "tool_use"
} else {
if finish := root.Get("response.candidates.0.finishReason"); finish.Exists() {
switch finish.String() {
case "MAX_TOKENS":
stopReason = "max_tokens"
case "STOP", "FINISH_REASON_UNSPECIFIED", "UNKNOWN":
stopReason = "end_turn"
default:
stopReason = "end_turn"
}
}
}
responseJSON, _ = sjson.SetBytes(responseJSON, "stop_reason", stopReason)
if promptTokens == 0 && outputTokens == 0 {
if usageMeta := root.Get("response.usageMetadata"); !usageMeta.Exists() {
responseJSON, _ = sjson.DeleteBytes(responseJSON, "usage")
}
}
return responseJSON
}
func ClaudeTokenCount(ctx context.Context, count int64) []byte {
return translatorcommon.ClaudeInputTokensJSON(count)
}

View file

@ -0,0 +1,20 @@
package claude
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(
Claude,
Antigravity,
ConvertClaudeRequestToAntigravity,
interfaces.TranslateResponse{
Stream: ConvertAntigravityResponseToClaude,
NonStream: ConvertAntigravityResponseToClaudeNonStream,
TokenCount: ClaudeTokenCount,
},
)
}

View file

@ -0,0 +1,228 @@
// Claude thinking signature validation wrappers for Antigravity bypass mode.
package claude
import (
"encoding/base64"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
maxBypassSignatureLen = signature.MaxClaudeThinkingSignatureLen
// Gemini carrier envelopes exist only on the Claude-facing wire. The request
// translator validates and unwraps them before writing native Gemini parts.
geminiClaudeCarrierPrefix = "cpa-gemini-carrier-v1:"
geminiClaudeCarrierNext = "next"
geminiClaudeCarrierPrevious = "previous"
geminiClaudeCarrierStandalone = "standalone"
geminiClaudeCarrierText = "text"
geminiClaudeCarrierFunction = "function"
geminiClaudeCarrierAny = "any"
)
type claudeSignatureTree = signature.ClaudeSignatureTree
func encodeGeminiClaudeCarrierSignature(rawSignature, direction, targetKind string) string {
rawSignature = strings.TrimSpace(rawSignature)
if rawSignature == "" {
return ""
}
return geminiClaudeCarrierPrefix + direction + ":" + targetKind + ":" + base64.RawStdEncoding.EncodeToString([]byte(rawSignature))
}
func decodeGeminiClaudeCarrierSignature(rawSignature string) (signatureValue, direction, targetKind string, marked, ok bool) {
rawSignature = strings.TrimSpace(rawSignature)
if !strings.HasPrefix(rawSignature, geminiClaudeCarrierPrefix) {
return rawSignature, "", "", false, true
}
marked = true
if len(rawSignature) > (signature.MaxGeminiThoughtSignatureLen*4/3)+1024 {
return "", "", "", true, false
}
fields := strings.SplitN(strings.TrimPrefix(rawSignature, geminiClaudeCarrierPrefix), ":", 3)
if len(fields) != 3 {
return "", "", "", true, false
}
direction, targetKind = fields[0], fields[1]
switch direction {
case geminiClaudeCarrierNext, geminiClaudeCarrierPrevious, geminiClaudeCarrierStandalone:
default:
return "", "", "", true, false
}
switch targetKind {
case geminiClaudeCarrierText, geminiClaudeCarrierFunction, geminiClaudeCarrierAny:
default:
return "", "", "", true, false
}
decoded, errDecode := base64.RawStdEncoding.DecodeString(fields[2])
if errDecode != nil || len(decoded) == 0 || strings.HasPrefix(string(decoded), geminiClaudeCarrierPrefix) {
return "", "", "", true, false
}
blockKind := signature.SignatureBlockKindGeminiModelPart
if targetKind == geminiClaudeCarrierFunction {
blockKind = signature.SignatureBlockKindGeminiFunctionCall
}
normalized, compatible := signature.CompatibleSignatureForProviderBlock(signature.SignatureProviderGemini, string(decoded), blockKind)
if !compatible || signature.IsGeminiThoughtSignatureBypass(signature.SignaturePayloadWithoutProviderPrefix(normalized)) {
return "", "", "", true, false
}
return normalized, direction, targetKind, true, true
}
func geminiClaudeSemanticTargetKind(block gjson.Result) string {
switch block.Get("type").String() {
case "text":
return geminiClaudeCarrierText
case "tool_use":
return geminiClaudeCarrierFunction
case "thinking":
if strings.TrimSpace(block.Get("thinking").String()) != "" {
return geminiClaudeCarrierText
}
}
return ""
}
func geminiClaudeCarrierMatchesAdjacent(blocks []gjson.Result, index int, direction, targetKind string) bool {
step := 1
if direction == geminiClaudeCarrierPrevious {
step = -1
}
for adjacent := index + step; adjacent >= 0 && adjacent < len(blocks); adjacent += step {
if kind := geminiClaudeSemanticTargetKind(blocks[adjacent]); kind != "" {
return targetKind == geminiClaudeCarrierAny || targetKind == kind
}
if blocks[adjacent].Get("type").String() != "thinking" || strings.TrimSpace(blocks[adjacent].Get("thinking").String()) != "" {
return false
}
}
return false
}
// StripEmptySignatureThinkingBlocks removes thinking blocks whose signatures
// are empty or not valid Claude thinking signatures. These usually come from
// proxy-generated responses where no real Claude signature exists.
func StripEmptySignatureThinkingBlocks(payload []byte) []byte {
return signature.StripInvalidClaudeThinkingBlocks(payload, signature.ClaudeSignatureValidationOptions{PrefixOnly: true})
}
// StripInvalidGeminiSignatureThinkingBlocks preserves only thinking carriers
// whose signatures can be replayed to Gemini. Claude Code uses these carriers
// to return provider-native signatures from prior translated responses.
func StripInvalidGeminiSignatureThinkingBlocks(payload []byte) []byte {
messages := gjson.GetBytes(payload, "messages")
if !messages.IsArray() {
return payload
}
changed := false
messageItems := make([][]byte, 0, len(messages.Array()))
for _, message := range messages.Array() {
messageJSON := []byte(message.Raw)
content := message.Get("content")
if !content.IsArray() {
messageItems = append(messageItems, messageJSON)
continue
}
contentChanged := false
assistantMessage := strings.EqualFold(message.Get("role").String(), "assistant")
contentBlocks := content.Array()
contentItems := make([][]byte, 0, len(contentBlocks))
pendingCarrierTargetKind := ""
for blockIndex, block := range contentBlocks {
if block.Get("type").String() == "thinking" {
rawSignature := strings.TrimSpace(block.Get("signature").String())
thinkingText := strings.TrimSpace(block.Get("thinking").String())
if rawSignature == "" && thinkingText != "" && (pendingCarrierTargetKind == geminiClaudeCarrierAny || pendingCarrierTargetKind == geminiClaudeCarrierText) {
pendingCarrierTargetKind = ""
contentItems = append(contentItems, []byte(block.Raw))
continue
}
innerSignature, direction, targetKind, marked, okCarrier := decodeGeminiClaudeCarrierSignature(rawSignature)
blockKind := signature.SignatureBlockKindGeminiModelPart
if marked && targetKind == geminiClaudeCarrierFunction {
blockKind = signature.SignatureBlockKindGeminiFunctionCall
}
invalidMarkedPlacement := false
if marked {
switch direction {
case geminiClaudeCarrierNext, geminiClaudeCarrierPrevious:
invalidMarkedPlacement = !geminiClaudeCarrierMatchesAdjacent(contentBlocks, blockIndex, direction, targetKind)
case geminiClaudeCarrierStandalone:
invalidMarkedPlacement = thinkingText != "" && targetKind == geminiClaudeCarrierFunction
}
if thinkingText != "" && direction == geminiClaudeCarrierPrevious {
invalidMarkedPlacement = true
}
}
if !okCarrier || !assistantMessage || invalidMarkedPlacement {
pendingCarrierTargetKind = ""
contentChanged = true
continue
}
if !marked {
innerSignature = rawSignature
}
if _, ok := signature.CompatibleSignatureForProviderBlock(signature.SignatureProviderGemini, innerSignature, blockKind); !ok {
pendingCarrierTargetKind = ""
contentChanged = true
continue
}
if marked && direction == geminiClaudeCarrierNext {
pendingCarrierTargetKind = targetKind
} else {
pendingCarrierTargetKind = ""
}
} else {
pendingCarrierTargetKind = ""
}
contentItems = append(contentItems, []byte(block.Raw))
}
if contentChanged {
messageJSON, _ = sjson.SetRawBytes(messageJSON, "content", translatorcommon.JoinRawArray(contentItems))
changed = true
}
messageItems = append(messageItems, messageJSON)
}
if !changed {
return payload
}
updated, errSet := sjson.SetRawBytes(payload, "messages", translatorcommon.JoinRawArray(messageItems))
if errSet != nil {
return payload
}
return updated
}
func StripInvalidBypassSignatureThinkingBlocks(payload []byte) []byte {
return signature.StripInvalidClaudeThinkingBlocks(payload, claudeBypassSignatureValidationOptions())
}
func ValidateClaudeBypassSignatures(inputRawJSON []byte) error {
return signature.ValidateClaudeThinkingSignatures(inputRawJSON, claudeBypassSignatureValidationOptions())
}
func normalizeClaudeBypassSignature(rawSignature string) (string, error) {
return signature.NormalizeClaudeThinkingSignature(rawSignature, claudeBypassSignatureValidationOptions())
}
func inspectDoubleLayerSignature(sig string) (*claudeSignatureTree, error) {
return signature.InspectClaudeDoubleLayerSignature(sig)
}
func inspectSingleLayerSignature(sig string) (*claudeSignatureTree, error) {
return signature.InspectClaudeSingleLayerSignature(sig)
}
func inspectClaudeSignaturePayload(payload []byte, encodingLayers int) (*claudeSignatureTree, error) {
return signature.InspectClaudeSignaturePayload(payload, encodingLayers)
}
func claudeBypassSignatureValidationOptions() signature.ClaudeSignatureValidationOptions {
return signature.ClaudeSignatureValidationOptions{Strict: cache.SignatureBypassStrictMode()}
}

View file

@ -0,0 +1,84 @@
package claude
import (
"testing"
"github.com/tidwall/gjson"
)
func TestGeminiClaudeCarrierSignatureRoundTrip(t *testing.T) {
validSignature := testGeminiEPrefixSignature(t)
for _, testCase := range []struct {
direction string
kind string
}{
{direction: geminiClaudeCarrierNext, kind: geminiClaudeCarrierText},
{direction: geminiClaudeCarrierPrevious, kind: geminiClaudeCarrierFunction},
{direction: geminiClaudeCarrierStandalone, kind: geminiClaudeCarrierAny},
} {
encoded := encodeGeminiClaudeCarrierSignature(validSignature, testCase.direction, testCase.kind)
decoded, direction, kind, marked, ok := decodeGeminiClaudeCarrierSignature(encoded)
if !marked || !ok || decoded != validSignature || direction != testCase.direction || kind != testCase.kind {
t.Fatalf("carrier round trip = (%q,%q,%q,%v,%v)", decoded, direction, kind, marked, ok)
}
}
}
func TestStripInvalidGeminiSignatureThinkingBlocksPreservesMarkedNonEmptyThinking(t *testing.T) {
validSignature := testGeminiEPrefixSignature(t)
standalone := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierStandalone, geminiClaudeCarrierText)
nextFunction := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierNext, geminiClaudeCarrierFunction)
invalidPrevious := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierPrevious, geminiClaudeCarrierText)
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"signed thought","signature":"` + standalone + `"},{"type":"thinking","thinking":"tool preface","signature":"` + nextFunction + `"},{"type":"tool_use","id":"tool-1","name":"run","input":{}},{"type":"thinking","thinking":"invalid backward","signature":"` + invalidPrevious + `"}]}]}`)
out := StripInvalidGeminiSignatureThinkingBlocks(input)
content := gjson.GetBytes(out, "messages.0.content").Array()
if len(content) != 3 || content[0].Get("signature").String() != standalone || content[1].Get("signature").String() != nextFunction || content[2].Get("type").String() != "tool_use" {
t.Fatalf("marked non-empty thinking validation changed carriers: %s", out)
}
}
func TestStripInvalidGeminiSignatureThinkingBlocksDropsMismatchedDirectionalThinking(t *testing.T) {
validSignature := testGeminiEPrefixSignature(t)
nextFunction := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierNext, geminiClaudeCarrierFunction)
standaloneFunction := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierStandalone, geminiClaudeCarrierFunction)
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"wrong next target","signature":"` + nextFunction + `"},{"type":"text","text":"visible"},{"type":"thinking","thinking":"wrong standalone target","signature":"` + standaloneFunction + `"}]}]}`)
out := StripInvalidGeminiSignatureThinkingBlocks(input)
content := gjson.GetBytes(out, "messages.0.content").Array()
if len(content) != 1 || content[0].Get("type").String() != "text" {
t.Fatalf("mismatched directional thinking was preserved: %s", out)
}
}
func TestStripInvalidGeminiSignatureThinkingBlocksDropsLegacyRawCarrierFromUserMessage(t *testing.T) {
validSignature := testGeminiEPrefixSignature(t)
input := []byte(`{"messages":[{"role":"user","content":[{"type":"thinking","thinking":"","signature":"` + validSignature + `"},{"type":"text","text":"user text"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"` + validSignature + `"},{"type":"text","text":"assistant text"}]}]}`)
out := StripInvalidGeminiSignatureThinkingBlocks(input)
userContent := gjson.GetBytes(out, "messages.0.content").Array()
assistantContent := gjson.GetBytes(out, "messages.1.content").Array()
if len(userContent) != 1 || userContent[0].Get("type").String() != "text" {
t.Fatalf("legacy raw carrier survived user message: %s", out)
}
if len(assistantContent) != 2 || assistantContent[0].Get("signature").String() != validSignature {
t.Fatalf("assistant legacy carrier was not preserved: %s", out)
}
}
func TestStripInvalidGeminiSignatureThinkingBlocks(t *testing.T) {
validSignature := testGeminiEPrefixSignature(t)
validCarrier := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierPrevious, geminiClaudeCarrierText)
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"text","text":"first"},{"type":"thinking","thinking":"","signature":"` + validSignature + `"},{"type":"thinking","thinking":"","signature":"` + validCarrier + `"},{"type":"thinking","thinking":"","signature":"cpa-gemini-carrier-v1:previous:text:invalid"},{"type":"thinking","thinking":"","signature":"invalid"},{"type":"text","text":"last"}]}]}`)
out := StripInvalidGeminiSignatureThinkingBlocks(input)
content := gjson.GetBytes(out, "messages.0.content").Array()
if len(content) != 4 {
t.Fatalf("content count = %d, want 4; output=%s", len(content), out)
}
if got := content[1].Get("signature").String(); got != validSignature {
t.Fatalf("preserved signature = %q, want Gemini signature", got)
}
if got := content[2].Get("signature").String(); got != validCarrier {
t.Fatalf("preserved carrier = %q, want directional carrier", got)
}
if got := content[3].Get("text").String(); got != "last" {
t.Fatalf("last text = %q, want last", got)
}
}

View file

@ -0,0 +1,502 @@
package claude
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type webSearchGroundingSupport struct {
StartIndex int64
EndIndex int64
Text string
ChunkURLs []string
ChunkTitle string
}
type webSearchCitedTextBlock struct {
Text string
Citations []map[string]any
}
const antigravityWebSearchSystemInstruction = "You are a search engine bot. You will be given a query from a user. Your task is to search the web for relevant information that will help the user. You MUST perform a web search. Do not respond or interact with the user, please respond as if they typed the query into a search bar."
func antigravitySupportsNativeGoogleSearch(model string) bool {
return registry.AntigravityWebSearchModelFor(model) != ""
}
func isClaudeTypedWebSearchToolType(toolType string) bool {
return toolType == "web_search_20250305" || toolType == "web_search_20260209"
}
func hasClaudeTypedWebSearchTool(payload []byte) bool {
tools := gjson.GetBytes(payload, "tools")
if !tools.IsArray() {
return false
}
for _, tool := range tools.Array() {
if isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
return true
}
}
return false
}
func hasOnlyClaudeTypedWebSearchTools(payload []byte) bool {
tools := gjson.GetBytes(payload, "tools")
if !tools.IsArray() {
return false
}
hasWebSearch := false
for _, tool := range tools.Array() {
if isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
hasWebSearch = true
continue
}
return false
}
return hasWebSearch
}
func allowsClaudeWebSearchToolChoice(payload []byte) bool {
toolChoice := gjson.GetBytes(payload, "tool_choice")
if !toolChoice.Exists() {
return true
}
if toolChoice.Type == gjson.String {
switch toolChoice.String() {
case "", "auto", "any":
return true
case "none":
return false
default:
return false
}
}
if !toolChoice.IsObject() {
return false
}
switch toolChoice.Get("type").String() {
case "", "auto", "any":
return true
case "tool":
return toolChoice.Get("name").String() == "web_search"
default:
return false
}
}
func shouldBuildAntigravityWebSearchRequest(model string, payload []byte) bool {
return antigravitySupportsNativeGoogleSearch(model) &&
hasOnlyClaudeTypedWebSearchTools(payload) &&
allowsClaudeWebSearchToolChoice(payload)
}
func buildAntigravityWebSearchRequest(model string, payload []byte) []byte {
query := extractClaudeWebSearchQuery(payload)
maxResultCount := extractClaudeWebSearchMaxUses(payload)
includedDomains := extractClaudeWebSearchAllowedDomains(payload)
out := []byte(`{"model":"","requestType":"web_search","request":{"contents":[{"role":"user","parts":[{"text":""}]}],"systemInstruction":{"role":"user","parts":[{"text":""}]},"tools":[{"googleSearch":{"enhancedContent":{"imageSearch":{"maxResultCount":5}}}}],"generationConfig":{"candidateCount":1}}}`)
out, _ = sjson.SetBytes(out, "model", model)
out, _ = sjson.SetBytes(out, "request.contents.0.parts.0.text", query)
out, _ = sjson.SetBytes(out, "request.systemInstruction.parts.0.text", antigravityWebSearchSystemInstruction)
out, _ = sjson.SetBytes(out, "request.tools.0.googleSearch.enhancedContent.imageSearch.maxResultCount", maxResultCount)
if len(includedDomains) > 0 {
if domainsJSON, err := json.Marshal(includedDomains); err == nil {
out, _ = sjson.SetRawBytes(out, "request.tools.0.googleSearch.includedDomains", domainsJSON)
}
}
return out
}
func extractClaudeWebSearchMaxUses(payload []byte) int64 {
const defaultMaxResultCount int64 = 5
tools := gjson.GetBytes(payload, "tools")
if !tools.IsArray() {
return defaultMaxResultCount
}
for _, tool := range tools.Array() {
if !isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
continue
}
maxUses := tool.Get("max_uses").Int()
if maxUses > 0 {
return maxUses
}
}
return defaultMaxResultCount
}
func extractClaudeWebSearchAllowedDomains(payload []byte) []string {
tools := gjson.GetBytes(payload, "tools")
if !tools.IsArray() {
return nil
}
for _, tool := range tools.Array() {
if !isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
continue
}
allowedDomains := tool.Get("allowed_domains")
if !allowedDomains.IsArray() {
return nil
}
domains := make([]string, 0, len(allowedDomains.Array()))
for _, domain := range allowedDomains.Array() {
if domain.Type != gjson.String {
continue
}
if trimmed := strings.TrimSpace(domain.String()); trimmed != "" {
domains = append(domains, trimmed)
}
}
return domains
}
return nil
}
func extractClaudeWebSearchQuery(payload []byte) string {
messages := gjson.GetBytes(payload, "messages")
if !messages.IsArray() {
return ""
}
messageResults := messages.Array()
for i := len(messageResults) - 1; i >= 0; i-- {
message := messageResults[i]
if role := message.Get("role").String(); role != "" && role != "user" {
continue
}
if query := extractClaudeTextContent(message.Get("content")); query != "" {
return query
}
}
return ""
}
func extractClaudeTextContent(content gjson.Result) string {
if content.Type == gjson.String {
return strings.TrimSpace(content.String())
}
if !content.IsArray() {
return ""
}
var b strings.Builder
for _, part := range content.Array() {
if text := strings.TrimSpace(part.Get("text").String()); text != "" {
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(text)
}
}
return strings.TrimSpace(b.String())
}
func hasAntigravityGoogleSearchTool(payload []byte) bool {
tools := gjson.GetBytes(payload, "request.tools")
if !tools.IsArray() {
return false
}
for _, tool := range tools.Array() {
if tool.Get("googleSearch").Exists() {
return true
}
}
return false
}
func shouldTranslateWebSearchGrounding(originalRequestRawJSON, requestRawJSON []byte) bool {
return hasClaudeTypedWebSearchTool(originalRequestRawJSON) && hasAntigravityGoogleSearchTool(requestRawJSON)
}
func antigravityGroundingMetadata(root gjson.Result) gjson.Result {
groundingMetadata := root.Get("response.candidates.0.groundingMetadata")
if groundingMetadata.Exists() {
return groundingMetadata
}
return root.Get("candidates.0.groundingMetadata")
}
func antigravityTextContent(root gjson.Result) string {
var textBuilder strings.Builder
parts := root.Get("response.candidates.0.content.parts")
if !parts.IsArray() {
parts = root.Get("candidates.0.content.parts")
}
if parts.IsArray() {
for _, part := range parts.Array() {
if text := part.Get("text"); text.Exists() {
textBuilder.WriteString(text.String())
}
}
}
return textBuilder.String()
}
func antigravityUsageTokens(root gjson.Result) (int64, int64) {
usage := root.Get("response.usageMetadata")
if !usage.Exists() {
usage = root.Get("usageMetadata")
}
inputTokens := usage.Get("promptTokenCount").Int()
outputTokens := usage.Get("candidatesTokenCount").Int() + usage.Get("thoughtsTokenCount").Int()
if outputTokens == 0 {
totalTokens := usage.Get("totalTokenCount").Int()
if totalTokens > 0 {
outputTokens = totalTokens - inputTokens
if outputTokens < 0 {
outputTokens = 0
}
}
}
return inputTokens, outputTokens
}
func webSearchQueryFromGrounding(groundingMetadata gjson.Result) string {
if queries := groundingMetadata.Get("webSearchQueries"); queries.IsArray() && len(queries.Array()) > 0 {
return queries.Array()[0].String()
}
return ""
}
func webSearchResultsFromGrounding(groundingMetadata gjson.Result) []byte {
results := []byte(`[]`)
groundingChunks := groundingMetadata.Get("groundingChunks")
if !groundingChunks.IsArray() {
return results
}
seenURLs := make(map[string]struct{})
for _, chunk := range groundingChunks.Array() {
web := chunk.Get("web")
if !web.Exists() {
continue
}
uri := strings.TrimSpace(web.Get("uri").String())
if uri == "" {
continue
}
if _, ok := seenURLs[uri]; ok {
continue
}
seenURLs[uri] = struct{}{}
result := []byte(`{"type":"web_search_result","page_age":null}`)
if title := web.Get("title"); title.Exists() {
result, _ = sjson.SetBytes(result, "title", title.String())
}
result, _ = sjson.SetBytes(result, "url", uri)
results, _ = sjson.SetRawBytes(results, "-1", result)
}
return results
}
func parseWebSearchGroundingSupports(groundingMetadata gjson.Result) []webSearchGroundingSupport {
groundingChunks := groundingMetadata.Get("groundingChunks")
if !groundingChunks.IsArray() {
return nil
}
chunks := groundingChunks.Array()
chunkData := make([]struct {
URL string
Title string
}, len(chunks))
for i, chunk := range chunks {
web := chunk.Get("web")
if web.Exists() {
chunkData[i].URL = web.Get("uri").String()
chunkData[i].Title = web.Get("title").String()
}
}
groundingSupports := groundingMetadata.Get("groundingSupports")
if !groundingSupports.IsArray() {
return nil
}
supports := make([]webSearchGroundingSupport, 0, len(groundingSupports.Array()))
for _, support := range groundingSupports.Array() {
segment := support.Get("segment")
if !segment.Exists() {
continue
}
parsed := webSearchGroundingSupport{
StartIndex: segment.Get("startIndex").Int(),
EndIndex: segment.Get("endIndex").Int(),
Text: segment.Get("text").String(),
}
if chunkIndices := support.Get("groundingChunkIndices"); chunkIndices.IsArray() {
for _, idx := range chunkIndices.Array() {
chunkIndex := int(idx.Int())
if chunkIndex < 0 || chunkIndex >= len(chunkData) {
continue
}
parsed.ChunkURLs = append(parsed.ChunkURLs, chunkData[chunkIndex].URL)
if parsed.ChunkTitle == "" {
parsed.ChunkTitle = chunkData[chunkIndex].Title
}
}
}
supports = append(supports, parsed)
}
return supports
}
func buildWebSearchCitedTextBlocks(textContent string, supports []webSearchGroundingSupport) []webSearchCitedTextBlock {
if len(supports) == 0 {
if textContent == "" {
return nil
}
return []webSearchCitedTextBlock{{Text: textContent}}
}
textBytes := []byte(textContent)
blocks := make([]webSearchCitedTextBlock, 0, len(supports)+1)
lastEnd := int64(0)
for _, support := range supports {
if support.EndIndex <= lastEnd {
continue
}
if support.StartIndex > lastEnd {
start := int(lastEnd)
end := min(int(support.StartIndex), len(textBytes))
if start < end {
blocks = append(blocks, webSearchCitedTextBlock{Text: string(textBytes[start:end])})
}
}
citedStart := support.StartIndex
if citedStart < lastEnd {
citedStart = lastEnd
}
citedText := ""
if citedStart < support.EndIndex {
start := min(int(citedStart), len(textBytes))
end := min(int(support.EndIndex), len(textBytes))
if start < end {
citedText = string(textBytes[start:end])
}
}
if citedText != "" && len(support.ChunkURLs) > 0 {
citation := map[string]any{
"type": "web_search_result_location",
"cited_text": citedText,
"url": support.ChunkURLs[0],
"title": support.ChunkTitle,
}
blocks = append(blocks, webSearchCitedTextBlock{
Text: citedText,
Citations: []map[string]any{citation},
})
}
if support.EndIndex > lastEnd {
lastEnd = support.EndIndex
}
}
if int(lastEnd) < len(textBytes) {
blocks = append(blocks, webSearchCitedTextBlock{Text: string(textBytes[lastEnd:])})
}
return blocks
}
func buildClaudeWebSearchContent(toolUseID string, textContent string, groundingMetadata gjson.Result) []byte {
content := []byte(`[]`)
serverToolUse := []byte(`{"type":"server_tool_use","id":"","name":"web_search","input":{}}`)
serverToolUse, _ = sjson.SetBytes(serverToolUse, "id", toolUseID)
if query := webSearchQueryFromGrounding(groundingMetadata); query != "" {
serverToolUse, _ = sjson.SetBytes(serverToolUse, "input.query", query)
}
content, _ = sjson.SetRawBytes(content, "-1", serverToolUse)
webSearchToolResult := []byte(`{"type":"web_search_tool_result","tool_use_id":"","content":[]}`)
webSearchToolResult, _ = sjson.SetBytes(webSearchToolResult, "tool_use_id", toolUseID)
webSearchToolResult, _ = sjson.SetRawBytes(webSearchToolResult, "content", webSearchResultsFromGrounding(groundingMetadata))
content, _ = sjson.SetRawBytes(content, "-1", webSearchToolResult)
for _, block := range buildWebSearchCitedTextBlocks(textContent, parseWebSearchGroundingSupports(groundingMetadata)) {
if block.Text == "" {
continue
}
textBlock := []byte(`{"type":"text","text":""}`)
textBlock, _ = sjson.SetBytes(textBlock, "text", block.Text)
if len(block.Citations) > 0 {
citationsJSON, _ := json.Marshal(block.Citations)
textBlock, _ = sjson.SetRawBytes(textBlock, "citations", citationsJSON)
}
content, _ = sjson.SetRawBytes(content, "-1", textBlock)
}
return content
}
func appendClaudeWebSearchStreamBlocks(appendEvent func(string, string), startIndex int, toolUseID string, textContent string, groundingMetadata gjson.Result) int {
contentIndex := startIndex
serverToolUseStart := fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"server_tool_use","id":"%s","name":"web_search","input":{}}}`,
contentIndex, toolUseID)
appendEvent("content_block_start", serverToolUseStart)
if query := webSearchQueryFromGrounding(groundingMetadata); query != "" {
queryJSON, _ := sjson.Set(`{}`, "query", query)
inputDelta := fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, contentIndex)
inputDelta, _ = sjson.Set(inputDelta, "delta.partial_json", queryJSON)
appendEvent("content_block_delta", inputDelta)
}
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, contentIndex))
contentIndex++
webSearchToolResultStart := fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"web_search_tool_result","tool_use_id":"%s","content":[]}}`,
contentIndex, toolUseID)
webSearchToolResultStart, _ = sjson.SetRaw(webSearchToolResultStart, "content_block.content", string(webSearchResultsFromGrounding(groundingMetadata)))
appendEvent("content_block_start", webSearchToolResultStart)
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, contentIndex))
contentIndex++
for _, block := range buildWebSearchCitedTextBlocks(textContent, parseWebSearchGroundingSupports(groundingMetadata)) {
if block.Text == "" {
continue
}
textBlockStart := fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, contentIndex)
if len(block.Citations) > 0 {
textBlockStart = fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"citations":[],"type":"text","text":""}}`, contentIndex)
}
appendEvent("content_block_start", textBlockStart)
for _, citation := range block.Citations {
citationJSON, _ := json.Marshal(citation)
citationDelta := fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"citations_delta","citation":%s}}`, contentIndex, string(citationJSON))
appendEvent("content_block_delta", citationDelta)
}
for _, chunk := range splitRunesForWebSearch(block.Text, 50) {
textDelta := fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, contentIndex)
textDelta, _ = sjson.Set(textDelta, "delta.text", chunk)
appendEvent("content_block_delta", textDelta)
}
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, contentIndex))
contentIndex++
}
return contentIndex
}
func splitRunesForWebSearch(text string, chunkSize int) []string {
if chunkSize <= 0 || text == "" {
return nil
}
runes := []rune(text)
chunks := make([]string, 0, (len(runes)+chunkSize-1)/chunkSize)
for start := 0; start < len(runes); start += chunkSize {
end := start + chunkSize
if end > len(runes) {
end = len(runes)
}
chunks = append(chunks, string(runes[start:end]))
}
return chunks
}
func newClaudeWebSearchToolUseID() string {
return fmt.Sprintf("srvtoolu_%d", time.Now().UnixNano())
}

View file

@ -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

View file

@ -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
}

View file

@ -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)
}
})
}
}

View 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,
},
)
}

View file

@ -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)
}
}

View file

@ -0,0 +1,19 @@
package interactions
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(
Interactions,
Antigravity,
ConvertInteractionsRequestToAntigravity,
interfaces.TranslateResponse{
Stream: ConvertAntigravityResponseToInteractions,
NonStream: ConvertAntigravityResponseToInteractionsNonStream,
},
)
}

View file

@ -0,0 +1,20 @@
package interactions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertInteractionsRequestToAntigravityNormalizesOpenAIFileDataURL(t *testing.T) {
input := []byte(`{"model":"gemini-3.5-flash","input":[{"type":"user_input","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
out := ConvertInteractionsRequestToAntigravity("gemini-3.5-flash", input, false)
inlineData := gjson.GetBytes(out, "request.contents.0.parts.0.inlineData")
if got := inlineData.Get("mimeType").String(); got != "application/pdf" {
t.Fatalf("inlineData.mimeType = %q, want application/pdf. Output: %s", got, out)
}
if got := inlineData.Get("data").String(); got != "JVBERi0xLjQK" {
t.Fatalf("inlineData.data = %q, want raw base64 payload. Output: %s", got, out)
}
}

View file

@ -0,0 +1,793 @@
package interactions
import (
"encoding/json"
"fmt"
"strings"
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"
)
func ConvertInteractionsRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) []byte {
root := gjson.ParseBytes(inputRawJSON)
functionNameMap := util.SanitizedFunctionNameMap(inputRawJSON)
out := []byte(`{"project":"","request":{"contents":[]},"model":""}`)
out, _ = sjson.SetBytes(out, "model", modelName)
if stream || root.Get("stream").Bool() {
out, _ = sjson.SetBytes(out, "request.stream", true)
}
out = copyInteractionsSystemToAntigravity(out, root)
out = copyInteractionsGenerationConfigToAntigravity(out, root)
contentItems := translatorcommon.NewRawArrayItems(root.Get("input.#").Int())
appendInteractionsInputToAntigravity(&contentItems, root.Get("input"))
out = translatorcommon.SetRawArrayItems(out, "request.contents", contentItems)
out = copyInteractionsToolsToAntigravity(out, root, functionNameMap)
out = rewriteInteractionsFunctionNames(out, functionNameMap)
out = attachDefaultAntigravitySafetySettings(out)
return out
}
func rewriteInteractionsFunctionNames(out []byte, functionNameMap map[string]string) []byte {
contents := gjson.GetBytes(out, "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
})
}
if canBatchContents {
contentsChanged := false
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 []string{"functionCall", "functionResponse"} {
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))
contentsChanged = true
}
contentItems = append(contentItems, contentJSON)
return true
})
if contentsChanged {
out, _ = sjson.SetRawBytes(out, "request.contents", translatorcommon.JoinRawArray(contentItems))
}
} else {
for contentIndex, content := range contents.Array() {
for partIndex, part := range content.Get("parts").Array() {
for _, field := range []string{"functionCall", "functionResponse"} {
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)
out, _ = sjson.SetBytes(out, path, mappedName)
}
}
}
}
allowedPath := "request.toolConfig.functionCallingConfig.allowedFunctionNames"
allowedNames := gjson.GetBytes(out, 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 {
out, _ = sjson.SetRawBytes(out, 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)
out, _ = sjson.SetBytes(out, path, mappedName)
}
}
return out
}
func copyInteractionsSystemToAntigravity(out []byte, root gjson.Result) []byte {
sys := root.Get("system_instruction")
if !sys.Exists() {
return out
}
if sys.Type == gjson.String {
instr := []byte(`{"parts":[{"text":""}]}`)
instr, _ = sjson.SetBytes(instr, "parts.0.text", sys.String())
out, _ = sjson.SetRawBytes(out, "request.systemInstruction", instr)
return out
}
if text := sys.Get("text"); text.Exists() && !sys.Get("parts").Exists() {
instr := []byte(`{"parts":[{"text":""}]}`)
instr, _ = sjson.SetBytes(instr, "parts.0.text", text.String())
out, _ = sjson.SetRawBytes(out, "request.systemInstruction", instr)
return out
}
out, _ = sjson.SetRawBytes(out, "request.systemInstruction", []byte(sys.Raw))
return out
}
func copyInteractionsGenerationConfigToAntigravity(out []byte, root gjson.Result) []byte {
if cfg := root.Get("generation_config"); cfg.Exists() {
out, _ = sjson.SetRawBytes(out, "request.generationConfig", convertSnakeCaseKeysToCamelCaseForAntigravity([]byte(cfg.Raw)))
} else if cfg := root.Get("generationConfig"); cfg.Exists() {
out, _ = sjson.SetRawBytes(out, "request.generationConfig", []byte(cfg.Raw))
}
out = normalizeInteractionsGenerationConfigForAntigravity(out)
out = copyInteractionsReasoningToAntigravity(out, root)
out = copyInteractionsResponseModalitiesToAntigravity(out, root)
out = copyInteractionsToolChoiceToAntigravity(out, root)
return out
}
func normalizeInteractionsGenerationConfigForAntigravity(out []byte) []byte {
if thinkingLevel := gjson.GetBytes(out, "request.generationConfig.thinkingLevel"); thinkingLevel.Exists() {
out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", []byte(thinkingLevel.Raw))
out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingLevel")
}
if thinkingBudget := gjson.GetBytes(out, "request.generationConfig.thinkingBudget"); thinkingBudget.Exists() {
out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", []byte(thinkingBudget.Raw))
out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingBudget")
}
if includeThoughts := gjson.GetBytes(out, "request.generationConfig.includeThoughts"); includeThoughts.Exists() {
out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", []byte(includeThoughts.Raw))
out, _ = sjson.DeleteBytes(out, "request.generationConfig.includeThoughts")
}
if summaries := gjson.GetBytes(out, "request.generationConfig.thinkingSummaries"); summaries.Exists() {
if includeThoughts, ok := antigravityThinkingSummariesIncludeThoughts(summaries); ok {
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
}
out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingSummaries")
}
if toolChoice := gjson.GetBytes(out, "request.generationConfig.toolChoice"); toolChoice.Exists() {
out, _ = sjson.DeleteBytes(out, "request.generationConfig.toolChoice")
}
return out
}
func copyInteractionsReasoningToAntigravity(out []byte, root gjson.Result) []byte {
reasoning := root.Get("reasoning")
if !reasoning.Exists() {
return out
}
effort := strings.ToLower(strings.TrimSpace(reasoning.Get("effort").String()))
if effort == "" {
effort = strings.ToLower(strings.TrimSpace(reasoning.Get("thinking_level").String()))
}
if effort != "" {
// Thinking amount and summary visibility are independent. This OpenAI-style
// compatibility alias controls only the amount; includeThoughts is written
// below only for an explicit Interactions summary selector.
if effort == "auto" {
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
} else {
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", effort)
}
}
if summary := reasoning.Get("summary"); summary.Exists() {
if includeThoughts, ok := antigravityThinkingSummariesIncludeThoughts(summary); ok {
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
}
}
return out
}
func copyInteractionsResponseModalitiesToAntigravity(out []byte, root gjson.Result) []byte {
mods := root.Get("response_modalities")
if !mods.Exists() {
mods = root.Get("responseModalities")
}
if !mods.Exists() || !mods.IsArray() {
return out
}
var responseMods []string
mods.ForEach(func(_, mod gjson.Result) bool {
switch strings.ToLower(strings.TrimSpace(mod.String())) {
case "text":
responseMods = append(responseMods, "TEXT")
case "image":
responseMods = append(responseMods, "IMAGE")
case "audio":
responseMods = append(responseMods, "AUDIO")
}
return true
})
if len(responseMods) > 0 {
out, _ = sjson.SetBytes(out, "request.generationConfig.responseModalities", responseMods)
}
return out
}
func copyInteractionsToolChoiceToAntigravity(out []byte, root gjson.Result) []byte {
toolChoice := root.Get("tool_choice")
if !toolChoice.Exists() {
toolChoice = root.Get("generation_config.tool_choice")
}
if !toolChoice.Exists() {
toolChoice = root.Get("generationConfig.toolChoice")
}
if !toolChoice.Exists() {
return out
}
mode := ""
var allowedNames []string
if toolChoice.Type == gjson.String {
switch strings.ToLower(strings.TrimSpace(toolChoice.String())) {
case "none":
mode = "NONE"
case "auto":
mode = "AUTO"
case "required", "any":
mode = "ANY"
}
} else if toolChoice.IsObject() {
switch strings.ToLower(strings.TrimSpace(toolChoice.Get("type").String())) {
case "none":
mode = "NONE"
case "auto":
mode = "AUTO"
case "required", "any":
mode = "ANY"
case "function":
mode = "ANY"
if name := toolChoice.Get("function.name").String(); strings.TrimSpace(name) != "" {
allowedNames = append(allowedNames, name)
}
case "tool":
mode = "ANY"
if name := toolChoice.Get("name").String(); strings.TrimSpace(name) != "" {
allowedNames = append(allowedNames, name)
}
}
}
if mode == "" {
return out
}
out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", mode)
if len(allowedNames) > 0 {
out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", allowedNames)
}
return out
}
func appendInteractionsInputToAntigravity(items *[][]byte, input gjson.Result) {
if !input.Exists() {
return
}
if input.Type == gjson.String {
appendAntigravityTextContent(items, "user", input.String())
return
}
if input.IsArray() {
input.ForEach(func(_, item gjson.Result) bool {
appendInteractionsStepToAntigravity(items, item, "user")
return true
})
return
}
if steps := input.Get("steps"); steps.Exists() && steps.IsArray() {
defaultRole := "user"
if role := input.Get("role").String(); role == "model" || role == "assistant" {
defaultRole = "model"
}
steps.ForEach(func(_, step gjson.Result) bool {
appendInteractionsStepToAntigravity(items, step, defaultRole)
return true
})
return
}
appendInteractionsStepToAntigravity(items, input, "user")
}
func appendInteractionsStepToAntigravity(items *[][]byte, step gjson.Result, defaultRole string) {
if step.Type == gjson.String {
appendAntigravityTextContent(items, defaultRole, step.String())
return
}
if steps := step.Get("steps"); steps.Exists() && steps.IsArray() {
role := defaultRole
if itemRole := step.Get("role").String(); itemRole == "model" || itemRole == "assistant" {
role = "model"
} else if itemRole == "user" {
role = "user"
}
steps.ForEach(func(_, child gjson.Result) bool {
appendInteractionsStepToAntigravity(items, child, role)
return true
})
return
}
switch step.Get("type").String() {
case "model_output":
appendInteractionsStepContentToAntigravity(items, "model", step, false)
case "thought":
appendInteractionsStepContentToAntigravity(items, "model", step, true)
case "function_call":
appendInteractionsFunctionCallToAntigravity(items, step)
case "function_result":
appendInteractionsFunctionResultToAntigravity(items, step)
case "user_input", "":
if step.Get("parts").Exists() {
appendInteractionsNativeContentToAntigravity(items, step, defaultRole)
} else {
appendInteractionsContentListToAntigravity(items, defaultRole, step.Get("content"))
}
default:
if step.Get("parts").Exists() {
appendInteractionsNativeContentToAntigravity(items, step, defaultRole)
} else if step.Get("content").Exists() {
appendInteractionsContentListToAntigravity(items, defaultRole, step.Get("content"))
} else if text := step.Get("text"); text.Exists() {
appendAntigravityTextContent(items, defaultRole, text.String())
}
}
}
func appendInteractionsNativeContentToAntigravity(items *[][]byte, step gjson.Result, defaultRole string) {
parts := step.Get("parts")
if !parts.Exists() || !parts.IsArray() {
return
}
partItems := make([][]byte, 0, 4)
parts.ForEach(func(_, part gjson.Result) bool {
if partJSON := interactionsNativeAntigravityPart(part); len(partJSON) > 0 {
partItems = append(partItems, partJSON)
}
return true
})
if len(partItems) > 0 {
role := antigravityContentRole(step.Get("role").String(), defaultRole)
*items = append(*items, antigravityContent(role, partItems))
}
}
func appendInteractionsStepContentToAntigravity(items *[][]byte, role string, step gjson.Result, thought bool) {
content := step.Get("content")
if !content.Exists() {
return
}
partItems := make([][]byte, 0, 4)
if content.IsArray() {
content.ForEach(func(_, part gjson.Result) bool {
if partJSON := appendInteractionsContentToAntigravityPart(nil, part, thought); len(partJSON) > 0 {
partItems = append(partItems, partJSON)
}
return true
})
} else if content.IsObject() {
if partJSON := appendInteractionsContentToAntigravityPart(nil, content, thought); len(partJSON) > 0 {
partItems = append(partItems, partJSON)
}
} else if content.Type == gjson.String {
partItems = append(partItems, antigravityTextPartJSON(content.String(), thought))
}
if len(partItems) > 0 {
*items = append(*items, antigravityContent(role, partItems))
}
}
func appendInteractionsContentListToAntigravity(items *[][]byte, role string, content gjson.Result) {
if !content.Exists() {
return
}
if content.IsArray() {
content.ForEach(func(_, part gjson.Result) bool {
appendInteractionsContentPartToAntigravity(items, role, part)
return true
})
return
}
if content.IsObject() {
appendInteractionsContentPartToAntigravity(items, role, content)
} else if content.Type == gjson.String {
appendAntigravityTextContent(items, role, content.String())
}
}
func appendInteractionsContentPartToAntigravity(items *[][]byte, role string, part gjson.Result) {
partJSON := appendInteractionsContentToAntigravityPart(nil, part, false)
if len(partJSON) > 0 {
*items = append(*items, antigravityContent(role, [][]byte{partJSON}))
}
}
func appendInteractionsContentToAntigravityPart(_ []byte, content gjson.Result, thought bool) []byte {
if text := content.Get("text"); text.Exists() {
return antigravityTextPartJSON(text.String(), thought)
}
if inline := content.Get("inline_data"); inline.Exists() {
return antigravityInlineDataPartJSON(inline)
}
if inline := content.Get("inlineData"); inline.Exists() {
return antigravityInlineDataPartJSON(inline)
}
switch strings.ToLower(strings.TrimSpace(content.Get("type").String())) {
case "text":
if text := content.Get("text"); text.Exists() {
return antigravityTextPartJSON(text.String(), thought)
}
case "image", "audio", "video", "document":
if mime := content.Get("mime_type"); mime.Exists() || content.Get("mimeType").Exists() {
mimeType := mime.String()
if mimeType == "" {
mimeType = content.Get("mimeType").String()
}
if data := content.Get("data").String(); data != "" {
return antigravityInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
}
}
if uri := content.Get("file_uri"); uri.Exists() || content.Get("fileUri").Exists() {
fileURI := uri.String()
if fileURI == "" {
fileURI = content.Get("fileUri").String()
}
mimeType := content.Get("mime_type").String()
if mimeType == "" {
mimeType = content.Get("mimeType").String()
}
return antigravityFileDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mimeType":%q,"fileUri":%q}`, mimeType, fileURI)))
}
if url := content.Get("url"); url.Exists() {
return antigravityInlineDataPartFromDataURL(url.String())
}
case "image_url":
return antigravityInlineDataPartFromDataURL(content.Get("image_url.url").String())
case "input_audio":
mimeType := antigravityInputAudioMimeType(content.Get("input_audio.format").String())
return antigravityInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, content.Get("input_audio.data").String())))
case "file":
filename := content.Get("file.filename").String()
fileData := content.Get("file.file_data").String()
if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, "", fileData); ok {
return antigravityInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
}
}
return nil
}
func appendInteractionsFunctionCallToAntigravity(items *[][]byte, step gjson.Result) {
part := []byte(`{"functionCall":{"name":"","args":{}}}`)
part, _ = sjson.SetBytes(part, "functionCall.name", step.Get("name").String())
if callID := step.Get("call_id"); callID.Exists() {
part, _ = sjson.SetBytes(part, "functionCall.id", callID.String())
} else if id := step.Get("id"); id.Exists() {
part, _ = sjson.SetBytes(part, "functionCall.id", id.String())
}
if args := step.Get("arguments"); args.Exists() {
part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(args.Raw))
}
*items = append(*items, antigravityContent("model", [][]byte{part}))
}
func appendInteractionsFunctionResultToAntigravity(items *[][]byte, step gjson.Result) {
part := []byte(`{"functionResponse":{"name":"","response":{}}}`)
part, _ = sjson.SetBytes(part, "functionResponse.name", step.Get("name").String())
if callID := step.Get("call_id"); callID.Exists() {
part, _ = sjson.SetBytes(part, "functionResponse.id", callID.String())
} else if id := step.Get("id"); id.Exists() {
part, _ = sjson.SetBytes(part, "functionResponse.id", id.String())
}
if result := step.Get("result"); result.Exists() {
part, _ = sjson.SetRawBytes(part, "functionResponse.response", []byte(result.Raw))
}
*items = append(*items, antigravityContent("user", [][]byte{part}))
}
func copyInteractionsToolsToAntigravity(out []byte, root gjson.Result, functionNameMap map[string]string) []byte {
tools := root.Get("tools")
if !tools.Exists() {
return out
}
if !tools.IsArray() {
out, _ = sjson.SetRawBytes(out, "request.tools", []byte(tools.Raw))
return out
}
var functionDeclarations [][]byte
var otherTools [][]byte
tools.ForEach(func(_, tool gjson.Result) bool {
if decls := tool.Get("functionDeclarations"); decls.Exists() && decls.IsArray() {
decls.ForEach(func(_, decl gjson.Result) bool {
if converted := antigravityFunctionDeclarationJSON(decl, functionNameMap); len(converted) > 0 {
functionDeclarations = append(functionDeclarations, converted)
}
return true
})
return true
}
if decls := tool.Get("function_declarations"); decls.Exists() && decls.IsArray() {
decls.ForEach(func(_, decl gjson.Result) bool {
if converted := antigravityFunctionDeclarationJSON(decl, functionNameMap); len(converted) > 0 {
functionDeclarations = append(functionDeclarations, converted)
}
return true
})
return true
}
if tool.Get("type").String() == "function" || tool.Get("name").Exists() {
if converted := antigravityFunctionDeclarationJSON(tool, functionNameMap); len(converted) > 0 {
functionDeclarations = append(functionDeclarations, converted)
}
return true
}
otherTools = append(otherTools, []byte(tool.Raw))
return true
})
deduplicated := util.DeduplicateFunctionDeclarations(translatorcommon.JoinRawArray(functionDeclarations))
hasFunction := len(deduplicated) > 2
if hasFunction || len(otherTools) > 0 {
toolItems := make([][]byte, 0, 1+len(otherTools))
if hasFunction {
functionToolNode := []byte(`{"functionDeclarations":[]}`)
functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", deduplicated)
toolItems = append(toolItems, functionToolNode)
}
toolItems = append(toolItems, otherTools...)
out, _ = sjson.SetRawBytes(out, "request.tools", translatorcommon.JoinRawArray(toolItems))
}
return out
}
func antigravityFunctionDeclarationJSON(decl gjson.Result, functionNameMap map[string]string) []byte {
fn := decl
if nested := decl.Get("function"); nested.Exists() && nested.IsObject() {
fn = nested
}
name := fn.Get("name").String()
if strings.TrimSpace(name) == "" {
return nil
}
out := []byte(`{"name":"","parametersJsonSchema":{"type":"object","properties":{}}}`)
out, _ = sjson.SetBytes(out, "name", util.MapSanitizedFunctionName(functionNameMap, name))
if desc := fn.Get("description"); desc.Exists() {
out, _ = sjson.SetBytes(out, "description", desc.String())
}
if params := fn.Get("parametersJsonSchema"); params.Exists() {
out, _ = sjson.SetRawBytes(out, "parametersJsonSchema", []byte(params.Raw))
} else if params := fn.Get("parameters"); params.Exists() {
out, _ = sjson.SetRawBytes(out, "parametersJsonSchema", []byte(params.Raw))
}
if response := fn.Get("response"); response.Exists() {
out, _ = sjson.SetRawBytes(out, "response", []byte(response.Raw))
}
if responseSchema := fn.Get("responseJsonSchema"); responseSchema.Exists() {
out, _ = sjson.SetRawBytes(out, "responseJsonSchema", []byte(responseSchema.Raw))
}
return out
}
func interactionsNativeAntigravityPart(part gjson.Result) []byte {
switch {
case part.Get("text").Exists(), part.Get("functionCall").Exists(), part.Get("functionResponse").Exists():
return []byte(part.Raw)
case part.Get("inlineData").Exists():
return antigravityInlineDataPartJSON(part.Get("inlineData"))
case part.Get("fileData").Exists():
return antigravityFileDataPartJSON(part.Get("fileData"))
case part.Get("inline_data").Exists():
return antigravityInlineDataPartJSON(part.Get("inline_data"))
case part.Get("file_data").Exists():
return antigravityFileDataPartJSON(part.Get("file_data"))
}
return nil
}
func antigravityTextPartJSON(text string, thought bool) []byte {
partJSON := []byte(`{"text":""}`)
partJSON, _ = sjson.SetBytes(partJSON, "text", text)
if thought {
partJSON, _ = sjson.SetBytes(partJSON, "thought", true)
}
return partJSON
}
func antigravityInlineDataPartJSON(inline gjson.Result) []byte {
mimeType := inline.Get("mimeType").String()
if mimeType == "" {
mimeType = inline.Get("mime_type").String()
}
data := inline.Get("data").String()
if mimeType == "" || data == "" {
return nil
}
partJSON := []byte(`{"inlineData":{"mimeType":"","data":""}}`)
partJSON, _ = sjson.SetBytes(partJSON, "inlineData.mimeType", mimeType)
partJSON, _ = sjson.SetBytes(partJSON, "inlineData.data", data)
return partJSON
}
func antigravityFileDataPartJSON(fileData gjson.Result) []byte {
mimeType := fileData.Get("mimeType").String()
if mimeType == "" {
mimeType = fileData.Get("mime_type").String()
}
fileURI := fileData.Get("fileUri").String()
if fileURI == "" {
fileURI = fileData.Get("file_uri").String()
}
if mimeType == "" || fileURI == "" {
return nil
}
partJSON := []byte(`{"fileData":{"mimeType":"","fileUri":""}}`)
partJSON, _ = sjson.SetBytes(partJSON, "fileData.mimeType", mimeType)
partJSON, _ = sjson.SetBytes(partJSON, "fileData.fileUri", fileURI)
return partJSON
}
func antigravityInlineDataPartFromDataURL(dataURL string) []byte {
if !strings.HasPrefix(dataURL, "data:") {
return nil
}
payload := dataURL[5:]
pieces := strings.SplitN(payload, ";", 2)
if len(pieces) != 2 || !strings.HasPrefix(pieces[1], "base64,") {
return nil
}
return antigravityInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, pieces[0], pieces[1][7:])))
}
func appendAntigravityTextContent(items *[][]byte, role, text string) {
part := antigravityTextPartJSON(text, false)
*items = append(*items, antigravityContent(antigravityContentRole(role, "user"), [][]byte{part}))
}
func antigravityContent(role string, parts [][]byte) []byte {
content := []byte(`{"role":"","parts":[]}`)
content, _ = sjson.SetBytes(content, "role", role)
content, _ = sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(parts))
return content
}
func antigravityContentRole(role, defaultRole string) string {
switch strings.ToLower(strings.TrimSpace(role)) {
case "model", "assistant":
return "model"
case "user":
return "user"
}
if defaultRole == "model" {
return "model"
}
return "user"
}
func antigravityInputAudioMimeType(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) {
case "wav":
return "audio/wav"
case "mp3":
return "audio/mpeg"
case "flac":
return "audio/flac"
case "opus":
return "audio/opus"
case "pcm16":
return "audio/pcm"
default:
return "audio/mpeg"
}
}
func antigravityThinkingSummariesIncludeThoughts(summary gjson.Result) (bool, bool) {
if summary.Type != gjson.String {
return false, false
}
switch strings.ToLower(strings.TrimSpace(summary.String())) {
case "auto":
return true, true
case "none":
return false, true
default:
return false, false
}
}
func convertSnakeCaseKeysToCamelCaseForAntigravity(raw []byte) []byte {
root := gjson.ParseBytes(raw)
if !root.Exists() {
return raw
}
out := []byte(`{}`)
out = copySnakeCaseValueToCamelCaseForAntigravity(out, "", root)
return out
}
func copySnakeCaseValueToCamelCaseForAntigravity(out []byte, path string, node gjson.Result) []byte {
if node.IsObject() {
node.ForEach(func(key, value gjson.Result) bool {
childPath := joinAntigravityJSONPath(path, toAntigravityCamelCase(key.String()))
out = copySnakeCaseValueToCamelCaseForAntigravity(out, childPath, value)
return true
})
return out
}
if node.IsArray() {
node.ForEach(func(_, value gjson.Result) bool {
out = copySnakeCaseValueToCamelCaseForAntigravity(out, path+".-1", value)
return true
})
return out
}
out, _ = sjson.SetRawBytes(out, path, []byte(node.Raw))
return out
}
func joinAntigravityJSONPath(path, key string) string {
if path == "" {
return key
}
return path + "." + key
}
func toAntigravityCamelCase(s string) string {
parts := strings.Split(s, "_")
if len(parts) == 0 {
return s
}
out := parts[0]
for _, part := range parts[1:] {
if part == "" {
continue
}
out += strings.ToUpper(part[:1]) + part[1:]
}
return out
}
func attachDefaultAntigravitySafetySettings(out []byte) []byte {
if gjson.GetBytes(out, "request.safetySettings").Exists() {
return out
}
settings := []map[string]string{
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
}
raw, errMarshal := json.Marshal(settings)
if errMarshal != nil {
return out
}
out, _ = sjson.SetRawBytes(out, "request.safetySettings", raw)
return out
}

View file

@ -0,0 +1,494 @@
package interactions
import (
"bytes"
"context"
"fmt"
"strings"
"time"
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"
)
type antigravityToInteractionsStreamState struct {
Started bool
Finished bool
Completed bool
Done bool
ActiveStepOpen bool
ID string
StepID string
ActiveStepType string
ActiveStepIndex int
StepIndex int
ToolNameMap map[string]string
}
func ConvertAntigravityResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
if param == nil {
var local any
param = &local
}
if *param == nil {
*param = &antigravityToInteractionsStreamState{
ID: fmt.Sprintf("interaction_%d", time.Now().UnixNano()),
ToolNameMap: util.DisambiguatedToolNameMap(originalRequestRawJSON),
}
}
st := (*param).(*antigravityToInteractionsStreamState)
payloads := antigravityStreamPayloads(rawJSON)
out := make([][]byte, 0)
for _, payload := range payloads {
if bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) {
if !st.Completed {
out = appendAntigravityInteractionsStepStop(out, st)
out = appendAntigravityInteractionsCompleted(out, st, modelName, gjson.Result{})
}
out = appendAntigravityInteractionsDone(out, st)
continue
}
root := unwrapAntigravityResponse(gjson.ParseBytes(payload))
root = restoreInteractionsFunctionNames(root, st.ToolNameMap)
if !root.Exists() {
continue
}
if !st.Started {
out = appendAntigravityInteractionsCreated(out, st, modelName)
out = appendAntigravityInteractionsStatusUpdate(out, st)
st.Started = true
}
root.Get("candidates.0.content.parts").ForEach(func(_, part gjson.Result) bool {
out = appendAntigravityPartToInteractionsStream(out, st, part)
return true
})
hasFinish := root.Get("candidates.0.finishReason").Exists()
hasUsage := hasAntigravityStreamUsage(root)
if hasFinish && !st.Finished {
out = appendAntigravityInteractionsStepStop(out, st)
st.Finished = true
}
if hasUsage && st.Finished && !st.Completed {
out = appendAntigravityInteractionsCompleted(out, st, modelName, root)
}
}
return out
}
func ConvertAntigravityResponseToInteractionsNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
root := unwrapAntigravityResponse(gjson.ParseBytes(rawJSON))
root = restoreInteractionsFunctionNames(root, util.DisambiguatedToolNameMap(originalRequestRawJSON))
out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`)
id := root.Get("responseId").String()
if id == "" {
id = fmt.Sprintf("interaction_%d", time.Now().UnixNano())
}
out, _ = sjson.SetBytes(out, "id", id)
out, _ = sjson.SetBytes(out, "model", modelName)
var steps [][]byte
root.Get("candidates.0.content.parts").ForEach(func(_, part gjson.Result) bool {
if step := antigravityPartToInteractionsStep(part); len(step) > 0 {
steps = append(steps, step)
}
return true
})
if len(steps) > 0 {
out = translatorcommon.SetRawArrayItems(out, "steps", steps)
}
out = setInteractionsUsageFromAntigravity(out, "usage", root)
return out
}
func antigravityStreamPayloads(rawJSON []byte) [][]byte {
trimmed := bytes.TrimSpace(rawJSON)
if bytes.HasPrefix(trimmed, []byte("data:")) {
return [][]byte{bytes.TrimSpace(trimmed[5:])}
}
root := gjson.ParseBytes(trimmed)
if root.IsArray() {
payloads := make([][]byte, 0)
root.ForEach(func(_, item gjson.Result) bool {
if response := item.Get("response"); response.Exists() {
payloads = append(payloads, []byte(response.Raw))
} else if item.Exists() {
payloads = append(payloads, []byte(item.Raw))
}
return true
})
if len(payloads) > 0 {
return payloads
}
}
return [][]byte{trimmed}
}
func unwrapAntigravityResponse(root gjson.Result) gjson.Result {
if response := root.Get("response"); response.Exists() {
response = restoreAntigravityUsageMetadata(response)
return response
}
return restoreAntigravityUsageMetadata(root)
}
func restoreInteractionsFunctionNames(root gjson.Result, nameMap map[string]string) gjson.Result {
if !root.Exists() || len(nameMap) == 0 {
return root
}
raw := []byte(root.Raw)
candidates := root.Get("candidates")
for candidateIndex, candidate := range candidates.Array() {
for partIndex, part := range candidate.Get("content.parts").Array() {
for _, field := range []string{"functionCall", "functionResponse"} {
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)
raw, _ = sjson.SetBytes(raw, path, restoredName)
}
}
}
return gjson.ParseBytes(raw)
}
func restoreAntigravityUsageMetadata(root gjson.Result) gjson.Result {
if !root.Get("usageMetadata").Exists() {
if cpaUsage := root.Get("cpaUsageMetadata"); cpaUsage.Exists() {
raw, _ := sjson.SetRawBytes([]byte(root.Raw), "usageMetadata", []byte(cpaUsage.Raw))
raw, _ = sjson.DeleteBytes(raw, "cpaUsageMetadata")
return gjson.ParseBytes(raw)
}
}
return root
}
func appendAntigravityInteractionsCreated(out [][]byte, st *antigravityToInteractionsStreamState, modelName string) [][]byte {
created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`)
created, _ = sjson.SetBytes(created, "interaction.id", st.ID)
created, _ = sjson.SetBytes(created, "interaction.model", modelName)
return append(out, translatorcommon.SSEEventData("interaction.created", created))
}
func appendAntigravityInteractionsStatusUpdate(out [][]byte, st *antigravityToInteractionsStreamState) [][]byte {
statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`)
statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID)
return append(out, translatorcommon.SSEEventData("interaction.status_update", statusUpdate))
}
func appendAntigravityInteractionsCompleted(out [][]byte, st *antigravityToInteractionsStreamState, modelName string, root gjson.Result) [][]byte {
now := time.Now().UTC().Format(time.RFC3339)
completed := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`)
completed, _ = sjson.SetBytes(completed, "interaction.id", st.ID)
completed, _ = sjson.SetBytes(completed, "interaction.created", now)
completed, _ = sjson.SetBytes(completed, "interaction.updated", now)
completed, _ = sjson.SetBytes(completed, "interaction.model", modelName)
if root.Exists() {
completed = setInteractionsStreamUsageFromAntigravity(completed, "interaction.usage", root)
}
out = append(out, translatorcommon.SSEEventData("interaction.completed", completed))
st.Completed = true
return out
}
func appendAntigravityInteractionsDone(out [][]byte, st *antigravityToInteractionsStreamState) [][]byte {
if st.Done {
return out
}
out = append(out, translatorcommon.SSEEventData("done", []byte("[DONE]")))
st.Done = true
return out
}
func appendAntigravityInteractionsStepStart(out [][]byte, st *antigravityToInteractionsStreamState, stepType string, part gjson.Result) [][]byte {
st.StepID = fmt.Sprintf("step_%d", time.Now().UnixNano())
st.ActiveStepIndex = st.StepIndex
st.StepIndex++
st.ActiveStepType = stepType
st.ActiveStepOpen = true
stepStart := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`)
stepStart, _ = sjson.SetBytes(stepStart, "index", st.ActiveStepIndex)
stepStart, _ = sjson.SetBytes(stepStart, "step.type", stepType)
if stepType == "function_call" {
id := antigravityFunctionPartID(part)
if id == "" {
id = st.StepID
}
stepStart, _ = sjson.SetBytes(stepStart, "step.id", id)
stepStart, _ = sjson.SetBytes(stepStart, "step.call_id", id)
stepStart, _ = sjson.SetBytes(stepStart, "step.name", part.Get("name").String())
stepStart, _ = sjson.SetRawBytes(stepStart, "step.arguments", []byte(`{}`))
}
return append(out, translatorcommon.SSEEventData("step.start", stepStart))
}
func appendAntigravityInteractionsStepStop(out [][]byte, st *antigravityToInteractionsStreamState) [][]byte {
if !st.ActiveStepOpen {
return out
}
stepStop := []byte(`{"index":0,"event_type":"step.stop"}`)
stepStop, _ = sjson.SetBytes(stepStop, "index", st.ActiveStepIndex)
out = append(out, translatorcommon.SSEEventData("step.stop", stepStop))
st.ActiveStepOpen = false
st.ActiveStepType = ""
return out
}
func ensureAntigravityInteractionsStep(out [][]byte, st *antigravityToInteractionsStreamState, stepType string, part gjson.Result) [][]byte {
if st.ActiveStepOpen && st.ActiveStepType == stepType {
return out
}
out = appendAntigravityInteractionsStepStop(out, st)
return appendAntigravityInteractionsStepStart(out, st, stepType, part)
}
func appendAntigravityPartToInteractionsStream(out [][]byte, st *antigravityToInteractionsStreamState, part gjson.Result) [][]byte {
if text := part.Get("text"); text.Exists() && text.String() != "" {
if part.Get("thought").Bool() {
out = ensureAntigravityInteractionsStep(out, st, "thought", gjson.Result{})
delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
delta, _ = sjson.SetBytes(delta, "delta.content.text", text.String())
out = append(out, translatorcommon.SSEEventData("step.delta", delta))
return appendAntigravityThoughtSignature(out, st, part)
}
out = ensureAntigravityInteractionsStep(out, st, "model_output", gjson.Result{})
delta := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
delta, _ = sjson.SetBytes(delta, "delta.text", text.String())
return append(out, translatorcommon.SSEEventData("step.delta", delta))
}
if fc := part.Get("functionCall"); fc.Exists() {
out = appendAntigravityThoughtSignature(out, st, part)
out = ensureAntigravityInteractionsStep(out, st, "function_call", fc)
delta := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
arguments := `{}`
if args := fc.Get("args"); args.Exists() {
arguments = args.Raw
}
delta, _ = sjson.SetBytes(delta, "delta.arguments", arguments)
out = append(out, translatorcommon.SSEEventData("step.delta", delta))
return appendAntigravityInteractionsStepStop(out, st)
}
if fr := part.Get("functionResponse"); fr.Exists() {
out = ensureAntigravityInteractionsStep(out, st, "function_result", fr)
delta := []byte(`{"index":0,"delta":{"type":"function_result","name":"","result":{}},"event_type":"step.delta"}`)
delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
delta, _ = sjson.SetBytes(delta, "delta.name", fr.Get("name").String())
if response := fr.Get("response"); response.Exists() {
delta, _ = sjson.SetRawBytes(delta, "delta.result", []byte(response.Raw))
}
out = append(out, translatorcommon.SSEEventData("step.delta", delta))
return appendAntigravityInteractionsStepStop(out, st)
}
return out
}
func appendAntigravityThoughtSignature(out [][]byte, st *antigravityToInteractionsStreamState, part gjson.Result) [][]byte {
if signature := antigravityThoughtSignature(part); signature != "" {
out = ensureAntigravityInteractionsStep(out, st, "thought", gjson.Result{})
signatureDelta := []byte(`{"index":0,"delta":{"signature":"","type":"thought_signature"},"event_type":"step.delta"}`)
signatureDelta, _ = sjson.SetBytes(signatureDelta, "index", st.ActiveStepIndex)
signatureDelta, _ = sjson.SetBytes(signatureDelta, "delta.signature", signature)
return append(out, translatorcommon.SSEEventData("step.delta", signatureDelta))
}
return out
}
func antigravityPartToInteractionsStep(part gjson.Result) []byte {
if fc := part.Get("functionCall"); fc.Exists() {
step := []byte(`{"type":"function_call","name":"","arguments":{}}`)
step, _ = sjson.SetBytes(step, "name", fc.Get("name").String())
if id := fc.Get("id"); id.Exists() {
step, _ = sjson.SetBytes(step, "call_id", id.String())
} else if callID := fc.Get("call_id"); callID.Exists() {
step, _ = sjson.SetBytes(step, "call_id", callID.String())
}
if args := fc.Get("args"); args.Exists() {
step, _ = sjson.SetRawBytes(step, "arguments", []byte(args.Raw))
}
return step
}
if fr := part.Get("functionResponse"); fr.Exists() {
step := []byte(`{"type":"function_result","name":"","result":{}}`)
step, _ = sjson.SetBytes(step, "name", fr.Get("name").String())
if id := fr.Get("id"); id.Exists() {
step, _ = sjson.SetBytes(step, "call_id", id.String())
} else if callID := fr.Get("call_id"); callID.Exists() {
step, _ = sjson.SetBytes(step, "call_id", callID.String())
}
if response := fr.Get("response"); response.Exists() {
step, _ = sjson.SetRawBytes(step, "result", []byte(response.Raw))
}
return step
}
if text := part.Get("text"); text.Exists() {
step := []byte(`{"type":"model_output","content":[]}`)
if part.Get("thought").Bool() {
step, _ = sjson.SetBytes(step, "type", "thought")
}
item := []byte(`{"type":"text","text":""}`)
item, _ = sjson.SetBytes(item, "text", text.String())
step = translatorcommon.SetRawArrayItems(step, "content", [][]byte{item})
return step
}
if inline := part.Get("inlineData"); inline.Exists() {
return antigravityInlineDataToInteractionsStep(inline)
}
if inline := part.Get("inline_data"); inline.Exists() {
return antigravityInlineDataToInteractionsStep(inline)
}
return nil
}
func antigravityInlineDataToInteractionsStep(inline gjson.Result) []byte {
mimeType := inline.Get("mimeType").String()
if mimeType == "" {
mimeType = inline.Get("mime_type").String()
}
data := inline.Get("data").String()
if mimeType == "" || data == "" {
return nil
}
contentType := "document"
lower := strings.ToLower(mimeType)
switch {
case strings.HasPrefix(lower, "image/"):
contentType = "image"
case strings.HasPrefix(lower, "audio/"):
contentType = "audio"
case strings.HasPrefix(lower, "video/"):
contentType = "video"
}
item := []byte(`{"type":"","mime_type":"","data":""}`)
item, _ = sjson.SetBytes(item, "type", contentType)
item, _ = sjson.SetBytes(item, "mime_type", mimeType)
item, _ = sjson.SetBytes(item, "data", data)
step := []byte(`{"type":"model_output","content":[]}`)
step, _ = sjson.SetRawBytes(step, "content.-1", item)
return step
}
func hasAntigravityStreamUsage(root gjson.Result) bool {
usage := antigravityUsageNode(root)
if !usage.Exists() {
return false
}
for _, path := range []string{
"promptTokenCount",
"candidatesTokenCount",
"totalTokenCount",
"thoughtsTokenCount",
"cachedContentTokenCount",
"prompt_token_count",
"candidates_token_count",
"total_token_count",
"thoughts_token_count",
"cached_content_token_count",
} {
if usage.Get(path).Exists() {
return true
}
}
return false
}
func setInteractionsUsageFromAntigravity(out []byte, path string, root gjson.Result) []byte {
usage := antigravityUsageNode(root)
if !usage.Exists() {
return out
}
out, _ = sjson.SetBytes(out, path+".input_tokens", firstAntigravityUsageInt(usage, "promptTokenCount", "prompt_token_count"))
out, _ = sjson.SetBytes(out, path+".output_tokens", firstAntigravityUsageInt(usage, "candidatesTokenCount", "candidates_token_count"))
if antigravityUsagePathExists(usage, "thoughtsTokenCount", "thoughts_token_count") {
out, _ = sjson.SetBytes(out, path+".reasoning_tokens", firstAntigravityUsageInt(usage, "thoughtsTokenCount", "thoughts_token_count"))
}
out, _ = sjson.SetBytes(out, path+".total_tokens", firstAntigravityUsageInt(usage, "totalTokenCount", "total_token_count"))
if antigravityUsagePathExists(usage, "cachedContentTokenCount", "cached_content_token_count") {
out, _ = sjson.SetBytes(out, path+".cached_tokens", firstAntigravityUsageInt(usage, "cachedContentTokenCount", "cached_content_token_count"))
}
return out
}
func setInteractionsStreamUsageFromAntigravity(out []byte, path string, root gjson.Result) []byte {
usage := antigravityUsageNode(root)
if !usage.Exists() {
return out
}
inputTokens := firstAntigravityUsageInt(usage, "promptTokenCount", "prompt_token_count")
outputTokens := firstAntigravityUsageInt(usage, "candidatesTokenCount", "candidates_token_count")
totalTokens := firstAntigravityUsageInt(usage, "totalTokenCount", "total_token_count")
thoughtTokens := firstAntigravityUsageInt(usage, "thoughtsTokenCount", "thoughts_token_count")
cachedTokens := firstAntigravityUsageInt(usage, "cachedContentTokenCount", "cached_content_token_count")
out, _ = sjson.SetBytes(out, path+".total_tokens", totalTokens)
out, _ = sjson.SetBytes(out, path+".total_input_tokens", inputTokens)
out, _ = sjson.SetRawBytes(out, path+".input_tokens_by_modality", []byte(fmt.Sprintf(`[{"modality":"text","tokens":%d}]`, inputTokens)))
out, _ = sjson.SetBytes(out, path+".total_cached_tokens", cachedTokens)
out, _ = sjson.SetBytes(out, path+".total_output_tokens", outputTokens)
out, _ = sjson.SetBytes(out, path+".total_tool_use_tokens", 0)
out, _ = sjson.SetBytes(out, path+".total_thought_tokens", thoughtTokens)
return out
}
func antigravityUsageNode(root gjson.Result) gjson.Result {
if usage := root.Get("usageMetadata"); usage.Exists() {
return usage
}
if usage := root.Get("usage_metadata"); usage.Exists() {
return usage
}
if usage := root.Get("cpaUsageMetadata"); usage.Exists() {
return usage
}
return gjson.Result{}
}
func firstAntigravityUsageInt(usage gjson.Result, paths ...string) int64 {
for _, path := range paths {
if value := usage.Get(path); value.Exists() {
return value.Int()
}
}
return 0
}
func antigravityUsagePathExists(usage gjson.Result, paths ...string) bool {
for _, path := range paths {
if usage.Get(path).Exists() {
return true
}
}
return false
}
func antigravityFunctionPartID(part gjson.Result) string {
if id := part.Get("id"); id.Exists() {
return id.String()
}
if callID := part.Get("call_id"); callID.Exists() {
return callID.String()
}
return ""
}
func antigravityThoughtSignature(part gjson.Result) string {
for _, path := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} {
if signature := strings.TrimSpace(part.Get(path).String()); signature != "" {
return signature
}
}
return ""
}

View file

@ -0,0 +1,216 @@
package interactions
import (
"bytes"
"context"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
)
func TestConvertInteractionsRequestToAntigravityWithToolMessagesDirect(t *testing.T) {
out := ConvertInteractionsRequestToAntigravity("antigravity-test", []byte(`{"model":"antigravity-test","system_instruction":"be brief","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}],"tools":[{"type":"function","name":"lookup","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}]}`), false)
if got := gjson.GetBytes(out, "request.systemInstruction.parts.0.text").String(); got != "be brief" {
t.Fatalf("request.systemInstruction.parts.0.text = %q, want be brief. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "hi" {
t.Fatalf("request.contents.0.parts.0.text = %q, want hi. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.name").String(); got != "lookup" {
t.Fatalf("functionCall.name = %q, want lookup. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.name").String(); got != "lookup" {
t.Fatalf("functionResponse.name = %q, want lookup. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "request.tools.0.functionDeclarations.0.name").String(); got != "lookup" {
t.Fatalf("request.tools.0.functionDeclarations.0.name = %q, want lookup. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "request.tools.0.functionDeclarations.0.parametersJsonSchema.properties.q.type").String(); got != "string" {
t.Fatalf("tool parameters schema was not preserved. Output: %s", string(out))
}
}
func TestConvertInteractionsRequestToAntigravityPreservesGenerationConfig(t *testing.T) {
out := ConvertInteractionsRequestToAntigravity("antigravity-test", []byte(`{"model":"antigravity-test","input":"hi","generation_config":{"max_output_tokens":16,"top_p":0.8,"tool_choice":"auto","thinking_level":"high","thinking_summaries":"auto"},"reasoning":{"summary":"auto"},"stream":true}`), true)
if gjson.GetBytes(out, "input").Exists() {
t.Fatalf("raw interactions input exists in translated request. Output: %s", string(out))
}
for _, path := range []string{
"request.generationConfig.toolChoice",
"request.generationConfig.thinkingLevel",
"request.generationConfig.thinkingSummaries",
} {
if gjson.GetBytes(out, path).Exists() {
t.Fatalf("%s exists, want omitted. Output: %s", path, string(out))
}
}
if got := gjson.GetBytes(out, "request.stream").Bool(); !got {
t.Fatalf("request.stream = false, want true. Output: %s", string(out))
}
if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "hi" {
t.Fatalf("request.contents.0.parts.0.text = %q, want hi. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "request.generationConfig.maxOutputTokens").Int(); got != 16 {
t.Fatalf("request.generationConfig.maxOutputTokens = %d, want 16. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "request.generationConfig.topP").Float(); got != 0.8 {
t.Fatalf("request.generationConfig.topP = %v, want 0.8. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" {
t.Fatalf("request.generationConfig.thinkingConfig.thinkingLevel = %q, want high. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts").Bool(); !got {
t.Fatalf("request.generationConfig.thinkingConfig.includeThoughts = false, want true. Output: %s", string(out))
}
if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String(); got != "AUTO" {
t.Fatalf("request.toolConfig.functionCallingConfig.mode = %q, want AUTO. Output: %s", got, string(out))
}
}
func TestConvertInteractionsReasoningToAntigravityKeepsSummaryIndependent(t *testing.T) {
tests := []struct {
name string
reasoning string
want bool
wantExists bool
}{
{name: "effort only leaves summaries unspecified", reasoning: `{"effort":"high"}`},
{name: "explicit auto enables summaries", reasoning: `{"effort":"high","summary":"auto"}`, want: true, wantExists: true},
{name: "explicit none disables summaries", reasoning: `{"effort":"high","summary":"none"}`, wantExists: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
body := []byte(`{"model":"antigravity-test","input":"hi","reasoning":` + test.reasoning + `}`)
out := ConvertInteractionsRequestToAntigravity("antigravity-test", body, false)
if got := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" {
t.Fatalf("thinkingLevel = %q, want high. Output: %s", got, out)
}
includeThoughts := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts")
if includeThoughts.Exists() != test.wantExists {
t.Fatalf("includeThoughts exists = %v, want %v. Output: %s", includeThoughts.Exists(), test.wantExists, out)
}
if test.wantExists && includeThoughts.Bool() != test.want {
t.Fatalf("includeThoughts = %v, want %v. Output: %s", includeThoughts.Bool(), test.want, out)
}
})
}
}
func TestConvertAntigravityResponseToInteractionsNonStream(t *testing.T) {
raw := []byte(`{"response":{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"text":"ok"},{"functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":2,"totalTokenCount":5}}}`)
out := ConvertAntigravityResponseToInteractionsNonStream(context.Background(), "antigravity-test", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "steps.0.content.0.text").String(); got != "ok" {
t.Fatalf("steps.0.content.0.text = %q, want ok. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "steps.1.type").String(); got != "function_call" {
t.Fatalf("steps.1.type = %q, want function_call. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 5 {
t.Fatalf("usage.total_tokens = %d, want 5. Output: %s", got, string(out))
}
}
func TestConvertAntigravityResponseToInteractionsStream(t *testing.T) {
ctx := context.WithValue(context.Background(), "alt", "")
var param any
events := ConvertAntigravityResponseToInteractions(ctx, "antigravity-test", nil, nil, []byte(`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]}}]}}`), &param)
payload := findAntigravityInteractionsEventPayload(events, "step.delta")
if len(payload) == 0 {
t.Fatalf("step.delta event not found: %q", events)
}
if got := gjson.GetBytes(payload, "delta.text").String(); got != "ok" {
t.Fatalf("delta.text = %q, want ok. Payload: %s", got, string(payload))
}
}
func TestConvertAntigravityResponseToInteractionsStreamFunctionCallStartHasCallID(t *testing.T) {
var param any
events := ConvertAntigravityResponseToInteractions(context.Background(), "antigravity-test", nil, nil, []byte(`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]}}]}}`), &param)
payload := findAntigravityInteractionsEventPayload(events, "step.start")
if got := gjson.GetBytes(payload, "step.call_id").String(); got != "call_1" {
t.Fatalf("step.call_id = %q, want call_1. Payload: %s", got, string(payload))
}
}
func TestConvertInteractionsRequestToAntigravityDeduplicatesAndDisambiguatesTools(t *testing.T) {
first := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build"
second := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build_logs"
inputJSON := []byte(`{
"input":[
{"type":"function_call","name":"` + second + `","call_id":"call_1","arguments":{}},
{"type":"function_result","name":"` + second + `","call_id":"call_1","result":{}}
],
"tools":[
{"functionDeclarations":[{"name":"lookup"},{"name":"` + first + `"}]},
{"function_declarations":[{"name":"lookup"},{"name":"` + second + `"}]}
],
"tool_choice":{"type":"function","function":{"name":"` + second + `"}}
}`)
out := ConvertInteractionsRequestToAntigravity("antigravity-test", inputJSON, false)
declarations := gjson.GetBytes(out, "request.tools.0.functionDeclarations").Array()
if len(declarations) != 3 {
t.Fatalf("declaration count = %d, want 3. Output: %s", len(declarations), out)
}
firstMapped := declarations[1].Get("name").String()
secondMapped := declarations[2].Get("name").String()
if firstMapped == secondMapped || len(secondMapped) > 64 {
t.Fatalf("collision names = %q and %q, want distinct names <= 64 chars", firstMapped, secondMapped)
}
if got := gjson.GetBytes(out, "request.contents.0.parts.0.functionCall.name").String(); got != secondMapped {
t.Fatalf("functionCall.name = %q, want %q. Output: %s", got, secondMapped, out)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse.name").String(); got != secondMapped {
t.Fatalf("functionResponse.name = %q, want %q. Output: %s", got, secondMapped, out)
}
if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != secondMapped {
t.Fatalf("allowedFunctionNames.0 = %q, want %q. Output: %s", got, secondMapped, out)
}
}
func TestConvertInteractionsRequestToAntigravityPreservesNameMappingWhitespace(t *testing.T) {
inputJSON := []byte(`{
"input":[{"type":"function_call","name":" read/file ","arguments":{}}],
"tools":[{"type":"function","name":" read/file ","parameters":{"type":"object"}}],
"tool_choice":{"type":"function","function":{"name":" read/file "}}
}`)
out := ConvertInteractionsRequestToAntigravity("antigravity-test", inputJSON, false)
declarationName := gjson.GetBytes(out, "request.tools.0.functionDeclarations.0.name").String()
callName := gjson.GetBytes(out, "request.contents.0.parts.0.functionCall.name").String()
allowedName := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String()
if declarationName == "" || callName != declarationName || allowedName != declarationName {
t.Fatalf("mapped names declaration=%q call=%q allowed=%q. Output: %s", declarationName, callName, allowedName, out)
}
}
func TestConvertAntigravityResponseToInteractionsRestoresDisambiguatedName(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":[{"name":"` + first + `"},{"name":"` + second + `"}]}`)
mapped := util.SanitizedFunctionNameMap(original)[second]
raw := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"` + mapped + `","args":{}}}]}}]}}`)
out := ConvertAntigravityResponseToInteractionsNonStream(context.Background(), "antigravity-test", original, nil, raw, nil)
if got := gjson.GetBytes(out, "steps.0.name").String(); got != second {
t.Fatalf("function call name = %q, want %q. Output: %s", got, second, out)
}
}
func findAntigravityInteractionsEventPayload(events [][]byte, eventType string) []byte {
prefix := []byte("data:")
for _, event := range events {
for _, line := range bytes.Split(event, []byte("\n")) {
line = bytes.TrimSpace(line)
if !bytes.HasPrefix(line, prefix) {
continue
}
payload := bytes.TrimSpace(line[len(prefix):])
if gjson.GetBytes(payload, "type").String() == eventType || gjson.GetBytes(payload, "event_type").String() == eventType {
return payload
}
}
}
return nil
}

View file

@ -0,0 +1,30 @@
package interactions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestRewriteInteractionsFunctionNamesReusesNormalizedPayload(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 := rewriteInteractionsFunctionNames(input, nil)
if &output[0] != &input[0] {
t.Fatal("normalized function names caused a payload copy")
}
}
func TestRewriteInteractionsFunctionNamesNormalizesNonStringNames(t *testing.T) {
input := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":true,"args":{}}}]}],"toolConfig":{"functionCallingConfig":{"allowedFunctionNames":[true]}}}}`)
output := rewriteInteractionsFunctionNames(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)
}
}

View file

@ -0,0 +1,20 @@
package chat_completions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIRequestToAntigravityNormalizesFileDataURL(t *testing.T) {
input := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
out := ConvertOpenAIRequestToAntigravity("gemini-2.5-pro", input, false)
inlineData := gjson.GetBytes(out, "request.contents.0.parts.0.inlineData")
if got := inlineData.Get("mimeType").String(); got != "application/pdf" {
t.Fatalf("inlineData.mimeType = %q, want application/pdf. Output: %s", got, out)
}
if got := inlineData.Get("data").String(); got != "JVBERi0xLjQK" {
t.Fatalf("inlineData.data = %q, want raw base64 payload. Output: %s", got, out)
}
}

View file

@ -0,0 +1,622 @@
// Package openai provides request translation functionality for OpenAI to Antigravity API compatibility.
// It converts OpenAI Chat Completions requests into Antigravity compatible JSON using gjson/sjson only.
package chat_completions
import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini"
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"
)
const antigravityFunctionThoughtSignature = "skip_thought_signature_validator"
// ConvertOpenAIRequestToAntigravity converts an OpenAI Chat Completions request (raw JSON)
// into a complete Antigravity request JSON. All JSON construction uses sjson and lookups use gjson.
//
// Parameters:
// - modelName: The name of the model to use for the request
// - rawJSON: The raw JSON request data from the OpenAI API
// - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)
//
// Returns:
// - []byte: The transformed request data in Antigravity API format
func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := inputRawJSON
functionNameMap := util.SanitizedFunctionNameMap(rawJSON)
// Base envelope (no default thinkingConfig)
out := []byte(`{"project":"","request":{"contents":[]},"model":"gemini-2.5-pro"}`)
// Model
out, _ = sjson.SetBytes(out, "model", modelName)
// Let user-provided generationConfig pass through
if genConfig := gjson.GetBytes(rawJSON, "generationConfig"); genConfig.Exists() {
out, _ = sjson.SetRawBytes(out, "request.generationConfig", []byte(genConfig.Raw))
} else if genConfig := gjson.GetBytes(rawJSON, "generation_config"); genConfig.Exists() {
out, _ = sjson.SetRawBytes(out, "request.generationConfig", []byte(genConfig.Raw))
}
// Apply thinking configuration: convert OpenAI reasoning_effort to Antigravity thinkingConfig.
// Inline translation-only mapping; capability checks happen later in ApplyThinking.
re := gjson.GetBytes(rawJSON, "reasoning_effort")
if re.Exists() {
effort := strings.ToLower(strings.TrimSpace(re.String()))
if effort != "" {
thinkingPath := "request.generationConfig.thinkingConfig"
if effort == "auto" {
out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1)
} else {
out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort)
}
}
}
out = applyOpenAIThinkingCompatibilityToAntigravity(out, rawJSON)
// Temperature/top_p/top_k/max_tokens/max_completion_tokens
if tr := gjson.GetBytes(rawJSON, "temperature"); tr.Exists() && tr.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "request.generationConfig.temperature", tr.Num)
}
if tpr := gjson.GetBytes(rawJSON, "top_p"); tpr.Exists() && tpr.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "request.generationConfig.topP", tpr.Num)
}
if tkr := gjson.GetBytes(rawJSON, "top_k"); tkr.Exists() && tkr.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "request.generationConfig.topK", tkr.Num)
}
if maxTok := gjson.GetBytes(rawJSON, "max_tokens"); maxTok.Exists() && maxTok.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "request.generationConfig.maxOutputTokens", maxTok.Num)
} else if mct := gjson.GetBytes(rawJSON, "max_completion_tokens"); mct.Exists() && mct.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "request.generationConfig.maxOutputTokens", mct.Num)
}
// Map OpenAI response_format to Antigravity structured output settings.
if responseFormat := gjson.GetBytes(rawJSON, "response_format"); responseFormat.Exists() {
switch responseFormatType := strings.ToLower(strings.TrimSpace(responseFormat.Get("type").String())); responseFormatType {
case "json_object", "json_schema":
for _, schemaKey := range []string{"responseSchema", "responseJsonSchema", "response_schema", "response_json_schema"} {
out, _ = sjson.DeleteBytes(out, "request.generationConfig."+schemaKey)
}
out, _ = sjson.SetBytes(out, "request.generationConfig.responseMimeType", "application/json")
if responseFormatType == "json_schema" {
if schema := responseFormat.Get("json_schema.schema"); schema.Exists() {
out, _ = sjson.SetRawBytes(out, "request.generationConfig.responseSchema", []byte(schema.Raw))
}
}
}
}
// Candidate count (OpenAI 'n' parameter)
if n := gjson.GetBytes(rawJSON, "n"); n.Exists() && n.Type == gjson.Number {
if val := n.Int(); val > 1 {
out, _ = sjson.SetBytes(out, "request.generationConfig.candidateCount", val)
}
}
// Map OpenAI modalities -> Antigravity request.generationConfig.responseModalities
// e.g. "modalities": ["image", "text"] -> ["IMAGE", "TEXT"]
if mods := gjson.GetBytes(rawJSON, "modalities"); mods.Exists() && mods.IsArray() {
var responseMods []string
for _, m := range mods.Array() {
switch strings.ToLower(m.String()) {
case "text":
responseMods = append(responseMods, "TEXT")
case "image":
responseMods = append(responseMods, "IMAGE")
}
}
if len(responseMods) > 0 {
out, _ = sjson.SetBytes(out, "request.generationConfig.responseModalities", responseMods)
}
}
// OpenRouter-style image_config support
// If the input uses top-level image_config.aspect_ratio, map it into request.generationConfig.imageConfig.aspectRatio.
if imgCfg := gjson.GetBytes(rawJSON, "image_config"); imgCfg.Exists() && imgCfg.IsObject() {
if ar := imgCfg.Get("aspect_ratio"); ar.Exists() && ar.Type == gjson.String {
out, _ = sjson.SetBytes(out, "request.generationConfig.imageConfig.aspectRatio", ar.Str)
}
if size := imgCfg.Get("image_size"); size.Exists() && size.Type == gjson.String {
out, _ = sjson.SetBytes(out, "request.generationConfig.imageConfig.imageSize", size.Str)
}
}
// messages -> systemInstruction + contents
messages := gjson.GetBytes(rawJSON, "messages")
if messages.IsArray() {
arr := messages.Array()
systemParts := make([][]byte, 0, 2)
contentItems := make([][]byte, 0, len(arr))
// First pass: assistant tool_calls id->name map
tcID2Name := map[string]string{}
for i := 0; i < len(arr); i++ {
m := arr[i]
if m.Get("role").String() == "assistant" {
tcs := m.Get("tool_calls")
if tcs.IsArray() {
for _, tc := range tcs.Array() {
if tc.Get("type").String() == "function" {
id := tc.Get("id").String()
name := tc.Get("function.name").String()
if id != "" && name != "" {
tcID2Name[id] = name
}
}
}
}
}
}
// Second pass build systemInstruction/tool responses cache
toolResponses := map[string]string{} // tool_call_id -> response text
for i := 0; i < len(arr); i++ {
m := arr[i]
role := m.Get("role").String()
if role == "tool" {
toolCallID := m.Get("tool_call_id").String()
if toolCallID != "" {
c := m.Get("content")
toolResponses[toolCallID] = c.Raw
}
}
}
for i := 0; i < len(arr); i++ {
m := arr[i]
role := m.Get("role").String()
content := m.Get("content")
if (role == "system" || role == "developer") && len(arr) > 1 {
// system -> request.systemInstruction as a user message style
if content.Type == gjson.String {
systemParts = append(systemParts, antigravityOpenAITextPart(content.String()))
} else if content.IsObject() && content.Get("type").String() == "text" {
systemParts = append(systemParts, antigravityOpenAITextPart(content.Get("text").String()))
} else if content.IsArray() {
for _, contentPart := range content.Array() {
systemParts = append(systemParts, antigravityOpenAITextPart(contentPart.Get("text").String()))
}
}
} else if role == "user" || ((role == "system" || role == "developer") && len(arr) == 1) {
partItems := make([][]byte, 0, 4)
if content.Type == gjson.String {
partItems = append(partItems, antigravityOpenAITextPart(content.String()))
} else if content.IsArray() {
for _, item := range content.Array() {
switch item.Get("type").String() {
case "text":
if text := item.Get("text").String(); text != "" {
partItems = append(partItems, antigravityOpenAITextPart(text))
}
case "image_url":
imageURL := item.Get("image_url.url").String()
if len(imageURL) > 5 {
pieces := strings.SplitN(imageURL[5:], ";", 2)
if len(pieces) == 2 && len(pieces[1]) > 7 {
part := antigravityOpenAIInlineDataPart(pieces[0], pieces[1][7:], false)
part, _ = sjson.SetBytes(part, "thoughtSignature", antigravityFunctionThoughtSignature)
partItems = append(partItems, part)
}
}
case "video_url":
videoURL := item.Get("video_url.url").String()
if len(videoURL) > 5 {
pieces := strings.SplitN(videoURL[5:], ";", 2)
if len(pieces) == 2 && len(pieces[1]) > 7 {
partItems = append(partItems, antigravityOpenAIInlineDataPart(pieces[0], pieces[1][7:], false))
}
}
case "file":
filename := item.Get("file.filename").String()
fileData := item.Get("file.file_data").String()
if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, "", fileData); ok {
partItems = append(partItems, antigravityOpenAIInlineDataPart(mimeType, data, false))
} else {
log.Warn("Invalid file data or unknown file name extension in user message, skip")
}
case "input_audio":
audioData := item.Get("input_audio.data").String()
if audioData != "" {
mimeType := antigravityOpenAIAudioMIMEType(item.Get("input_audio.format").String())
partItems = append(partItems, antigravityOpenAIInlineDataPart(mimeType, audioData, true))
}
}
}
}
contentItems = append(contentItems, antigravityOpenAIContent("user", partItems))
} else if role == "assistant" {
partItems := make([][]byte, 0, 4)
if reasoningContent := m.Get("reasoning_content"); reasoningContent.Type == gjson.String && reasoningContent.String() != "" {
part := antigravityOpenAITextPart(reasoningContent.String())
part, _ = sjson.SetBytes(part, "thought", true)
part, _ = sjson.SetBytes(part, "thoughtSignature", antigravityFunctionThoughtSignature)
partItems = append(partItems, part)
}
if content.Type == gjson.String && content.String() != "" {
partItems = append(partItems, antigravityOpenAITextPart(content.String()))
} else if content.IsArray() {
for _, item := range content.Array() {
switch item.Get("type").String() {
case "text":
if text := item.Get("text").String(); text != "" {
partItems = append(partItems, antigravityOpenAITextPart(text))
}
case "image_url":
imageURL := item.Get("image_url.url").String()
if len(imageURL) > 5 {
pieces := strings.SplitN(imageURL[5:], ";", 2)
if len(pieces) == 2 && len(pieces[1]) > 7 {
part := antigravityOpenAIInlineDataPart(pieces[0], pieces[1][7:], false)
part, _ = sjson.SetBytes(part, "thoughtSignature", antigravityFunctionThoughtSignature)
partItems = append(partItems, part)
}
}
}
}
}
tcs := m.Get("tool_calls")
if tcs.IsArray() {
functionIDs := make([]string, 0)
for _, tc := range tcs.Array() {
if tc.Get("type").String() != "function" {
continue
}
functionID := tc.Get("id").String()
functionName := util.MapSanitizedFunctionName(functionNameMap, tc.Get("function.name").String())
if functionName == "" {
continue
}
functionArgs := tc.Get("function.arguments").String()
part := []byte(`{"functionCall":{"id":"","name":""}}`)
part, _ = sjson.SetBytes(part, "functionCall.id", functionID)
part, _ = sjson.SetBytes(part, "functionCall.name", functionName)
if gjson.Valid(functionArgs) {
part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(functionArgs))
} else {
part, _ = sjson.SetBytes(part, "functionCall.args.params", []byte(functionArgs))
}
part, _ = sjson.SetBytes(part, "thoughtSignature", antigravityFunctionThoughtSignature)
partItems = append(partItems, part)
if functionID != "" {
functionIDs = append(functionIDs, functionID)
}
}
if len(partItems) > 0 {
contentItems = append(contentItems, antigravityOpenAIContent("model", partItems))
}
responseParts := make([][]byte, 0, len(functionIDs))
for _, functionID := range functionIDs {
if name, ok := tcID2Name[functionID]; ok {
part := []byte(`{"functionResponse":{"id":"","name":""}}`)
part, _ = sjson.SetBytes(part, "functionResponse.id", functionID)
part, _ = sjson.SetBytes(part, "functionResponse.name", util.MapSanitizedFunctionName(functionNameMap, name))
response := toolResponses[functionID]
if response == "" {
response = "{}"
}
if response != "null" {
parsed := gjson.Parse(response)
if parsed.Type == gjson.JSON {
part, _ = sjson.SetRawBytes(part, "functionResponse.response.result", []byte(parsed.Raw))
} else {
part, _ = sjson.SetBytes(part, "functionResponse.response.result", response)
}
}
responseParts = append(responseParts, part)
}
}
if len(responseParts) > 0 {
contentItems = append(contentItems, antigravityOpenAIContent("user", responseParts))
}
} else if len(partItems) > 0 {
contentItems = append(contentItems, antigravityOpenAIContent("model", partItems))
}
}
}
if len(systemParts) > 0 {
out, _ = sjson.SetRawBytes(out, "request.systemInstruction", antigravityOpenAIContent("user", systemParts))
}
out = translatorcommon.SetRawArrayItems(out, "request.contents", contentItems)
}
// tools -> request.tools[].functionDeclarations + request.tools[].googleSearch/codeExecution/urlContext passthrough
tools := gjson.GetBytes(rawJSON, "tools")
toolResults := tools.Array()
if tools.IsArray() && len(toolResults) > 0 {
functionDeclarations := make([][]byte, 0, len(toolResults))
googleSearchNodes := make([][]byte, 0)
codeExecutionNodes := make([][]byte, 0)
urlContextNodes := make([][]byte, 0)
for _, t := range toolResults {
if t.Get("type").String() == "function" {
fn := t.Get("function")
if fn.Exists() && fn.IsObject() {
fnRaw := fn.Raw
if fn.Get("parameters").Exists() {
renamed, errRename := util.RenameKey(fnRaw, "parameters", "parametersJsonSchema")
if errRename != nil {
log.Warnf("Failed to rename parameters for tool '%s': %v", fn.Get("name").String(), errRename)
var errSet error
fnRawBytes, errSet := sjson.SetBytes([]byte(fnRaw), "parametersJsonSchema.type", "object")
if errSet != nil {
log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
fnRaw = string(fnRawBytes)
fnRawBytes, errSet = sjson.SetRawBytes([]byte(fnRaw), "parametersJsonSchema.properties", []byte(`{}`))
if errSet != nil {
log.Warnf("Failed to set default schema properties for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
fnRaw = string(fnRawBytes)
} else {
fnRaw = renamed
}
} else {
var errSet error
fnRawBytes, errSet := sjson.SetBytes([]byte(fnRaw), "parametersJsonSchema.type", "object")
if errSet != nil {
log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
fnRaw = string(fnRawBytes)
fnRawBytes, errSet = sjson.SetRawBytes([]byte(fnRaw), "parametersJsonSchema.properties", []byte(`{}`))
if errSet != nil {
log.Warnf("Failed to set default schema properties for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
fnRaw = string(fnRawBytes)
}
fnRawBytes := []byte(fnRaw)
nameResult := fn.Get("name")
originalName := nameResult.String()
mappedName := util.MapSanitizedFunctionName(functionNameMap, originalName)
if nameResult.Type != gjson.String || mappedName != originalName {
fnRawBytes, _ = sjson.SetBytes(fnRawBytes, "name", mappedName)
}
if gjson.GetBytes(fnRawBytes, "strict").Exists() {
fnRawBytes, _ = sjson.DeleteBytes(fnRawBytes, "strict")
}
functionDeclarations = append(functionDeclarations, fnRawBytes)
}
}
if gs := t.Get("google_search"); gs.Exists() {
googleToolNode := []byte(`{}`)
var errSet error
googleToolNode, errSet = sjson.SetRawBytes(googleToolNode, "googleSearch", []byte(gs.Raw))
if errSet != nil {
log.Warnf("Failed to set googleSearch tool: %v", errSet)
continue
}
googleSearchNodes = append(googleSearchNodes, googleToolNode)
}
if ce := t.Get("code_execution"); ce.Exists() {
codeToolNode := []byte(`{}`)
var errSet error
codeToolNode, errSet = sjson.SetRawBytes(codeToolNode, "codeExecution", []byte(ce.Raw))
if errSet != nil {
log.Warnf("Failed to set codeExecution tool: %v", errSet)
continue
}
codeExecutionNodes = append(codeExecutionNodes, codeToolNode)
}
if uc := t.Get("url_context"); uc.Exists() {
urlToolNode := []byte(`{}`)
var errSet error
urlToolNode, errSet = sjson.SetRawBytes(urlToolNode, "urlContext", []byte(uc.Raw))
if errSet != nil {
log.Warnf("Failed to set urlContext tool: %v", errSet)
continue
}
urlContextNodes = append(urlContextNodes, urlToolNode)
}
}
deduplicated := util.DeduplicateFunctionDeclarations(translatorcommon.JoinRawArray(functionDeclarations))
hasFunction := len(deduplicated) > 2
if hasFunction || len(googleSearchNodes) > 0 || len(codeExecutionNodes) > 0 || len(urlContextNodes) > 0 {
toolItems := make([][]byte, 0, 1+len(googleSearchNodes)+len(codeExecutionNodes)+len(urlContextNodes))
if hasFunction {
functionToolNode := []byte(`{"functionDeclarations":[]}`)
functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", deduplicated)
toolItems = append(toolItems, functionToolNode)
}
toolItems = append(toolItems, googleSearchNodes...)
toolItems = append(toolItems, codeExecutionNodes...)
toolItems = append(toolItems, urlContextNodes...)
out, _ = sjson.SetRawBytes(out, "request.tools", translatorcommon.JoinRawArray(toolItems))
}
}
out = applyOpenAIToolChoiceToAntigravity(out, rawJSON, functionNameMap)
if strings.Contains(strings.ToLower(modelName), "claude") {
out = gemini.SanitizeAntigravityClaudeGeminiRequestSignatures(modelName, out)
}
return common.AttachDefaultSafetySettings(out, "request.safetySettings")
}
func antigravityOpenAITextPart(text string) []byte {
part := []byte(`{"text":""}`)
part, _ = sjson.SetBytes(part, "text", text)
return part
}
func antigravityOpenAIInlineDataPart(mimeType, data string, snakeCase bool) []byte {
part := []byte(`{"inlineData":{"mimeType":"","data":""}}`)
if snakeCase {
part = []byte(`{"inlineData":{"mime_type":"","data":""}}`)
part, _ = sjson.SetBytes(part, "inlineData.mime_type", mimeType)
} else {
part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
}
part, _ = sjson.SetBytes(part, "inlineData.data", data)
return part
}
func antigravityOpenAIContent(role string, parts [][]byte) []byte {
content := []byte(`{"role":"","parts":[]}`)
content, _ = sjson.SetBytes(content, "role", role)
content, _ = sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(parts))
return content
}
func antigravityOpenAIAudioMIMEType(format string) string {
switch format {
case "mp3":
return "audio/mpeg"
case "ogg":
return "audio/ogg"
case "flac":
return "audio/flac"
case "aac":
return "audio/aac"
case "webm":
return "audio/webm"
case "pcm16":
return "audio/pcm"
case "g711_ulaw", "g711_alaw":
return "audio/basic"
case "", "wav":
return "audio/wav"
default:
return "audio/" + format
}
}
func applyOpenAIToolChoiceToAntigravity(out, rawJSON []byte, functionNameMap map[string]string) []byte {
toolChoice := gjson.GetBytes(rawJSON, "tool_choice")
if !toolChoice.Exists() {
return out
}
mode := ""
allowedName := ""
if toolChoice.Type == gjson.String {
switch strings.ToLower(strings.TrimSpace(toolChoice.String())) {
case "none":
mode = "NONE"
case "auto":
mode = "AUTO"
case "required", "any":
mode = "ANY"
}
} else if toolChoice.IsObject() && strings.EqualFold(toolChoice.Get("type").String(), "function") {
mode = "ANY"
allowedName = toolChoice.Get("function.name").String()
}
if mode == "" {
return out
}
out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", mode)
if strings.TrimSpace(allowedName) != "" {
mappedName := util.MapSanitizedFunctionName(functionNameMap, allowedName)
out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{mappedName})
}
return out
}
func applyOpenAIThinkingCompatibilityToAntigravity(out []byte, rawJSON []byte) []byte {
out = normalizeAntigravityOpenAIThinkingConfig(out)
config := thinking.ExtractSummaryConfig(rawJSON, "openai")
return thinking.ApplySummaryConfig(out, "antigravity", config)
}
func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte {
for _, prefix := range []string{
"request.generationConfig.thinking_config",
"request.generationConfig.thinkingConfig",
} {
if sourcePath := prefix + ".includeThoughts"; gjson.GetBytes(out, sourcePath).Exists() {
includeThoughts := gjson.GetBytes(out, sourcePath)
out = setAntigravityOpenAIBoolResultIfValid(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
if includeThoughts.Type != gjson.True && includeThoughts.Type != gjson.False {
out, _ = sjson.DeleteBytes(out, sourcePath)
}
}
if sourcePath := prefix + ".include_thoughts"; gjson.GetBytes(out, sourcePath).Exists() {
includeThoughts := gjson.GetBytes(out, sourcePath)
out = setAntigravityOpenAIBoolResultIfValid(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
if includeThoughts.Type != gjson.True && includeThoughts.Type != gjson.False {
out, _ = sjson.DeleteBytes(out, sourcePath)
}
}
if thinkingLevel := gjson.GetBytes(out, prefix+".thinkingLevel"); thinkingLevel.Exists() {
out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingLevel", thinkingLevel)
}
if thinkingLevel := gjson.GetBytes(out, prefix+".thinking_level"); thinkingLevel.Exists() {
out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingLevel", thinkingLevel)
}
if thinkingBudget := gjson.GetBytes(out, prefix+".thinkingBudget"); thinkingBudget.Exists() {
out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingBudget", thinkingBudget)
}
if thinkingBudget := gjson.GetBytes(out, prefix+".thinking_budget"); thinkingBudget.Exists() {
out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingBudget", thinkingBudget)
}
}
for _, path := range []string{
"request.generationConfig.includeThoughts",
"request.generationConfig.include_thoughts",
} {
if includeThoughts := gjson.GetBytes(out, path); includeThoughts.Exists() {
out = setAntigravityOpenAIBoolResultIfValid(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
}
}
for _, path := range []string{
"request.generationConfig.thinking_config",
"request.generationConfig.thinkingConfig.include_thoughts",
"request.generationConfig.thinkingConfig.thinking_level",
"request.generationConfig.thinkingConfig.thinking_budget",
"request.generationConfig.includeThoughts",
"request.generationConfig.include_thoughts",
} {
if gjson.GetBytes(out, path).Exists() {
out, _ = sjson.DeleteBytes(out, path)
}
}
return out
}
func setAntigravityOpenAIBoolResultIfValid(out []byte, path string, value gjson.Result) []byte {
switch value.Type {
case gjson.True:
return setAntigravityOpenAIBoolIfDifferent(out, path, true)
case gjson.False:
return setAntigravityOpenAIBoolIfDifferent(out, path, false)
default:
return out
}
}
func setAntigravityOpenAIBoolIfDifferent(out []byte, path string, value bool) []byte {
current := gjson.GetBytes(out, path)
if value && current.Type == gjson.True || !value && current.Type == gjson.False {
return out
}
updated, errSet := sjson.SetBytes(out, path, value)
if errSet != nil {
return out
}
return updated
}
func setAntigravityOpenAIRawIfDifferent(out []byte, path string, value gjson.Result) []byte {
current := gjson.GetBytes(out, path)
if current.Exists() && current.Raw == value.Raw {
return out
}
updated, errSet := sjson.SetRawBytes(out, path, []byte(value.Raw))
if errSet != nil {
return out
}
return updated
}

View file

@ -0,0 +1,494 @@
package chat_completions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIRequestToAntigravitySkipsEmptyTextPartsWithoutNulls(t *testing.T) {
inputJSON := `{
"model": "gemini-3-flash",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": ""},
{"type": "input_audio", "input_audio": {"data": "SUQzBA==", "format": "mp3"}}
]
},
{
"role": "assistant",
"content": [{"type": "text", "text": ""}],
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "read_file", "arguments": "{\"path\":\"a.txt\"}"}
}]
},
{"role": "tool", "tool_call_id": "call_1", "content": "{\"output\":\"ok\"}"},
{"role": "user", "content": "done"}
]
}`
result := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
userParts := gjson.GetBytes(result, "request.contents.0.parts").Array()
if len(userParts) != 1 {
t.Fatalf("user parts length = %d, want 1. Output: %s", len(userParts), result)
}
if userParts[0].Type == gjson.Null {
t.Fatalf("user parts.0 is null. Output: %s", result)
}
if got := userParts[0].Get("inlineData.mime_type").String(); got != "audio/mpeg" {
t.Fatalf("audio mime_type = %q, want audio/mpeg. Output: %s", got, result)
}
assistantParts := gjson.GetBytes(result, "request.contents.1.parts").Array()
if len(assistantParts) != 1 {
t.Fatalf("assistant parts length = %d, want 1. Output: %s", len(assistantParts), result)
}
if assistantParts[0].Type == gjson.Null {
t.Fatalf("assistant parts.0 is null. Output: %s", result)
}
if !assistantParts[0].Get("functionCall").Exists() {
t.Fatalf("functionCall missing. Output: %s", result)
}
}
func TestConvertOpenAIRequestToAntigravity_ClaudeModelSanitizesUnsignedReasoningContent(t *testing.T) {
inputJSON := `{
"model": "claude-sonnet-4-6",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "visible text", "reasoning_content": "unsigned reasoning"},
{"role": "user", "content": "say ok"}
]
}`
result := ConvertOpenAIRequestToAntigravity("claude-sonnet-4-6", []byte(inputJSON), false)
contents := gjson.GetBytes(result, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("contents length = %d, want 3. Output: %s", len(contents), result)
}
parts := contents[1].Get("parts").Array()
if len(parts) != 1 {
t.Fatalf("model parts length = %d, want 1 (thinking part dropped). Output: %s", len(parts), result)
}
if got := parts[0].Get("text").String(); got != "visible text" {
t.Fatalf("parts[0].text = %q, want visible text. Output: %s", got, result)
}
if parts[0].Get("thought").Exists() {
t.Fatalf("parts[0] should not be thought part. Output: %s", result)
}
}
func TestConvertOpenAIRequestToAntigravity_ClaudeModelDropsEmptyAssistantTurnAfterSanitizingReasoningContent(t *testing.T) {
inputJSON := `{
"model": "claude-sonnet-4-6",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "", "reasoning_content": "unsigned reasoning"},
{"role": "user", "content": "say ok"}
]
}`
result := ConvertOpenAIRequestToAntigravity("claude-sonnet-4-6", []byte(inputJSON), false)
contents := gjson.GetBytes(result, "request.contents").Array()
if len(contents) != 2 {
t.Fatalf("contents length = %d, want 2 (empty model turn dropped). Output: %s", len(contents), result)
}
if got := contents[0].Get("role").String(); got != "user" {
t.Fatalf("contents[0].role = %q, want user. Output: %s", got, result)
}
if got := contents[1].Get("role").String(); got != "user" {
t.Fatalf("contents[1].role = %q, want user. Output: %s", got, result)
}
}
func TestConvertOpenAIRequestToAntigravityPreservesReasoningContent(t *testing.T) {
inputJSON := `{
"model": "gemini-3-flash",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "", "reasoning_content": "thinking only"},
{"role": "user", "content": "say ok"}
]
}`
result := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), true)
contents := gjson.GetBytes(result, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("contents length = %d, want 3. Output: %s", len(contents), result)
}
part := contents[1].Get("parts.0")
if got := contents[1].Get("role").String(); got != "model" {
t.Fatalf("contents.1.role = %q, want model. Output: %s", got, result)
}
if got := part.Get("text").String(); got != "thinking only" {
t.Fatalf("reasoning text = %q, want thinking only. Output: %s", got, result)
}
if !part.Get("thought").Bool() {
t.Fatalf("reasoning part should be marked as thought. Output: %s", result)
}
if got := part.Get("thoughtSignature").String(); got != antigravityFunctionThoughtSignature {
t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, result)
}
}
func TestConvertOpenAIRequestToAntigravityPreservesReasoningBeforeVisibleContentAndToolCall(t *testing.T) {
inputJSON := `{
"model": "gemini-3-flash",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "visible answer", "reasoning_content": "thinking only", "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "read_file", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "call_1", "content": "{\"output\":\"ok\"}"},
{"role": "user", "content": "say ok"}
]
}`
result := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), true)
contents := gjson.GetBytes(result, "request.contents").Array()
if len(contents) != 4 {
t.Fatalf("contents length = %d, want 4. Output: %s", len(contents), result)
}
parts := contents[1].Get("parts").Array()
if len(parts) != 3 {
t.Fatalf("model parts length = %d, want 3. Output: %s", len(parts), result)
}
if got := parts[0].Get("text").String(); got != "thinking only" || !parts[0].Get("thought").Bool() {
t.Fatalf("first part should be the reasoning thought. Output: %s", result)
}
if got := parts[1].Get("text").String(); got != "visible answer" || parts[1].Get("thought").Bool() {
t.Fatalf("second part should be visible assistant content. Output: %s", result)
}
if got := parts[2].Get("functionCall.name").String(); got != "read_file" {
t.Fatalf("functionCall.name = %q, want read_file. Output: %s", got, result)
}
if got := parts[2].Get("thoughtSignature").String(); got != antigravityFunctionThoughtSignature {
t.Fatalf("functionCall thoughtSignature = %q, want bypass sentinel. Output: %s", got, result)
}
if got := contents[2].Get("parts.0.functionResponse.name").String(); got != "read_file" {
t.Fatalf("functionResponse.name = %q, want read_file. Output: %s", got, result)
}
}
func TestConvertOpenAIRequestToAntigravitySkipsEmptyAssistantMessages(t *testing.T) {
inputJSON := `{
"model": "gemini-3-flash",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "", "tool_calls": [{"type": "function", "function": {"name": "", "arguments": "{}"}}, {"type": "custom"}]},
{"role": "user", "content": "say ok"}
]
}`
result := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), true)
contents := gjson.GetBytes(result, "request.contents").Array()
if len(contents) != 2 {
t.Fatalf("contents length = %d, want 2. Output: %s", len(contents), result)
}
}
func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) {
tests := []struct {
name string
body string
wantExists bool
want bool
}{
{
name: "Missing summary intent leaves include thoughts absent",
body: `{
"model":"gemini-3.1-pro-low",
"messages":[{"role":"user","content":"hi"}]
}`,
},
{
name: "Reasoning effort enables thoughts",
body: `{
"model":"gemini-3.1-pro-low",
"messages":[{"role":"user","content":"hi"}],
"reasoning_effort":"high"
}`,
wantExists: true,
want: true,
},
{
name: "GenerationConfig snake include thoughts",
body: `{
"model":"gemini-3.1-pro-low",
"messages":[{"role":"user","content":"hi"}],
"generationConfig":{"thinkingConfig":{"include_thoughts":true}}
}`,
wantExists: true,
want: true,
},
{
name: "String include thoughts is ignored",
body: `{
"model":"gemini-3.1-pro-low",
"messages":[{"role":"user","content":"hi"}],
"generationConfig":{"thinkingConfig":{"includeThoughts":"true"}}
}`,
},
{
name: "Top-level thinking include thoughts",
body: `{
"model":"gemini-3.1-pro-low",
"messages":[{"role":"user","content":"hi"}],
"thinking":{"include_thoughts":true}
}`,
wantExists: true,
want: true,
},
{
name: "Reasoning exclude false includes thoughts",
body: `{
"model":"gemini-3.1-pro-low",
"messages":[{"role":"user","content":"hi"}],
"reasoning":{"exclude":false}
}`,
wantExists: true,
want: true,
},
{
name: "Reasoning exclude true hides thoughts",
body: `{
"model":"gemini-3.1-pro-low",
"messages":[{"role":"user","content":"hi"}],
"reasoning":{"exclude":true}
}`,
wantExists: true,
want: false,
},
{
name: "Google extension disables thoughts",
body: `{
"model":"gemini-3.1-pro-low",
"messages":[{"role":"user","content":"hi"}],
"reasoning_effort":"high",
"extra_body":{"google":{"thinking_config":{"include_thoughts":false}}}
}`,
wantExists: true,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConvertOpenAIRequestToAntigravity("gemini-3.1-pro-low", []byte(tt.body), false)
includeThoughts := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts")
if includeThoughts.Exists() != tt.wantExists {
t.Fatalf("includeThoughts exists = %v, want %v. Output: %s", includeThoughts.Exists(), tt.wantExists, result)
}
if tt.wantExists {
if got := includeThoughts.Bool(); got != tt.want {
t.Fatalf("includeThoughts = %v, want %v. Output: %s", got, tt.want, result)
}
}
if snake := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.include_thoughts"); snake.Exists() {
t.Fatalf("include_thoughts should be normalized away. Output: %s", result)
}
})
}
}
func TestConvertOpenAIRequestToAntigravityDeduplicatesAndDisambiguatesTools(t *testing.T) {
first := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build"
second := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build_logs"
inputJSON := `{
"messages":[
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"` + second + `","arguments":"{}"}}]},
{"role":"tool","tool_call_id":"call_1","content":"{}"}
],
"tools":[
{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}},
{"type":"function","function":{"name":"lookup","description":"duplicate","parameters":{"type":"object"}}},
{"type":"function","function":{"name":"` + first + `","parameters":{"type":"object"}}},
{"type":"function","function":{"name":"` + second + `","parameters":{"type":"object"}}}
],
"tool_choice":{"type":"function","function":{"name":"` + second + `"}}
}`
out := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
declarations := gjson.GetBytes(out, "request.tools.0.functionDeclarations").Array()
if len(declarations) != 3 {
t.Fatalf("declaration count = %d, want 3. Output: %s", len(declarations), out)
}
firstMapped := declarations[1].Get("name").String()
secondMapped := declarations[2].Get("name").String()
if firstMapped == secondMapped || len(secondMapped) > 64 {
t.Fatalf("collision names = %q and %q, want distinct names <= 64 chars", firstMapped, secondMapped)
}
if got := gjson.GetBytes(out, "request.contents.0.parts.0.functionCall.name").String(); got != secondMapped {
t.Fatalf("functionCall.name = %q, want %q. Output: %s", got, secondMapped, out)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse.name").String(); got != secondMapped {
t.Fatalf("functionResponse.name = %q, want %q. Output: %s", got, secondMapped, out)
}
if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != secondMapped {
t.Fatalf("allowedFunctionNames.0 = %q, want %q. Output: %s", got, secondMapped, out)
}
}
func TestConvertOpenAIRequestToAntigravityMapsToolChoiceModes(t *testing.T) {
for _, tt := range []struct {
choice string
mode string
}{
{choice: `"none"`, mode: "NONE"},
{choice: `"auto"`, mode: "AUTO"},
{choice: `"required"`, mode: "ANY"},
} {
t.Run(tt.mode+tt.choice, func(t *testing.T) {
inputJSON := []byte(`{"messages":[{"role":"user","content":"hi"}],"tool_choice":` + tt.choice + `}`)
out := ConvertOpenAIRequestToAntigravity("gemini-3-flash", inputJSON, false)
if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String(); got != tt.mode {
t.Fatalf("tool choice mode = %q, want %q. Output: %s", got, tt.mode, out)
}
})
}
}
func TestConvertOpenAIRequestToAntigravityMapsResponseFormatJSONObject(t *testing.T) {
inputJSON := []byte(`{
"model":"gemini-3.6-flash-high",
"messages":[{"role":"user","content":"hi"}],
"generationConfig":{
"responseSchema":{"type":"string","description":"stale"},
"responseJsonSchema":{"type":"string"},
"response_schema":{"type":"string"},
"response_json_schema":{"type":"string"}
},
"response_format":{"type":"json_object"}
}`)
out := ConvertOpenAIRequestToAntigravity("gemini-3.6-flash-high", inputJSON, false)
if got := gjson.GetBytes(out, "request.generationConfig.responseMimeType").String(); got != "application/json" {
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, out)
}
if gjson.GetBytes(out, "request.generationConfig.responseSchema").Exists() {
t.Fatalf("responseSchema should not be set for json_object. Output: %s", out)
}
assertNoResponseSchemaAliases(t, out)
}
func TestConvertOpenAIRequestToAntigravityMapsResponseFormatJSONSchema(t *testing.T) {
inputJSON := []byte(`{
"model":"gemini-3.6-flash-high",
"messages":[{"role":"user","content":"hi"}],
"generationConfig":{
"responseSchema":{"type":"string","description":"stale"},
"responseJsonSchema":{"type":"string"},
"response_schema":{"type":"string"},
"response_json_schema":{"type":"string"}
},
"response_format":{
"type":"json_schema",
"json_schema":{
"name":"verdict",
"schema":{
"type":"object",
"properties":{"score":{"type":"integer"}},
"required":["score"]
}
}
}
}`)
out := ConvertOpenAIRequestToAntigravity("gemini-3.6-flash-high", inputJSON, false)
if got := gjson.GetBytes(out, "request.generationConfig.responseMimeType").String(); got != "application/json" {
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, out)
}
schema := gjson.GetBytes(out, "request.generationConfig.responseSchema")
if !schema.Exists() {
t.Fatalf("responseSchema missing. Output: %s", out)
}
if got := schema.Get("properties.score.type").String(); got != "integer" {
t.Fatalf("responseSchema.properties.score.type = %q, want integer. Output: %s", got, out)
}
if schema.Get("description").Exists() {
t.Fatalf("stale responseSchema survived. Output: %s", out)
}
assertNoResponseSchemaAliases(t, out)
}
func assertNoResponseSchemaAliases(t *testing.T, out []byte) {
t.Helper()
for _, schemaKey := range []string{"responseJsonSchema", "response_schema", "response_json_schema"} {
if gjson.GetBytes(out, "request.generationConfig."+schemaKey).Exists() {
t.Errorf("stale %s survived response_format mapping. Output: %s", schemaKey, out)
}
}
}
func TestConvertOpenAIRequestToAntigravityTranslatesVideoURL(t *testing.T) {
inputJSON := []byte(`{
"model": "gemini-3.7-flash-high",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "Name the colours in order"},
{"type": "video_url", "video_url": {"url": "data:video/mp4;base64,AAAAIGZ0eXBtcDQy"}}
]
}]
}`)
out := ConvertOpenAIRequestToAntigravity("gemini-3.7-flash-high", inputJSON, false)
parts := gjson.GetBytes(out, "request.contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("parts length = %d, want 2. Output: %s", len(parts), out)
}
if got := parts[0].Get("text").String(); got != "Name the colours in order" {
t.Fatalf("parts[0].text = %q, want 'Name the colours in order'", got)
}
inlineData := parts[1].Get("inlineData")
if !inlineData.Exists() {
t.Fatalf("parts[1].inlineData missing. Output: %s", out)
}
if got := inlineData.Get("mimeType").String(); got != "video/mp4" {
t.Fatalf("inlineData.mimeType = %q, want video/mp4. Output: %s", got, out)
}
if got := inlineData.Get("data").String(); got != "AAAAIGZ0eXBtcDQy" {
t.Fatalf("inlineData.data = %q, want AAAAIGZ0eXBtcDQy. Output: %s", got, out)
}
}
func TestConvertOpenAIRequestToAntigravity_MaxCompletionTokens(t *testing.T) {
tests := []struct {
name string
body string
expected float64
}{
{
name: "only max_tokens",
body: `{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}],"max_tokens":100}`,
expected: 100,
},
{
name: "only max_completion_tokens",
body: `{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}],"max_completion_tokens":200}`,
expected: 200,
},
{
name: "max_tokens preferred over max_completion_tokens",
body: `{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}],"max_tokens":100,"max_completion_tokens":200}`,
expected: 100,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := ConvertOpenAIRequestToAntigravity("gemini-2.5-flash", []byte(tt.body), false)
got := gjson.GetBytes(out, "request.generationConfig.maxOutputTokens")
if !got.Exists() {
t.Fatalf("request.generationConfig.maxOutputTokens missing. Output: %s", out)
}
if got.Float() != tt.expected {
t.Fatalf("maxOutputTokens = %v, want %v. Output: %s", got.Float(), tt.expected, out)
}
})
}
}

View file

@ -0,0 +1,272 @@
// Package openai provides response translation functionality for Antigravity to OpenAI API compatibility.
// This package handles the conversion of Antigravity API responses into OpenAI Chat Completions-compatible
// JSON format, transforming streaming events and non-streaming responses into the format
// expected by OpenAI API clients. It supports both streaming and non-streaming modes,
// handling text content, tool calls, reasoning content, and usage metadata appropriately.
package chat_completions
import (
"bytes"
"context"
"fmt"
"strings"
"sync/atomic"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
. "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/chat-completions"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// convertCliResponseToOpenAIChatParams holds parameters for response conversion.
type convertCliResponseToOpenAIChatParams struct {
UnixTimestamp int64
FunctionIndex int
SawToolCall bool // Tracks if any tool call was seen in the entire stream
UpstreamFinishReason string // Caches the upstream finish reason for final chunk
SanitizedNameMap map[string]string
}
// functionCallIDCounter provides a process-wide unique counter for function call identifiers.
var functionCallIDCounter uint64
// ConvertAntigravityResponseToOpenAI translates a single chunk of a streaming response from the
// Antigravity API format to the OpenAI Chat Completions streaming format.
// It processes various Antigravity event types and transforms them into OpenAI-compatible JSON responses.
// The function handles text content, tool calls, reasoning content, and usage metadata, outputting
// responses that match the OpenAI API format. It supports incremental updates for streaming responses.
//
// 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 response from the Antigravity API
// - param: A pointer to a parameter object for maintaining state between calls
//
// Returns:
// - [][]byte: A slice of OpenAI-compatible JSON responses
func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
if *param == nil {
*param = &convertCliResponseToOpenAIChatParams{
UnixTimestamp: 0,
FunctionIndex: 0,
SanitizedNameMap: util.DisambiguatedToolNameMap(originalRequestRawJSON),
}
}
if (*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap == nil {
(*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap = util.DisambiguatedToolNameMap(originalRequestRawJSON)
}
if bytes.Equal(rawJSON, []byte("[DONE]")) {
return [][]byte{}
}
// Initialize the OpenAI SSE template.
template := []byte(`{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`)
// Extract and set the model version.
if modelVersionResult := gjson.GetBytes(rawJSON, "response.modelVersion"); modelVersionResult.Exists() {
template, _ = sjson.SetBytes(template, "model", modelVersionResult.String())
}
// Extract and set the creation timestamp.
if createTimeResult := gjson.GetBytes(rawJSON, "response.createTime"); createTimeResult.Exists() {
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
if err == nil {
(*param).(*convertCliResponseToOpenAIChatParams).UnixTimestamp = t.Unix()
}
template, _ = sjson.SetBytes(template, "created", (*param).(*convertCliResponseToOpenAIChatParams).UnixTimestamp)
} else {
template, _ = sjson.SetBytes(template, "created", (*param).(*convertCliResponseToOpenAIChatParams).UnixTimestamp)
}
// Extract and set the response ID.
if responseIDResult := gjson.GetBytes(rawJSON, "response.responseId"); responseIDResult.Exists() {
template, _ = sjson.SetBytes(template, "id", responseIDResult.String())
}
// Cache the finish reason - do NOT set it in output yet (will be set on final chunk)
if finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason"); finishReasonResult.Exists() {
(*param).(*convertCliResponseToOpenAIChatParams).UpstreamFinishReason = strings.ToUpper(finishReasonResult.String())
}
// Extract and set usage metadata (token counts).
if usageResult := gjson.GetBytes(rawJSON, "response.usageMetadata"); usageResult.Exists() {
cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int()
template, _ = sjson.SetBytes(template, "usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int())
if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokenCountResult.Int())
}
promptTokenCount := usageResult.Get("promptTokenCount").Int()
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
template, _ = sjson.SetBytes(template, "usage.prompt_tokens", promptTokenCount)
if thoughtsTokenCount > 0 {
template, _ = sjson.SetBytes(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
}
// Include cached token count if present (indicates prompt caching is working)
if cachedTokenCount > 0 {
var err error
template, err = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount)
if err != nil {
log.Warnf("antigravity openai response: failed to set cached_tokens: %v", err)
}
}
}
// Process the main content part of the response.
partsResult := gjson.GetBytes(rawJSON, "response.candidates.0.content.parts")
if partsResult.IsArray() {
partResults := partsResult.Array()
for i := 0; i < len(partResults); i++ {
partResult := partResults[i]
partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall")
thoughtSignatureResult := partResult.Get("thoughtSignature")
if !thoughtSignatureResult.Exists() {
thoughtSignatureResult = partResult.Get("thought_signature")
}
inlineDataResult := partResult.Get("inlineData")
if !inlineDataResult.Exists() {
inlineDataResult = partResult.Get("inline_data")
}
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""
hasContentPayload := partTextResult.Exists() || functionCallResult.Exists() || inlineDataResult.Exists()
// Ignore encrypted thoughtSignature but keep any actual content in the same part.
if hasThoughtSignature && !hasContentPayload {
continue
}
if partTextResult.Exists() {
textContent := partTextResult.String()
// Handle text content, distinguishing between regular content and reasoning/thoughts.
if partResult.Get("thought").Bool() {
template, _ = sjson.SetBytes(template, "choices.0.delta.reasoning_content", textContent)
} else {
template, _ = sjson.SetBytes(template, "choices.0.delta.content", textContent)
}
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
} else if functionCallResult.Exists() {
// Handle function call content.
(*param).(*convertCliResponseToOpenAIChatParams).SawToolCall = true // Persist across chunks
toolCallsResult := gjson.GetBytes(template, "choices.0.delta.tool_calls")
functionCallIndex := (*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex
(*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex++
if toolCallsResult.Exists() && toolCallsResult.IsArray() {
functionCallIndex = len(toolCallsResult.Array())
} else {
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
}
functionCallTemplate := []byte(`{"id": "","index": 0,"type": "function","function": {"name": "","arguments": ""}}`)
fcName := util.RestoreSanitizedToolName((*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap, functionCallResult.Get("name").String())
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)))
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "index", functionCallIndex)
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", fcName)
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.arguments", fcArgsResult.Raw)
}
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallTemplate)
} else if inlineDataResult.Exists() {
data := inlineDataResult.Get("data").String()
if data == "" {
continue
}
mimeType := inlineDataResult.Get("mimeType").String()
if mimeType == "" {
mimeType = inlineDataResult.Get("mime_type").String()
}
if mimeType == "" {
mimeType = "image/png"
}
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
imagesResult := gjson.GetBytes(template, "choices.0.delta.images")
if !imagesResult.Exists() || !imagesResult.IsArray() {
template, _ = sjson.SetRawBytes(template, "choices.0.delta.images", []byte(`[]`))
}
imageIndex := len(gjson.GetBytes(template, "choices.0.delta.images").Array())
imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex)
imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload)
}
}
}
// Determine finish_reason only on the final chunk (has both finishReason and usage metadata)
params := (*param).(*convertCliResponseToOpenAIChatParams)
upstreamFinishReason := params.UpstreamFinishReason
sawToolCall := params.SawToolCall
usageExists := gjson.GetBytes(rawJSON, "response.usageMetadata").Exists()
isFinalChunk := upstreamFinishReason != "" && usageExists
if isFinalChunk {
var finishReason string
if sawToolCall {
finishReason = "tool_calls"
} else if upstreamFinishReason == "MAX_TOKENS" {
finishReason = "max_tokens"
} else {
finishReason = "stop"
}
template, _ = sjson.SetBytes(template, "choices.0.finish_reason", finishReason)
template, _ = sjson.SetBytes(template, "choices.0.native_finish_reason", strings.ToLower(upstreamFinishReason))
}
return [][]byte{template}
}
// ConvertAntigravityResponseToOpenAINonStream converts a non-streaming Antigravity response to a non-streaming OpenAI response.
// This function processes the complete Antigravity response and transforms it into a single OpenAI-compatible
// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all
// the information into a single response that matches the OpenAI API 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
// - rawJSON: The raw JSON response from the Antigravity API
// - param: A pointer to a parameter object for the conversion
//
// Returns:
// - []byte: An OpenAI-compatible JSON response containing all message content and metadata
func ConvertAntigravityResponseToOpenAINonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
responseResult := gjson.GetBytes(rawJSON, "response")
if responseResult.Exists() {
responseJSON := restoreAntigravityOpenAIFunctionNames([]byte(responseResult.Raw), originalRequestRawJSON)
return ConvertGeminiResponseToOpenAINonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, responseJSON, param)
}
return []byte{}
}
func restoreAntigravityOpenAIFunctionNames(rawJSON, originalRequestRawJSON []byte) []byte {
nameMap := util.DisambiguatedToolNameMap(originalRequestRawJSON)
if len(nameMap) == 0 {
return rawJSON
}
candidates := gjson.GetBytes(rawJSON, "candidates")
for candidateIndex, candidate := range candidates.Array() {
for partIndex, part := range candidate.Get("content.parts").Array() {
for _, field := range []string{"functionCall", "functionResponse"} {
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)
rawJSON, _ = sjson.SetBytes(rawJSON, path, restoredName)
}
}
}
return rawJSON
}

View file

@ -0,0 +1,196 @@
package chat_completions
import (
"context"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
)
func TestFinishReasonToolCallsNotOverwritten(t *testing.T) {
ctx := context.Background()
var param any
// Chunk 1: Contains functionCall - should set SawToolCall = true
chunk1 := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_files","args":{"path":"."}}}]}}]}}`)
result1 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk1, &param)
// Verify chunk1 has no finish_reason (null)
if len(result1) != 1 {
t.Fatalf("Expected 1 result from chunk1, got %d", len(result1))
}
fr1 := gjson.GetBytes(result1[0], "choices.0.finish_reason")
if fr1.Exists() && fr1.String() != "" && fr1.Type.String() != "Null" {
t.Errorf("Expected finish_reason to be null in chunk1, got: %v", fr1.String())
}
// Chunk 2: Contains finishReason STOP + usage (final chunk, no functionCall)
// This simulates what the upstream sends AFTER the tool call chunk
chunk2 := []byte(`{"response":{"candidates":[{"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":20,"totalTokenCount":30}}}`)
result2 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk2, &param)
// Verify chunk2 has finish_reason: "tool_calls" (not "stop")
if len(result2) != 1 {
t.Fatalf("Expected 1 result from chunk2, got %d", len(result2))
}
fr2 := gjson.GetBytes(result2[0], "choices.0.finish_reason").String()
if fr2 != "tool_calls" {
t.Errorf("Expected finish_reason 'tool_calls', got: %s", fr2)
}
// Verify native_finish_reason is lowercase upstream value
nfr2 := gjson.GetBytes(result2[0], "choices.0.native_finish_reason").String()
if nfr2 != "stop" {
t.Errorf("Expected native_finish_reason 'stop', got: %s", nfr2)
}
}
func TestFinishReasonStopForNormalText(t *testing.T) {
ctx := context.Background()
var param any
// Chunk 1: Text content only
chunk1 := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"Hello world"}]}}]}}`)
ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk1, &param)
// Chunk 2: Final chunk with STOP
chunk2 := []byte(`{"response":{"candidates":[{"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}}`)
result2 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk2, &param)
// Verify finish_reason is "stop" (no tool calls were made)
fr := gjson.GetBytes(result2[0], "choices.0.finish_reason").String()
if fr != "stop" {
t.Errorf("Expected finish_reason 'stop', got: %s", fr)
}
}
func TestFinishReasonMaxTokens(t *testing.T) {
ctx := context.Background()
var param any
// Chunk 1: Text content
chunk1 := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}}`)
ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk1, &param)
// Chunk 2: Final chunk with MAX_TOKENS
chunk2 := []byte(`{"response":{"candidates":[{"finishReason":"MAX_TOKENS"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":100,"totalTokenCount":110}}}`)
result2 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk2, &param)
// Verify finish_reason is "max_tokens"
fr := gjson.GetBytes(result2[0], "choices.0.finish_reason").String()
if fr != "max_tokens" {
t.Errorf("Expected finish_reason 'max_tokens', got: %s", fr)
}
}
func TestToolCallTakesPriorityOverMaxTokens(t *testing.T) {
ctx := context.Background()
var param any
// Chunk 1: Contains functionCall
chunk1 := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"test","args":{}}}]}}]}}`)
ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk1, &param)
// Chunk 2: Final chunk with MAX_TOKENS (but we had a tool call, so tool_calls should win)
chunk2 := []byte(`{"response":{"candidates":[{"finishReason":"MAX_TOKENS"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":100,"totalTokenCount":110}}}`)
result2 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk2, &param)
// Verify finish_reason is "tool_calls" (takes priority over max_tokens)
fr := gjson.GetBytes(result2[0], "choices.0.finish_reason").String()
if fr != "tool_calls" {
t.Errorf("Expected finish_reason 'tool_calls', got: %s", fr)
}
}
func TestNoFinishReasonOnIntermediateChunks(t *testing.T) {
ctx := context.Background()
var param any
// Chunk 1: Text content (no finish reason, no usage)
chunk1 := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}}`)
result1 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk1, &param)
// Verify no finish_reason on intermediate chunk
fr1 := gjson.GetBytes(result1[0], "choices.0.finish_reason")
if fr1.Exists() && fr1.String() != "" && fr1.Type.String() != "Null" {
t.Errorf("Expected no finish_reason on intermediate chunk, got: %v", fr1)
}
// Chunk 2: More text (no finish reason, no usage)
chunk2 := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":" world"}]}}]}}`)
result2 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk2, &param)
// Verify no finish_reason on intermediate chunk
fr2 := gjson.GetBytes(result2[0], "choices.0.finish_reason")
if fr2.Exists() && fr2.String() != "" && fr2.Type.String() != "Null" {
t.Errorf("Expected no finish_reason on intermediate chunk, got: %v", fr2)
}
}
func TestConvertAntigravityResponseToOpenAIIncludesZeroCompletionTokensWhenMissing(t *testing.T) {
var param any
chunk := []byte(`{"response":{"usageMetadata":{"promptTokenCount":16,"thoughtsTokenCount":42,"totalTokenCount":58}}}`)
result := ConvertAntigravityResponseToOpenAI(context.Background(), "model", nil, nil, chunk, &param)
if len(result) != 1 {
t.Fatalf("expected 1 result, got %d", len(result))
}
completionTokens := gjson.GetBytes(result[0], "usage.completion_tokens")
if !completionTokens.Exists() || completionTokens.Int() != 0 {
t.Fatalf("completion_tokens = %s, want present with value 0. Output: %s", completionTokens.Raw, result[0])
}
}
func TestConvertAntigravityResponseToOpenAINonStreamRestoresDisambiguatedName(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":[
{"type":"function","function":{"name":"` + first + `"}},
{"type":"function","function":{"name":"` + second + `"}}
]}`)
mapped := util.SanitizedFunctionNameMap(original)[second]
responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"` + mapped + `","args":{}}}]}}]}}`)
output := ConvertAntigravityResponseToOpenAINonStream(context.Background(), "gemini-3-flash", original, nil, responseJSON, nil)
if got := gjson.GetBytes(output, "choices.0.message.tool_calls.0.function.name").String(); got != second {
t.Fatalf("function.name = %q, want %q. Output: %s", got, second, output)
}
}
func TestConvertAntigravityResponseToOpenAINonStreamIncludesReasoningContent(t *testing.T) {
ctx := context.Background()
responseJSON := []byte(`{
"response": {
"candidates": [{
"index": 0,
"content": {
"parts": [
{"text": "I need to multiply 17 by 24.", "thought": true},
{"text": "408", "thoughtSignature": "sig-final-answer"}
]
},
"finishReason": "STOP"
}],
"usageMetadata": {
"promptTokenCount": 16,
"candidatesTokenCount": 3,
"thoughtsTokenCount": 42,
"totalTokenCount": 61
},
"modelVersion": "gemini-3.1-pro-low",
"responseId": "resp-reasoning"
}
}`)
output := ConvertAntigravityResponseToOpenAINonStream(ctx, "gemini-3.1-pro-low", nil, nil, responseJSON, nil)
if got := gjson.GetBytes(output, "choices.0.message.reasoning_content").String(); got != "I need to multiply 17 by 24." {
t.Fatalf("reasoning_content = %q, want thought text. Output: %s", got, output)
}
if got := gjson.GetBytes(output, "choices.0.message.content").String(); got != "408" {
t.Fatalf("content = %q, want final answer. Output: %s", got, output)
}
if got := gjson.GetBytes(output, "usage.completion_tokens_details.reasoning_tokens").Int(); got != 42 {
t.Fatalf("reasoning_tokens = %d, want 42. Output: %s", got, output)
}
}

View file

@ -0,0 +1,19 @@
package chat_completions
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(
OpenAI,
Antigravity,
ConvertOpenAIRequestToAntigravity,
interfaces.TranslateResponse{
Stream: ConvertAntigravityResponseToOpenAI,
NonStream: ConvertAntigravityResponseToOpenAINonStream,
},
)
}

View file

@ -0,0 +1,13 @@
package chat_completions
import "testing"
func TestNormalizeAntigravityOpenAIThinkingConfigReusesCanonicalConfig(t *testing.T) {
input := []byte(`{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true,"thinkingLevel":"high","thinkingBudget":8192}}}}`)
output := normalizeAntigravityOpenAIThinkingConfig(input)
if &output[0] != &input[0] {
t.Fatal("canonical thinking config caused a payload copy")
}
}

View file

@ -0,0 +1,204 @@
package responses
import (
"encoding/json"
"strings"
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
. "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini"
. "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
func ConvertOpenAIResponsesRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := inputRawJSON
rawJSON = ConvertOpenAIResponsesRequestToGemini(modelName, rawJSON, stream)
rawJSON = rewriteOpenAIResponsesReasoningForAntigravityClaude(modelName, inputRawJSON, rawJSON)
return ConvertGeminiRequestToAntigravity(modelName, rawJSON, stream)
}
type antigravityClaudeReasoningSignature struct {
Signature string
HasRawSignature bool
RawSignatureLen int
DetectedProvider sigcompat.SignatureProvider
}
func rewriteOpenAIResponsesReasoningForAntigravityClaude(modelName string, inputRawJSON, geminiJSON []byte) []byte {
if sigcompat.SignatureProviderFromModelName(modelName) != sigcompat.SignatureProviderClaude {
return geminiJSON
}
reasoningSignatures := antigravityClaudeReasoningSignatures(inputRawJSON)
if len(reasoningSignatures) == 0 {
return geminiJSON
}
var root map[string]any
if err := json.Unmarshal(geminiJSON, &root); err != nil {
log.WithError(err).Debug("antigravity responses translator: failed to parse Gemini request for Claude signature rewrite")
return geminiJSON
}
contents, ok := root["contents"].([]any)
if !ok {
return geminiJSON
}
reasoningIndex := 0
changed := false
rewrittenContents := make([]any, 0, len(contents))
for contentIndex, contentValue := range contents {
content, ok := contentValue.(map[string]any)
if !ok {
rewrittenContents = append(rewrittenContents, contentValue)
continue
}
parts, ok := content["parts"].([]any)
if !ok {
rewrittenContents = append(rewrittenContents, content)
continue
}
rewrittenParts := make([]any, 0, len(parts))
for partIndex, partValue := range parts {
part, ok := partValue.(map[string]any)
if !ok || part["thought"] != true {
rewrittenParts = append(rewrittenParts, partValue)
continue
}
var reasoningSig antigravityClaudeReasoningSignature
if reasoningIndex < len(reasoningSignatures) {
reasoningSig = reasoningSignatures[reasoningIndex]
}
reasoningIndex++
if reasoningSig.Signature == "" {
changed = true
logDroppedOpenAIResponsesAntigravityClaudeReasoning(modelName, contentIndex, partIndex, reasoningIndex-1, reasoningSig)
continue
}
if text, _ := part["text"].(string); strings.TrimSpace(text) == "" {
changed = true
logDroppedOpenAIResponsesAntigravityClaudeEmptyReasoning(modelName, contentIndex, partIndex, reasoningIndex-1, reasoningSig)
continue
}
if currentSignature, _ := part["thoughtSignature"].(string); currentSignature != reasoningSig.Signature {
changed = true
logNormalizedOpenAIResponsesAntigravityClaudeReasoning(modelName, contentIndex, partIndex, reasoningIndex-1, reasoningSig)
}
part["thoughtSignature"] = reasoningSig.Signature
rewrittenParts = append(rewrittenParts, part)
}
if len(rewrittenParts) == 0 {
changed = true
continue
}
content["parts"] = rewrittenParts
rewrittenContents = append(rewrittenContents, content)
}
if !changed {
return geminiJSON
}
root["contents"] = rewrittenContents
out, err := json.Marshal(root)
if err != nil {
log.WithError(err).Debug("antigravity responses translator: failed to marshal Claude signature rewrite")
return geminiJSON
}
return out
}
func antigravityClaudeReasoningSignatures(inputRawJSON []byte) []antigravityClaudeReasoningSignature {
input := gjson.GetBytes(inputRawJSON, "input")
if !input.IsArray() {
return nil
}
signatures := make([]antigravityClaudeReasoningSignature, 0)
input.ForEach(func(_, item gjson.Result) bool {
itemType := item.Get("type").String()
if itemType == "" && item.Get("role").Exists() {
itemType = "message"
}
if itemType != "reasoning" {
return true
}
rawSignatureResult := item.Get("encrypted_content")
rawSignature := rawSignatureResult.String()
signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSignature)
reasoningSignature := antigravityClaudeReasoningSignature{
HasRawSignature: rawSignatureResult.Exists(),
RawSignatureLen: len(rawSignature),
DetectedProvider: sigcompat.SignatureProviderUnknown,
}
if rawSignature != "" {
reasoningSignature.DetectedProvider = sigcompat.DetectSignatureProviderForBlock(rawSignature, sigcompat.SignatureBlockKindClaudeThinking)
}
if ok {
reasoningSignature.Signature = signature
}
signatures = append(signatures, reasoningSignature)
return true
})
return signatures
}
func logDroppedOpenAIResponsesAntigravityClaudeReasoning(modelName string, contentIndex, partIndex, reasoningIndex int, sig antigravityClaudeReasoningSignature) {
log.WithFields(log.Fields{
"component": "signature_sanitizer",
"translator": "antigravity_openai_responses",
"target_provider": string(sigcompat.SignatureProviderClaude),
"action": "drop_thinking_block",
"reason": "missing_or_incompatible_signature",
"model": modelName,
"content_index": contentIndex,
"part_index": partIndex,
"reasoning_index": reasoningIndex,
"has_signature": sig.HasRawSignature,
"signature_length": sig.RawSignatureLen,
"detected_provider": string(sig.DetectedProvider),
}).Debug("antigravity responses translator: dropped Claude reasoning block with incompatible encrypted_content")
}
func logDroppedOpenAIResponsesAntigravityClaudeEmptyReasoning(modelName string, contentIndex, partIndex, reasoningIndex int, sig antigravityClaudeReasoningSignature) {
log.WithFields(log.Fields{
"component": "signature_sanitizer",
"translator": "antigravity_openai_responses",
"target_provider": string(sigcompat.SignatureProviderClaude),
"action": "drop_thinking_block",
"reason": "empty_thinking_text",
"model": modelName,
"content_index": contentIndex,
"part_index": partIndex,
"reasoning_index": reasoningIndex,
"has_signature": sig.HasRawSignature,
"signature_length": sig.RawSignatureLen,
"detected_provider": string(sig.DetectedProvider),
}).Debug("antigravity responses translator: dropped Claude reasoning block with empty thinking text")
}
func logNormalizedOpenAIResponsesAntigravityClaudeReasoning(modelName string, contentIndex, partIndex, reasoningIndex int, sig antigravityClaudeReasoningSignature) {
log.WithFields(log.Fields{
"component": "signature_sanitizer",
"translator": "antigravity_openai_responses",
"target_provider": string(sigcompat.SignatureProviderClaude),
"action": "normalize_signature",
"reason": "compatible_claude_signature",
"model": modelName,
"content_index": contentIndex,
"part_index": partIndex,
"reasoning_index": reasoningIndex,
"has_signature": sig.HasRawSignature,
"signature_length": sig.RawSignatureLen,
"detected_provider": string(sig.DetectedProvider),
}).Debug("antigravity responses translator: normalized Claude reasoning encrypted_content before upstream")
}

View file

@ -0,0 +1,403 @@
package responses
import (
"encoding/base64"
"strings"
"testing"
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/tidwall/gjson"
"google.golang.org/protobuf/encoding/protowire"
)
func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningKeepsClaudeSignature(t *testing.T) {
nativeSig := testAntigravityResponsesClaudeSignature(t)
antigravitySig, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(nativeSig)
if !ok {
t.Fatal("test Claude signature should be compatible with Antigravity Claude")
}
tests := []struct {
name string
encrypted string
}{
{
name: "Claude native E signature",
encrypted: nativeSig,
},
{
name: "Antigravity double-layer R signature",
encrypted: antigravitySig,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
raw := []byte(`{
"model": "claude-opus-4-6-thinking",
"input": [
{
"id": "rs_prev",
"type": "reasoning",
"encrypted_content": "` + tt.encrypted + `",
"summary": [{"type": "summary_text", "text": "internal reasoning"}]
},
{
"role": "assistant",
"content": [{"type": "output_text", "text": "visible answer"}]
},
{
"role": "user",
"content": [{"type": "input_text", "text": "continue"}]
}
]
}`)
out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
part := gjson.GetBytes(out, "request.contents.0.parts.0")
if !part.Get("thought").Bool() {
t.Fatalf("first part should remain a thought block. Output: %s", out)
}
if got := part.Get("thoughtSignature").String(); got != antigravitySig {
t.Fatalf("thoughtSignature prefix/len = %q/%d, want %q/%d. Output: %s",
firstByte(got), len(got), firstByte(antigravitySig), len(antigravitySig), out)
}
if got := part.Get("text").String(); got != "internal reasoning" {
t.Fatalf("thought text = %q, want internal reasoning. Output: %s", got, out)
}
})
}
}
func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningDropsIncompatibleSignature(t *testing.T) {
raw := []byte(`{
"model": "claude-opus-4-6-thinking",
"input": [
{
"id": "rs_prev",
"type": "reasoning",
"encrypted_content": "` + testAntigravityResponsesGPTSignature() + `",
"summary": [{"type": "summary_text", "text": "must not reach Claude"}]
},
{
"role": "assistant",
"content": [{"type": "output_text", "text": "visible answer"}]
},
{
"role": "user",
"content": [{"type": "input_text", "text": "continue"}]
}
]
}`)
out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
if strings.Contains(string(out), sigcompat.GeminiSkipThoughtSignatureValidator) {
t.Fatalf("Claude target must not receive Gemini bypass signature. Output: %s", out)
}
if gjson.GetBytes(out, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
t.Fatalf("incompatible reasoning block should be dropped. Output: %s", out)
}
if strings.Contains(string(out), "must not reach Claude") {
t.Fatalf("incompatible reasoning text should be dropped. Output: %s", out)
}
if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "visible answer" {
t.Fatalf("visible assistant text = %q, want visible answer. Output: %s", got, out)
}
}
func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningDropsEmptyThinkingText(t *testing.T) {
rawSignature := testAntigravityResponsesClaudeSignature(t)
raw := []byte(`{
"model": "claude-opus-4-6-thinking",
"input": [
{
"id": "rs_prev",
"type": "reasoning",
"encrypted_content": "` + rawSignature + `",
"summary": []
},
{
"role": "assistant",
"content": [{"type": "output_text", "text": "visible answer"}]
},
{
"role": "user",
"content": [{"type": "input_text", "text": "continue"}]
}
]
}`)
out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
if gjson.GetBytes(out, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
t.Fatalf("empty-text reasoning block should be dropped for Antigravity Claude. Output: %s", out)
}
if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "visible answer" {
t.Fatalf("visible assistant text = %q, want visible answer. Output: %s", got, out)
}
}
func testAntigravityResponsesClaudeSignature(t *testing.T) string {
t.Helper()
return testAntigravityResponsesClaudeSignatureForModel(t, "claude-sonnet-4-6")
}
func testAntigravityResponsesClaudeSignatureForModel(t *testing.T, model string) string {
t.Helper()
channelBlock := []byte{}
channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType)
channelBlock = protowire.AppendVarint(channelBlock, 12)
channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType)
channelBlock = protowire.AppendVarint(channelBlock, 2)
channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType)
channelBlock = protowire.AppendString(channelBlock, model)
container := []byte{}
container = protowire.AppendTag(container, 1, protowire.BytesType)
container = protowire.AppendBytes(container, channelBlock)
payload := []byte{}
payload = protowire.AppendTag(payload, 2, protowire.BytesType)
payload = protowire.AppendBytes(payload, container)
payload = protowire.AppendTag(payload, 3, protowire.VarintType)
payload = protowire.AppendVarint(payload, 1)
return base64.StdEncoding.EncodeToString(payload)
}
func testAntigravityResponsesGPTSignature() string {
payload := make([]byte, 1+8+16+16+32)
payload[0] = 0x80
payload[8] = 1
for i := 9; i < len(payload); i++ {
payload[i] = byte(i)
}
return base64.URLEncoding.EncodeToString(payload)
}
func firstByte(s string) string {
if s == "" {
return ""
}
return s[:1]
}
func TestConvertOpenAIResponsesRequestToAntigravity_EmptyClaudeReasoningDoesNotShiftLaterSignature(t *testing.T) {
rawSig1 := testAntigravityResponsesClaudeSignatureForModel(t, "claude-sonnet-4-6")
rawSig2 := testAntigravityResponsesClaudeSignatureForModel(t, "claude-opus-4-6")
expectedSig2, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSig2)
if !ok {
t.Fatal("second Claude signature should be compatible")
}
raw := []byte(`{
"model":"claude-opus-4-6-thinking",
"input":[
{"type":"reasoning","encrypted_content":"` + rawSig1 + `","summary":[]},
{"role":"user","content":[{"type":"input_text","text":"boundary"}]},
{"type":"reasoning","encrypted_content":"` + rawSig2 + `","summary":[{"type":"summary_text","text":"second reasoning"}]},
{"role":"user","content":[{"type":"input_text","text":"continue"}]}
]
}`)
out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
var thoughts []gjson.Result
for _, content := range gjson.GetBytes(out, "request.contents").Array() {
for _, part := range content.Get("parts").Array() {
if part.Get("thought").Bool() {
thoughts = append(thoughts, part)
}
}
}
if len(thoughts) != 1 {
t.Fatalf("thought count = %d, want only the non-empty reasoning item. Output: %s", len(thoughts), out)
}
if got := thoughts[0].Get("text").String(); got != "second reasoning" {
t.Fatalf("thought text = %q, want second reasoning. Output: %s", got, out)
}
if got := thoughts[0].Get("thoughtSignature").String(); got != expectedSig2 {
t.Fatalf("later thought received the wrong signature prefix/len = %q/%d, want %q/%d. Output: %s", firstByte(got), len(got), firstByte(expectedSig2), len(expectedSig2), out)
}
}
func TestConvertOpenAIResponsesRequestToAntigravity_EmptyClaudeReasoningBeforeFunctionDoesNotShiftLaterSignature(t *testing.T) {
rawSig1 := testAntigravityResponsesClaudeSignatureForModel(t, "claude-sonnet-4-6")
rawSig2 := testAntigravityResponsesClaudeSignatureForModel(t, "claude-opus-4-6")
expectedSig2, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSig2)
if !ok {
t.Fatal("second Claude signature should be compatible")
}
raw := []byte(`{
"model":"claude-opus-4-6-thinking",
"input":[
{"type":"reasoning","encrypted_content":"` + rawSig1 + `","summary":[]},
{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}"},
{"type":"function_call_output","call_id":"call-1","output":"ok"},
{"type":"reasoning","encrypted_content":"` + rawSig2 + `","summary":[{"type":"summary_text","text":"second reasoning"}]},
{"role":"user","content":[{"type":"input_text","text":"continue"}]}
]
}`)
out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
var thoughts []gjson.Result
for _, content := range gjson.GetBytes(out, "request.contents").Array() {
for _, part := range content.Get("parts").Array() {
if part.Get("thought").Bool() {
thoughts = append(thoughts, part)
}
}
}
if len(thoughts) != 1 || thoughts[0].Get("text").String() != "second reasoning" {
t.Fatalf("later reasoning placement malformed. Output: %s", out)
}
if got := thoughts[0].Get("thoughtSignature").String(); got != expectedSig2 {
t.Fatalf("later thought received the wrong signature prefix/len = %q/%d, want %q/%d. Output: %s", firstByte(got), len(got), firstByte(expectedSig2), len(expectedSig2), out)
}
}
func TestConvertOpenAIResponsesRequestToAntigravity_GeminiReasoningUsesNativeThoughtSignaturePlacement(t *testing.T) {
sig := "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
raw := []byte(`{"model":"gemini-3.5-flash","input":[{"type":"reasoning","encrypted_content":"gemini#` + sig + `","summary":[{"type":"summary_text","text":"reasoning summary"}]}]}`)
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash-agent", raw, false)
parts := gjson.GetBytes(out, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("parts length = %d, want 1. Output: %s", len(parts), out)
}
if got := parts[0].Get("thought").Bool(); !got {
t.Fatalf("parts[0] should be thought. Output: %s", out)
}
if got := parts[0].Get("thoughtSignature").String(); got != sig {
t.Fatalf("parts[0].thoughtSignature = %q, want preserved Gemini signature. Output: %s", got, out)
}
}
func TestConvertOpenAIResponsesRequestToAntigravity_PreservesToolResultImage(t *testing.T) {
inputJSON := `{
"model": "gemini-3-flash",
"input": [
{"role": "user", "content": [{"type": "input_text", "text": "请帮我读取分析这张图片"}]},
{"type": "function_call", "id": "fc_read", "call_id": "call_read_1", "name": "read", "arguments": "{\"path\":\"/path/to/image.png\"}"},
{
"type": "function_call_output",
"call_id": "call_read_1",
"output": [
{"type": "input_text", "text": "Read image file [image/png]"},
{"type": "input_image", "detail": "auto", "image_url": "data:image/png;base64,QUJD"}
]
}
]
}`
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
contents := gjson.GetBytes(out, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 contents, got %d. Output: %s", len(contents), out)
}
funcContent := contents[2]
if got := funcContent.Get("role").String(); got != "user" {
t.Fatalf("role = %q, want user. Output: %s", got, out)
}
funcResp := funcContent.Get("parts.0.functionResponse")
if !funcResp.Exists() {
t.Fatalf("functionResponse should exist. Output: %s", out)
}
if got := funcResp.Get("id").String(); got != "call_read_1" {
t.Fatalf("id = %q, want call_read_1", got)
}
if got := funcResp.Get("name").String(); got != "read" {
t.Fatalf("name = %q, want read", got)
}
inlineData := funcResp.Get("parts.0.inlineData")
if !inlineData.Exists() {
t.Fatalf("expected functionResponse.parts.0.inlineData to exist, got: %s", out)
}
if got := inlineData.Get("mimeType").String(); got != "image/png" {
t.Errorf("expected mimeType image/png, got %q", got)
}
if got := inlineData.Get("data").String(); got != "QUJD" {
t.Errorf("expected data QUJD, got %q", got)
}
}
func TestConvertOpenAIResponsesRequestToAntigravity_AttachesParallelToolImagesToNearestResponse(t *testing.T) {
inputJSON := `{
"model": "gemini-3-flash",
"input": [
{"role": "user", "content": [{"type": "input_text", "text": "read both"}]},
{"type": "function_call", "id": "fc_a", "call_id": "call_a", "name": "read", "arguments": "{\"path\":\"/tmp/a.png\"}"},
{"type": "function_call", "id": "fc_b", "call_id": "call_b", "name": "read", "arguments": "{\"path\":\"/tmp/b.png\"}"},
{
"type": "function_call_output",
"call_id": "call_a",
"output": [
{"type": "input_text", "text": "file A"},
{"type": "input_image", "image_url": "data:image/png;base64,AAA"}
]
},
{
"type": "function_call_output",
"call_id": "call_b",
"output": [
{"type": "input_text", "text": "file B"},
{"type": "input_image", "image_url": "data:image/jpeg;base64,BBB"}
]
}
]
}`
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
parts := gjson.GetBytes(out, "request.contents.2.parts").Array()
if len(parts) != 2 {
t.Fatalf("function parts = %d, want 2. Output: %s", len(parts), out)
}
got := map[string]string{}
for _, part := range parts {
fr := part.Get("functionResponse")
got[fr.Get("id").String()] = fr.Get("parts.0.inlineData.data").String()
}
if got["call_a"] != "AAA" {
t.Fatalf("call_a image = %q, want AAA. Output: %s", got["call_a"], out)
}
if got["call_b"] != "BBB" {
t.Fatalf("call_b image = %q, want BBB. Output: %s", got["call_b"], out)
}
}
func TestConvertOpenAIResponsesRequestToAntigravity_PreservesAdditionalToolsAndToolConfig(t *testing.T) {
inputJSON := `{
"model": "gemini-3-flash",
"input": [
{
"type": "additional_tools",
"tools": [
{
"type": "namespace",
"name": "functions",
"tools": [
{"type": "custom", "name": "exec", "description": "Execute a command"},
{"type": "function", "name": "continuity_probe", "description": "Probe", "parameters": {"type": "object", "properties": {"value": {"type": "string"}}, "required": ["value"]}}
]
}
]
},
{"role": "user", "content": [{"type": "input_text", "text": "test"}]}
],
"tool_choice": {
"type": "function",
"name": "continuity_probe",
"namespace": "functions"
}
}`
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
if !gjson.ValidBytes(out) {
t.Fatalf("invalid JSON output: %s", out)
}
decls := gjson.GetBytes(out, "request.tools.0.functionDeclarations").Array()
if len(decls) != 2 {
t.Fatalf("expected 2 functionDeclarations in request.tools, got %d; raw: %s", len(decls), out)
}
mode := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String()
if mode != "ANY" {
t.Fatalf("mode = %q, want ANY", mode)
}
allowed := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String()
if allowed != "functions__continuity_probe" {
t.Fatalf("allowedFunctionNames.0 = %q, want functions__continuity_probe", allowed)
}
}

View file

@ -0,0 +1,35 @@
package responses
import (
"context"
. "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses"
"github.com/tidwall/gjson"
)
func ConvertAntigravityResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
responseResult := gjson.GetBytes(rawJSON, "response")
if responseResult.Exists() {
rawJSON = []byte(responseResult.Raw)
}
return ConvertGeminiResponseToOpenAIResponses(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)
}
func ConvertAntigravityResponseToOpenAIResponsesNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
responseResult := gjson.GetBytes(rawJSON, "response")
if responseResult.Exists() {
rawJSON = []byte(responseResult.Raw)
}
requestResult := gjson.GetBytes(originalRequestRawJSON, "request")
if requestResult.Exists() {
originalRequestRawJSON = []byte(requestResult.Raw)
}
requestResult = gjson.GetBytes(requestRawJSON, "request")
if requestResult.Exists() {
requestRawJSON = []byte(requestResult.Raw)
}
return ConvertGeminiResponseToOpenAIResponsesNonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)
}

View file

@ -0,0 +1,142 @@
package responses
import (
"context"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertAntigravityResponseToOpenAIResponsesNonStream_PreservesOpenAITools(t *testing.T) {
originalRequest := []byte(`{
"model": "gemini-3.5-flash-low",
"input": "Call get_weather for Tokyo.",
"tools": [{
"type": "function",
"name": "get_weather",
"description": "Get weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}
}],
"tool_choice": "required"
}`)
translatedRequest := []byte(`{
"request": {
"model": "gemini-3.5-flash-low",
"tools": [{
"functionDeclarations": [{
"name": "get_weather",
"description": "Get weather for a city",
"parameters": {
"type": "OBJECT",
"properties": {"city": {"type": "STRING"}},
"required": ["city"]
}
}]
}]
}
}`)
rawResponse := []byte(`{
"response": {
"responseId": "antigravity-tool-response",
"candidates": [{
"content": {
"parts": [{
"functionCall": {
"name": "get_weather",
"args": {"city": "Tokyo"}
}
}]
},
"finishReason": "STOP"
}]
}
}`)
output := ConvertAntigravityResponseToOpenAIResponsesNonStream(
context.Background(),
"gemini-3.5-flash-low",
originalRequest,
translatedRequest,
rawResponse,
nil,
)
if !gjson.ValidBytes(output) {
t.Fatalf("converter returned invalid JSON: %s", output)
}
if got := gjson.GetBytes(output, "tools.0.type").String(); got != "function" {
t.Fatalf("tools.0.type = %q, want function; output=%s", got, output)
}
if gjson.GetBytes(output, "tools.0.functionDeclarations").Exists() {
t.Fatalf("OpenAI response contains Gemini-native functionDeclarations: %s", output)
}
if got := gjson.GetBytes(output, "output.0.type").String(); got != "function_call" {
t.Fatalf("output.0.type = %q, want function_call; output=%s", got, output)
}
if got := gjson.GetBytes(output, "output.0.name").String(); got != "get_weather" {
t.Fatalf("output.0.name = %q, want get_weather; output=%s", got, output)
}
arguments := gjson.GetBytes(output, "output.0.arguments").String()
if !gjson.Valid(arguments) || gjson.Get(arguments, "city").String() != "Tokyo" {
t.Fatalf("output.0.arguments = %q, want JSON arguments with city Tokyo; output=%s", arguments, output)
}
}
func TestConvertAntigravityResponseToOpenAIResponses_RestoresAdditionalNamespaceCustomToolCall(t *testing.T) {
originalRequest := []byte(`{
"model": "gemini-3.5-flash-low",
"input": [{
"type": "additional_tools",
"tools": [{
"type": "namespace",
"name": "functions",
"tools": [{"type": "custom", "name": "exec"}]
}]
}]
}`)
rawResponse := []byte(`{
"response": {
"responseId": "antigravity-custom-response",
"candidates": [{
"content": {
"parts": [{
"functionCall": {
"name": "functions__exec",
"args": {"input": "pwd"}
}
}]
},
"finishReason": "STOP"
}]
}
}`)
output := ConvertAntigravityResponseToOpenAIResponsesNonStream(
context.Background(),
"gemini-3.5-flash-low",
originalRequest,
nil,
rawResponse,
nil,
)
if !gjson.ValidBytes(output) {
t.Fatalf("invalid JSON output: %s", output)
}
if got := gjson.GetBytes(output, "output.0.type").String(); got != "custom_tool_call" {
t.Fatalf("output.0.type = %q, want custom_tool_call; output=%s", got, output)
}
if got := gjson.GetBytes(output, "output.0.name").String(); got != "exec" {
t.Fatalf("output.0.name = %q, want exec", got)
}
if got := gjson.GetBytes(output, "output.0.namespace").String(); got != "functions" {
t.Fatalf("output.0.namespace = %q, want functions", got)
}
if got := gjson.GetBytes(output, "output.0.input").String(); got != "pwd" {
t.Fatalf("output.0.input = %q, want pwd", got)
}
}

View file

@ -0,0 +1,19 @@
package responses
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(
OpenaiResponse,
Antigravity,
ConvertOpenAIResponsesRequestToAntigravity,
interfaces.TranslateResponse{
Stream: ConvertAntigravityResponseToOpenAIResponses,
NonStream: ConvertAntigravityResponseToOpenAIResponsesNonStream,
},
)
}