Compare commits

...

6 Commits

Author SHA1 Message Date
Luis Pater
024bc25b2c Merge branch 'router-for-me:main' into main 2025-12-17 13:18:47 +08:00
Luis Pater
ffdfad8482 Fixed: #551
fix(translator): standardize content node handling across translators for assistant and tool calls
2025-12-17 13:16:07 +08:00
Luis Pater
b91ee8d008 Merge branch 'router-for-me:main' into main 2025-12-17 04:01:19 +08:00
Luis Pater
6586f08584 fix(translator): correct funcName extraction and ensure proper handling of function response data in Antigravity Claude requests 2025-12-17 03:57:35 +08:00
Luis Pater
f49e887fe6 Merge pull request #570 from fuguiKz/fix/antigravity-thinking-signature
Fix invalid thinking signature when proxying Claude via Antigravity
2025-12-17 03:04:41 +08:00
kz
b602eae215 Fix antigravity Claude thinking signature handling 2025-12-17 02:28:58 +08:00
6 changed files with 316 additions and 210 deletions

View File

@@ -84,13 +84,18 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
contentResult := contentResults[j] contentResult := contentResults[j]
contentTypeResult := contentResult.Get("type") contentTypeResult := contentResult.Get("type")
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" { if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {
prompt := contentResult.Get("thinking").String() // Claude "thinking" blocks are internal-only. They also require a valid provider signature
// when replayed as conversation history. Since we cannot mint signatures, only forward
// thinking blocks when the client provides a non-empty signature; otherwise, drop them.
signatureResult := contentResult.Get("signature") signatureResult := contentResult.Get("signature")
signature := geminiCLIClaudeThoughtSignature if signatureResult.Type == gjson.String && signatureResult.String() != "" {
if signatureResult.Exists() { prompt := contentResult.Get("thinking").String()
signature = signatureResult.String() clientContent.Parts = append(clientContent.Parts, client.Part{
Text: prompt,
Thought: true,
ThoughtSignature: signatureResult.String(),
})
} }
clientContent.Parts = append(clientContent.Parts, client.Part{Text: prompt, Thought: true, ThoughtSignature: signature})
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" { } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
prompt := contentResult.Get("text").String() prompt := contentResult.Get("text").String()
clientContent.Parts = append(clientContent.Parts, client.Part{Text: prompt}) clientContent.Parts = append(clientContent.Parts, client.Part{Text: prompt})
@@ -117,9 +122,17 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
funcName := toolCallID funcName := toolCallID
toolCallIDs := strings.Split(toolCallID, "-") toolCallIDs := strings.Split(toolCallID, "-")
if len(toolCallIDs) > 1 { if len(toolCallIDs) > 1 {
funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-2], "-")
} }
responseData := contentResult.Get("content").Raw functionResponseResult := contentResult.Get("content")
responseData := ""
if functionResponseResult.Type == gjson.String {
responseData = functionResponseResult.String()
} else {
responseData = contentResult.Get("content").Raw
}
functionResponse := client.FunctionResponse{ID: toolCallID, Name: funcName, Response: map[string]interface{}{"result": responseData}} functionResponse := client.FunctionResponse{ID: toolCallID, Name: funcName, Response: map[string]interface{}{"result": responseData}}
clientContent.Parts = append(clientContent.Parts, client.Part{FunctionResponse: &functionResponse}) clientContent.Parts = append(clientContent.Parts, client.Part{FunctionResponse: &functionResponse})
} }
@@ -134,7 +147,9 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
} }
} }
} }
contents = append(contents, clientContent) if len(clientContent.Parts) > 0 {
contents = append(contents, clientContent)
}
} else if contentsResult.Type == gjson.String { } else if contentsResult.Type == gjson.String {
prompt := contentsResult.String() prompt := contentsResult.String()
contents = append(contents, client.Content{Role: role, Parts: []client.Part{{Text: prompt}}}) contents = append(contents, client.Content{Role: role, Parts: []client.Part{{Text: prompt}}})

View File

@@ -114,44 +114,54 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq
// Extract the different types of content from each part // Extract the different types of content from each part
partTextResult := partResult.Get("text") partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall") functionCallResult := partResult.Get("functionCall")
thoughtSignatureResult := partResult.Get("thoughtSignature")
if !thoughtSignatureResult.Exists() {
thoughtSignatureResult = partResult.Get("thought_signature")
}
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""
isThought := partResult.Get("thought").Bool()
// Some Antigravity/Vertex Claude streams emit the thought signature as a standalone part
// (no text payload). Claude requires this signature to be replayed verbatim on subsequent turns.
if isThought && hasThoughtSignature && !partTextResult.Exists() && !functionCallResult.Exists() {
if params.ResponseType == 2 {
output = output + "event: content_block_delta\n"
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex), "delta.signature", thoughtSignatureResult.String())
output = output + fmt.Sprintf("data: %s\n\n\n", data)
params.HasContent = true
}
continue
}
// Handle text content (both regular content and thinking) // Handle text content (both regular content and thinking)
if partTextResult.Exists() { if partTextResult.Exists() {
// Process thinking content (internal reasoning) // Process thinking content (internal reasoning)
if partResult.Get("thought").Bool() { if isThought {
if thoughtSignature := partResult.Get("thoughtSignature"); thoughtSignature.Exists() && thoughtSignature.String() != "" { // Ensure we have an open thinking block to attach thinking/signature deltas to.
output = output + "event: content_block_delta\n" if params.ResponseType != 2 {
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex), "delta.signature", thoughtSignature.String())
output = output + fmt.Sprintf("data: %s\n\n\n", data)
params.HasContent = true
} else if params.ResponseType == 2 { // Continue existing thinking block if already in thinking state
output = output + "event: content_block_delta\n"
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex), "delta.thinking", partTextResult.String())
output = output + fmt.Sprintf("data: %s\n\n\n", data)
params.HasContent = true
} else {
// Transition from another state to thinking
// First, close any existing content block
if params.ResponseType != 0 { if params.ResponseType != 0 {
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"
}
output = output + "event: content_block_stop\n" output = output + "event: content_block_stop\n"
output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, params.ResponseIndex) output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, params.ResponseIndex)
output = output + "\n\n\n" output = output + "\n\n\n"
params.ResponseIndex++ params.ResponseIndex++
} }
// Start a new thinking content block
output = output + "event: content_block_start\n" output = output + "event: content_block_start\n"
output = output + fmt.Sprintf(`data: {"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, params.ResponseIndex) output = output + fmt.Sprintf(`data: {"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, params.ResponseIndex)
output = output + "\n\n\n" output = output + "\n\n\n"
params.ResponseType = 2
}
if partTextResult.String() != "" {
output = output + "event: content_block_delta\n" output = output + "event: content_block_delta\n"
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex), "delta.thinking", partTextResult.String()) data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex), "delta.thinking", partTextResult.String())
output = output + fmt.Sprintf("data: %s\n\n\n", data) output = output + fmt.Sprintf("data: %s\n\n\n", data)
params.ResponseType = 2 // Set state to thinking params.HasContent = true
}
if hasThoughtSignature {
output = output + "event: content_block_delta\n"
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex), "delta.signature", thoughtSignatureResult.String())
output = output + fmt.Sprintf("data: %s\n\n\n", data)
params.HasContent = true params.HasContent = true
} }
} else { } else {
@@ -368,6 +378,7 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or
var contentBlocks []interface{} var contentBlocks []interface{}
textBuilder := strings.Builder{} textBuilder := strings.Builder{}
thinkingBuilder := strings.Builder{} thinkingBuilder := strings.Builder{}
thinkingSignature := ""
toolIDCounter := 0 toolIDCounter := 0
hasToolCall := false hasToolCall := false
@@ -386,19 +397,37 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or
if thinkingBuilder.Len() == 0 { if thinkingBuilder.Len() == 0 {
return return
} }
contentBlocks = append(contentBlocks, map[string]interface{}{ block := map[string]interface{}{
"type": "thinking", "type": "thinking",
"thinking": thinkingBuilder.String(), "thinking": thinkingBuilder.String(),
}) }
if thinkingSignature != "" {
block["signature"] = thinkingSignature
}
contentBlocks = append(contentBlocks, block)
thinkingBuilder.Reset() thinkingBuilder.Reset()
thinkingSignature = ""
} }
if parts.IsArray() { if parts.IsArray() {
for _, part := range parts.Array() { for _, part := range parts.Array() {
thoughtSignatureResult := part.Get("thoughtSignature")
if !thoughtSignatureResult.Exists() {
thoughtSignatureResult = part.Get("thought_signature")
}
if part.Get("thought").Bool() && thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" && (!part.Get("text").Exists() || part.Get("text").String() == "") {
// Signature-only thought part (no text payload).
thinkingSignature = thoughtSignatureResult.String()
continue
}
if text := part.Get("text"); text.Exists() && text.String() != "" { if text := part.Get("text"); text.Exists() && text.String() != "" {
if part.Get("thought").Bool() { if part.Get("thought").Bool() {
flushText() flushText()
thinkingBuilder.WriteString(text.String()) thinkingBuilder.WriteString(text.String())
if thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" {
thinkingSignature = thoughtSignatureResult.String()
}
continue continue
} }
flushThinking() flushThinking()

View File

@@ -222,62 +222,61 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
} }
out, _ = sjson.SetRawBytes(out, "request.contents.-1", node) out, _ = sjson.SetRawBytes(out, "request.contents.-1", node)
} else if role == "assistant" { } else if role == "assistant" {
node := []byte(`{"role":"model","parts":[]}`)
p := 0
if content.Type == gjson.String { if content.Type == gjson.String {
// Assistant text -> single model content node, _ = sjson.SetBytes(node, "parts.-1.text", content.String())
node := []byte(`{"role":"model","parts":[{"text":""}]}`)
node, _ = sjson.SetBytes(node, "parts.0.text", content.String())
out, _ = sjson.SetRawBytes(out, "request.contents.-1", node) out, _ = sjson.SetRawBytes(out, "request.contents.-1", node)
} else if !content.Exists() || content.Type == gjson.Null { p++
// Tool calls -> single model content with functionCall parts }
tcs := m.Get("tool_calls")
if tcs.IsArray() {
node := []byte(`{"role":"model","parts":[]}`)
p := 0
fIDs := make([]string, 0)
for _, tc := range tcs.Array() {
if tc.Get("type").String() != "function" {
continue
}
fid := tc.Get("id").String()
fname := tc.Get("function.name").String()
fargs := tc.Get("function.arguments").String()
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.id", fid)
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname)
node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs))
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature)
p++
if fid != "" {
fIDs = append(fIDs, fid)
}
}
out, _ = sjson.SetRawBytes(out, "request.contents.-1", node)
// Append a single tool content combining name + response per function // Tool calls -> single model content with functionCall parts
toolNode := []byte(`{"role":"user","parts":[]}`) tcs := m.Get("tool_calls")
pp := 0 if tcs.IsArray() {
for _, fid := range fIDs { fIDs := make([]string, 0)
if name, ok := tcID2Name[fid]; ok { for _, tc := range tcs.Array() {
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.id", fid) if tc.Get("type").String() != "function" {
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) continue
resp := toolResponses[fid] }
if resp == "" { fid := tc.Get("id").String()
resp = "{}" fname := tc.Get("function.name").String()
} fargs := tc.Get("function.arguments").String()
// Handle non-JSON output gracefully (matches dev branch approach) node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.id", fid)
if resp != "null" { node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname)
parsed := gjson.Parse(resp) node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs))
if parsed.Type == gjson.JSON { node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature)
toolNode, _ = sjson.SetRawBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", []byte(parsed.Raw)) p++
} else { if fid != "" {
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", resp) fIDs = append(fIDs, fid)
} }
} }
pp++ out, _ = sjson.SetRawBytes(out, "request.contents.-1", node)
// Append a single tool content combining name + response per function
toolNode := []byte(`{"role":"user","parts":[]}`)
pp := 0
for _, fid := range fIDs {
if name, ok := tcID2Name[fid]; ok {
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.id", fid)
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name)
resp := toolResponses[fid]
if resp == "" {
resp = "{}"
} }
// Handle non-JSON output gracefully (matches dev branch approach)
if resp != "null" {
parsed := gjson.Parse(resp)
if parsed.Type == gjson.JSON {
toolNode, _ = sjson.SetRawBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", []byte(parsed.Raw))
} else {
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", resp)
}
}
pp++
} }
if pp > 0 { }
out, _ = sjson.SetRawBytes(out, "request.contents.-1", toolNode) if pp > 0 {
} out, _ = sjson.SetRawBytes(out, "request.contents.-1", toolNode)
} }
} }
} }
@@ -361,18 +360,3 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
// itoa converts int to string without strconv import for few usages. // itoa converts int to string without strconv import for few usages.
func itoa(i int) string { return fmt.Sprintf("%d", i) } func itoa(i int) string { return fmt.Sprintf("%d", i) }
// quoteIfNeeded ensures a string is valid JSON value (quotes plain text), pass-through for JSON objects/arrays.
func quoteIfNeeded(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "\"\""
}
if len(s) > 0 && (s[0] == '{' || s[0] == '[') {
return s
}
// escape quotes minimally
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "\"", "\\\"")
return "\"" + s + "\""
}

