Add projects

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,20 @@
package claude
import (
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
translator.Register(
Claude,
Antigravity,
ConvertClaudeRequestToAntigravity,
interfaces.TranslateResponse{
Stream: ConvertAntigravityResponseToClaude,
NonStream: ConvertAntigravityResponseToClaudeNonStream,
TokenCount: ClaudeTokenCount,
},
)
}

View file

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

View file

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

View file

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