Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
|
|
@ -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
|
|
@ -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, ¶ms.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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
20
backend/internal/translator/antigravity/claude/init.go
Normal file
20
backend/internal/translator/antigravity/claude/init.go
Normal 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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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()}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
502
backend/internal/translator/antigravity/claude/web_search.go
Normal file
502
backend/internal/translator/antigravity/claude/web_search.go
Normal 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())
|
||||
}
|
||||
Loading…
Reference in a new issue