View File

@@ -205,52 +205,52 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
} }
out, _ = sjson.SetRawBytes(out, "request.contents.-1", node) out, _ = sjson.SetRawBytes(out, "request.contents.-1", node)
} else if role == "assistant" { } else if role == "assistant" {
p := 0
node := []byte(`{"role":"model","parts":[]}`)
if content.Type == gjson.String { if content.Type == gjson.String {
// Assistant text -> single model content // Assistant text -> single model content
node := []byte(`{"role":"model","parts":[{"text":""}]}`) node, _ = sjson.SetBytes(node, "parts.-1.text", content.String())
node, _ = sjson.SetBytes(node, "parts.0.text", content.String())
out, _ = sjson.SetRawBytes(out, "request.contents.-1", node) out, _ = sjson.SetRawBytes(out, "request.contents.-1", node)
} else if !content.Exists() || content.Type == gjson.Null { p++
// Tool calls -> single model content with functionCall parts }
tcs := m.Get("tool_calls")
if tcs.IsArray() {
node := []byte(`{"role":"model","parts":[]}`)
p := 0
fIDs := make([]string, 0)
for _, tc := range tcs.Array() {
if tc.Get("type").String() != "function" {
continue
}
fid := tc.Get("id").String()
fname := tc.Get("function.name").String()
fargs := tc.Get("function.arguments").String()
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname)
node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs))
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature)
p++
if fid != "" {
fIDs = append(fIDs, fid)
}
}
out, _ = sjson.SetRawBytes(out, "request.contents.-1", node)
// Append a single tool content combining name + response per function // Tool calls -> single model content with functionCall parts
toolNode := []byte(`{"role":"tool","parts":[]}`) tcs := m.Get("tool_calls")
pp := 0 if tcs.IsArray() {
for _, fid := range fIDs { fIDs := make([]string, 0)
if name, ok := tcID2Name[fid]; ok { for _, tc := range tcs.Array() {
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) if tc.Get("type").String() != "function" {
resp := toolResponses[fid] continue
if resp == "" { }
resp = "{}" fid := tc.Get("id").String()
} fname := tc.Get("function.name").String()
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", []byte(resp)) fargs := tc.Get("function.arguments").String()
pp++ node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname)
node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs))
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature)
p++
if fid != "" {
fIDs = append(fIDs, fid)
}
}
out, _ = sjson.SetRawBytes(out, "request.contents.-1", node)
// Append a single tool content combining name + response per function
toolNode := []byte(`{"role":"tool","parts":[]}`)
pp := 0
for _, fid := range fIDs {
if name, ok := tcID2Name[fid]; ok {
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name)
resp := toolResponses[fid]
if resp == "" {
resp = "{}"
} }
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", []byte(resp))
pp++
} }
if pp > 0 { }
out, _ = sjson.SetRawBytes(out, "request.contents.-1", toolNode) if pp > 0 {
} out, _ = sjson.SetRawBytes(out, "request.contents.-1", toolNode)
} }
} }
} }
@@ -334,18 +334,3 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
// itoa converts int to string without strconv import for few usages. // itoa converts int to string without strconv import for few usages.
func itoa(i int) string { return fmt.Sprintf("%d", i) } func itoa(i int) string { return fmt.Sprintf("%d", i) }
// quoteIfNeeded ensures a string is valid JSON value (quotes plain text), pass-through for JSON objects/arrays.
func quoteIfNeeded(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "\"\""
}
if len(s) > 0 && (s[0] == '{' || s[0] == '[') {
return s
}
// escape quotes minimally
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "\"", "\\\"")
return "\"" + s + "\""
}

