diff --git a/internal/signature/gemini_sanitize.go b/internal/signature/gemini_sanitize.go index e639255c..9678c46d 100644 --- a/internal/signature/gemini_sanitize.go +++ b/internal/signature/gemini_sanitize.go @@ -1,7 +1,6 @@ package signature import ( - "fmt" "strings" log "github.com/sirupsen/logrus" @@ -34,23 +33,29 @@ func SanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string) } contents := gjson.GetBytes(payload, contentsPath) - if !contents.IsArray() { + if !contents.IsArray() || !geminiContentsThoughtSignaturesNeedSanitize(contents) { return payload } + contentsChanged := false + contentItems := make([][]byte, 0, int(contents.Get("#").Int())) contents.ForEach(func(contentIdx, content gjson.Result) bool { - isModelTurn := content.Get("role").String() == "model" parts := content.Get("parts") if !parts.IsArray() { + contentItems = append(contentItems, []byte(content.Raw)) return true } + isModelTurn := content.Get("role").String() == "model" + partsChanged := false + partItems := make([][]byte, 0, int(parts.Get("#").Int())) parts.ForEach(func(partIdx, part gjson.Result) bool { - partPath := fmt.Sprintf("%s.%d.parts.%d", contentsPath, contentIdx.Int(), partIdx.Int()) + partJSON := []byte(part.Raw) if part.Get("functionResponse").Exists() { _, hadSignature := geminiPartThoughtSignature(part) - payload = deleteGeminiPartThoughtSignatureFields(payload, partPath) if hadSignature { + partJSON = deleteGeminiPartThoughtSignatureFields(partJSON) + partsChanged = true logGeminiThoughtSignatureSanitize(contentsPath, int(contentIdx.Int()), int(partIdx.Int()), SignatureCompatibilityDecision{ TargetProvider: SignatureProviderGemini, BlockKind: SignatureBlockKindGeminiModelPart, @@ -58,9 +63,11 @@ func SanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string) Reason: "user-turn functionResponse parts cannot replay thought signatures", }, "", true) } + partItems = append(partItems, partJSON) return true } if !isModelTurn { + partItems = append(partItems, partJSON) return true } @@ -68,6 +75,7 @@ func SanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string) hasThought := part.Get("thought").Exists() rawSignature, hasSignature := geminiPartThoughtSignature(part) if !hasFunctionCall && !hasThought && !hasSignature { + partItems = append(partItems, partJSON) return true } @@ -75,19 +83,72 @@ func SanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string) if hasFunctionCall { blockKind = SignatureBlockKindGeminiFunctionCall } - payload = deleteGeminiPartThoughtSignatureFields(payload, partPath) decision := DecideSignatureCompatibility(SignatureProviderGemini, rawSignature, blockKind) replaySignature := GeminiReplaySignatureOrBypass(rawSignature, blockKind) - payload, _ = sjson.SetBytes(payload, partPath+".thoughtSignature", replaySignature) + if !hasNormalizedGeminiPartThoughtSignature(part, replaySignature) { + partJSON = deleteGeminiPartThoughtSignatureFields(partJSON) + partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", replaySignature) + partsChanged = true + } if decision.Action != SignatureActionPreserve { logGeminiThoughtSignatureSanitize(contentsPath, int(contentIdx.Int()), int(partIdx.Int()), decision, rawSignature, hasSignature) } + partItems = append(partItems, partJSON) return true }) + + contentJSON := []byte(content.Raw) + if partsChanged { + contentJSON, _ = sjson.SetRawBytes(contentJSON, "parts", joinGeminiSignatureRawArray(partItems)) + contentsChanged = true + } + contentItems = append(contentItems, contentJSON) return true }) - return payload + if !contentsChanged { + return payload + } + updated, errSet := sjson.SetRawBytes(payload, contentsPath, joinGeminiSignatureRawArray(contentItems)) + if errSet != nil { + return payload + } + return updated +} + +func geminiContentsThoughtSignaturesNeedSanitize(contents gjson.Result) bool { + needsSanitize := false + contents.ForEach(func(_, content gjson.Result) bool { + isModelTurn := content.Get("role").String() == "model" + parts := content.Get("parts") + if !parts.IsArray() { + return true + } + parts.ForEach(func(_, part gjson.Result) bool { + if part.Get("functionResponse").Exists() { + _, needsSanitize = geminiPartThoughtSignature(part) + return !needsSanitize + } + if !isModelTurn { + return true + } + hasFunctionCall := part.Get("functionCall").Exists() + hasThought := part.Get("thought").Exists() + rawSignature, hasSignature := geminiPartThoughtSignature(part) + if !hasFunctionCall && !hasThought && !hasSignature { + return true + } + blockKind := SignatureBlockKindGeminiModelPart + if hasFunctionCall { + blockKind = SignatureBlockKindGeminiFunctionCall + } + replaySignature := GeminiReplaySignatureOrBypass(rawSignature, blockKind) + needsSanitize = !hasNormalizedGeminiPartThoughtSignature(part, replaySignature) + return !needsSanitize + }) + return !needsSanitize + }) + return needsSanitize } func logGeminiThoughtSignatureSanitize(contentsPath string, contentIndex, partIndex int, decision SignatureCompatibilityDecision, rawSignature string, hasSignature bool) { @@ -106,16 +167,18 @@ func logGeminiThoughtSignatureSanitize(contentsPath string, contentIndex, partIn }).Debug("gemini request: sanitized thoughtSignature before upstream") } +var geminiPartThoughtSignaturePaths = []string{ + "thoughtSignature", + "thought_signature", + "functionCall.thoughtSignature", + "functionCall.thought_signature", + "functionResponse.thoughtSignature", + "functionResponse.thought_signature", + "extra_content.google.thought_signature", +} + func geminiPartThoughtSignature(part gjson.Result) (string, bool) { - for _, path := range []string{ - "thoughtSignature", - "thought_signature", - "functionCall.thoughtSignature", - "functionCall.thought_signature", - "functionResponse.thoughtSignature", - "functionResponse.thought_signature", - "extra_content.google.thought_signature", - } { + for _, path := range geminiPartThoughtSignaturePaths { result := part.Get(path) if result.Exists() { return result.String(), true @@ -124,17 +187,51 @@ func geminiPartThoughtSignature(part gjson.Result) (string, bool) { return "", false } -func deleteGeminiPartThoughtSignatureFields(payload []byte, partPath string) []byte { - for _, path := range []string{ - "thoughtSignature", - "thought_signature", - "functionCall.thoughtSignature", - "functionCall.thought_signature", - "functionResponse.thoughtSignature", - "functionResponse.thought_signature", - "extra_content.google.thought_signature", - } { - payload, _ = sjson.DeleteBytes(payload, partPath+"."+path) +func hasNormalizedGeminiPartThoughtSignature(part gjson.Result, replaySignature string) bool { + canonicalCount := 0 + part.ForEach(func(key, _ gjson.Result) bool { + if key.String() == "thoughtSignature" { + canonicalCount++ + } + return true + }) + canonical := part.Get("thoughtSignature") + if canonicalCount != 1 || canonical.Type != gjson.String || canonical.String() != replaySignature { + return false + } + for _, path := range geminiPartThoughtSignaturePaths[1:] { + if part.Get(path).Exists() { + return false + } + } + return true +} + +func deleteGeminiPartThoughtSignatureFields(payload []byte) []byte { + for _, path := range geminiPartThoughtSignaturePaths { + for gjson.GetBytes(payload, path).Exists() { + updated, errDelete := sjson.DeleteBytes(payload, path) + if errDelete != nil || len(updated) >= len(payload) { + break + } + payload = updated + } } return payload } + +func joinGeminiSignatureRawArray(items [][]byte) []byte { + size := len(items) + 1 + for _, item := range items { + size += len(item) + } + out := make([]byte, 0, size) + out = append(out, '[') + for index, item := range items { + if index > 0 { + out = append(out, ',') + } + out = append(out, item...) + } + return append(out, ']') +} diff --git a/internal/signature/gemini_sanitize_test.go b/internal/signature/gemini_sanitize_test.go index 8faf8a85..63cf456e 100644 --- a/internal/signature/gemini_sanitize_test.go +++ b/internal/signature/gemini_sanitize_test.go @@ -41,6 +41,8 @@ func assertSignatureDebugDoesNotLeak(t *testing.T, hook *test.Hook, forbidden st } } +var benchmarkSanitizeGeminiRequestOutput []byte + func TestSanitizeGeminiRequestThoughtSignaturesPreservesGeminiSignature(t *testing.T) { sig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39}) input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{}},"thoughtSignature":"` + sig + `"}]}]}`) @@ -50,6 +52,35 @@ func TestSanitizeGeminiRequestThoughtSignaturesPreservesGeminiSignature(t *testi if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != sig { t.Fatalf("thoughtSignature = %q, want %q. Output: %s", got, sig, string(out)) } + if &out[0] != &input[0] { + t.Fatal("compatible canonical signature payload was copied") + } +} + +func TestSanitizeGeminiRequestThoughtSignaturesNormalizesDuplicateCanonicalField(t *testing.T) { + input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{}},"thoughtSignature":"` + GeminiSkipThoughtSignatureValidator + `","thoughtSignature":"bad","thoughtSignature":"worse"}]}]}`) + + out := SanitizeGeminiRequestThoughtSignatures(input, "contents") + + if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator { + t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, out) + } + if count := strings.Count(string(out), `"thoughtSignature"`); count != 1 { + t.Fatalf("thoughtSignature field count = %d, want 1. Output: %s", count, out) + } +} + +func TestSanitizeGeminiRequestThoughtSignaturesReusesUnsignedFunctionResponsePayload(t *testing.T) { + input := []byte(`{"contents":[{"role":"user","parts":[{"functionResponse":{"name":"f","response":{"result":"ok"}}}]}]}`) + + out := SanitizeGeminiRequestThoughtSignatures(input, "contents") + + if &out[0] != &input[0] { + t.Fatal("unsigned function response payload was copied") + } + if string(out) != string(input) { + t.Fatalf("payload changed:\n got: %s\nwant: %s", out, input) + } } func TestSanitizeGeminiRequestThoughtSignaturesReplacesBase64UUIDFunctionCall(t *testing.T) { @@ -108,8 +139,46 @@ func TestSanitizeGeminiRequestThoughtSignaturesReplacesField2WrappedUUIDFunction } } +func BenchmarkSanitizeGeminiRequestThoughtSignaturesNormalizedHistory(b *testing.B) { + for _, turns := range []int{1, 16, 64} { + b.Run(fmt.Sprintf("turns_%d", turns), func(b *testing.B) { + input := normalizedGeminiSignatureHistory(turns, 8<<20) + output := SanitizeGeminiRequestThoughtSignatures(input, "contents") + if &output[0] != &input[0] { + b.Fatal("normalized payload was copied") + } + b.ReportAllocs() + b.SetBytes(int64(len(input))) + b.ResetTimer() + + for b.Loop() { + benchmarkSanitizeGeminiRequestOutput = SanitizeGeminiRequestThoughtSignatures(input, "contents") + } + }) + } +} + +func normalizedGeminiSignatureHistory(turns, totalPayloadBytes int) []byte { + payload := strings.Repeat("x", totalPayloadBytes/turns) + var builder strings.Builder + builder.Grow(totalPayloadBytes + turns*256) + builder.WriteString(`{"contents":[`) + for i := 0; i < turns; i++ { + if i > 0 { + builder.WriteByte(',') + } + builder.WriteString(`{"role":"model","parts":[{"functionCall":{"name":"lookup","args":{"value":"`) + builder.WriteString(payload) + builder.WriteString(`"}},"thoughtSignature":"`) + builder.WriteString(GeminiSkipThoughtSignatureValidator) + builder.WriteString(`"}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","response":{"result":"ok"}}}]}`) + } + builder.WriteString(`]}`) + return []byte(builder.String()) +} + func TestSanitizeGeminiRequestThoughtSignaturesRemovesFunctionResponseSignature(t *testing.T) { - input := []byte(`{"contents":[{"role":"user","parts":[{"functionResponse":{"name":"f","response":{"result":"ok"},"thoughtSignature":"bad"},"thoughtSignature":"bad"}]}]}`) + input := []byte(`{"contents":[{"role":"user","parts":[{"functionResponse":{"name":"f","response":{"result":"ok"},"thoughtSignature":"bad","thoughtSignature":"worse"},"thoughtSignature":"bad"}]}]}`) out := SanitizeGeminiRequestThoughtSignatures(input, "contents") diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index a33aa333..1efeb3a0 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -587,30 +587,33 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } clientContentJSON := antigravityClaudeContent(role, partItems) if role == "model" && len(partItems) > 1 { - var thinkingParts []gjson.Result - var regularParts []gjson.Result - var functionCallParts []gjson.Result + var thinkingParts [][]byte + var regularParts [][]byte + var functionCallParts [][]byte + needsReorder := false + previousCategory := -1 for _, partJSON := range partItems { part := gjson.ParseBytes(partJSON) + category := 1 if part.Get("thought").Bool() { - thinkingParts = append(thinkingParts, part) + category = 0 + thinkingParts = append(thinkingParts, partJSON) } else if part.Get("functionCall").Exists() { - functionCallParts = append(functionCallParts, part) + category = 2 + functionCallParts = append(functionCallParts, partJSON) } else { - regularParts = append(regularParts, part) + regularParts = append(regularParts, partJSON) } + needsReorder = needsReorder || category < previousCategory + previousCategory = category } - newParts := make([]interface{}, 0, len(partItems)) - for _, part := range thinkingParts { - newParts = append(newParts, part.Value()) + if needsReorder { + newParts := make([][]byte, 0, len(partItems)) + newParts = append(newParts, thinkingParts...) + newParts = append(newParts, regularParts...) + newParts = append(newParts, functionCallParts...) + clientContentJSON, _ = sjson.SetRawBytes(clientContentJSON, "parts", translatorcommon.JoinRawArray(newParts)) } - for _, part := range regularParts { - newParts = append(newParts, part.Value()) - } - for _, part := range functionCallParts { - newParts = append(newParts, part.Value()) - } - clientContentJSON, _ = sjson.SetBytes(clientContentJSON, "parts", newParts) } contentItems = append(contentItems, clientContentJSON) } else if contentsResult.Type == gjson.String { @@ -642,7 +645,12 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ inputSchema := util.CleanJSONSchemaForAntigravity(inputSchemaResult.Raw) tool, _ := sjson.DeleteBytes([]byte(toolResult.Raw), "input_schema") tool, _ = sjson.SetRawBytes(tool, "parametersJsonSchema", []byte(inputSchema)) - tool, _ = sjson.SetBytes(tool, "name", util.MapSanitizedFunctionName(functionNameMap, gjson.GetBytes(tool, "name").String())) + 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 diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request.go b/internal/translator/antigravity/gemini/antigravity_gemini_request.go index 4fc4a0d9..1824fcb2 100644 --- a/internal/translator/antigravity/gemini/antigravity_gemini_request.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request.go @@ -42,7 +42,9 @@ func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ templateBytes, _ := sjson.SetRawBytes([]byte(template), "request", rawJSON) templateBytes, _ = sjson.SetBytes(templateBytes, "model", modelName) template = string(templateBytes) - template, _ = sjson.Delete(template, "request.model") + if gjson.Get(template, "request.model").Exists() { + template, _ = sjson.Delete(template, "request.model") + } template, errFixCLIToolResponse := fixCLIToolResponse(template) if errFixCLIToolResponse != nil { @@ -87,44 +89,61 @@ func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ toolsResult := gjson.GetBytes(rawJSON, "request.tools") if toolsResult.IsArray() { seenFunctionNames := make(map[string]struct{}) + toolsChanged := false var toolItems [][]byte toolsResult.ForEach(func(toolIndex, tool gjson.Result) bool { toolJSON := []byte(tool.Raw) + toolChanged := false for _, key := range []string{"functionDeclarations", "function_declarations"} { declarations := tool.Get(key) if !declarations.IsArray() { continue } + declarationsChanged := false var declarationItems [][]byte declarations.ForEach(func(_, declaration gjson.Result) bool { - mappedName := util.MapSanitizedFunctionName(functionNameMap, declaration.Get("name").String()) + nameResult := declaration.Get("name") + originalName := nameResult.String() + mappedName := util.MapSanitizedFunctionName(functionNameMap, originalName) if mappedName != "" { if _, exists := seenFunctionNames[mappedName]; exists { + declarationsChanged = true return true } seenFunctionNames[mappedName] = struct{}{} } declarationJSON := []byte(declaration.Raw) - declarationJSON, _ = sjson.SetBytes(declarationJSON, "name", mappedName) + if nameResult.Type != gjson.String || mappedName != originalName { + declarationJSON, _ = sjson.SetBytes(declarationJSON, "name", mappedName) + declarationsChanged = true + } if parameters := declaration.Get("parameters"); parameters.Exists() { declarationJSON, _ = sjson.SetRawBytes(declarationJSON, "parametersJsonSchema", []byte(parameters.Raw)) declarationJSON, _ = sjson.DeleteBytes(declarationJSON, "parameters") + declarationsChanged = true } declarationItems = append(declarationItems, declarationJSON) return true }) - var errSet error - toolJSON, errSet = sjson.SetRawBytes(toolJSON, key, translatorcommon.JoinRawArray(declarationItems)) - if errSet != nil { - log.Warnf("failed to normalize function declarations in tool %d: %v", toolIndex.Int(), errSet) + if declarationsChanged { + var errSet error + toolJSON, errSet = sjson.SetRawBytes(toolJSON, key, translatorcommon.JoinRawArray(declarationItems)) + if errSet != nil { + log.Warnf("failed to normalize function declarations in tool %d: %v", toolIndex.Int(), errSet) + } else { + toolChanged = true + } } } + toolsChanged = toolsChanged || toolChanged toolItems = append(toolItems, toolJSON) return true }) - rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.tools", translatorcommon.JoinRawArray(toolItems)) + if toolsChanged { + rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.tools", translatorcommon.JoinRawArray(toolItems)) + } rawJSON = removeEmptyGeminiFunctionTools(rawJSON) } rawJSON = rewriteGeminiFunctionNames(rawJSON, functionNameMap) @@ -140,6 +159,11 @@ func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ func removeEmptyGeminiFunctionTools(rawJSON []byte) []byte { tools := gjson.GetBytes(rawJSON, "request.tools") + if tools.IsArray() && len(tools.Array()) == 0 { + rawJSON, _ = sjson.DeleteBytes(rawJSON, "request.tools") + return rawJSON + } + changed := false var cleanedTools [][]byte for _, tool := range tools.Array() { toolJSON := []byte(tool.Raw) @@ -147,14 +171,19 @@ func removeEmptyGeminiFunctionTools(rawJSON []byte) []byte { for _, key := range []string{"functionDeclarations", "function_declarations"} { if declarations := tool.Get(key); declarations.IsArray() && len(declarations.Array()) == 0 { toolJSON, _ = sjson.DeleteBytes(toolJSON, key) + changed = true } } if len(gjson.ParseBytes(toolJSON).Map()) == 0 { + changed = true continue } } cleanedTools = append(cleanedTools, toolJSON) } + if !changed { + return rawJSON + } if len(cleanedTools) == 0 { rawJSON, _ = sjson.DeleteBytes(rawJSON, "request.tools") return rawJSON @@ -186,11 +215,16 @@ func rewriteGeminiFunctionNames(rawJSON []byte, functionNameMap map[string]strin content.Get("parts").ForEach(func(_, part gjson.Result) bool { partJSON := []byte(part.Raw) for _, field := range []string{"functionCall", "functionResponse", "function_call", "function_response"} { - name := part.Get(field + ".name").String() + nameResult := part.Get(field + ".name") + name := nameResult.String() if name == "" { continue } - partJSON, _ = sjson.SetBytes(partJSON, field+".name", util.MapSanitizedFunctionName(functionNameMap, name)) + mappedName := util.MapSanitizedFunctionName(functionNameMap, name) + if nameResult.Type == gjson.String && mappedName == name { + continue + } + partJSON, _ = sjson.SetBytes(partJSON, field+".name", mappedName) partsChanged = true } partItems = append(partItems, partJSON) @@ -210,12 +244,17 @@ func rewriteGeminiFunctionNames(rawJSON []byte, functionNameMap map[string]strin for contentIndex, content := range contents.Array() { for partIndex, part := range content.Get("parts").Array() { for _, field := range []string{"functionCall", "functionResponse", "function_call", "function_response"} { - name := part.Get(field + ".name").String() + nameResult := part.Get(field + ".name") + name := nameResult.String() if name == "" { continue } + mappedName := util.MapSanitizedFunctionName(functionNameMap, name) + if nameResult.Type == gjson.String && mappedName == name { + continue + } path := fmt.Sprintf("request.contents.%d.parts.%d.%s.name", contentIndex, partIndex, field) - rawJSON, _ = sjson.SetBytes(rawJSON, path, util.MapSanitizedFunctionName(functionNameMap, name)) + rawJSON, _ = sjson.SetBytes(rawJSON, path, mappedName) } } } @@ -227,17 +266,26 @@ func rewriteGeminiFunctionNames(rawJSON []byte, functionNameMap map[string]strin } { allowedNames := gjson.GetBytes(rawJSON, allowedPath) if allowedNames.IsArray() { + namesChanged := false nameItems := make([][]byte, 0, 4) allowedNames.ForEach(func(_, name gjson.Result) bool { - mappedName, _ := json.Marshal(util.MapSanitizedFunctionName(functionNameMap, name.String())) - nameItems = append(nameItems, mappedName) + mappedName := util.MapSanitizedFunctionName(functionNameMap, name.String()) + namesChanged = namesChanged || name.Type != gjson.String || mappedName != name.String() + mappedNameJSON, _ := json.Marshal(mappedName) + nameItems = append(nameItems, mappedNameJSON) return true }) - rawJSON, _ = sjson.SetRawBytes(rawJSON, allowedPath, translatorcommon.JoinRawArray(nameItems)) + if namesChanged { + rawJSON, _ = sjson.SetRawBytes(rawJSON, allowedPath, translatorcommon.JoinRawArray(nameItems)) + } } else { for index, name := range allowedNames.Array() { + mappedName := util.MapSanitizedFunctionName(functionNameMap, name.String()) + if name.Type == gjson.String && mappedName == name.String() { + continue + } path := fmt.Sprintf("%s.%d", allowedPath, index) - rawJSON, _ = sjson.SetBytes(rawJSON, path, util.MapSanitizedFunctionName(functionNameMap, name.String())) + rawJSON, _ = sjson.SetBytes(rawJSON, path, mappedName) } } } @@ -511,6 +559,26 @@ func fixCLIToolResponse(input string) (string, error) { return input, fmt.Errorf("contents not found in input") } + needsGrouping := false + allContentsAreObjects := true + contents.ForEach(func(_, content gjson.Result) bool { + if !content.IsObject() { + allContentsAreObjects = false + return true + } + content.Get("parts").ForEach(func(_, part gjson.Result) bool { + if part.Get("functionResponse").Exists() { + needsGrouping = true + return false + } + return true + }) + return !needsGrouping + }) + if contents.IsArray() && allContentsAreObjects && !needsGrouping { + return input, nil + } + // Initialize data structures for processing and grouping contentItems := translatorcommon.NewRawArrayItems(contents.Get("#").Int()) var pendingGroups []*FunctionCallGroup // Groups awaiting completion with responses diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_response.go b/internal/translator/antigravity/gemini/antigravity_gemini_response.go index 23d8f6b2..2c61913b 100644 --- a/internal/translator/antigravity/gemini/antigravity_gemini_response.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_response.go @@ -95,12 +95,17 @@ func restoreGeminiFunctionNames(chunk, originalRequestRawJSON []byte) []byte { for candidateIndex, candidate := range candidates.Array() { for partIndex, part := range candidate.Get("content.parts").Array() { for _, field := range []string{"functionCall", "functionResponse", "function_call", "function_response"} { - name := part.Get(field + ".name").String() + nameResult := part.Get(field + ".name") + name := nameResult.String() if name == "" { continue } + restoredName := util.RestoreSanitizedToolName(nameMap, name) + if nameResult.Type == gjson.String && restoredName == name { + continue + } path := fmt.Sprintf("candidates.%d.content.parts.%d.%s.name", candidateIndex, partIndex, field) - chunk, _ = sjson.SetBytes(chunk, path, util.RestoreSanitizedToolName(nameMap, name)) + chunk, _ = sjson.SetBytes(chunk, path, restoredName) } } } diff --git a/internal/translator/antigravity/gemini/noop_optimization_test.go b/internal/translator/antigravity/gemini/noop_optimization_test.go new file mode 100644 index 00000000..16ea1317 --- /dev/null +++ b/internal/translator/antigravity/gemini/noop_optimization_test.go @@ -0,0 +1,74 @@ +package gemini + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestRewriteGeminiFunctionNamesReusesNormalizedPayload(t *testing.T) { + input := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","args":{}}}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","response":{"result":"ok"}}}]}],"toolConfig":{"functionCallingConfig":{"allowedFunctionNames":["lookup"]}}}}`) + + output := rewriteGeminiFunctionNames(input, nil) + + if &output[0] != &input[0] { + t.Fatal("normalized function names caused a payload copy") + } +} + +func TestRemoveEmptyGeminiFunctionToolsReusesNormalizedPayload(t *testing.T) { + input := []byte(`{"request":{"tools":[{"functionDeclarations":[{"name":"lookup"}]}]}}`) + + output := removeEmptyGeminiFunctionTools(input) + + if &output[0] != &input[0] { + t.Fatal("non-empty tools caused a payload copy") + } +} + +func TestRemoveEmptyGeminiFunctionToolsDeletesEmptyArray(t *testing.T) { + input := []byte(`{"request":{"tools":[]}}`) + + output := removeEmptyGeminiFunctionTools(input) + + if gjson.GetBytes(output, "request.tools").Exists() { + t.Fatalf("empty tools should be removed: %s", output) + } +} + +func TestRewriteGeminiFunctionNamesNormalizesNonStringNames(t *testing.T) { + input := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":true,"args":{}}}]}],"toolConfig":{"functionCallingConfig":{"allowedFunctionNames":[true]}}}}`) + + output := rewriteGeminiFunctionNames(input, nil) + + if name := gjson.GetBytes(output, "request.contents.0.parts.0.functionCall.name"); name.Type != gjson.String || name.String() != "true" { + t.Fatalf("functionCall.name = %s, want string true", name.Raw) + } + if name := gjson.GetBytes(output, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0"); name.Type != gjson.String || name.String() != "true" { + t.Fatalf("allowedFunctionNames.0 = %s, want string true", name.Raw) + } +} + +func TestFixCLIToolResponseReusesHistoryWithoutFunctionResponses(t *testing.T) { + input := `{"request":{"contents":[{"role":"user","parts":[{"text":"hello"}]},{"role":"model","parts":[{"text":"world"}]}]}}` + + output, errFix := fixCLIToolResponse(input) + if errFix != nil { + t.Fatalf("fixCLIToolResponse returned an error: %v", errFix) + } + if output != input { + t.Fatalf("history changed:\n got: %s\nwant: %s", output, input) + } +} + +func TestFixCLIToolResponsePreservesObjectNormalization(t *testing.T) { + input := `{"request":{"contents":{"first":{"role":"user","parts":[{"text":"hello"}]}}}}` + + output, errFix := fixCLIToolResponse(input) + if errFix != nil { + t.Fatalf("fixCLIToolResponse returned an error: %v", errFix) + } + if !gjson.Get(output, "request.contents").IsArray() { + t.Fatalf("contents should be normalized to an array: %s", output) + } +} diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_request.go b/internal/translator/antigravity/interactions/interactions_antigravity_request.go index 5fc445a4..123b42dc 100644 --- a/internal/translator/antigravity/interactions/interactions_antigravity_request.go +++ b/internal/translator/antigravity/interactions/interactions_antigravity_request.go @@ -53,11 +53,16 @@ func rewriteInteractionsFunctionNames(out []byte, functionNameMap map[string]str content.Get("parts").ForEach(func(_, part gjson.Result) bool { partJSON := []byte(part.Raw) for _, field := range []string{"functionCall", "functionResponse"} { - name := part.Get(field + ".name").String() + nameResult := part.Get(field + ".name") + name := nameResult.String() if name == "" { continue } - partJSON, _ = sjson.SetBytes(partJSON, field+".name", util.MapSanitizedFunctionName(functionNameMap, name)) + mappedName := util.MapSanitizedFunctionName(functionNameMap, name) + if nameResult.Type == gjson.String && mappedName == name { + continue + } + partJSON, _ = sjson.SetBytes(partJSON, field+".name", mappedName) partsChanged = true } partItems = append(partItems, partJSON) @@ -77,12 +82,17 @@ func rewriteInteractionsFunctionNames(out []byte, functionNameMap map[string]str for contentIndex, content := range contents.Array() { for partIndex, part := range content.Get("parts").Array() { for _, field := range []string{"functionCall", "functionResponse"} { - name := part.Get(field + ".name").String() + nameResult := part.Get(field + ".name") + name := nameResult.String() if name == "" { continue } + mappedName := util.MapSanitizedFunctionName(functionNameMap, name) + if nameResult.Type == gjson.String && mappedName == name { + continue + } path := fmt.Sprintf("request.contents.%d.parts.%d.%s.name", contentIndex, partIndex, field) - out, _ = sjson.SetBytes(out, path, util.MapSanitizedFunctionName(functionNameMap, name)) + out, _ = sjson.SetBytes(out, path, mappedName) } } } @@ -91,17 +101,26 @@ func rewriteInteractionsFunctionNames(out []byte, functionNameMap map[string]str allowedPath := "request.toolConfig.functionCallingConfig.allowedFunctionNames" allowedNames := gjson.GetBytes(out, allowedPath) if allowedNames.IsArray() { + namesChanged := false nameItems := make([][]byte, 0, 4) allowedNames.ForEach(func(_, name gjson.Result) bool { - mappedName, _ := json.Marshal(util.MapSanitizedFunctionName(functionNameMap, name.String())) - nameItems = append(nameItems, mappedName) + mappedName := util.MapSanitizedFunctionName(functionNameMap, name.String()) + namesChanged = namesChanged || name.Type != gjson.String || mappedName != name.String() + mappedNameJSON, _ := json.Marshal(mappedName) + nameItems = append(nameItems, mappedNameJSON) return true }) - out, _ = sjson.SetRawBytes(out, allowedPath, translatorcommon.JoinRawArray(nameItems)) + if namesChanged { + out, _ = sjson.SetRawBytes(out, allowedPath, translatorcommon.JoinRawArray(nameItems)) + } } else { for index, name := range allowedNames.Array() { + mappedName := util.MapSanitizedFunctionName(functionNameMap, name.String()) + if name.Type == gjson.String && mappedName == name.String() { + continue + } path := fmt.Sprintf("%s.%d", allowedPath, index) - out, _ = sjson.SetBytes(out, path, util.MapSanitizedFunctionName(functionNameMap, name.String())) + out, _ = sjson.SetBytes(out, path, mappedName) } } return out @@ -572,7 +591,6 @@ func antigravityFunctionDeclarationJSON(decl gjson.Result, functionNameMap map[s if responseSchema := fn.Get("responseJsonSchema"); responseSchema.Exists() { out, _ = sjson.SetRawBytes(out, "responseJsonSchema", []byte(responseSchema.Raw)) } - out, _ = sjson.DeleteBytes(out, "strict") return out } diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_response.go b/internal/translator/antigravity/interactions/interactions_antigravity_response.go index f7cafd10..4b46c060 100644 --- a/internal/translator/antigravity/interactions/interactions_antigravity_response.go +++ b/internal/translator/antigravity/interactions/interactions_antigravity_response.go @@ -143,12 +143,17 @@ func restoreInteractionsFunctionNames(root gjson.Result, nameMap map[string]stri for candidateIndex, candidate := range candidates.Array() { for partIndex, part := range candidate.Get("content.parts").Array() { for _, field := range []string{"functionCall", "functionResponse"} { - name := part.Get(field + ".name").String() + nameResult := part.Get(field + ".name") + name := nameResult.String() if name == "" { continue } + restoredName := util.RestoreSanitizedToolName(nameMap, name) + if nameResult.Type == gjson.String && restoredName == name { + continue + } path := fmt.Sprintf("candidates.%d.content.parts.%d.%s.name", candidateIndex, partIndex, field) - raw, _ = sjson.SetBytes(raw, path, util.RestoreSanitizedToolName(nameMap, name)) + raw, _ = sjson.SetBytes(raw, path, restoredName) } } } diff --git a/internal/translator/antigravity/interactions/noop_optimization_test.go b/internal/translator/antigravity/interactions/noop_optimization_test.go new file mode 100644 index 00000000..976f1d17 --- /dev/null +++ b/internal/translator/antigravity/interactions/noop_optimization_test.go @@ -0,0 +1,30 @@ +package interactions + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestRewriteInteractionsFunctionNamesReusesNormalizedPayload(t *testing.T) { + input := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","args":{}}}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","response":{"result":"ok"}}}]}],"toolConfig":{"functionCallingConfig":{"allowedFunctionNames":["lookup"]}}}}`) + + output := rewriteInteractionsFunctionNames(input, nil) + + if &output[0] != &input[0] { + t.Fatal("normalized function names caused a payload copy") + } +} + +func TestRewriteInteractionsFunctionNamesNormalizesNonStringNames(t *testing.T) { + input := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":true,"args":{}}}]}],"toolConfig":{"functionCallingConfig":{"allowedFunctionNames":[true]}}}}`) + + output := rewriteInteractionsFunctionNames(input, nil) + + if name := gjson.GetBytes(output, "request.contents.0.parts.0.functionCall.name"); name.Type != gjson.String || name.String() != "true" { + t.Fatalf("functionCall.name = %s, want string true", name.Raw) + } + if name := gjson.GetBytes(output, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0"); name.Type != gjson.String || name.String() != "true" { + t.Fatalf("allowedFunctionNames.0 = %s, want string true", name.Raw) + } +} 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 ad005316..98cc95c7 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -349,9 +349,16 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ fnRaw = string(fnRawBytes) } fnRawBytes := []byte(fnRaw) - fnRawBytes, _ = sjson.SetBytes(fnRawBytes, "name", util.MapSanitizedFunctionName(functionNameMap, fn.Get("name").String())) - fnRaw, _ = sjson.Delete(string(fnRawBytes), "strict") - functionDeclarations = append(functionDeclarations, []byte(fnRaw)) + nameResult := fn.Get("name") + originalName := nameResult.String() + mappedName := util.MapSanitizedFunctionName(functionNameMap, originalName) + if nameResult.Type != gjson.String || mappedName != originalName { + fnRawBytes, _ = sjson.SetBytes(fnRawBytes, "name", mappedName) + } + if gjson.GetBytes(fnRawBytes, "strict").Exists() { + fnRawBytes, _ = sjson.DeleteBytes(fnRawBytes, "strict") + } + functionDeclarations = append(functionDeclarations, fnRawBytes) } } if gs := t.Get("google_search"); gs.Exists() { @@ -496,16 +503,16 @@ func applyOpenAIThinkingCompatibilityToAntigravity(out []byte, rawJSON []byte, m "reasoning.include_thoughts", } { if value := gjson.GetBytes(rawJSON, path); value.Exists() { - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", value.Bool()) + out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", value.Bool()) } } if exclude := gjson.GetBytes(rawJSON, "reasoning.exclude"); exclude.Exists() { - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", !exclude.Bool()) + out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", !exclude.Bool()) } if !gjson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts").Exists() && antigravityOpenAIDefaultIncludeThoughts(modelName) { - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", true) + out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", true) } return normalizeAntigravityOpenAIThinkingConfig(out) @@ -517,22 +524,22 @@ func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte { "request.generationConfig.thinkingConfig", } { if includeThoughts := gjson.GetBytes(out, prefix+".includeThoughts"); includeThoughts.Exists() { - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts.Bool()) + out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts.Bool()) } if includeThoughts := gjson.GetBytes(out, prefix+".include_thoughts"); includeThoughts.Exists() { - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts.Bool()) + out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts.Bool()) } if thinkingLevel := gjson.GetBytes(out, prefix+".thinkingLevel"); thinkingLevel.Exists() { - out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", []byte(thinkingLevel.Raw)) + out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingLevel", thinkingLevel) } if thinkingLevel := gjson.GetBytes(out, prefix+".thinking_level"); thinkingLevel.Exists() { - out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", []byte(thinkingLevel.Raw)) + out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingLevel", thinkingLevel) } if thinkingBudget := gjson.GetBytes(out, prefix+".thinkingBudget"); thinkingBudget.Exists() { - out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", []byte(thinkingBudget.Raw)) + out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingBudget", thinkingBudget) } if thinkingBudget := gjson.GetBytes(out, prefix+".thinking_budget"); thinkingBudget.Exists() { - out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", []byte(thinkingBudget.Raw)) + out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingBudget", thinkingBudget) } } @@ -541,7 +548,7 @@ func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte { "request.generationConfig.include_thoughts", } { if includeThoughts := gjson.GetBytes(out, path); includeThoughts.Exists() { - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts.Bool()) + out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts.Bool()) } } @@ -553,12 +560,38 @@ func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte { "request.generationConfig.includeThoughts", "request.generationConfig.include_thoughts", } { - out, _ = sjson.DeleteBytes(out, path) + if gjson.GetBytes(out, path).Exists() { + out, _ = sjson.DeleteBytes(out, path) + } } return out } +func setAntigravityOpenAIBoolIfDifferent(out []byte, path string, value bool) []byte { + current := gjson.GetBytes(out, path) + if value && current.Type == gjson.True || !value && current.Type == gjson.False { + return out + } + updated, errSet := sjson.SetBytes(out, path, value) + if errSet != nil { + return out + } + return updated +} + +func setAntigravityOpenAIRawIfDifferent(out []byte, path string, value gjson.Result) []byte { + current := gjson.GetBytes(out, path) + if current.Exists() && current.Raw == value.Raw { + return out + } + updated, errSet := sjson.SetRawBytes(out, path, []byte(value.Raw)) + if errSet != nil { + return out + } + return updated +} + func antigravityOpenAIDefaultIncludeThoughts(modelName string) bool { modelName = strings.ToLower(modelName) return strings.Contains(modelName, "gemini-3") diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go index bc3e535c..60fe4895 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go @@ -256,12 +256,17 @@ func restoreAntigravityOpenAIFunctionNames(rawJSON, originalRequestRawJSON []byt for candidateIndex, candidate := range candidates.Array() { for partIndex, part := range candidate.Get("content.parts").Array() { for _, field := range []string{"functionCall", "functionResponse"} { - name := part.Get(field + ".name").String() + nameResult := part.Get(field + ".name") + name := nameResult.String() if name == "" { continue } + restoredName := util.RestoreSanitizedToolName(nameMap, name) + if nameResult.Type == gjson.String && restoredName == name { + continue + } path := fmt.Sprintf("candidates.%d.content.parts.%d.%s.name", candidateIndex, partIndex, field) - rawJSON, _ = sjson.SetBytes(rawJSON, path, util.RestoreSanitizedToolName(nameMap, name)) + rawJSON, _ = sjson.SetBytes(rawJSON, path, restoredName) } } } diff --git a/internal/translator/antigravity/openai/chat-completions/noop_optimization_test.go b/internal/translator/antigravity/openai/chat-completions/noop_optimization_test.go new file mode 100644 index 00000000..7024e502 --- /dev/null +++ b/internal/translator/antigravity/openai/chat-completions/noop_optimization_test.go @@ -0,0 +1,13 @@ +package chat_completions + +import "testing" + +func TestNormalizeAntigravityOpenAIThinkingConfigReusesCanonicalConfig(t *testing.T) { + input := []byte(`{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true,"thinkingLevel":"high","thinkingBudget":8192}}}}`) + + output := normalizeAntigravityOpenAIThinkingConfig(input) + + if &output[0] != &input[0] { + t.Fatal("canonical thinking config caused a payload copy") + } +} diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index 9ed4d620..ccbaa9d0 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -377,16 +377,10 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream anthropicTool, _ = sjson.SetBytes(anthropicTool, "description", desc.String()) } if params := funcDecl.Get("parameters"); params.Exists() { - // Clean up the parameters schema for Claude Code compatibility - cleaned := []byte(params.Raw) - cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false) - cleaned, _ = sjson.SetBytes(cleaned, "$schema", "http://json-schema.org/draft-07/schema#") + cleaned := normalizeClaudeToolSchema(params) anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", cleaned) } else if params = funcDecl.Get("parametersJsonSchema"); params.Exists() { - // Clean up the parameters schema for Claude Code compatibility - cleaned := []byte(params.Raw) - cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false) - cleaned, _ = sjson.SetBytes(cleaned, "$schema", "http://json-schema.org/draft-07/schema#") + cleaned := normalizeClaudeToolSchema(params) anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", cleaned) } @@ -416,12 +410,29 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream return out } +func normalizeClaudeToolSchema(parameters gjson.Result) []byte { + cleaned := []byte(parameters.Raw) + if parameters.Get("additionalProperties").Type != gjson.False { + cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false) + } + const schema = "http://json-schema.org/draft-07/schema#" + currentSchema := parameters.Get("$schema") + if currentSchema.Type != gjson.String || currentSchema.String() != schema { + cleaned, _ = sjson.SetBytes(cleaned, "$schema", schema) + } + return cleaned +} + func lowercaseClaudeToolSchemaTypes(tool []byte) []byte { var pathsToLower []string util.Walk(gjson.ParseBytes(tool), "", "type", &pathsToLower) for _, path := range pathsToLower { typeValue := gjson.GetBytes(tool, path) - tool, _ = sjson.SetBytes(tool, path, strings.ToLower(typeValue.String())) + normalizedType := strings.ToLower(typeValue.String()) + if typeValue.Type == gjson.String && normalizedType == typeValue.String() { + continue + } + tool, _ = sjson.SetBytes(tool, path, normalizedType) } return tool } diff --git a/internal/translator/claude/gemini/noop_optimization_test.go b/internal/translator/claude/gemini/noop_optimization_test.go new file mode 100644 index 00000000..f6e2e8bd --- /dev/null +++ b/internal/translator/claude/gemini/noop_optimization_test.go @@ -0,0 +1,63 @@ +package gemini + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestNormalizeClaudeToolSchemaPreservesCanonicalSchema(t *testing.T) { + input := []byte(`{"type":"object","properties":{"value":{"type":"string"}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}`) + + output := normalizeClaudeToolSchema(gjson.ParseBytes(input)) + + if string(output) != string(input) { + t.Fatalf("canonical schema changed:\n got: %s\nwant: %s", output, input) + } +} + +func TestNormalizeClaudeToolSchemaCorrectsWrongTypes(t *testing.T) { + input := []byte(`{"type":"object","additionalProperties":"false","$schema":123}`) + + output := normalizeClaudeToolSchema(gjson.ParseBytes(input)) + + if additionalProperties := gjson.GetBytes(output, "additionalProperties"); additionalProperties.Type != gjson.False { + t.Fatalf("additionalProperties = %s, want false", additionalProperties.Raw) + } + if schema := gjson.GetBytes(output, "$schema"); schema.Type != gjson.String || schema.String() != "http://json-schema.org/draft-07/schema#" { + t.Fatalf("$schema = %s, want canonical string", schema.Raw) + } +} + +func TestLowercaseClaudeToolSchemaTypesReusesLowercaseSchema(t *testing.T) { + input := []byte(`{"name":"lookup","input_schema":{"type":"object","properties":{"value":{"type":"string"}}}}`) + + output := lowercaseClaudeToolSchemaTypes(input) + + if &output[0] != &input[0] { + t.Fatal("lowercase schema types caused a payload copy") + } +} + +func TestLowercaseClaudeToolSchemaTypesNormalizesNonStringType(t *testing.T) { + input := []byte(`{"input_schema":{"type":123}}`) + + output := lowercaseClaudeToolSchemaTypes(input) + + if got := gjson.GetBytes(output, "input_schema.type"); got.Type != gjson.String || got.String() != "123" { + t.Fatalf("input_schema.type = %s, want string 123", got.Raw) + } +} + +func TestLowercaseClaudeToolSchemaTypesNormalizesUppercaseTypes(t *testing.T) { + input := []byte(`{"input_schema":{"type":"OBJECT","properties":{"value":{"type":"STRING"}}}}`) + + output := lowercaseClaudeToolSchemaTypes(input) + + if got := gjson.GetBytes(output, "input_schema.type").String(); got != "object" { + t.Fatalf("input_schema.type = %q, want object", got) + } + if got := gjson.GetBytes(output, "input_schema.properties.value.type").String(); got != "string" { + t.Fatalf("nested type = %q, want string", got) + } +} diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_response.go b/internal/translator/claude/openai/chat-completions/claude_openai_response.go index 99c75238..002d3166 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_response.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_response.go @@ -459,11 +459,11 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina } if toolCallsCount > 0 { out, _ = sjson.SetBytes(out, "choices.0.finish_reason", "tool_calls") - } else { - out, _ = sjson.SetBytes(out, "choices.0.finish_reason", mapAnthropicStopReasonToOpenAI(stopReason)) + } else if finishReason := mapAnthropicStopReasonToOpenAI(stopReason); finishReason != "stop" { + out, _ = sjson.SetBytes(out, "choices.0.finish_reason", finishReason) } - } else { - out, _ = sjson.SetBytes(out, "choices.0.finish_reason", mapAnthropicStopReasonToOpenAI(stopReason)) + } else if finishReason := mapAnthropicStopReasonToOpenAI(stopReason); finishReason != "stop" { + out, _ = sjson.SetBytes(out, "choices.0.finish_reason", finishReason) } return out diff --git a/internal/translator/claude/openai/chat-completions/noop_optimization_test.go b/internal/translator/claude/openai/chat-completions/noop_optimization_test.go new file mode 100644 index 00000000..1a917ca3 --- /dev/null +++ b/internal/translator/claude/openai/chat-completions/noop_optimization_test.go @@ -0,0 +1,30 @@ +package chat_completions + +import ( + "context" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertClaudeResponseToOpenAINonStreamFinishReasons(t *testing.T) { + tests := []struct { + name string + stopReason string + want string + }{ + {name: "missing", want: "stop"}, + {name: "end_turn", stopReason: "end_turn", want: "stop"}, + {name: "stop_sequence", stopReason: "stop_sequence", want: "stop"}, + {name: "max_tokens", stopReason: "max_tokens", want: "length"}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + raw := []byte(`data: {"type":"message_delta","delta":{"stop_reason":"` + testCase.stopReason + `"}}`) + output := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, raw, nil) + if got := gjson.GetBytes(output, "choices.0.finish_reason").String(); got != testCase.want { + t.Fatalf("finish_reason = %q, want %q", got, testCase.want) + } + }) + } +} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go index e31a856a..671f98d0 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go @@ -900,10 +900,18 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string // Usage inputTokens, outputTokens, totalTokens, cachedTokens := usageTokens.OpenAIResponsesUsage() - out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens) - out, _ = sjson.SetBytes(out, "usage.input_tokens_details.cached_tokens", cachedTokens) - out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens) - out, _ = sjson.SetBytes(out, "usage.total_tokens", totalTokens) + if inputTokens != 0 { + out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens) + } + if cachedTokens != 0 { + out, _ = sjson.SetBytes(out, "usage.input_tokens_details.cached_tokens", cachedTokens) + } + if outputTokens != 0 { + out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens) + } + if totalTokens != 0 { + out, _ = sjson.SetBytes(out, "usage.total_tokens", totalTokens) + } if reasoningBuf.Len() > 0 { // Rough estimate similar to chat completions reasoningTokens := int64(len(reasoningBuf.String()) / 4) diff --git a/internal/translator/claude/openai/responses/noop_optimization_test.go b/internal/translator/claude/openai/responses/noop_optimization_test.go new file mode 100644 index 00000000..81ebb505 --- /dev/null +++ b/internal/translator/claude/openai/responses/noop_optimization_test.go @@ -0,0 +1,21 @@ +package responses + +import ( + "context" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertClaudeResponseToOpenAIResponsesNonStreamKeepsZeroUsageDefaults(t *testing.T) { + input := []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}`) + + output := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "", nil, nil, input, nil) + + for _, path := range []string{"usage.input_tokens", "usage.input_tokens_details.cached_tokens", "usage.output_tokens", "usage.total_tokens"} { + value := gjson.GetBytes(output, path) + if !value.Exists() || value.Int() != 0 { + t.Fatalf("%s = %s, want zero", path, value.Raw) + } + } +} diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 888bfa45..3a27e850 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -273,23 +273,31 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) continue } tool := []byte(toolResult.Raw) - tool, _ = sjson.SetBytes(tool, "type", "function") + if toolResult.Get("type").Type != gjson.String || toolResult.Get("type").String() != "function" { + tool, _ = sjson.SetBytes(tool, "type", "function") + } // Apply shortened name if needed if v := toolResult.Get("name"); v.Exists() { - name := v.String() + originalName := v.String() + name := originalName if short, ok := toolNameMap[name]; ok { name = short } else { name = shortenNameIfNeeded(name) } - tool, _ = sjson.SetBytes(tool, "name", name) + if v.Type != gjson.String || name != originalName { + tool, _ = sjson.SetBytes(tool, "name", name) + } } tool, _ = sjson.SetRawBytes(tool, "parameters", []byte(normalizeToolParameters(toolResult.Get("input_schema").Raw))) - tool, _ = sjson.DeleteBytes(tool, "input_schema") - tool, _ = sjson.DeleteBytes(tool, "parameters.$schema") - tool, _ = sjson.DeleteBytes(tool, "cache_control") - tool, _ = sjson.DeleteBytes(tool, "defer_loading") - tool, _ = sjson.SetBytes(tool, "strict", false) + for _, path := range []string{"input_schema", "parameters.$schema", "cache_control", "defer_loading"} { + if gjson.GetBytes(tool, path).Exists() { + tool, _ = sjson.DeleteBytes(tool, path) + } + } + if gjson.GetBytes(tool, "strict").Type != gjson.False { + tool, _ = sjson.SetBytes(tool, "strict", false) + } toolItems = append(toolItems, tool) } } diff --git a/internal/translator/codex/claude/noop_optimization_test.go b/internal/translator/codex/claude/noop_optimization_test.go new file mode 100644 index 00000000..49754946 --- /dev/null +++ b/internal/translator/codex/claude/noop_optimization_test.go @@ -0,0 +1,18 @@ +package claude + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertClaudeRequestToCodexNormalizesNonStringToolName(t *testing.T) { + input := []byte(`{"messages":[],"tools":[{"name":123,"input_schema":{"type":"object"}}]}`) + + output := ConvertClaudeRequestToCodex("gpt-test", input, false) + + name := gjson.GetBytes(output, "tools.0.name") + if name.Type != gjson.String || name.String() != "123" { + t.Fatalf("tools.0.name = %s, want string 123", name.Raw) + } +} diff --git a/internal/translator/codex/gemini/codex_gemini_request.go b/internal/translator/codex/gemini/codex_gemini_request.go index a53a067e..e61dc975 100644 --- a/internal/translator/codex/gemini/codex_gemini_request.go +++ b/internal/translator/codex/gemini/codex_gemini_request.go @@ -262,16 +262,10 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) tool, _ = sjson.SetBytes(tool, "description", v.String()) } if prm := fn.Get("parameters"); prm.Exists() { - // Remove optional $schema field if present - cleaned := []byte(prm.Raw) - cleaned, _ = sjson.DeleteBytes(cleaned, "$schema") - cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false) + cleaned := cleanGeminiCodexToolParameters(prm) tool, _ = sjson.SetRawBytes(tool, "parameters", cleaned) } else if prm = fn.Get("parametersJsonSchema"); prm.Exists() { - // Remove optional $schema field if present - cleaned := []byte(prm.Raw) - cleaned, _ = sjson.DeleteBytes(cleaned, "$schema") - cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false) + cleaned := cleanGeminiCodexToolParameters(prm) tool, _ = sjson.SetRawBytes(tool, "parameters", cleaned) } tool, _ = sjson.SetBytes(tool, "strict", false) @@ -342,7 +336,11 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) if typeValue.Type != gjson.String { continue } - out, _ = sjson.SetBytes(out, fullPath, strings.ToLower(typeValue.String())) + normalizedType := strings.ToLower(typeValue.String()) + if normalizedType == typeValue.String() { + continue + } + out, _ = sjson.SetBytes(out, fullPath, normalizedType) } return out @@ -357,7 +355,10 @@ func setCodexToolChoiceFromGeminiToolConfig(out []byte, functionCallingConfig gj case "NONE": out, _ = sjson.SetBytes(out, "tool_choice", "none") case "AUTO": - out, _ = sjson.SetBytes(out, "tool_choice", "auto") + current := gjson.GetBytes(out, "tool_choice") + if current.Type != gjson.String || current.String() != "auto" { + out, _ = sjson.SetBytes(out, "tool_choice", "auto") + } case "ANY": allowedNames := functionCallingConfig.Get("allowedFunctionNames") allowedNameItems := allowedNames.Array() @@ -372,6 +373,17 @@ func setCodexToolChoiceFromGeminiToolConfig(out []byte, functionCallingConfig gj return out } +func cleanGeminiCodexToolParameters(parameters gjson.Result) []byte { + cleaned := []byte(parameters.Raw) + if parameters.Get("$schema").Exists() { + cleaned, _ = sjson.DeleteBytes(cleaned, "$schema") + } + if additionalProperties := parameters.Get("additionalProperties"); additionalProperties.Type != gjson.False { + cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false) + } + return cleaned +} + func codexMessageWithPart(role string, part []byte) []byte { msg := []byte(`{"type":"message","role":"","content":[]}`) msg, _ = sjson.SetBytes(msg, "role", role) diff --git a/internal/translator/codex/gemini/noop_optimization_test.go b/internal/translator/codex/gemini/noop_optimization_test.go new file mode 100644 index 00000000..508382be --- /dev/null +++ b/internal/translator/codex/gemini/noop_optimization_test.go @@ -0,0 +1,41 @@ +package gemini + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestCleanGeminiCodexToolParametersPreservesCanonicalSchema(t *testing.T) { + input := []byte(`{"type":"object","properties":{"value":{"type":"string"}},"additionalProperties":false}`) + + output := cleanGeminiCodexToolParameters(gjson.ParseBytes(input)) + + if string(output) != string(input) { + t.Fatalf("canonical schema changed:\n got: %s\nwant: %s", output, input) + } +} + +func TestSetCodexToolChoiceFromGeminiToolConfigReusesAutoChoice(t *testing.T) { + input := []byte(`{"tool_choice":"auto","input":[]}`) + config := gjson.Parse(`{"mode":"AUTO"}`) + + output := setCodexToolChoiceFromGeminiToolConfig(input, config) + + if &output[0] != &input[0] { + t.Fatal("AUTO tool choice caused a payload copy") + } +} + +func TestCleanGeminiCodexToolParametersNormalizesSchema(t *testing.T) { + input := []byte(`{"type":"object","$schema":"draft","additionalProperties":true}`) + + output := cleanGeminiCodexToolParameters(gjson.ParseBytes(input)) + + if gjson.GetBytes(output, "$schema").Exists() { + t.Fatal("$schema should be removed") + } + if additionalProperties := gjson.GetBytes(output, "additionalProperties"); additionalProperties.Type != gjson.False { + t.Fatalf("additionalProperties = %s, want false", additionalProperties.Raw) + } +} diff --git a/internal/translator/codex/interactions/interactions_codex_request.go b/internal/translator/codex/interactions/interactions_codex_request.go index e37ad33f..89b083f2 100644 --- a/internal/translator/codex/interactions/interactions_codex_request.go +++ b/internal/translator/codex/interactions/interactions_codex_request.go @@ -348,19 +348,34 @@ func copyInteractionsToolsToCodex(out []byte, root gjson.Result) []byte { func copyInteractionsCodexTopLevel(out []byte, root gjson.Result) []byte { if serviceTier := normalizeInteractionsCodexServiceTier(root.Get("service_tier")); serviceTier != "" { - out, _ = sjson.SetBytes(out, "service_tier", serviceTier) + current := gjson.GetBytes(out, "service_tier") + if current.Type != gjson.String || current.String() != serviceTier { + out, _ = sjson.SetBytes(out, "service_tier", serviceTier) + } } if toolChoice := root.Get("tool_choice"); toolChoice.Exists() { - out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(toolChoice.Raw)) + out = setInteractionsCodexRawIfDifferent(out, "tool_choice", toolChoice) } for _, path := range []string{"parallel_tool_calls", "store", "metadata", "include", "truncation"} { if value := root.Get(path); value.Exists() { - out, _ = sjson.SetRawBytes(out, path, []byte(value.Raw)) + out = setInteractionsCodexRawIfDifferent(out, path, value) } } return out } +func setInteractionsCodexRawIfDifferent(out []byte, path string, value gjson.Result) []byte { + current := gjson.GetBytes(out, path) + if current.Exists() && current.Raw == value.Raw { + return out + } + updated, errSet := sjson.SetRawBytes(out, path, []byte(value.Raw)) + if errSet != nil { + return out + } + return updated +} + func appendInteractionsThoughtToCodex(items *[][]byte, step gjson.Result) { text := interactionsCodexContentText(step.Get("content")) if text == "" { @@ -565,8 +580,12 @@ func codexToolFromDeclaration(declaration gjson.Result) map[string]any { func cleanedCodexToolParameters(params gjson.Result) json.RawMessage { cleaned := []byte(params.Raw) - cleaned, _ = sjson.DeleteBytes(cleaned, "$schema") - cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false) + if params.Get("$schema").Exists() { + cleaned, _ = sjson.DeleteBytes(cleaned, "$schema") + } + if params.Get("additionalProperties").Type != gjson.False { + cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false) + } return json.RawMessage(cleaned) } diff --git a/internal/translator/codex/interactions/noop_optimization_test.go b/internal/translator/codex/interactions/noop_optimization_test.go new file mode 100644 index 00000000..8b1da38f --- /dev/null +++ b/internal/translator/codex/interactions/noop_optimization_test.go @@ -0,0 +1,28 @@ +package interactions + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestCleanedCodexToolParametersPreservesCanonicalSchema(t *testing.T) { + input := []byte(`{"type":"object","properties":{"value":{"type":"string"}},"additionalProperties":false}`) + + output := []byte(cleanedCodexToolParameters(gjson.ParseBytes(input))) + + if string(output) != string(input) { + t.Fatalf("canonical schema changed:\n got: %s\nwant: %s", output, input) + } +} + +func TestSetInteractionsCodexRawIfDifferentReusesMatchingValue(t *testing.T) { + input := []byte(`{"tool_choice":"auto","input":[]}`) + value := gjson.Parse(`"auto"`) + + output := setInteractionsCodexRawIfDifferent(input, "tool_choice", value) + + if &output[0] != &input[0] { + t.Fatal("matching raw value caused a payload copy") + } +} diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response.go b/internal/translator/codex/openai/chat-completions/codex_openai_response.go index 89bac12d..7feb9fdb 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_response.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_response.go @@ -459,12 +459,10 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original // Set content and reasoning content if found if contentText != "" { template, _ = sjson.SetBytes(template, "choices.0.message.content", contentText) - template, _ = sjson.SetBytes(template, "choices.0.message.role", "assistant") } if reasoningText != "" { template, _ = sjson.SetBytes(template, "choices.0.message.reasoning_content", reasoningText) - template, _ = sjson.SetBytes(template, "choices.0.message.role", "assistant") } // Add tool calls if any @@ -473,7 +471,6 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original for _, toolCall := range toolCalls { template, _ = sjson.SetRawBytes(template, "choices.0.message.tool_calls.-1", toolCall) } - template, _ = sjson.SetBytes(template, "choices.0.message.role", "assistant") } // Add images if any @@ -482,7 +479,6 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original for _, image := range images { template, _ = sjson.SetRawBytes(template, "choices.0.message.images.-1", image) } - template, _ = sjson.SetBytes(template, "choices.0.message.role", "assistant") } } diff --git a/internal/translator/codex/openai/chat-completions/noop_optimization_test.go b/internal/translator/codex/openai/chat-completions/noop_optimization_test.go new file mode 100644 index 00000000..1f5f5db3 --- /dev/null +++ b/internal/translator/codex/openai/chat-completions/noop_optimization_test.go @@ -0,0 +1,18 @@ +package chat_completions + +import ( + "context" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertCodexResponseToOpenAINonStreamKeepsAssistantRole(t *testing.T) { + input := []byte(`{"type":"response.completed","response":{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"hello"}]}]}}`) + + output := ConvertCodexResponseToOpenAINonStream(context.Background(), "", nil, nil, input, nil) + + if role := gjson.GetBytes(output, "choices.0.message.role").String(); role != "assistant" { + t.Fatalf("role = %q, want assistant", role) + } +} diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index f6641a5e..ac41628d 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -207,13 +207,17 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) if err != nil { return true } - tool, _ = sjson.DeleteBytes(tool, "strict") - tool, _ = sjson.DeleteBytes(tool, "input_examples") - tool, _ = sjson.DeleteBytes(tool, "type") - tool, _ = sjson.DeleteBytes(tool, "cache_control") - tool, _ = sjson.DeleteBytes(tool, "defer_loading") - tool, _ = sjson.DeleteBytes(tool, "eager_input_streaming") - tool, _ = sjson.SetBytes(tool, "name", util.SanitizeFunctionName(gjson.GetBytes(tool, "name").String())) + for _, path := range []string{"strict", "input_examples", "type", "cache_control", "defer_loading", "eager_input_streaming"} { + if toolResult.Get(path).Exists() { + tool, _ = sjson.DeleteBytes(tool, path) + } + } + nameResult := toolResult.Get("name") + originalName := nameResult.String() + sanitizedName := util.SanitizeFunctionName(originalName) + if nameResult.Type != gjson.String || sanitizedName != originalName { + tool, _ = sjson.SetBytes(tool, "name", sanitizedName) + } if gjson.ValidBytes(tool) && gjson.ParseBytes(tool).IsObject() { toolItems = append(toolItems, tool) } @@ -224,8 +228,6 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) tools := []byte(`[{"functionDeclarations":[]}]`) tools, _ = sjson.SetRawBytes(tools, "0.functionDeclarations", translatorcommon.JoinRawArray(toolItems)) out, _ = sjson.SetRawBytes(out, "tools", tools) - } else { - out, _ = sjson.DeleteBytes(out, "tools") } } diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index abbb2dd0..c6a2dd80 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -350,13 +350,22 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) fnRaw = string(fnRawBytes) } fnRawBytes := []byte(fnRaw) - fnRawBytes, _ = sjson.SetBytes(fnRawBytes, "name", util.SanitizeFunctionName(fn.Get("name").String())) - fnRaw = string(fnRawBytes) - if parameters := gjson.Get(fnRaw, "parametersJsonSchema"); parameters.Exists() { - fnRaw, _ = sjson.SetRaw(fnRaw, "parametersJsonSchema", util.CleanJSONSchemaForGemini(parameters.Raw)) + nameResult := fn.Get("name") + originalName := nameResult.String() + sanitizedName := util.SanitizeFunctionName(originalName) + if nameResult.Type != gjson.String || sanitizedName != originalName { + fnRawBytes, _ = sjson.SetBytes(fnRawBytes, "name", sanitizedName) } - fnRaw, _ = sjson.Delete(fnRaw, "strict") - functionDeclarations = append(functionDeclarations, []byte(fnRaw)) + if parameters := gjson.GetBytes(fnRawBytes, "parametersJsonSchema"); parameters.Exists() { + cleanedParameters := util.CleanJSONSchemaForGemini(parameters.Raw) + if cleanedParameters != parameters.Raw { + fnRawBytes, _ = sjson.SetRawBytes(fnRawBytes, "parametersJsonSchema", []byte(cleanedParameters)) + } + } + if gjson.GetBytes(fnRawBytes, "strict").Exists() { + fnRawBytes, _ = sjson.DeleteBytes(fnRawBytes, "strict") + } + functionDeclarations = append(functionDeclarations, fnRawBytes) } } if gs := t.Get("google_search"); gs.Exists() { diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go index 155a8c5f..cb44c258 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go @@ -150,6 +150,14 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR } partsResult := candidate.Get("content.parts") + assistantRoleSet := false + setAssistantRole := func() { + if assistantRoleSet { + return + } + template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant") + assistantRoleSet = true + } if partsResult.IsArray() { partResults := partsResult.Array() @@ -176,13 +184,13 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR if partTextResult.Exists() { text := partTextResult.String() + setAssistantRole() // Handle text content, distinguishing between regular content and reasoning/thoughts. if partResult.Get("thought").Bool() { template, _ = sjson.SetBytes(template, "choices.0.delta.reasoning_content", text) } else { template, _ = sjson.SetBytes(template, "choices.0.delta.content", text) } - template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant") } else if functionCallResult.Exists() { // Handle function call content. p.SawToolCall[candidateIndex] = true @@ -206,7 +214,7 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.arguments", fcArgsResult.Raw) } - template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant") + setAssistantRole() template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallTemplate) } else if inlineDataResult.Exists() { data := inlineDataResult.Get("data").String() @@ -229,7 +237,7 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`) imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex) imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL) - template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant") + setAssistantRole() template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload) } } @@ -365,7 +373,6 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina oldVal := gjson.GetBytes(choiceTemplate, "message.content").String() choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "message.content", oldVal+partTextResult.String()) } - choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "message.role", "assistant") } else if functionCallResult.Exists() { // Append function call content to the tool_calls array. hasFunctionCall = true @@ -380,7 +387,6 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", fcArgsResult.Raw) } - choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "message.role", "assistant") choiceTemplate, _ = sjson.SetRawBytes(choiceTemplate, "message.tool_calls.-1", functionCallItemTemplate) } else if inlineDataResult.Exists() { data := inlineDataResult.Get("data").String() @@ -401,7 +407,6 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`) imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex) imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL) - choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "message.role", "assistant") choiceTemplate, _ = sjson.SetRawBytes(choiceTemplate, "message.images.-1", imagePayload) } } diff --git a/internal/translator/gemini/openai/chat-completions/noop_optimization_test.go b/internal/translator/gemini/openai/chat-completions/noop_optimization_test.go new file mode 100644 index 00000000..b69d0f3d --- /dev/null +++ b/internal/translator/gemini/openai/chat-completions/noop_optimization_test.go @@ -0,0 +1,55 @@ +package chat_completions + +import ( + "context" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertOpenAIRequestToGeminiNormalizesToolNameAndStrict(t *testing.T) { + input := []byte(`{"messages":[],"tools":[{"type":"function","function":{"name":true,"strict":true,"parameters":{"type":"object"}}}]}`) + + output := ConvertOpenAIRequestToGemini("gemini-test", input, false) + + name := gjson.GetBytes(output, "tools.0.functionDeclarations.0.name") + if name.Type != gjson.String || name.String() != "true" { + t.Fatalf("tool name = %s, want string true", name.Raw) + } + if gjson.GetBytes(output, "tools.0.functionDeclarations.0.strict").Exists() { + t.Fatal("strict should be removed") + } +} + +func TestConvertGeminiResponseToOpenAINonStreamKeepsAssistantRole(t *testing.T) { + input := []byte(`{"candidates":[{"index":0,"content":{"parts":[{"text":"hello"}]},"finishReason":"STOP"}]}`) + + output := ConvertGeminiResponseToOpenAINonStream(context.Background(), "", nil, nil, input, nil) + + if role := gjson.GetBytes(output, "choices.0.message.role").String(); role != "assistant" { + t.Fatalf("role = %q, want assistant", role) + } +} + +func TestConvertGeminiResponseToOpenAIStreamingSetsAssistantRoleOnce(t *testing.T) { + input := []byte(`{"candidates":[{"index":0,"content":{"parts":[{"text":"hello"},{"functionCall":{"name":"lookup","args":{}}},{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}}]}`) + var param any + + outputs := ConvertGeminiResponseToOpenAI(context.Background(), "", nil, nil, input, ¶m) + + if len(outputs) != 1 { + t.Fatalf("output count = %d, want 1", len(outputs)) + } + if role := gjson.GetBytes(outputs[0], "choices.0.delta.role").String(); role != "assistant" { + t.Fatalf("role = %q, want assistant", role) + } + if got := gjson.GetBytes(outputs[0], "choices.0.delta.content").String(); got != "hello" { + t.Fatalf("content = %q, want hello", got) + } + if !gjson.GetBytes(outputs[0], "choices.0.delta.tool_calls.0").Exists() { + t.Fatal("tool call should be present") + } + if !gjson.GetBytes(outputs[0], "choices.0.delta.images.0").Exists() { + t.Fatal("image should be present") + } +} diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index 21e2d2e6..81621a3f 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -32,7 +32,6 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte part := []byte(`{"text":""}`) part, _ = sjson.SetBytes(part, "text", instructions.String()) systemParts = append(systemParts, part) - out, _ = sjson.SetRawBytes(out, "systemInstruction", geminiSystemInstruction(systemParts)) } // Convert input messages to Gemini contents format @@ -409,25 +408,16 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte // Handle temperature if present if temperature := root.Get("temperature"); temperature.Exists() { - if !gjson.GetBytes(out, "generationConfig").Exists() { - out, _ = sjson.SetRawBytes(out, "generationConfig", []byte(`{}`)) - } out, _ = sjson.SetBytes(out, "generationConfig.temperature", temperature.Float()) } // Handle top_p if present if topP := root.Get("top_p"); topP.Exists() { - if !gjson.GetBytes(out, "generationConfig").Exists() { - out, _ = sjson.SetRawBytes(out, "generationConfig", []byte(`{}`)) - } out, _ = sjson.SetBytes(out, "generationConfig.topP", topP.Float()) } // Handle stop sequences if stopSequences := root.Get("stop_sequences"); stopSequences.Exists() && stopSequences.IsArray() { - if !gjson.GetBytes(out, "generationConfig").Exists() { - out, _ = sjson.SetRawBytes(out, "generationConfig", []byte(`{}`)) - } var sequences []string stopSequences.ForEach(func(_, seq gjson.Result) bool { sequences = append(sequences, seq.String()) @@ -594,12 +584,9 @@ func applyOpenAIResponsesTextFormatToGemini(out []byte, root gjson.Result) []byt formatType := strings.ToLower(strings.TrimSpace(textFormat.Get("type").String())) switch formatType { case "json_object": - out = ensureGeminiGenerationConfig(out) out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json") case "json_schema": - out = ensureGeminiGenerationConfig(out) out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json") - out, _ = sjson.DeleteBytes(out, "generationConfig.responseSchema") schema := textFormat.Get("schema") if !schema.Exists() { @@ -612,10 +599,3 @@ func applyOpenAIResponsesTextFormatToGemini(out []byte, root gjson.Result) []byt return out } - -func ensureGeminiGenerationConfig(out []byte) []byte { - if !gjson.GetBytes(out, "generationConfig").Exists() { - out, _ = sjson.SetRawBytes(out, "generationConfig", []byte(`{}`)) - } - return out -} diff --git a/internal/translator/gemini/openai/responses/noop_optimization_test.go b/internal/translator/gemini/openai/responses/noop_optimization_test.go new file mode 100644 index 00000000..255ab295 --- /dev/null +++ b/internal/translator/gemini/openai/responses/noop_optimization_test.go @@ -0,0 +1,32 @@ +package responses + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertOpenAIResponsesRequestToGeminiBuildsGenerationConfigWithoutIntermediateObject(t *testing.T) { + input := []byte(`{"input":"hello","temperature":0.5,"top_p":0.9,"stop_sequences":["done"],"text":{"format":{"type":"json_schema","schema":{"type":"object"}}}}`) + + output := ConvertOpenAIResponsesRequestToGemini("gemini-test", input, false) + + if got := gjson.GetBytes(output, "generationConfig.temperature").Float(); got != 0.5 { + t.Fatalf("temperature = %v, want 0.5", got) + } + if got := gjson.GetBytes(output, "generationConfig.topP").Float(); got != 0.9 { + t.Fatalf("topP = %v, want 0.9", got) + } + if got := gjson.GetBytes(output, "generationConfig.stopSequences.0").String(); got != "done" { + t.Fatalf("stop sequence = %q, want done", got) + } + if got := gjson.GetBytes(output, "generationConfig.responseMimeType").String(); got != "application/json" { + t.Fatalf("responseMimeType = %q, want application/json", got) + } + if !gjson.GetBytes(output, "generationConfig.responseJsonSchema").Exists() { + t.Fatal("responseJsonSchema should be present") + } + if gjson.GetBytes(output, "generationConfig.responseSchema").Exists() { + t.Fatal("responseSchema should not be present") + } +} diff --git a/internal/translator/openai/openai/chat-completions/openai_openai_request.go b/internal/translator/openai/openai/chat-completions/openai_openai_request.go index f2e6fadc..dd3ee5ab 100644 --- a/internal/translator/openai/openai/chat-completions/openai_openai_request.go +++ b/internal/translator/openai/openai/chat-completions/openai_openai_request.go @@ -3,6 +3,7 @@ package chat_completions import ( + "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -17,6 +18,11 @@ import ( // Returns: // - []byte: The transformed request data in OpenAI API format func ConvertOpenAIRequestToOpenAI(modelName string, inputRawJSON []byte, _ bool) []byte { + currentModel := gjson.GetBytes(inputRawJSON, "model") + if currentModel.Type == gjson.String && currentModel.String() == modelName { + return inputRawJSON + } + // Update the "model" field in the JSON payload with the provided modelName // The sjson.SetBytes function returns a new byte slice with the updated JSON. updatedJSON, err := sjson.SetBytes(inputRawJSON, "model", modelName) diff --git a/internal/translator/openai/openai/chat-completions/openai_openai_request_test.go b/internal/translator/openai/openai/chat-completions/openai_openai_request_test.go new file mode 100644 index 00000000..80a71a28 --- /dev/null +++ b/internal/translator/openai/openai/chat-completions/openai_openai_request_test.go @@ -0,0 +1,27 @@ +package chat_completions + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertOpenAIRequestToOpenAIReusesMatchingModelPayload(t *testing.T) { + input := []byte(`{"model":"gpt-test","messages":[{"role":"user","content":"hello"}]}`) + + output := ConvertOpenAIRequestToOpenAI("gpt-test", input, false) + + if &output[0] != &input[0] { + t.Fatal("matching model caused a payload copy") + } +} + +func TestConvertOpenAIRequestToOpenAIUpdatesDifferentModel(t *testing.T) { + input := []byte(`{"model":"old-model","messages":[]}`) + + output := ConvertOpenAIRequestToOpenAI("new-model", input, false) + + if model := gjson.GetBytes(output, "model").String(); model != "new-model" { + t.Fatalf("model = %q, want new-model", model) + } +} diff --git a/internal/translator/request_benchmark_test.go b/internal/translator/request_benchmark_test.go index 333a4a01..3e7c0f01 100644 --- a/internal/translator/request_benchmark_test.go +++ b/internal/translator/request_benchmark_test.go @@ -1,6 +1,7 @@ package translator import ( + "bytes" "encoding/json" "fmt" "strings" @@ -10,6 +11,10 @@ import ( "github.com/tidwall/gjson" ) +const benchmarkHistorySentinel = "benchmark-final-history-turn" + +var benchmarkRequestTranslationOutput []byte + func BenchmarkRequestTranslationLargeHistory(b *testing.B) { benchmarkRequestTranslation(b, 64) } @@ -36,7 +41,7 @@ func benchmarkRequestTranslation(b *testing.B, turns int) { }{ {source: "claude", targets: []string{"openai", "gemini", "codex", "interactions", "antigravity"}}, {source: "gemini", targets: []string{"openai", "claude", "codex", "interactions", "antigravity", "gemini"}}, - {source: "openai", targets: []string{"claude", "gemini", "codex", "interactions", "antigravity"}}, + {source: "openai", targets: []string{"claude", "gemini", "codex", "interactions", "antigravity", "openai"}}, {source: "openai-response", targets: []string{"claude", "gemini", "codex", "interactions", "openai"}}, {source: "interactions", targets: []string{"claude", "gemini", "codex", "openai", "openai-response", "antigravity"}}, } @@ -45,14 +50,18 @@ func benchmarkRequestTranslation(b *testing.B, turns int) { request := requests[route.source] for _, target := range route.targets { b.Run(route.source+"_to_"+target, func(b *testing.B) { - if output := translatorapi.Request(route.source, target, "gemini-2.5-pro", request, true); !gjson.ValidBytes(output) { + output := translatorapi.Request(route.source, target, "gemini-2.5-pro", request, true) + if !gjson.ValidBytes(output) { b.Fatalf("translator generated invalid JSON: %s", output) } + if turns > 0 && !bytes.Contains(output, []byte(benchmarkHistorySentinel)) { + b.Fatal("translator dropped the final benchmark history turn") + } b.ReportAllocs() b.SetBytes(int64(len(request))) b.ResetTimer() - for i := 0; i < b.N; i++ { - translatorapi.Request(route.source, target, "gemini-2.5-pro", request, true) + for b.Loop() { + benchmarkRequestTranslationOutput = translatorapi.Request(route.source, target, "gemini-2.5-pro", request, true) } }) } @@ -74,6 +83,9 @@ func benchmarkClaudeRequest(turns int) []byte { }}, ) } + if turns > 0 { + messages = append(messages, map[string]any{"role": "user", "content": benchmarkHistorySentinel}) + } return benchmarkJSON(map[string]any{ "system": []any{map[string]any{"type": "text", "text": payload}}, "messages": messages, @@ -96,6 +108,9 @@ func benchmarkGeminiRequest(turns int) []byte { }}, ) } + if turns > 0 { + contents = append(contents, map[string]any{"role": "user", "parts": []any{map[string]any{"text": benchmarkHistorySentinel}}}) + } return benchmarkJSON(map[string]any{ "system_instruction": map[string]any{"parts": []any{map[string]any{"text": payload}}}, "contents": contents, @@ -118,7 +133,11 @@ func benchmarkOpenAIRequest(turns int) []byte { map[string]any{"role": "tool", "tool_call_id": callID, "content": payload}, ) } + if turns > 0 { + messages = append(messages, map[string]any{"role": "user", "content": benchmarkHistorySentinel}) + } return benchmarkJSON(map[string]any{ + "model": "gemini-2.5-pro", "messages": messages, "tools": []any{map[string]any{"type": "function", "function": map[string]any{ "name": "lookup", "description": payload, "parameters": benchmarkSchema(), @@ -137,6 +156,9 @@ func benchmarkOpenAIResponsesRequest(turns int) []byte { map[string]any{"type": "function_call_output", "call_id": callID, "output": payload}, ) } + if turns > 0 { + input = append(input, map[string]any{"type": "message", "role": "user", "content": []any{map[string]any{"type": "input_text", "text": benchmarkHistorySentinel}}}) + } return benchmarkJSON(map[string]any{ "instructions": payload, "input": input, @@ -157,6 +179,9 @@ func benchmarkInteractionsRequest(turns int) []byte { map[string]any{"type": "function_result", "call_id": callID, "name": "lookup", "result": payload}, ) } + if turns > 0 { + input = append(input, map[string]any{"type": "user_input", "content": []any{map[string]any{"type": "text", "text": benchmarkHistorySentinel}}}) + } return benchmarkJSON(map[string]any{ "system_instruction": payload, "input": input, diff --git a/internal/translator/response_benchmark_test.go b/internal/translator/response_benchmark_test.go new file mode 100644 index 00000000..32c0eec5 --- /dev/null +++ b/internal/translator/response_benchmark_test.go @@ -0,0 +1,74 @@ +package translator + +import ( + "bytes" + "context" + "strings" + "testing" + + translatorapi "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" + "github.com/tidwall/gjson" +) + +var benchmarkResponseTranslationOutput []byte + +func BenchmarkResponseTranslationLargePayload(b *testing.B) { + payload := strings.Repeat("x", 8<<20) + cases := []struct { + name string + from string + to string + rawJSON []byte + }{ + { + name: "gemini_to_openai", + from: "gemini", + to: "openai", + rawJSON: []byte(`{"modelVersion":"gemini-test","candidates":[{"index":0,"content":{"parts":[{"text":"` + payload + `"}]},"finishReason":"STOP"}]}`), + }, + { + name: "codex_to_openai", + from: "codex", + to: "openai", + rawJSON: []byte(`{"type":"response.completed","response":{"id":"resp_1","created_at":1700000000,"model":"gpt-test","status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"` + payload + `"}]}]}}`), + }, + { + name: "claude_to_openai", + from: "claude", + to: "openai", + rawJSON: claudeLargeTextResponse(payload), + }, + { + name: "claude_to_openai-response", + from: "claude", + to: "openai-response", + rawJSON: claudeLargeTextResponse(payload), + }, + } + + for _, testCase := range cases { + b.Run(testCase.name, func(b *testing.B) { + output := translatorapi.ResponseNonStream(testCase.from, testCase.to, context.Background(), "benchmark-model", nil, nil, testCase.rawJSON, nil) + if !gjson.ValidBytes(output) { + b.Fatalf("translator generated invalid JSON: %s", output) + } + if !bytes.Contains(output, []byte(payload)) { + b.Fatal("translator dropped the benchmark payload") + } + b.ReportAllocs() + b.SetBytes(int64(len(testCase.rawJSON))) + b.ResetTimer() + + for b.Loop() { + benchmarkResponseTranslationOutput = translatorapi.ResponseNonStream(testCase.from, testCase.to, context.Background(), "benchmark-model", nil, nil, testCase.rawJSON, nil) + } + }) + } +} + +func claudeLargeTextResponse(payload string) []byte { + return []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"model\":\"claude-test\"}}\n" + + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\"}}\n" + + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"" + payload + "\"}}\n" + + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n") +}