mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 01:16:47 +08:00
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:
@@ -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, ']')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+47
-14
@@ -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")
|
||||
|
||||
+7
-2
@@ -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, ¶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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user