View File

@@ -207,15 +207,16 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
} }
out, _ = sjson.SetRawBytes(out, "contents.-1", node) out, _ = sjson.SetRawBytes(out, "contents.-1", node)
} else if role == "assistant" { } else if role == "assistant" {
node := []byte(`{"role":"model","parts":[]}`)
p := 0
if content.Type == gjson.String { if content.Type == gjson.String {
// Assistant text -> single model content // Assistant text -> single model content
node := []byte(`{"role":"model","parts":[{"text":""}]}`) node, _ = sjson.SetBytes(node, "parts.-1.text", content.String())
node, _ = sjson.SetBytes(node, "parts.0.text", content.String())
out, _ = sjson.SetRawBytes(out, "contents.-1", node) out, _ = sjson.SetRawBytes(out, "contents.-1", node)
p++
} else if content.IsArray() { } else if content.IsArray() {
// Assistant multimodal content (e.g. text + image) -> single model content with parts // Assistant multimodal content (e.g. text + image) -> single model content with parts
node := []byte(`{"role":"model","parts":[]}`)
p := 0
for _, item := range content.Array() { for _, item := range content.Array() {
switch item.Get("type").String() { switch item.Get("type").String() {
case "text": case "text":
@@ -237,47 +238,45 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
} }
} }
out, _ = sjson.SetRawBytes(out, "contents.-1", node) out, _ = sjson.SetRawBytes(out, "contents.-1", node)
} else if !content.Exists() || content.Type == gjson.Null { }
// Tool calls -> single model content with functionCall parts
tcs := m.Get("tool_calls")
if tcs.IsArray() {
node := []byte(`{"role":"model","parts":[]}`)
p := 0
fIDs := make([]string, 0)
for _, tc := range tcs.Array() {
if tc.Get("type").String() != "function" {
continue
}
fid := tc.Get("id").String()
fname := tc.Get("function.name").String()
fargs := tc.Get("function.arguments").String()
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname)
node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs))
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiFunctionThoughtSignature)
p++
if fid != "" {
fIDs = append(fIDs, fid)
}
}
out, _ = sjson.SetRawBytes(out, "contents.-1", node)
// Append a single tool content combining name + response per function // Tool calls -> single model content with functionCall parts
toolNode := []byte(`{"role":"tool","parts":[]}`) tcs := m.Get("tool_calls")
pp := 0 if tcs.IsArray() {
for _, fid := range fIDs { fIDs := make([]string, 0)
if name, ok := tcID2Name[fid]; ok { for _, tc := range tcs.Array() {
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) if tc.Get("type").String() != "function" {
resp := toolResponses[fid] continue
if resp == "" { }
resp = "{}" fid := tc.Get("id").String()
} fname := tc.Get("function.name").String()
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", []byte(resp)) fargs := tc.Get("function.arguments").String()
pp++ node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname)
node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs))
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiFunctionThoughtSignature)
p++
if fid != "" {
fIDs = append(fIDs, fid)
}
}
out, _ = sjson.SetRawBytes(out, "contents.-1", node)
// Append a single tool content combining name + response per function
toolNode := []byte(`{"role":"tool","parts":[]}`)
pp := 0
for _, fid := range fIDs {
if name, ok := tcID2Name[fid]; ok {
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name)
resp := toolResponses[fid]
if resp == "" {
resp = "{}"
} }
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", []byte(resp))
pp++
} }
if pp > 0 { }
out, _ = sjson.SetRawBytes(out, "contents.-1", toolNode) if pp > 0 {
} out, _ = sjson.SetRawBytes(out, "contents.-1", toolNode)
} }
} }
} }
@@ -363,18 +362,3 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
// itoa converts int to string without strconv import for few usages. // itoa converts int to string without strconv import for few usages.
func itoa(i int) string { return fmt.Sprintf("%d", i) } func itoa(i int) string { return fmt.Sprintf("%d", i) }
// quoteIfNeeded ensures a string is valid JSON value (quotes plain text), pass-through for JSON objects/arrays.
func quoteIfNeeded(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "\"\""
}
if len(s) > 0 && (s[0] == '{' || s[0] == '[') {
return s
}
// escape quotes minimally
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "\"", "\\\"")
return "\"" + s + "\""
}

