From 62a2c0d374f29b8827b1b3f148b22d5c035578f2 Mon Sep 17 00:00:00 2001 From: sususu98 <33882693+sususu98@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:34:52 +0800 Subject: [PATCH] fix(antigravity): sanitize unsigned thinking signatures in OpenAI Chat Completions for Claude models (#4562) Fixes #4514 by sanitizing unsigned thinking signatures for Claude target models in OpenAI Chat Completions requests to Antigravity. - Export SanitizeAntigravityClaudeGeminiRequestSignatures in gemini request translator and call it for Claude target models in OpenAI Chat Completions requests. - Use json.Decoder with UseNumber() in SanitizeAntigravityClaudeGeminiRequestSignatures to preserve exact JSON number precision (preventing float64 truncation of large integers and high-precision numbers during unmarshal/marshal). - Add unit tests verifying unsigned reasoning_content sanitization, empty turn dropping, exact number precision preservation, and functionCall signature stripping for Claude target models. --- .../gemini/antigravity_gemini_request.go | 9 ++- .../gemini/antigravity_gemini_request_test.go | 74 +++++++++++++++++++ .../antigravity_openai_request.go | 4 + .../antigravity_openai_request_test.go | 50 +++++++++++++ 4 files changed, 134 insertions(+), 3 deletions(-) diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request.go b/internal/translator/antigravity/gemini/antigravity_gemini_request.go index 1824fcb2..925cfa6d 100644 --- a/internal/translator/antigravity/gemini/antigravity_gemini_request.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request.go @@ -6,6 +6,7 @@ package gemini import ( + "bytes" "encoding/json" "fmt" "strings" @@ -149,7 +150,7 @@ func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ rawJSON = rewriteGeminiFunctionNames(rawJSON, functionNameMap) if strings.Contains(strings.ToLower(modelName), "claude") { - rawJSON = sanitizeAntigravityClaudeGeminiRequestSignatures(modelName, rawJSON) + rawJSON = SanitizeAntigravityClaudeGeminiRequestSignatures(modelName, rawJSON) } else { rawJSON = signature.SanitizeGeminiRequestThoughtSignatures(rawJSON, "request.contents") } @@ -292,9 +293,11 @@ func rewriteGeminiFunctionNames(rawJSON []byte, functionNameMap map[string]strin return rawJSON } -func sanitizeAntigravityClaudeGeminiRequestSignatures(modelName string, rawJSON []byte) []byte { +func SanitizeAntigravityClaudeGeminiRequestSignatures(modelName string, rawJSON []byte) []byte { var root map[string]any - if err := json.Unmarshal(rawJSON, &root); err != nil { + decoder := json.NewDecoder(bytes.NewReader(rawJSON)) + decoder.UseNumber() + if err := decoder.Decode(&root); err != nil { log.WithError(err).Debug("antigravity gemini translator: failed to parse request for Claude signature sanitize") return rawJSON } diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go index a177ad9e..a10e2c9a 100644 --- a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go @@ -705,3 +705,77 @@ func TestConvertGeminiRequestToAntigravityMapsSnakeCaseFunctionReferences(t *tes } } } + +func TestSanitizeAntigravityClaudeGeminiRequestSignatures_PreservesNumberPrecision(t *testing.T) { + inputJSON := []byte(`{ + "project": "", + "model": "claude-sonnet-4-6", + "request": { + "contents": [ + { + "role": "model", + "parts": [ + { + "text": "thinking", + "thought": true, + "thoughtSignature": "invalid" + }, + { + "functionCall": { + "name": "calc", + "args": { + "n": 12345678901234567890, + "big": 9007199254740993 + } + } + } + ] + } + ] + } + }`) + + output := SanitizeAntigravityClaudeGeminiRequestSignatures("claude-sonnet-4-6", inputJSON) + outputStr := string(output) + + bigVal := gjson.Get(outputStr, "request.contents.0.parts.0.functionCall.args.big").Raw + nVal := gjson.Get(outputStr, "request.contents.0.parts.0.functionCall.args.n").Raw + + if bigVal != "9007199254740993" { + t.Errorf("Precision lost for big: got %s, want 9007199254740993", bigVal) + } + if nVal != "12345678901234567890" { + t.Errorf("Precision lost for n: got %s, want 12345678901234567890", nVal) + } +} + +func TestSanitizeAntigravityClaudeGeminiRequestSignatures_StripsFunctionCallSignatureForClaudeModel(t *testing.T) { + inputJSON := []byte(`{ + "project": "", + "model": "claude-sonnet-4-6", + "request": { + "contents": [ + { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "calc", + "args": {} + }, + "thoughtSignature": "skip_thought_signature_validator" + } + ] + } + ] + } + }`) + + output := SanitizeAntigravityClaudeGeminiRequestSignatures("claude-sonnet-4-6", inputJSON) + outputStr := string(output) + + sig := gjson.Get(outputStr, "request.contents.0.parts.0.thoughtSignature") + if sig.Exists() { + t.Fatalf("expected functionCall thoughtSignature to be stripped for Claude target model, got %s", sig.Raw) + } +} diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go index 98cc95c7..af0afa5d 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -5,6 +5,7 @@ package chat_completions import ( "strings" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini" translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" @@ -409,6 +410,9 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ } out = applyOpenAIToolChoiceToAntigravity(out, rawJSON, functionNameMap) + if strings.Contains(strings.ToLower(modelName), "claude") { + out = gemini.SanitizeAntigravityClaudeGeminiRequestSignatures(modelName, out) + } return common.AttachDefaultSafetySettings(out, "request.safetySettings") } diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go index d85e580d..0bf1a0fd 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go @@ -55,6 +55,56 @@ func TestConvertOpenAIRequestToAntigravitySkipsEmptyTextPartsWithoutNulls(t *tes } } +func TestConvertOpenAIRequestToAntigravity_ClaudeModelSanitizesUnsignedReasoningContent(t *testing.T) { + inputJSON := `{ + "model": "claude-sonnet-4-6", + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "visible text", "reasoning_content": "unsigned reasoning"}, + {"role": "user", "content": "say ok"} + ] + }` + + result := ConvertOpenAIRequestToAntigravity("claude-sonnet-4-6", []byte(inputJSON), false) + contents := gjson.GetBytes(result, "request.contents").Array() + if len(contents) != 3 { + t.Fatalf("contents length = %d, want 3. Output: %s", len(contents), result) + } + parts := contents[1].Get("parts").Array() + if len(parts) != 1 { + t.Fatalf("model parts length = %d, want 1 (thinking part dropped). Output: %s", len(parts), result) + } + if got := parts[0].Get("text").String(); got != "visible text" { + t.Fatalf("parts[0].text = %q, want visible text. Output: %s", got, result) + } + if parts[0].Get("thought").Exists() { + t.Fatalf("parts[0] should not be thought part. Output: %s", result) + } +} + +func TestConvertOpenAIRequestToAntigravity_ClaudeModelDropsEmptyAssistantTurnAfterSanitizingReasoningContent(t *testing.T) { + inputJSON := `{ + "model": "claude-sonnet-4-6", + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "", "reasoning_content": "unsigned reasoning"}, + {"role": "user", "content": "say ok"} + ] + }` + + result := ConvertOpenAIRequestToAntigravity("claude-sonnet-4-6", []byte(inputJSON), false) + contents := gjson.GetBytes(result, "request.contents").Array() + if len(contents) != 2 { + t.Fatalf("contents length = %d, want 2 (empty model turn dropped). Output: %s", len(contents), result) + } + if got := contents[0].Get("role").String(); got != "user" { + t.Fatalf("contents[0].role = %q, want user. Output: %s", got, result) + } + if got := contents[1].Get("role").String(); got != "user" { + t.Fatalf("contents[1].role = %q, want user. Output: %s", got, result) + } +} + func TestConvertOpenAIRequestToAntigravityPreservesReasoningContent(t *testing.T) { inputJSON := `{ "model": "gemini-3-flash", -- 2.54.0