perf(translator): add unit tests for payload reuse and normalization across translators

- Added tests to ensure no-copy payload reuse for various translators (Codex, Gemini, OpenAI, Claude, etc.).
- Covered scenarios for field normalization, tool parameter cleaning, and thought signature sanitization.
- Included a benchmark for SanitizeGeminiRequestThoughtSignatures with large normalized payloads.
This commit is contained in:
Luis Pater
2026-07-20 03:50:35 +08:00
parent 0f52284e6c
commit 64291120e7
36 changed files with 1093 additions and 180 deletions
+125 -28
View File
@@ -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, ']')
}
+70 -1
View File
@@ -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")
@@ -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
@@ -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
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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")
@@ -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)
}
}
}
@@ -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")
}
}
@@ -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
}
@@ -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)
}
}
@@ -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
@@ -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)
}
})
}
}
@@ -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)
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
@@ -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)
}
}
@@ -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)
}
@@ -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")
}
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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")
}
}
@@ -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() {
@@ -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)
}
}
@@ -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, &param)
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")
}
}
@@ -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
}
@@ -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")
}
}
@@ -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)
@@ -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)
}
}
+29 -4
View File
@@ -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,
@@ -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")
}