View File

@@ -0,0 +1,109 @@
package test
import (
"context"
"strings"
"testing"
agclaude "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/claude"
"github.com/tidwall/gjson"
)
func TestAntigravityClaudeRequest_DropsUnsignedThinkingBlocks(t *testing.T) {
model := "gemini-claude-sonnet-4-5-thinking"
input := []byte(`{
"model":"` + model + `",
"messages":[
{"role":"assistant","content":[{"type":"thinking","thinking":"secret without signature"}]},
{"role":"user","content":[{"type":"text","text":"hi"}]}
]
}`)
out := agclaude.ConvertClaudeRequestToAntigravity(model, input, false)
contents := gjson.GetBytes(out, "request.contents")
if !contents.Exists() || !contents.IsArray() {
t.Fatalf("expected request.contents array, got: %s", string(out))
}
if got := len(contents.Array()); got != 1 {
t.Fatalf("expected 1 content message after dropping unsigned thinking-only assistant message, got %d: %s", got, contents.Raw)
}
if role := contents.Array()[0].Get("role").String(); role != "user" {
t.Fatalf("expected remaining message role=user, got %q", role)
}
}
func TestAntigravityClaudeStreamResponse_EmitsSignatureDeltaForStandaloneSignaturePart(t *testing.T) {
raw := []byte(`{
"response":{
"responseId":"resp_1",
"modelVersion":"claude-sonnet-4-5-thinking",
"candidates":[{
"content":{"parts":[
{"text":"THOUGHT","thought":true},
{"thought":true,"thoughtSignature":"sig123"},
{"text":"ANSWER","thought":false}
]},
"finishReason":"STOP"
}],
"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"thoughtsTokenCount":1,"totalTokenCount":3}
}
}`)
var param any
chunks := agclaude.ConvertAntigravityResponseToClaude(context.Background(), "", nil, nil, raw, &param)
joined := strings.Join(chunks, "")
if !strings.Contains(joined, `"type":"signature_delta"`) {
t.Fatalf("expected signature_delta in stream output, got: %s", joined)
}
if !strings.Contains(joined, `"signature":"sig123"`) {
t.Fatalf("expected signature sig123 in stream output, got: %s", joined)
}
// Signature delta must be attached to the thinking content block (index 0 in this minimal stream).
if !strings.Contains(joined, `{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig123"}}`) {
t.Fatalf("expected signature_delta to target thinking block index 0, got: %s", joined)
}
}
func TestAntigravityClaudeNonStreamResponse_IncludesThinkingSignature(t *testing.T) {
raw := []byte(`{
"response":{
"responseId":"resp_1",
"modelVersion":"claude-sonnet-4-5-thinking",
"candidates":[{
"content":{"parts":[
{"text":"THOUGHT","thought":true},
{"thought":true,"thoughtSignature":"sig123"},
{"text":"ANSWER","thought":false}
]},
"finishReason":"STOP"
}],
"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"thoughtsTokenCount":1,"totalTokenCount":3}
}
}`)
out := agclaude.ConvertAntigravityResponseToClaudeNonStream(context.Background(), "", nil, nil, raw, nil)
if !gjson.Valid(out) {
t.Fatalf("expected valid JSON output, got: %s", out)
}
content := gjson.Get(out, "content")
if !content.Exists() || !content.IsArray() {
t.Fatalf("expected content array in output, got: %s", out)
}
found := false
for _, block := range content.Array() {
if block.Get("type").String() != "thinking" {
continue
}
found = true
if got := block.Get("signature").String(); got != "sig123" {
t.Fatalf("expected thinking.signature=sig123, got %q (block=%s)", got, block.Raw)
}
if got := block.Get("thinking").String(); got != "THOUGHT" {
t.Fatalf("expected thinking.thinking=THOUGHT, got %q (block=%s)", got, block.Raw)
}
}
if !found {
t.Fatalf("expected a thinking block in output, got: %s", out)
}
}