Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
|
|
@ -0,0 +1,20 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertOpenAIRequestToAntigravityNormalizesFileDataURL(t *testing.T) {
|
||||
input := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
|
||||
|
||||
out := ConvertOpenAIRequestToAntigravity("gemini-2.5-pro", input, false)
|
||||
inlineData := gjson.GetBytes(out, "request.contents.0.parts.0.inlineData")
|
||||
if got := inlineData.Get("mimeType").String(); got != "application/pdf" {
|
||||
t.Fatalf("inlineData.mimeType = %q, want application/pdf. Output: %s", got, out)
|
||||
}
|
||||
if got := inlineData.Get("data").String(); got != "JVBERi0xLjQK" {
|
||||
t.Fatalf("inlineData.data = %q, want raw base64 payload. Output: %s", got, out)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,622 @@
|
|||
// Package openai provides request translation functionality for OpenAI to Antigravity API compatibility.
|
||||
// It converts OpenAI Chat Completions requests into Antigravity compatible JSON using gjson/sjson only.
|
||||
package chat_completions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini"
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const antigravityFunctionThoughtSignature = "skip_thought_signature_validator"
|
||||
|
||||
// ConvertOpenAIRequestToAntigravity converts an OpenAI Chat Completions request (raw JSON)
|
||||
// into a complete Antigravity request JSON. All JSON construction uses sjson and lookups use gjson.
|
||||
//
|
||||
// Parameters:
|
||||
// - modelName: The name of the model to use for the request
|
||||
// - rawJSON: The raw JSON request data from the OpenAI API
|
||||
// - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: The transformed request data in Antigravity API format
|
||||
func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte {
|
||||
rawJSON := inputRawJSON
|
||||
functionNameMap := util.SanitizedFunctionNameMap(rawJSON)
|
||||
// Base envelope (no default thinkingConfig)
|
||||
out := []byte(`{"project":"","request":{"contents":[]},"model":"gemini-2.5-pro"}`)
|
||||
|
||||
// Model
|
||||
out, _ = sjson.SetBytes(out, "model", modelName)
|
||||
|
||||
// Let user-provided generationConfig pass through
|
||||
if genConfig := gjson.GetBytes(rawJSON, "generationConfig"); genConfig.Exists() {
|
||||
out, _ = sjson.SetRawBytes(out, "request.generationConfig", []byte(genConfig.Raw))
|
||||
} else if genConfig := gjson.GetBytes(rawJSON, "generation_config"); genConfig.Exists() {
|
||||
out, _ = sjson.SetRawBytes(out, "request.generationConfig", []byte(genConfig.Raw))
|
||||
}
|
||||
|
||||
// Apply thinking configuration: convert OpenAI reasoning_effort to Antigravity thinkingConfig.
|
||||
// Inline translation-only mapping; capability checks happen later in ApplyThinking.
|
||||
re := gjson.GetBytes(rawJSON, "reasoning_effort")
|
||||
if re.Exists() {
|
||||
effort := strings.ToLower(strings.TrimSpace(re.String()))
|
||||
if effort != "" {
|
||||
thinkingPath := "request.generationConfig.thinkingConfig"
|
||||
if effort == "auto" {
|
||||
out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1)
|
||||
} else {
|
||||
out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort)
|
||||
}
|
||||
}
|
||||
}
|
||||
out = applyOpenAIThinkingCompatibilityToAntigravity(out, rawJSON)
|
||||
|
||||
// Temperature/top_p/top_k/max_tokens/max_completion_tokens
|
||||
if tr := gjson.GetBytes(rawJSON, "temperature"); tr.Exists() && tr.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "request.generationConfig.temperature", tr.Num)
|
||||
}
|
||||
if tpr := gjson.GetBytes(rawJSON, "top_p"); tpr.Exists() && tpr.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "request.generationConfig.topP", tpr.Num)
|
||||
}
|
||||
if tkr := gjson.GetBytes(rawJSON, "top_k"); tkr.Exists() && tkr.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "request.generationConfig.topK", tkr.Num)
|
||||
}
|
||||
if maxTok := gjson.GetBytes(rawJSON, "max_tokens"); maxTok.Exists() && maxTok.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "request.generationConfig.maxOutputTokens", maxTok.Num)
|
||||
} else if mct := gjson.GetBytes(rawJSON, "max_completion_tokens"); mct.Exists() && mct.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "request.generationConfig.maxOutputTokens", mct.Num)
|
||||
}
|
||||
|
||||
// Map OpenAI response_format to Antigravity structured output settings.
|
||||
if responseFormat := gjson.GetBytes(rawJSON, "response_format"); responseFormat.Exists() {
|
||||
switch responseFormatType := strings.ToLower(strings.TrimSpace(responseFormat.Get("type").String())); responseFormatType {
|
||||
case "json_object", "json_schema":
|
||||
for _, schemaKey := range []string{"responseSchema", "responseJsonSchema", "response_schema", "response_json_schema"} {
|
||||
out, _ = sjson.DeleteBytes(out, "request.generationConfig."+schemaKey)
|
||||
}
|
||||
out, _ = sjson.SetBytes(out, "request.generationConfig.responseMimeType", "application/json")
|
||||
if responseFormatType == "json_schema" {
|
||||
if schema := responseFormat.Get("json_schema.schema"); schema.Exists() {
|
||||
out, _ = sjson.SetRawBytes(out, "request.generationConfig.responseSchema", []byte(schema.Raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Candidate count (OpenAI 'n' parameter)
|
||||
if n := gjson.GetBytes(rawJSON, "n"); n.Exists() && n.Type == gjson.Number {
|
||||
if val := n.Int(); val > 1 {
|
||||
out, _ = sjson.SetBytes(out, "request.generationConfig.candidateCount", val)
|
||||
}
|
||||
}
|
||||
|
||||
// Map OpenAI modalities -> Antigravity request.generationConfig.responseModalities
|
||||
// e.g. "modalities": ["image", "text"] -> ["IMAGE", "TEXT"]
|
||||
if mods := gjson.GetBytes(rawJSON, "modalities"); mods.Exists() && mods.IsArray() {
|
||||
var responseMods []string
|
||||
for _, m := range mods.Array() {
|
||||
switch strings.ToLower(m.String()) {
|
||||
case "text":
|
||||
responseMods = append(responseMods, "TEXT")
|
||||
case "image":
|
||||
responseMods = append(responseMods, "IMAGE")
|
||||
}
|
||||
}
|
||||
if len(responseMods) > 0 {
|
||||
out, _ = sjson.SetBytes(out, "request.generationConfig.responseModalities", responseMods)
|
||||
}
|
||||
}
|
||||
|
||||
// OpenRouter-style image_config support
|
||||
// If the input uses top-level image_config.aspect_ratio, map it into request.generationConfig.imageConfig.aspectRatio.
|
||||
if imgCfg := gjson.GetBytes(rawJSON, "image_config"); imgCfg.Exists() && imgCfg.IsObject() {
|
||||
if ar := imgCfg.Get("aspect_ratio"); ar.Exists() && ar.Type == gjson.String {
|
||||
out, _ = sjson.SetBytes(out, "request.generationConfig.imageConfig.aspectRatio", ar.Str)
|
||||
}
|
||||
if size := imgCfg.Get("image_size"); size.Exists() && size.Type == gjson.String {
|
||||
out, _ = sjson.SetBytes(out, "request.generationConfig.imageConfig.imageSize", size.Str)
|
||||
}
|
||||
}
|
||||
|
||||
// messages -> systemInstruction + contents
|
||||
messages := gjson.GetBytes(rawJSON, "messages")
|
||||
if messages.IsArray() {
|
||||
arr := messages.Array()
|
||||
systemParts := make([][]byte, 0, 2)
|
||||
contentItems := make([][]byte, 0, len(arr))
|
||||
// First pass: assistant tool_calls id->name map
|
||||
tcID2Name := map[string]string{}
|
||||
for i := 0; i < len(arr); i++ {
|
||||
m := arr[i]
|
||||
if m.Get("role").String() == "assistant" {
|
||||
tcs := m.Get("tool_calls")
|
||||
if tcs.IsArray() {
|
||||
for _, tc := range tcs.Array() {
|
||||
if tc.Get("type").String() == "function" {
|
||||
id := tc.Get("id").String()
|
||||
name := tc.Get("function.name").String()
|
||||
if id != "" && name != "" {
|
||||
tcID2Name[id] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass build systemInstruction/tool responses cache
|
||||
toolResponses := map[string]string{} // tool_call_id -> response text
|
||||
for i := 0; i < len(arr); i++ {
|
||||
m := arr[i]
|
||||
role := m.Get("role").String()
|
||||
if role == "tool" {
|
||||
toolCallID := m.Get("tool_call_id").String()
|
||||
if toolCallID != "" {
|
||||
c := m.Get("content")
|
||||
toolResponses[toolCallID] = c.Raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < len(arr); i++ {
|
||||
m := arr[i]
|
||||
role := m.Get("role").String()
|
||||
content := m.Get("content")
|
||||
|
||||
if (role == "system" || role == "developer") && len(arr) > 1 {
|
||||
// system -> request.systemInstruction as a user message style
|
||||
if content.Type == gjson.String {
|
||||
systemParts = append(systemParts, antigravityOpenAITextPart(content.String()))
|
||||
} else if content.IsObject() && content.Get("type").String() == "text" {
|
||||
systemParts = append(systemParts, antigravityOpenAITextPart(content.Get("text").String()))
|
||||
} else if content.IsArray() {
|
||||
for _, contentPart := range content.Array() {
|
||||
systemParts = append(systemParts, antigravityOpenAITextPart(contentPart.Get("text").String()))
|
||||
}
|
||||
}
|
||||
} else if role == "user" || ((role == "system" || role == "developer") && len(arr) == 1) {
|
||||
partItems := make([][]byte, 0, 4)
|
||||
if content.Type == gjson.String {
|
||||
partItems = append(partItems, antigravityOpenAITextPart(content.String()))
|
||||
} else if content.IsArray() {
|
||||
for _, item := range content.Array() {
|
||||
switch item.Get("type").String() {
|
||||
case "text":
|
||||
if text := item.Get("text").String(); text != "" {
|
||||
partItems = append(partItems, antigravityOpenAITextPart(text))
|
||||
}
|
||||
case "image_url":
|
||||
imageURL := item.Get("image_url.url").String()
|
||||
if len(imageURL) > 5 {
|
||||
pieces := strings.SplitN(imageURL[5:], ";", 2)
|
||||
if len(pieces) == 2 && len(pieces[1]) > 7 {
|
||||
part := antigravityOpenAIInlineDataPart(pieces[0], pieces[1][7:], false)
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", antigravityFunctionThoughtSignature)
|
||||
partItems = append(partItems, part)
|
||||
}
|
||||
}
|
||||
case "video_url":
|
||||
videoURL := item.Get("video_url.url").String()
|
||||
if len(videoURL) > 5 {
|
||||
pieces := strings.SplitN(videoURL[5:], ";", 2)
|
||||
if len(pieces) == 2 && len(pieces[1]) > 7 {
|
||||
partItems = append(partItems, antigravityOpenAIInlineDataPart(pieces[0], pieces[1][7:], false))
|
||||
}
|
||||
}
|
||||
case "file":
|
||||
filename := item.Get("file.filename").String()
|
||||
fileData := item.Get("file.file_data").String()
|
||||
if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, "", fileData); ok {
|
||||
partItems = append(partItems, antigravityOpenAIInlineDataPart(mimeType, data, false))
|
||||
} else {
|
||||
log.Warn("Invalid file data or unknown file name extension in user message, skip")
|
||||
}
|
||||
case "input_audio":
|
||||
audioData := item.Get("input_audio.data").String()
|
||||
if audioData != "" {
|
||||
mimeType := antigravityOpenAIAudioMIMEType(item.Get("input_audio.format").String())
|
||||
partItems = append(partItems, antigravityOpenAIInlineDataPart(mimeType, audioData, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
contentItems = append(contentItems, antigravityOpenAIContent("user", partItems))
|
||||
} else if role == "assistant" {
|
||||
partItems := make([][]byte, 0, 4)
|
||||
if reasoningContent := m.Get("reasoning_content"); reasoningContent.Type == gjson.String && reasoningContent.String() != "" {
|
||||
part := antigravityOpenAITextPart(reasoningContent.String())
|
||||
part, _ = sjson.SetBytes(part, "thought", true)
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", antigravityFunctionThoughtSignature)
|
||||
partItems = append(partItems, part)
|
||||
}
|
||||
if content.Type == gjson.String && content.String() != "" {
|
||||
partItems = append(partItems, antigravityOpenAITextPart(content.String()))
|
||||
} else if content.IsArray() {
|
||||
for _, item := range content.Array() {
|
||||
switch item.Get("type").String() {
|
||||
case "text":
|
||||
if text := item.Get("text").String(); text != "" {
|
||||
partItems = append(partItems, antigravityOpenAITextPart(text))
|
||||
}
|
||||
case "image_url":
|
||||
imageURL := item.Get("image_url.url").String()
|
||||
if len(imageURL) > 5 {
|
||||
pieces := strings.SplitN(imageURL[5:], ";", 2)
|
||||
if len(pieces) == 2 && len(pieces[1]) > 7 {
|
||||
part := antigravityOpenAIInlineDataPart(pieces[0], pieces[1][7:], false)
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", antigravityFunctionThoughtSignature)
|
||||
partItems = append(partItems, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tcs := m.Get("tool_calls")
|
||||
if tcs.IsArray() {
|
||||
functionIDs := make([]string, 0)
|
||||
for _, tc := range tcs.Array() {
|
||||
if tc.Get("type").String() != "function" {
|
||||
continue
|
||||
}
|
||||
functionID := tc.Get("id").String()
|
||||
functionName := util.MapSanitizedFunctionName(functionNameMap, tc.Get("function.name").String())
|
||||
if functionName == "" {
|
||||
continue
|
||||
}
|
||||
functionArgs := tc.Get("function.arguments").String()
|
||||
part := []byte(`{"functionCall":{"id":"","name":""}}`)
|
||||
part, _ = sjson.SetBytes(part, "functionCall.id", functionID)
|
||||
part, _ = sjson.SetBytes(part, "functionCall.name", functionName)
|
||||
if gjson.Valid(functionArgs) {
|
||||
part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(functionArgs))
|
||||
} else {
|
||||
part, _ = sjson.SetBytes(part, "functionCall.args.params", []byte(functionArgs))
|
||||
}
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", antigravityFunctionThoughtSignature)
|
||||
partItems = append(partItems, part)
|
||||
if functionID != "" {
|
||||
functionIDs = append(functionIDs, functionID)
|
||||
}
|
||||
}
|
||||
if len(partItems) > 0 {
|
||||
contentItems = append(contentItems, antigravityOpenAIContent("model", partItems))
|
||||
}
|
||||
|
||||
responseParts := make([][]byte, 0, len(functionIDs))
|
||||
for _, functionID := range functionIDs {
|
||||
if name, ok := tcID2Name[functionID]; ok {
|
||||
part := []byte(`{"functionResponse":{"id":"","name":""}}`)
|
||||
part, _ = sjson.SetBytes(part, "functionResponse.id", functionID)
|
||||
part, _ = sjson.SetBytes(part, "functionResponse.name", util.MapSanitizedFunctionName(functionNameMap, name))
|
||||
response := toolResponses[functionID]
|
||||
if response == "" {
|
||||
response = "{}"
|
||||
}
|
||||
if response != "null" {
|
||||
parsed := gjson.Parse(response)
|
||||
if parsed.Type == gjson.JSON {
|
||||
part, _ = sjson.SetRawBytes(part, "functionResponse.response.result", []byte(parsed.Raw))
|
||||
} else {
|
||||
part, _ = sjson.SetBytes(part, "functionResponse.response.result", response)
|
||||
}
|
||||
}
|
||||
responseParts = append(responseParts, part)
|
||||
}
|
||||
}
|
||||
if len(responseParts) > 0 {
|
||||
contentItems = append(contentItems, antigravityOpenAIContent("user", responseParts))
|
||||
}
|
||||
} else if len(partItems) > 0 {
|
||||
contentItems = append(contentItems, antigravityOpenAIContent("model", partItems))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(systemParts) > 0 {
|
||||
out, _ = sjson.SetRawBytes(out, "request.systemInstruction", antigravityOpenAIContent("user", systemParts))
|
||||
}
|
||||
out = translatorcommon.SetRawArrayItems(out, "request.contents", contentItems)
|
||||
}
|
||||
|
||||
// tools -> request.tools[].functionDeclarations + request.tools[].googleSearch/codeExecution/urlContext passthrough
|
||||
tools := gjson.GetBytes(rawJSON, "tools")
|
||||
toolResults := tools.Array()
|
||||
if tools.IsArray() && len(toolResults) > 0 {
|
||||
functionDeclarations := make([][]byte, 0, len(toolResults))
|
||||
googleSearchNodes := make([][]byte, 0)
|
||||
codeExecutionNodes := make([][]byte, 0)
|
||||
urlContextNodes := make([][]byte, 0)
|
||||
for _, t := range toolResults {
|
||||
if t.Get("type").String() == "function" {
|
||||
fn := t.Get("function")
|
||||
if fn.Exists() && fn.IsObject() {
|
||||
fnRaw := fn.Raw
|
||||
if fn.Get("parameters").Exists() {
|
||||
renamed, errRename := util.RenameKey(fnRaw, "parameters", "parametersJsonSchema")
|
||||
if errRename != nil {
|
||||
log.Warnf("Failed to rename parameters for tool '%s': %v", fn.Get("name").String(), errRename)
|
||||
var errSet error
|
||||
fnRawBytes, errSet := sjson.SetBytes([]byte(fnRaw), "parametersJsonSchema.type", "object")
|
||||
if errSet != nil {
|
||||
log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
|
||||
continue
|
||||
}
|
||||
fnRaw = string(fnRawBytes)
|
||||
fnRawBytes, errSet = sjson.SetRawBytes([]byte(fnRaw), "parametersJsonSchema.properties", []byte(`{}`))
|
||||
if errSet != nil {
|
||||
log.Warnf("Failed to set default schema properties for tool '%s': %v", fn.Get("name").String(), errSet)
|
||||
continue
|
||||
}
|
||||
fnRaw = string(fnRawBytes)
|
||||
} else {
|
||||
fnRaw = renamed
|
||||
}
|
||||
} else {
|
||||
var errSet error
|
||||
fnRawBytes, errSet := sjson.SetBytes([]byte(fnRaw), "parametersJsonSchema.type", "object")
|
||||
if errSet != nil {
|
||||
log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
|
||||
continue
|
||||
}
|
||||
fnRaw = string(fnRawBytes)
|
||||
fnRawBytes, errSet = sjson.SetRawBytes([]byte(fnRaw), "parametersJsonSchema.properties", []byte(`{}`))
|
||||
if errSet != nil {
|
||||
log.Warnf("Failed to set default schema properties for tool '%s': %v", fn.Get("name").String(), errSet)
|
||||
continue
|
||||
}
|
||||
fnRaw = string(fnRawBytes)
|
||||
}
|
||||
fnRawBytes := []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() {
|
||||
googleToolNode := []byte(`{}`)
|
||||
var errSet error
|
||||
googleToolNode, errSet = sjson.SetRawBytes(googleToolNode, "googleSearch", []byte(gs.Raw))
|
||||
if errSet != nil {
|
||||
log.Warnf("Failed to set googleSearch tool: %v", errSet)
|
||||
continue
|
||||
}
|
||||
googleSearchNodes = append(googleSearchNodes, googleToolNode)
|
||||
}
|
||||
if ce := t.Get("code_execution"); ce.Exists() {
|
||||
codeToolNode := []byte(`{}`)
|
||||
var errSet error
|
||||
codeToolNode, errSet = sjson.SetRawBytes(codeToolNode, "codeExecution", []byte(ce.Raw))
|
||||
if errSet != nil {
|
||||
log.Warnf("Failed to set codeExecution tool: %v", errSet)
|
||||
continue
|
||||
}
|
||||
codeExecutionNodes = append(codeExecutionNodes, codeToolNode)
|
||||
}
|
||||
if uc := t.Get("url_context"); uc.Exists() {
|
||||
urlToolNode := []byte(`{}`)
|
||||
var errSet error
|
||||
urlToolNode, errSet = sjson.SetRawBytes(urlToolNode, "urlContext", []byte(uc.Raw))
|
||||
if errSet != nil {
|
||||
log.Warnf("Failed to set urlContext tool: %v", errSet)
|
||||
continue
|
||||
}
|
||||
urlContextNodes = append(urlContextNodes, urlToolNode)
|
||||
}
|
||||
}
|
||||
deduplicated := util.DeduplicateFunctionDeclarations(translatorcommon.JoinRawArray(functionDeclarations))
|
||||
hasFunction := len(deduplicated) > 2
|
||||
if hasFunction || len(googleSearchNodes) > 0 || len(codeExecutionNodes) > 0 || len(urlContextNodes) > 0 {
|
||||
toolItems := make([][]byte, 0, 1+len(googleSearchNodes)+len(codeExecutionNodes)+len(urlContextNodes))
|
||||
if hasFunction {
|
||||
functionToolNode := []byte(`{"functionDeclarations":[]}`)
|
||||
functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", deduplicated)
|
||||
toolItems = append(toolItems, functionToolNode)
|
||||
}
|
||||
toolItems = append(toolItems, googleSearchNodes...)
|
||||
toolItems = append(toolItems, codeExecutionNodes...)
|
||||
toolItems = append(toolItems, urlContextNodes...)
|
||||
out, _ = sjson.SetRawBytes(out, "request.tools", translatorcommon.JoinRawArray(toolItems))
|
||||
}
|
||||
}
|
||||
|
||||
out = applyOpenAIToolChoiceToAntigravity(out, rawJSON, functionNameMap)
|
||||
if strings.Contains(strings.ToLower(modelName), "claude") {
|
||||
out = gemini.SanitizeAntigravityClaudeGeminiRequestSignatures(modelName, out)
|
||||
}
|
||||
return common.AttachDefaultSafetySettings(out, "request.safetySettings")
|
||||
}
|
||||
|
||||
func antigravityOpenAITextPart(text string) []byte {
|
||||
part := []byte(`{"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", text)
|
||||
return part
|
||||
}
|
||||
|
||||
func antigravityOpenAIInlineDataPart(mimeType, data string, snakeCase bool) []byte {
|
||||
part := []byte(`{"inlineData":{"mimeType":"","data":""}}`)
|
||||
if snakeCase {
|
||||
part = []byte(`{"inlineData":{"mime_type":"","data":""}}`)
|
||||
part, _ = sjson.SetBytes(part, "inlineData.mime_type", mimeType)
|
||||
} else {
|
||||
part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
|
||||
}
|
||||
part, _ = sjson.SetBytes(part, "inlineData.data", data)
|
||||
return part
|
||||
}
|
||||
|
||||
func antigravityOpenAIContent(role string, parts [][]byte) []byte {
|
||||
content := []byte(`{"role":"","parts":[]}`)
|
||||
content, _ = sjson.SetBytes(content, "role", role)
|
||||
content, _ = sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(parts))
|
||||
return content
|
||||
}
|
||||
|
||||
func antigravityOpenAIAudioMIMEType(format string) string {
|
||||
switch format {
|
||||
case "mp3":
|
||||
return "audio/mpeg"
|
||||
case "ogg":
|
||||
return "audio/ogg"
|
||||
case "flac":
|
||||
return "audio/flac"
|
||||
case "aac":
|
||||
return "audio/aac"
|
||||
case "webm":
|
||||
return "audio/webm"
|
||||
case "pcm16":
|
||||
return "audio/pcm"
|
||||
case "g711_ulaw", "g711_alaw":
|
||||
return "audio/basic"
|
||||
case "", "wav":
|
||||
return "audio/wav"
|
||||
default:
|
||||
return "audio/" + format
|
||||
}
|
||||
}
|
||||
|
||||
func applyOpenAIToolChoiceToAntigravity(out, rawJSON []byte, functionNameMap map[string]string) []byte {
|
||||
toolChoice := gjson.GetBytes(rawJSON, "tool_choice")
|
||||
if !toolChoice.Exists() {
|
||||
return out
|
||||
}
|
||||
|
||||
mode := ""
|
||||
allowedName := ""
|
||||
if toolChoice.Type == gjson.String {
|
||||
switch strings.ToLower(strings.TrimSpace(toolChoice.String())) {
|
||||
case "none":
|
||||
mode = "NONE"
|
||||
case "auto":
|
||||
mode = "AUTO"
|
||||
case "required", "any":
|
||||
mode = "ANY"
|
||||
}
|
||||
} else if toolChoice.IsObject() && strings.EqualFold(toolChoice.Get("type").String(), "function") {
|
||||
mode = "ANY"
|
||||
allowedName = toolChoice.Get("function.name").String()
|
||||
}
|
||||
if mode == "" {
|
||||
return out
|
||||
}
|
||||
|
||||
out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", mode)
|
||||
if strings.TrimSpace(allowedName) != "" {
|
||||
mappedName := util.MapSanitizedFunctionName(functionNameMap, allowedName)
|
||||
out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{mappedName})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyOpenAIThinkingCompatibilityToAntigravity(out []byte, rawJSON []byte) []byte {
|
||||
out = normalizeAntigravityOpenAIThinkingConfig(out)
|
||||
config := thinking.ExtractSummaryConfig(rawJSON, "openai")
|
||||
return thinking.ApplySummaryConfig(out, "antigravity", config)
|
||||
}
|
||||
|
||||
func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte {
|
||||
for _, prefix := range []string{
|
||||
"request.generationConfig.thinking_config",
|
||||
"request.generationConfig.thinkingConfig",
|
||||
} {
|
||||
if sourcePath := prefix + ".includeThoughts"; gjson.GetBytes(out, sourcePath).Exists() {
|
||||
includeThoughts := gjson.GetBytes(out, sourcePath)
|
||||
out = setAntigravityOpenAIBoolResultIfValid(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
|
||||
if includeThoughts.Type != gjson.True && includeThoughts.Type != gjson.False {
|
||||
out, _ = sjson.DeleteBytes(out, sourcePath)
|
||||
}
|
||||
}
|
||||
if sourcePath := prefix + ".include_thoughts"; gjson.GetBytes(out, sourcePath).Exists() {
|
||||
includeThoughts := gjson.GetBytes(out, sourcePath)
|
||||
out = setAntigravityOpenAIBoolResultIfValid(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
|
||||
if includeThoughts.Type != gjson.True && includeThoughts.Type != gjson.False {
|
||||
out, _ = sjson.DeleteBytes(out, sourcePath)
|
||||
}
|
||||
}
|
||||
if thinkingLevel := gjson.GetBytes(out, prefix+".thinkingLevel"); thinkingLevel.Exists() {
|
||||
out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingLevel", thinkingLevel)
|
||||
}
|
||||
if thinkingLevel := gjson.GetBytes(out, prefix+".thinking_level"); thinkingLevel.Exists() {
|
||||
out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingLevel", thinkingLevel)
|
||||
}
|
||||
if thinkingBudget := gjson.GetBytes(out, prefix+".thinkingBudget"); thinkingBudget.Exists() {
|
||||
out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingBudget", thinkingBudget)
|
||||
}
|
||||
if thinkingBudget := gjson.GetBytes(out, prefix+".thinking_budget"); thinkingBudget.Exists() {
|
||||
out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingBudget", thinkingBudget)
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"request.generationConfig.includeThoughts",
|
||||
"request.generationConfig.include_thoughts",
|
||||
} {
|
||||
if includeThoughts := gjson.GetBytes(out, path); includeThoughts.Exists() {
|
||||
out = setAntigravityOpenAIBoolResultIfValid(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"request.generationConfig.thinking_config",
|
||||
"request.generationConfig.thinkingConfig.include_thoughts",
|
||||
"request.generationConfig.thinkingConfig.thinking_level",
|
||||
"request.generationConfig.thinkingConfig.thinking_budget",
|
||||
"request.generationConfig.includeThoughts",
|
||||
"request.generationConfig.include_thoughts",
|
||||
} {
|
||||
if gjson.GetBytes(out, path).Exists() {
|
||||
out, _ = sjson.DeleteBytes(out, path)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func setAntigravityOpenAIBoolResultIfValid(out []byte, path string, value gjson.Result) []byte {
|
||||
switch value.Type {
|
||||
case gjson.True:
|
||||
return setAntigravityOpenAIBoolIfDifferent(out, path, true)
|
||||
case gjson.False:
|
||||
return setAntigravityOpenAIBoolIfDifferent(out, path, false)
|
||||
default:
|
||||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,494 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertOpenAIRequestToAntigravitySkipsEmptyTextPartsWithoutNulls(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3-flash",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": ""},
|
||||
{"type": "input_audio", "input_audio": {"data": "SUQzBA==", "format": "mp3"}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": ""}],
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": "{\"path\":\"a.txt\"}"}
|
||||
}]
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "{\"output\":\"ok\"}"},
|
||||
{"role": "user", "content": "done"}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
|
||||
userParts := gjson.GetBytes(result, "request.contents.0.parts").Array()
|
||||
if len(userParts) != 1 {
|
||||
t.Fatalf("user parts length = %d, want 1. Output: %s", len(userParts), result)
|
||||
}
|
||||
if userParts[0].Type == gjson.Null {
|
||||
t.Fatalf("user parts.0 is null. Output: %s", result)
|
||||
}
|
||||
if got := userParts[0].Get("inlineData.mime_type").String(); got != "audio/mpeg" {
|
||||
t.Fatalf("audio mime_type = %q, want audio/mpeg. Output: %s", got, result)
|
||||
}
|
||||
|
||||
assistantParts := gjson.GetBytes(result, "request.contents.1.parts").Array()
|
||||
if len(assistantParts) != 1 {
|
||||
t.Fatalf("assistant parts length = %d, want 1. Output: %s", len(assistantParts), result)
|
||||
}
|
||||
if assistantParts[0].Type == gjson.Null {
|
||||
t.Fatalf("assistant parts.0 is null. Output: %s", result)
|
||||
}
|
||||
if !assistantParts[0].Get("functionCall").Exists() {
|
||||
t.Fatalf("functionCall missing. Output: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToAntigravity_ClaudeModelSanitizesUnsignedReasoningContent(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "claude-sonnet-4-6",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "visible text", "reasoning_content": "unsigned reasoning"},
|
||||
{"role": "user", "content": "say ok"}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToAntigravity("claude-sonnet-4-6", []byte(inputJSON), false)
|
||||
contents := gjson.GetBytes(result, "request.contents").Array()
|
||||
if len(contents) != 3 {
|
||||
t.Fatalf("contents length = %d, want 3. Output: %s", len(contents), result)
|
||||
}
|
||||
parts := contents[1].Get("parts").Array()
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("model parts length = %d, want 1 (thinking part dropped). Output: %s", len(parts), result)
|
||||
}
|
||||
if got := parts[0].Get("text").String(); got != "visible text" {
|
||||
t.Fatalf("parts[0].text = %q, want visible text. Output: %s", got, result)
|
||||
}
|
||||
if parts[0].Get("thought").Exists() {
|
||||
t.Fatalf("parts[0] should not be thought part. Output: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToAntigravity_ClaudeModelDropsEmptyAssistantTurnAfterSanitizingReasoningContent(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "claude-sonnet-4-6",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "", "reasoning_content": "unsigned reasoning"},
|
||||
{"role": "user", "content": "say ok"}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToAntigravity("claude-sonnet-4-6", []byte(inputJSON), false)
|
||||
contents := gjson.GetBytes(result, "request.contents").Array()
|
||||
if len(contents) != 2 {
|
||||
t.Fatalf("contents length = %d, want 2 (empty model turn dropped). Output: %s", len(contents), result)
|
||||
}
|
||||
if got := contents[0].Get("role").String(); got != "user" {
|
||||
t.Fatalf("contents[0].role = %q, want user. Output: %s", got, result)
|
||||
}
|
||||
if got := contents[1].Get("role").String(); got != "user" {
|
||||
t.Fatalf("contents[1].role = %q, want user. Output: %s", got, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToAntigravityPreservesReasoningContent(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3-flash",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "", "reasoning_content": "thinking only"},
|
||||
{"role": "user", "content": "say ok"}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), true)
|
||||
contents := gjson.GetBytes(result, "request.contents").Array()
|
||||
if len(contents) != 3 {
|
||||
t.Fatalf("contents length = %d, want 3. Output: %s", len(contents), result)
|
||||
}
|
||||
part := contents[1].Get("parts.0")
|
||||
if got := contents[1].Get("role").String(); got != "model" {
|
||||
t.Fatalf("contents.1.role = %q, want model. Output: %s", got, result)
|
||||
}
|
||||
if got := part.Get("text").String(); got != "thinking only" {
|
||||
t.Fatalf("reasoning text = %q, want thinking only. Output: %s", got, result)
|
||||
}
|
||||
if !part.Get("thought").Bool() {
|
||||
t.Fatalf("reasoning part should be marked as thought. Output: %s", result)
|
||||
}
|
||||
if got := part.Get("thoughtSignature").String(); got != antigravityFunctionThoughtSignature {
|
||||
t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToAntigravityPreservesReasoningBeforeVisibleContentAndToolCall(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3-flash",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "visible answer", "reasoning_content": "thinking only", "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "read_file", "arguments": "{}"}}]},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "{\"output\":\"ok\"}"},
|
||||
{"role": "user", "content": "say ok"}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), true)
|
||||
contents := gjson.GetBytes(result, "request.contents").Array()
|
||||
if len(contents) != 4 {
|
||||
t.Fatalf("contents length = %d, want 4. Output: %s", len(contents), result)
|
||||
}
|
||||
parts := contents[1].Get("parts").Array()
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("model parts length = %d, want 3. Output: %s", len(parts), result)
|
||||
}
|
||||
if got := parts[0].Get("text").String(); got != "thinking only" || !parts[0].Get("thought").Bool() {
|
||||
t.Fatalf("first part should be the reasoning thought. Output: %s", result)
|
||||
}
|
||||
if got := parts[1].Get("text").String(); got != "visible answer" || parts[1].Get("thought").Bool() {
|
||||
t.Fatalf("second part should be visible assistant content. Output: %s", result)
|
||||
}
|
||||
if got := parts[2].Get("functionCall.name").String(); got != "read_file" {
|
||||
t.Fatalf("functionCall.name = %q, want read_file. Output: %s", got, result)
|
||||
}
|
||||
if got := parts[2].Get("thoughtSignature").String(); got != antigravityFunctionThoughtSignature {
|
||||
t.Fatalf("functionCall thoughtSignature = %q, want bypass sentinel. Output: %s", got, result)
|
||||
}
|
||||
if got := contents[2].Get("parts.0.functionResponse.name").String(); got != "read_file" {
|
||||
t.Fatalf("functionResponse.name = %q, want read_file. Output: %s", got, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToAntigravitySkipsEmptyAssistantMessages(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3-flash",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "", "tool_calls": [{"type": "function", "function": {"name": "", "arguments": "{}"}}, {"type": "custom"}]},
|
||||
{"role": "user", "content": "say ok"}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), true)
|
||||
contents := gjson.GetBytes(result, "request.contents").Array()
|
||||
if len(contents) != 2 {
|
||||
t.Fatalf("contents length = %d, want 2. Output: %s", len(contents), result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantExists bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "Missing summary intent leaves include thoughts absent",
|
||||
body: `{
|
||||
"model":"gemini-3.1-pro-low",
|
||||
"messages":[{"role":"user","content":"hi"}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "Reasoning effort enables thoughts",
|
||||
body: `{
|
||||
"model":"gemini-3.1-pro-low",
|
||||
"messages":[{"role":"user","content":"hi"}],
|
||||
"reasoning_effort":"high"
|
||||
}`,
|
||||
wantExists: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "GenerationConfig snake include thoughts",
|
||||
body: `{
|
||||
"model":"gemini-3.1-pro-low",
|
||||
"messages":[{"role":"user","content":"hi"}],
|
||||
"generationConfig":{"thinkingConfig":{"include_thoughts":true}}
|
||||
}`,
|
||||
wantExists: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "String include thoughts is ignored",
|
||||
body: `{
|
||||
"model":"gemini-3.1-pro-low",
|
||||
"messages":[{"role":"user","content":"hi"}],
|
||||
"generationConfig":{"thinkingConfig":{"includeThoughts":"true"}}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "Top-level thinking include thoughts",
|
||||
body: `{
|
||||
"model":"gemini-3.1-pro-low",
|
||||
"messages":[{"role":"user","content":"hi"}],
|
||||
"thinking":{"include_thoughts":true}
|
||||
}`,
|
||||
wantExists: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Reasoning exclude false includes thoughts",
|
||||
body: `{
|
||||
"model":"gemini-3.1-pro-low",
|
||||
"messages":[{"role":"user","content":"hi"}],
|
||||
"reasoning":{"exclude":false}
|
||||
}`,
|
||||
wantExists: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Reasoning exclude true hides thoughts",
|
||||
body: `{
|
||||
"model":"gemini-3.1-pro-low",
|
||||
"messages":[{"role":"user","content":"hi"}],
|
||||
"reasoning":{"exclude":true}
|
||||
}`,
|
||||
wantExists: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Google extension disables thoughts",
|
||||
body: `{
|
||||
"model":"gemini-3.1-pro-low",
|
||||
"messages":[{"role":"user","content":"hi"}],
|
||||
"reasoning_effort":"high",
|
||||
"extra_body":{"google":{"thinking_config":{"include_thoughts":false}}}
|
||||
}`,
|
||||
wantExists: true,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ConvertOpenAIRequestToAntigravity("gemini-3.1-pro-low", []byte(tt.body), false)
|
||||
includeThoughts := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts")
|
||||
if includeThoughts.Exists() != tt.wantExists {
|
||||
t.Fatalf("includeThoughts exists = %v, want %v. Output: %s", includeThoughts.Exists(), tt.wantExists, result)
|
||||
}
|
||||
if tt.wantExists {
|
||||
if got := includeThoughts.Bool(); got != tt.want {
|
||||
t.Fatalf("includeThoughts = %v, want %v. Output: %s", got, tt.want, result)
|
||||
}
|
||||
}
|
||||
if snake := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.include_thoughts"); snake.Exists() {
|
||||
t.Fatalf("include_thoughts should be normalized away. Output: %s", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToAntigravityDeduplicatesAndDisambiguatesTools(t *testing.T) {
|
||||
first := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build"
|
||||
second := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build_logs"
|
||||
inputJSON := `{
|
||||
"messages":[
|
||||
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"` + second + `","arguments":"{}"}}]},
|
||||
{"role":"tool","tool_call_id":"call_1","content":"{}"}
|
||||
],
|
||||
"tools":[
|
||||
{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}},
|
||||
{"type":"function","function":{"name":"lookup","description":"duplicate","parameters":{"type":"object"}}},
|
||||
{"type":"function","function":{"name":"` + first + `","parameters":{"type":"object"}}},
|
||||
{"type":"function","function":{"name":"` + second + `","parameters":{"type":"object"}}}
|
||||
],
|
||||
"tool_choice":{"type":"function","function":{"name":"` + second + `"}}
|
||||
}`
|
||||
|
||||
out := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
|
||||
declarations := gjson.GetBytes(out, "request.tools.0.functionDeclarations").Array()
|
||||
if len(declarations) != 3 {
|
||||
t.Fatalf("declaration count = %d, want 3. Output: %s", len(declarations), out)
|
||||
}
|
||||
firstMapped := declarations[1].Get("name").String()
|
||||
secondMapped := declarations[2].Get("name").String()
|
||||
if firstMapped == secondMapped || len(secondMapped) > 64 {
|
||||
t.Fatalf("collision names = %q and %q, want distinct names <= 64 chars", firstMapped, secondMapped)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "request.contents.0.parts.0.functionCall.name").String(); got != secondMapped {
|
||||
t.Fatalf("functionCall.name = %q, want %q. Output: %s", got, secondMapped, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse.name").String(); got != secondMapped {
|
||||
t.Fatalf("functionResponse.name = %q, want %q. Output: %s", got, secondMapped, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != secondMapped {
|
||||
t.Fatalf("allowedFunctionNames.0 = %q, want %q. Output: %s", got, secondMapped, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToAntigravityMapsToolChoiceModes(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
choice string
|
||||
mode string
|
||||
}{
|
||||
{choice: `"none"`, mode: "NONE"},
|
||||
{choice: `"auto"`, mode: "AUTO"},
|
||||
{choice: `"required"`, mode: "ANY"},
|
||||
} {
|
||||
t.Run(tt.mode+tt.choice, func(t *testing.T) {
|
||||
inputJSON := []byte(`{"messages":[{"role":"user","content":"hi"}],"tool_choice":` + tt.choice + `}`)
|
||||
out := ConvertOpenAIRequestToAntigravity("gemini-3-flash", inputJSON, false)
|
||||
if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String(); got != tt.mode {
|
||||
t.Fatalf("tool choice mode = %q, want %q. Output: %s", got, tt.mode, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToAntigravityMapsResponseFormatJSONObject(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model":"gemini-3.6-flash-high",
|
||||
"messages":[{"role":"user","content":"hi"}],
|
||||
"generationConfig":{
|
||||
"responseSchema":{"type":"string","description":"stale"},
|
||||
"responseJsonSchema":{"type":"string"},
|
||||
"response_schema":{"type":"string"},
|
||||
"response_json_schema":{"type":"string"}
|
||||
},
|
||||
"response_format":{"type":"json_object"}
|
||||
}`)
|
||||
|
||||
out := ConvertOpenAIRequestToAntigravity("gemini-3.6-flash-high", inputJSON, false)
|
||||
if got := gjson.GetBytes(out, "request.generationConfig.responseMimeType").String(); got != "application/json" {
|
||||
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, out)
|
||||
}
|
||||
if gjson.GetBytes(out, "request.generationConfig.responseSchema").Exists() {
|
||||
t.Fatalf("responseSchema should not be set for json_object. Output: %s", out)
|
||||
}
|
||||
assertNoResponseSchemaAliases(t, out)
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToAntigravityMapsResponseFormatJSONSchema(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model":"gemini-3.6-flash-high",
|
||||
"messages":[{"role":"user","content":"hi"}],
|
||||
"generationConfig":{
|
||||
"responseSchema":{"type":"string","description":"stale"},
|
||||
"responseJsonSchema":{"type":"string"},
|
||||
"response_schema":{"type":"string"},
|
||||
"response_json_schema":{"type":"string"}
|
||||
},
|
||||
"response_format":{
|
||||
"type":"json_schema",
|
||||
"json_schema":{
|
||||
"name":"verdict",
|
||||
"schema":{
|
||||
"type":"object",
|
||||
"properties":{"score":{"type":"integer"}},
|
||||
"required":["score"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
out := ConvertOpenAIRequestToAntigravity("gemini-3.6-flash-high", inputJSON, false)
|
||||
if got := gjson.GetBytes(out, "request.generationConfig.responseMimeType").String(); got != "application/json" {
|
||||
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, out)
|
||||
}
|
||||
schema := gjson.GetBytes(out, "request.generationConfig.responseSchema")
|
||||
if !schema.Exists() {
|
||||
t.Fatalf("responseSchema missing. Output: %s", out)
|
||||
}
|
||||
if got := schema.Get("properties.score.type").String(); got != "integer" {
|
||||
t.Fatalf("responseSchema.properties.score.type = %q, want integer. Output: %s", got, out)
|
||||
}
|
||||
if schema.Get("description").Exists() {
|
||||
t.Fatalf("stale responseSchema survived. Output: %s", out)
|
||||
}
|
||||
assertNoResponseSchemaAliases(t, out)
|
||||
}
|
||||
|
||||
func assertNoResponseSchemaAliases(t *testing.T, out []byte) {
|
||||
t.Helper()
|
||||
for _, schemaKey := range []string{"responseJsonSchema", "response_schema", "response_json_schema"} {
|
||||
if gjson.GetBytes(out, "request.generationConfig."+schemaKey).Exists() {
|
||||
t.Errorf("stale %s survived response_format mapping. Output: %s", schemaKey, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToAntigravityTranslatesVideoURL(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gemini-3.7-flash-high",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Name the colours in order"},
|
||||
{"type": "video_url", "video_url": {"url": "data:video/mp4;base64,AAAAIGZ0eXBtcDQy"}}
|
||||
]
|
||||
}]
|
||||
}`)
|
||||
|
||||
out := ConvertOpenAIRequestToAntigravity("gemini-3.7-flash-high", inputJSON, false)
|
||||
parts := gjson.GetBytes(out, "request.contents.0.parts").Array()
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("parts length = %d, want 2. Output: %s", len(parts), out)
|
||||
}
|
||||
|
||||
if got := parts[0].Get("text").String(); got != "Name the colours in order" {
|
||||
t.Fatalf("parts[0].text = %q, want 'Name the colours in order'", got)
|
||||
}
|
||||
|
||||
inlineData := parts[1].Get("inlineData")
|
||||
if !inlineData.Exists() {
|
||||
t.Fatalf("parts[1].inlineData missing. Output: %s", out)
|
||||
}
|
||||
if got := inlineData.Get("mimeType").String(); got != "video/mp4" {
|
||||
t.Fatalf("inlineData.mimeType = %q, want video/mp4. Output: %s", got, out)
|
||||
}
|
||||
if got := inlineData.Get("data").String(); got != "AAAAIGZ0eXBtcDQy" {
|
||||
t.Fatalf("inlineData.data = %q, want AAAAIGZ0eXBtcDQy. Output: %s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToAntigravity_MaxCompletionTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "only max_tokens",
|
||||
body: `{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}],"max_tokens":100}`,
|
||||
expected: 100,
|
||||
},
|
||||
{
|
||||
name: "only max_completion_tokens",
|
||||
body: `{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}],"max_completion_tokens":200}`,
|
||||
expected: 200,
|
||||
},
|
||||
{
|
||||
name: "max_tokens preferred over max_completion_tokens",
|
||||
body: `{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}],"max_tokens":100,"max_completion_tokens":200}`,
|
||||
expected: 100,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
out := ConvertOpenAIRequestToAntigravity("gemini-2.5-flash", []byte(tt.body), false)
|
||||
got := gjson.GetBytes(out, "request.generationConfig.maxOutputTokens")
|
||||
if !got.Exists() {
|
||||
t.Fatalf("request.generationConfig.maxOutputTokens missing. Output: %s", out)
|
||||
}
|
||||
if got.Float() != tt.expected {
|
||||
t.Fatalf("maxOutputTokens = %v, want %v. Output: %s", got.Float(), tt.expected, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
// Package openai provides response translation functionality for Antigravity to OpenAI API compatibility.
|
||||
// This package handles the conversion of Antigravity API responses into OpenAI Chat Completions-compatible
|
||||
// JSON format, transforming streaming events and non-streaming responses into the format
|
||||
// expected by OpenAI API clients. It supports both streaming and non-streaming modes,
|
||||
// handling text content, tool calls, reasoning content, and usage metadata appropriately.
|
||||
package chat_completions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/chat-completions"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// convertCliResponseToOpenAIChatParams holds parameters for response conversion.
|
||||
type convertCliResponseToOpenAIChatParams struct {
|
||||
UnixTimestamp int64
|
||||
FunctionIndex int
|
||||
SawToolCall bool // Tracks if any tool call was seen in the entire stream
|
||||
UpstreamFinishReason string // Caches the upstream finish reason for final chunk
|
||||
SanitizedNameMap map[string]string
|
||||
}
|
||||
|
||||
// functionCallIDCounter provides a process-wide unique counter for function call identifiers.
|
||||
var functionCallIDCounter uint64
|
||||
|
||||
// ConvertAntigravityResponseToOpenAI translates a single chunk of a streaming response from the
|
||||
// Antigravity API format to the OpenAI Chat Completions streaming format.
|
||||
// It processes various Antigravity event types and transforms them into OpenAI-compatible JSON responses.
|
||||
// The function handles text content, tool calls, reasoning content, and usage metadata, outputting
|
||||
// responses that match the OpenAI API format. It supports incremental updates for streaming responses.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request, used for cancellation and timeout handling
|
||||
// - modelName: The name of the model being used for the response (unused in current implementation)
|
||||
// - rawJSON: The raw JSON response from the Antigravity API
|
||||
// - param: A pointer to a parameter object for maintaining state between calls
|
||||
//
|
||||
// Returns:
|
||||
// - [][]byte: A slice of OpenAI-compatible JSON responses
|
||||
func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
if *param == nil {
|
||||
*param = &convertCliResponseToOpenAIChatParams{
|
||||
UnixTimestamp: 0,
|
||||
FunctionIndex: 0,
|
||||
SanitizedNameMap: util.DisambiguatedToolNameMap(originalRequestRawJSON),
|
||||
}
|
||||
}
|
||||
if (*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap == nil {
|
||||
(*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap = util.DisambiguatedToolNameMap(originalRequestRawJSON)
|
||||
}
|
||||
|
||||
if bytes.Equal(rawJSON, []byte("[DONE]")) {
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
// Initialize the OpenAI SSE template.
|
||||
template := []byte(`{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`)
|
||||
|
||||
// Extract and set the model version.
|
||||
if modelVersionResult := gjson.GetBytes(rawJSON, "response.modelVersion"); modelVersionResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "model", modelVersionResult.String())
|
||||
}
|
||||
|
||||
// Extract and set the creation timestamp.
|
||||
if createTimeResult := gjson.GetBytes(rawJSON, "response.createTime"); createTimeResult.Exists() {
|
||||
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
|
||||
if err == nil {
|
||||
(*param).(*convertCliResponseToOpenAIChatParams).UnixTimestamp = t.Unix()
|
||||
}
|
||||
template, _ = sjson.SetBytes(template, "created", (*param).(*convertCliResponseToOpenAIChatParams).UnixTimestamp)
|
||||
} else {
|
||||
template, _ = sjson.SetBytes(template, "created", (*param).(*convertCliResponseToOpenAIChatParams).UnixTimestamp)
|
||||
}
|
||||
|
||||
// Extract and set the response ID.
|
||||
if responseIDResult := gjson.GetBytes(rawJSON, "response.responseId"); responseIDResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "id", responseIDResult.String())
|
||||
}
|
||||
|
||||
// Cache the finish reason - do NOT set it in output yet (will be set on final chunk)
|
||||
if finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason"); finishReasonResult.Exists() {
|
||||
(*param).(*convertCliResponseToOpenAIChatParams).UpstreamFinishReason = strings.ToUpper(finishReasonResult.String())
|
||||
}
|
||||
|
||||
// Extract and set usage metadata (token counts).
|
||||
if usageResult := gjson.GetBytes(rawJSON, "response.usageMetadata"); usageResult.Exists() {
|
||||
cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int()
|
||||
template, _ = sjson.SetBytes(template, "usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int())
|
||||
if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokenCountResult.Int())
|
||||
}
|
||||
promptTokenCount := usageResult.Get("promptTokenCount").Int()
|
||||
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
|
||||
template, _ = sjson.SetBytes(template, "usage.prompt_tokens", promptTokenCount)
|
||||
if thoughtsTokenCount > 0 {
|
||||
template, _ = sjson.SetBytes(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
|
||||
}
|
||||
// Include cached token count if present (indicates prompt caching is working)
|
||||
if cachedTokenCount > 0 {
|
||||
var err error
|
||||
template, err = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount)
|
||||
if err != nil {
|
||||
log.Warnf("antigravity openai response: failed to set cached_tokens: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process the main content part of the response.
|
||||
partsResult := gjson.GetBytes(rawJSON, "response.candidates.0.content.parts")
|
||||
if partsResult.IsArray() {
|
||||
partResults := partsResult.Array()
|
||||
for i := 0; i < len(partResults); i++ {
|
||||
partResult := partResults[i]
|
||||
partTextResult := partResult.Get("text")
|
||||
functionCallResult := partResult.Get("functionCall")
|
||||
thoughtSignatureResult := partResult.Get("thoughtSignature")
|
||||
if !thoughtSignatureResult.Exists() {
|
||||
thoughtSignatureResult = partResult.Get("thought_signature")
|
||||
}
|
||||
inlineDataResult := partResult.Get("inlineData")
|
||||
if !inlineDataResult.Exists() {
|
||||
inlineDataResult = partResult.Get("inline_data")
|
||||
}
|
||||
|
||||
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""
|
||||
hasContentPayload := partTextResult.Exists() || functionCallResult.Exists() || inlineDataResult.Exists()
|
||||
|
||||
// Ignore encrypted thoughtSignature but keep any actual content in the same part.
|
||||
if hasThoughtSignature && !hasContentPayload {
|
||||
continue
|
||||
}
|
||||
|
||||
if partTextResult.Exists() {
|
||||
textContent := partTextResult.String()
|
||||
|
||||
// Handle text content, distinguishing between regular content and reasoning/thoughts.
|
||||
if partResult.Get("thought").Bool() {
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.reasoning_content", textContent)
|
||||
} else {
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.content", textContent)
|
||||
}
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
|
||||
} else if functionCallResult.Exists() {
|
||||
// Handle function call content.
|
||||
(*param).(*convertCliResponseToOpenAIChatParams).SawToolCall = true // Persist across chunks
|
||||
toolCallsResult := gjson.GetBytes(template, "choices.0.delta.tool_calls")
|
||||
functionCallIndex := (*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex
|
||||
(*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex++
|
||||
if toolCallsResult.Exists() && toolCallsResult.IsArray() {
|
||||
functionCallIndex = len(toolCallsResult.Array())
|
||||
} else {
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
|
||||
}
|
||||
|
||||
functionCallTemplate := []byte(`{"id": "","index": 0,"type": "function","function": {"name": "","arguments": ""}}`)
|
||||
fcName := util.RestoreSanitizedToolName((*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap, functionCallResult.Get("name").String())
|
||||
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)))
|
||||
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "index", functionCallIndex)
|
||||
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", fcName)
|
||||
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
|
||||
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.arguments", fcArgsResult.Raw)
|
||||
}
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallTemplate)
|
||||
} else if inlineDataResult.Exists() {
|
||||
data := inlineDataResult.Get("data").String()
|
||||
if data == "" {
|
||||
continue
|
||||
}
|
||||
mimeType := inlineDataResult.Get("mimeType").String()
|
||||
if mimeType == "" {
|
||||
mimeType = inlineDataResult.Get("mime_type").String()
|
||||
}
|
||||
if mimeType == "" {
|
||||
mimeType = "image/png"
|
||||
}
|
||||
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
|
||||
imagesResult := gjson.GetBytes(template, "choices.0.delta.images")
|
||||
if !imagesResult.Exists() || !imagesResult.IsArray() {
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.images", []byte(`[]`))
|
||||
}
|
||||
imageIndex := len(gjson.GetBytes(template, "choices.0.delta.images").Array())
|
||||
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")
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine finish_reason only on the final chunk (has both finishReason and usage metadata)
|
||||
params := (*param).(*convertCliResponseToOpenAIChatParams)
|
||||
upstreamFinishReason := params.UpstreamFinishReason
|
||||
sawToolCall := params.SawToolCall
|
||||
|
||||
usageExists := gjson.GetBytes(rawJSON, "response.usageMetadata").Exists()
|
||||
isFinalChunk := upstreamFinishReason != "" && usageExists
|
||||
|
||||
if isFinalChunk {
|
||||
var finishReason string
|
||||
if sawToolCall {
|
||||
finishReason = "tool_calls"
|
||||
} else if upstreamFinishReason == "MAX_TOKENS" {
|
||||
finishReason = "max_tokens"
|
||||
} else {
|
||||
finishReason = "stop"
|
||||
}
|
||||
template, _ = sjson.SetBytes(template, "choices.0.finish_reason", finishReason)
|
||||
template, _ = sjson.SetBytes(template, "choices.0.native_finish_reason", strings.ToLower(upstreamFinishReason))
|
||||
}
|
||||
|
||||
return [][]byte{template}
|
||||
}
|
||||
|
||||
// ConvertAntigravityResponseToOpenAINonStream converts a non-streaming Antigravity response to a non-streaming OpenAI response.
|
||||
// This function processes the complete Antigravity response and transforms it into a single OpenAI-compatible
|
||||
// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all
|
||||
// the information into a single response that matches the OpenAI API format.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request, used for cancellation and timeout handling
|
||||
// - modelName: The name of the model being used for the response
|
||||
// - rawJSON: The raw JSON response from the Antigravity API
|
||||
// - param: A pointer to a parameter object for the conversion
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: An OpenAI-compatible JSON response containing all message content and metadata
|
||||
func ConvertAntigravityResponseToOpenAINonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
|
||||
responseResult := gjson.GetBytes(rawJSON, "response")
|
||||
if responseResult.Exists() {
|
||||
responseJSON := restoreAntigravityOpenAIFunctionNames([]byte(responseResult.Raw), originalRequestRawJSON)
|
||||
return ConvertGeminiResponseToOpenAINonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, responseJSON, param)
|
||||
}
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
func restoreAntigravityOpenAIFunctionNames(rawJSON, originalRequestRawJSON []byte) []byte {
|
||||
nameMap := util.DisambiguatedToolNameMap(originalRequestRawJSON)
|
||||
if len(nameMap) == 0 {
|
||||
return rawJSON
|
||||
}
|
||||
candidates := gjson.GetBytes(rawJSON, "candidates")
|
||||
for candidateIndex, candidate := range candidates.Array() {
|
||||
for partIndex, part := range candidate.Get("content.parts").Array() {
|
||||
for _, field := range []string{"functionCall", "functionResponse"} {
|
||||
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, restoredName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return rawJSON
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestFinishReasonToolCallsNotOverwritten(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
// Chunk 1: Contains functionCall - should set SawToolCall = true
|
||||
chunk1 := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_files","args":{"path":"."}}}]}}]}}`)
|
||||
result1 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk1, ¶m)
|
||||
|
||||
// Verify chunk1 has no finish_reason (null)
|
||||
if len(result1) != 1 {
|
||||
t.Fatalf("Expected 1 result from chunk1, got %d", len(result1))
|
||||
}
|
||||
fr1 := gjson.GetBytes(result1[0], "choices.0.finish_reason")
|
||||
if fr1.Exists() && fr1.String() != "" && fr1.Type.String() != "Null" {
|
||||
t.Errorf("Expected finish_reason to be null in chunk1, got: %v", fr1.String())
|
||||
}
|
||||
|
||||
// Chunk 2: Contains finishReason STOP + usage (final chunk, no functionCall)
|
||||
// This simulates what the upstream sends AFTER the tool call chunk
|
||||
chunk2 := []byte(`{"response":{"candidates":[{"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":20,"totalTokenCount":30}}}`)
|
||||
result2 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk2, ¶m)
|
||||
|
||||
// Verify chunk2 has finish_reason: "tool_calls" (not "stop")
|
||||
if len(result2) != 1 {
|
||||
t.Fatalf("Expected 1 result from chunk2, got %d", len(result2))
|
||||
}
|
||||
fr2 := gjson.GetBytes(result2[0], "choices.0.finish_reason").String()
|
||||
if fr2 != "tool_calls" {
|
||||
t.Errorf("Expected finish_reason 'tool_calls', got: %s", fr2)
|
||||
}
|
||||
|
||||
// Verify native_finish_reason is lowercase upstream value
|
||||
nfr2 := gjson.GetBytes(result2[0], "choices.0.native_finish_reason").String()
|
||||
if nfr2 != "stop" {
|
||||
t.Errorf("Expected native_finish_reason 'stop', got: %s", nfr2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishReasonStopForNormalText(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
// Chunk 1: Text content only
|
||||
chunk1 := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"Hello world"}]}}]}}`)
|
||||
ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk1, ¶m)
|
||||
|
||||
// Chunk 2: Final chunk with STOP
|
||||
chunk2 := []byte(`{"response":{"candidates":[{"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}}`)
|
||||
result2 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk2, ¶m)
|
||||
|
||||
// Verify finish_reason is "stop" (no tool calls were made)
|
||||
fr := gjson.GetBytes(result2[0], "choices.0.finish_reason").String()
|
||||
if fr != "stop" {
|
||||
t.Errorf("Expected finish_reason 'stop', got: %s", fr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishReasonMaxTokens(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
// Chunk 1: Text content
|
||||
chunk1 := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}}`)
|
||||
ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk1, ¶m)
|
||||
|
||||
// Chunk 2: Final chunk with MAX_TOKENS
|
||||
chunk2 := []byte(`{"response":{"candidates":[{"finishReason":"MAX_TOKENS"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":100,"totalTokenCount":110}}}`)
|
||||
result2 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk2, ¶m)
|
||||
|
||||
// Verify finish_reason is "max_tokens"
|
||||
fr := gjson.GetBytes(result2[0], "choices.0.finish_reason").String()
|
||||
if fr != "max_tokens" {
|
||||
t.Errorf("Expected finish_reason 'max_tokens', got: %s", fr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallTakesPriorityOverMaxTokens(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
// Chunk 1: Contains functionCall
|
||||
chunk1 := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"test","args":{}}}]}}]}}`)
|
||||
ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk1, ¶m)
|
||||
|
||||
// Chunk 2: Final chunk with MAX_TOKENS (but we had a tool call, so tool_calls should win)
|
||||
chunk2 := []byte(`{"response":{"candidates":[{"finishReason":"MAX_TOKENS"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":100,"totalTokenCount":110}}}`)
|
||||
result2 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk2, ¶m)
|
||||
|
||||
// Verify finish_reason is "tool_calls" (takes priority over max_tokens)
|
||||
fr := gjson.GetBytes(result2[0], "choices.0.finish_reason").String()
|
||||
if fr != "tool_calls" {
|
||||
t.Errorf("Expected finish_reason 'tool_calls', got: %s", fr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoFinishReasonOnIntermediateChunks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
// Chunk 1: Text content (no finish reason, no usage)
|
||||
chunk1 := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}}`)
|
||||
result1 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk1, ¶m)
|
||||
|
||||
// Verify no finish_reason on intermediate chunk
|
||||
fr1 := gjson.GetBytes(result1[0], "choices.0.finish_reason")
|
||||
if fr1.Exists() && fr1.String() != "" && fr1.Type.String() != "Null" {
|
||||
t.Errorf("Expected no finish_reason on intermediate chunk, got: %v", fr1)
|
||||
}
|
||||
|
||||
// Chunk 2: More text (no finish reason, no usage)
|
||||
chunk2 := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":" world"}]}}]}}`)
|
||||
result2 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk2, ¶m)
|
||||
|
||||
// Verify no finish_reason on intermediate chunk
|
||||
fr2 := gjson.GetBytes(result2[0], "choices.0.finish_reason")
|
||||
if fr2.Exists() && fr2.String() != "" && fr2.Type.String() != "Null" {
|
||||
t.Errorf("Expected no finish_reason on intermediate chunk, got: %v", fr2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAntigravityResponseToOpenAIIncludesZeroCompletionTokensWhenMissing(t *testing.T) {
|
||||
var param any
|
||||
chunk := []byte(`{"response":{"usageMetadata":{"promptTokenCount":16,"thoughtsTokenCount":42,"totalTokenCount":58}}}`)
|
||||
|
||||
result := ConvertAntigravityResponseToOpenAI(context.Background(), "model", nil, nil, chunk, ¶m)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(result))
|
||||
}
|
||||
completionTokens := gjson.GetBytes(result[0], "usage.completion_tokens")
|
||||
if !completionTokens.Exists() || completionTokens.Int() != 0 {
|
||||
t.Fatalf("completion_tokens = %s, want present with value 0. Output: %s", completionTokens.Raw, result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAntigravityResponseToOpenAINonStreamRestoresDisambiguatedName(t *testing.T) {
|
||||
first := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build"
|
||||
second := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build_logs"
|
||||
original := []byte(`{"tools":[
|
||||
{"type":"function","function":{"name":"` + first + `"}},
|
||||
{"type":"function","function":{"name":"` + second + `"}}
|
||||
]}`)
|
||||
mapped := util.SanitizedFunctionNameMap(original)[second]
|
||||
responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"` + mapped + `","args":{}}}]}}]}}`)
|
||||
|
||||
output := ConvertAntigravityResponseToOpenAINonStream(context.Background(), "gemini-3-flash", original, nil, responseJSON, nil)
|
||||
if got := gjson.GetBytes(output, "choices.0.message.tool_calls.0.function.name").String(); got != second {
|
||||
t.Fatalf("function.name = %q, want %q. Output: %s", got, second, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAntigravityResponseToOpenAINonStreamIncludesReasoningContent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
responseJSON := []byte(`{
|
||||
"response": {
|
||||
"candidates": [{
|
||||
"index": 0,
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": "I need to multiply 17 by 24.", "thought": true},
|
||||
{"text": "408", "thoughtSignature": "sig-final-answer"}
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 16,
|
||||
"candidatesTokenCount": 3,
|
||||
"thoughtsTokenCount": 42,
|
||||
"totalTokenCount": 61
|
||||
},
|
||||
"modelVersion": "gemini-3.1-pro-low",
|
||||
"responseId": "resp-reasoning"
|
||||
}
|
||||
}`)
|
||||
|
||||
output := ConvertAntigravityResponseToOpenAINonStream(ctx, "gemini-3.1-pro-low", nil, nil, responseJSON, nil)
|
||||
if got := gjson.GetBytes(output, "choices.0.message.reasoning_content").String(); got != "I need to multiply 17 by 24." {
|
||||
t.Fatalf("reasoning_content = %q, want thought text. Output: %s", got, output)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "choices.0.message.content").String(); got != "408" {
|
||||
t.Fatalf("content = %q, want final answer. Output: %s", got, output)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "usage.completion_tokens_details.reasoning_tokens").Int(); got != 42 {
|
||||
t.Fatalf("reasoning_tokens = %d, want 42. Output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
OpenAI,
|
||||
Antigravity,
|
||||
ConvertOpenAIRequestToAntigravity,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertAntigravityResponseToOpenAI,
|
||||
NonStream: ConvertAntigravityResponseToOpenAINonStream,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini"
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func ConvertOpenAIResponsesRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
rawJSON := inputRawJSON
|
||||
rawJSON = ConvertOpenAIResponsesRequestToGemini(modelName, rawJSON, stream)
|
||||
rawJSON = rewriteOpenAIResponsesReasoningForAntigravityClaude(modelName, inputRawJSON, rawJSON)
|
||||
return ConvertGeminiRequestToAntigravity(modelName, rawJSON, stream)
|
||||
}
|
||||
|
||||
type antigravityClaudeReasoningSignature struct {
|
||||
Signature string
|
||||
HasRawSignature bool
|
||||
RawSignatureLen int
|
||||
DetectedProvider sigcompat.SignatureProvider
|
||||
}
|
||||
|
||||
func rewriteOpenAIResponsesReasoningForAntigravityClaude(modelName string, inputRawJSON, geminiJSON []byte) []byte {
|
||||
if sigcompat.SignatureProviderFromModelName(modelName) != sigcompat.SignatureProviderClaude {
|
||||
return geminiJSON
|
||||
}
|
||||
|
||||
reasoningSignatures := antigravityClaudeReasoningSignatures(inputRawJSON)
|
||||
if len(reasoningSignatures) == 0 {
|
||||
return geminiJSON
|
||||
}
|
||||
|
||||
var root map[string]any
|
||||
if err := json.Unmarshal(geminiJSON, &root); err != nil {
|
||||
log.WithError(err).Debug("antigravity responses translator: failed to parse Gemini request for Claude signature rewrite")
|
||||
return geminiJSON
|
||||
}
|
||||
|
||||
contents, ok := root["contents"].([]any)
|
||||
if !ok {
|
||||
return geminiJSON
|
||||
}
|
||||
|
||||
reasoningIndex := 0
|
||||
changed := false
|
||||
rewrittenContents := make([]any, 0, len(contents))
|
||||
for contentIndex, contentValue := range contents {
|
||||
content, ok := contentValue.(map[string]any)
|
||||
if !ok {
|
||||
rewrittenContents = append(rewrittenContents, contentValue)
|
||||
continue
|
||||
}
|
||||
|
||||
parts, ok := content["parts"].([]any)
|
||||
if !ok {
|
||||
rewrittenContents = append(rewrittenContents, content)
|
||||
continue
|
||||
}
|
||||
|
||||
rewrittenParts := make([]any, 0, len(parts))
|
||||
for partIndex, partValue := range parts {
|
||||
part, ok := partValue.(map[string]any)
|
||||
if !ok || part["thought"] != true {
|
||||
rewrittenParts = append(rewrittenParts, partValue)
|
||||
continue
|
||||
}
|
||||
|
||||
var reasoningSig antigravityClaudeReasoningSignature
|
||||
if reasoningIndex < len(reasoningSignatures) {
|
||||
reasoningSig = reasoningSignatures[reasoningIndex]
|
||||
}
|
||||
reasoningIndex++
|
||||
|
||||
if reasoningSig.Signature == "" {
|
||||
changed = true
|
||||
logDroppedOpenAIResponsesAntigravityClaudeReasoning(modelName, contentIndex, partIndex, reasoningIndex-1, reasoningSig)
|
||||
continue
|
||||
}
|
||||
if text, _ := part["text"].(string); strings.TrimSpace(text) == "" {
|
||||
changed = true
|
||||
logDroppedOpenAIResponsesAntigravityClaudeEmptyReasoning(modelName, contentIndex, partIndex, reasoningIndex-1, reasoningSig)
|
||||
continue
|
||||
}
|
||||
|
||||
if currentSignature, _ := part["thoughtSignature"].(string); currentSignature != reasoningSig.Signature {
|
||||
changed = true
|
||||
logNormalizedOpenAIResponsesAntigravityClaudeReasoning(modelName, contentIndex, partIndex, reasoningIndex-1, reasoningSig)
|
||||
}
|
||||
part["thoughtSignature"] = reasoningSig.Signature
|
||||
rewrittenParts = append(rewrittenParts, part)
|
||||
}
|
||||
|
||||
if len(rewrittenParts) == 0 {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
content["parts"] = rewrittenParts
|
||||
rewrittenContents = append(rewrittenContents, content)
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return geminiJSON
|
||||
}
|
||||
|
||||
root["contents"] = rewrittenContents
|
||||
out, err := json.Marshal(root)
|
||||
if err != nil {
|
||||
log.WithError(err).Debug("antigravity responses translator: failed to marshal Claude signature rewrite")
|
||||
return geminiJSON
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func antigravityClaudeReasoningSignatures(inputRawJSON []byte) []antigravityClaudeReasoningSignature {
|
||||
input := gjson.GetBytes(inputRawJSON, "input")
|
||||
if !input.IsArray() {
|
||||
return nil
|
||||
}
|
||||
|
||||
signatures := make([]antigravityClaudeReasoningSignature, 0)
|
||||
input.ForEach(func(_, item gjson.Result) bool {
|
||||
itemType := item.Get("type").String()
|
||||
if itemType == "" && item.Get("role").Exists() {
|
||||
itemType = "message"
|
||||
}
|
||||
if itemType != "reasoning" {
|
||||
return true
|
||||
}
|
||||
|
||||
rawSignatureResult := item.Get("encrypted_content")
|
||||
rawSignature := rawSignatureResult.String()
|
||||
signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSignature)
|
||||
reasoningSignature := antigravityClaudeReasoningSignature{
|
||||
HasRawSignature: rawSignatureResult.Exists(),
|
||||
RawSignatureLen: len(rawSignature),
|
||||
DetectedProvider: sigcompat.SignatureProviderUnknown,
|
||||
}
|
||||
if rawSignature != "" {
|
||||
reasoningSignature.DetectedProvider = sigcompat.DetectSignatureProviderForBlock(rawSignature, sigcompat.SignatureBlockKindClaudeThinking)
|
||||
}
|
||||
if ok {
|
||||
reasoningSignature.Signature = signature
|
||||
}
|
||||
signatures = append(signatures, reasoningSignature)
|
||||
return true
|
||||
})
|
||||
return signatures
|
||||
}
|
||||
|
||||
func logDroppedOpenAIResponsesAntigravityClaudeReasoning(modelName string, contentIndex, partIndex, reasoningIndex int, sig antigravityClaudeReasoningSignature) {
|
||||
log.WithFields(log.Fields{
|
||||
"component": "signature_sanitizer",
|
||||
"translator": "antigravity_openai_responses",
|
||||
"target_provider": string(sigcompat.SignatureProviderClaude),
|
||||
"action": "drop_thinking_block",
|
||||
"reason": "missing_or_incompatible_signature",
|
||||
"model": modelName,
|
||||
"content_index": contentIndex,
|
||||
"part_index": partIndex,
|
||||
"reasoning_index": reasoningIndex,
|
||||
"has_signature": sig.HasRawSignature,
|
||||
"signature_length": sig.RawSignatureLen,
|
||||
"detected_provider": string(sig.DetectedProvider),
|
||||
}).Debug("antigravity responses translator: dropped Claude reasoning block with incompatible encrypted_content")
|
||||
}
|
||||
|
||||
func logDroppedOpenAIResponsesAntigravityClaudeEmptyReasoning(modelName string, contentIndex, partIndex, reasoningIndex int, sig antigravityClaudeReasoningSignature) {
|
||||
log.WithFields(log.Fields{
|
||||
"component": "signature_sanitizer",
|
||||
"translator": "antigravity_openai_responses",
|
||||
"target_provider": string(sigcompat.SignatureProviderClaude),
|
||||
"action": "drop_thinking_block",
|
||||
"reason": "empty_thinking_text",
|
||||
"model": modelName,
|
||||
"content_index": contentIndex,
|
||||
"part_index": partIndex,
|
||||
"reasoning_index": reasoningIndex,
|
||||
"has_signature": sig.HasRawSignature,
|
||||
"signature_length": sig.RawSignatureLen,
|
||||
"detected_provider": string(sig.DetectedProvider),
|
||||
}).Debug("antigravity responses translator: dropped Claude reasoning block with empty thinking text")
|
||||
}
|
||||
|
||||
func logNormalizedOpenAIResponsesAntigravityClaudeReasoning(modelName string, contentIndex, partIndex, reasoningIndex int, sig antigravityClaudeReasoningSignature) {
|
||||
log.WithFields(log.Fields{
|
||||
"component": "signature_sanitizer",
|
||||
"translator": "antigravity_openai_responses",
|
||||
"target_provider": string(sigcompat.SignatureProviderClaude),
|
||||
"action": "normalize_signature",
|
||||
"reason": "compatible_claude_signature",
|
||||
"model": modelName,
|
||||
"content_index": contentIndex,
|
||||
"part_index": partIndex,
|
||||
"reasoning_index": reasoningIndex,
|
||||
"has_signature": sig.HasRawSignature,
|
||||
"signature_length": sig.RawSignatureLen,
|
||||
"detected_provider": string(sig.DetectedProvider),
|
||||
}).Debug("antigravity responses translator: normalized Claude reasoning encrypted_content before upstream")
|
||||
}
|
||||
|
|
@ -0,0 +1,403 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
"github.com/tidwall/gjson"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningKeepsClaudeSignature(t *testing.T) {
|
||||
nativeSig := testAntigravityResponsesClaudeSignature(t)
|
||||
antigravitySig, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(nativeSig)
|
||||
if !ok {
|
||||
t.Fatal("test Claude signature should be compatible with Antigravity Claude")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
encrypted string
|
||||
}{
|
||||
{
|
||||
name: "Claude native E signature",
|
||||
encrypted: nativeSig,
|
||||
},
|
||||
{
|
||||
name: "Antigravity double-layer R signature",
|
||||
encrypted: antigravitySig,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"model": "claude-opus-4-6-thinking",
|
||||
"input": [
|
||||
{
|
||||
"id": "rs_prev",
|
||||
"type": "reasoning",
|
||||
"encrypted_content": "` + tt.encrypted + `",
|
||||
"summary": [{"type": "summary_text", "text": "internal reasoning"}]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "visible answer"}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "continue"}]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
|
||||
part := gjson.GetBytes(out, "request.contents.0.parts.0")
|
||||
if !part.Get("thought").Bool() {
|
||||
t.Fatalf("first part should remain a thought block. Output: %s", out)
|
||||
}
|
||||
if got := part.Get("thoughtSignature").String(); got != antigravitySig {
|
||||
t.Fatalf("thoughtSignature prefix/len = %q/%d, want %q/%d. Output: %s",
|
||||
firstByte(got), len(got), firstByte(antigravitySig), len(antigravitySig), out)
|
||||
}
|
||||
if got := part.Get("text").String(); got != "internal reasoning" {
|
||||
t.Fatalf("thought text = %q, want internal reasoning. Output: %s", got, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningDropsIncompatibleSignature(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"model": "claude-opus-4-6-thinking",
|
||||
"input": [
|
||||
{
|
||||
"id": "rs_prev",
|
||||
"type": "reasoning",
|
||||
"encrypted_content": "` + testAntigravityResponsesGPTSignature() + `",
|
||||
"summary": [{"type": "summary_text", "text": "must not reach Claude"}]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "visible answer"}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "continue"}]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
|
||||
if strings.Contains(string(out), sigcompat.GeminiSkipThoughtSignatureValidator) {
|
||||
t.Fatalf("Claude target must not receive Gemini bypass signature. Output: %s", out)
|
||||
}
|
||||
if gjson.GetBytes(out, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
|
||||
t.Fatalf("incompatible reasoning block should be dropped. Output: %s", out)
|
||||
}
|
||||
if strings.Contains(string(out), "must not reach Claude") {
|
||||
t.Fatalf("incompatible reasoning text should be dropped. Output: %s", out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "visible answer" {
|
||||
t.Fatalf("visible assistant text = %q, want visible answer. Output: %s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningDropsEmptyThinkingText(t *testing.T) {
|
||||
rawSignature := testAntigravityResponsesClaudeSignature(t)
|
||||
raw := []byte(`{
|
||||
"model": "claude-opus-4-6-thinking",
|
||||
"input": [
|
||||
{
|
||||
"id": "rs_prev",
|
||||
"type": "reasoning",
|
||||
"encrypted_content": "` + rawSignature + `",
|
||||
"summary": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "visible answer"}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "continue"}]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
|
||||
if gjson.GetBytes(out, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
|
||||
t.Fatalf("empty-text reasoning block should be dropped for Antigravity Claude. Output: %s", out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "visible answer" {
|
||||
t.Fatalf("visible assistant text = %q, want visible answer. Output: %s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func testAntigravityResponsesClaudeSignature(t *testing.T) string {
|
||||
t.Helper()
|
||||
return testAntigravityResponsesClaudeSignatureForModel(t, "claude-sonnet-4-6")
|
||||
}
|
||||
|
||||
func testAntigravityResponsesClaudeSignatureForModel(t *testing.T, model string) string {
|
||||
t.Helper()
|
||||
channelBlock := []byte{}
|
||||
channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType)
|
||||
channelBlock = protowire.AppendVarint(channelBlock, 12)
|
||||
channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType)
|
||||
channelBlock = protowire.AppendVarint(channelBlock, 2)
|
||||
channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType)
|
||||
channelBlock = protowire.AppendString(channelBlock, model)
|
||||
|
||||
container := []byte{}
|
||||
container = protowire.AppendTag(container, 1, protowire.BytesType)
|
||||
container = protowire.AppendBytes(container, channelBlock)
|
||||
|
||||
payload := []byte{}
|
||||
payload = protowire.AppendTag(payload, 2, protowire.BytesType)
|
||||
payload = protowire.AppendBytes(payload, container)
|
||||
payload = protowire.AppendTag(payload, 3, protowire.VarintType)
|
||||
payload = protowire.AppendVarint(payload, 1)
|
||||
return base64.StdEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
func testAntigravityResponsesGPTSignature() string {
|
||||
payload := make([]byte, 1+8+16+16+32)
|
||||
payload[0] = 0x80
|
||||
payload[8] = 1
|
||||
for i := 9; i < len(payload); i++ {
|
||||
payload[i] = byte(i)
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
func firstByte(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return s[:1]
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_EmptyClaudeReasoningDoesNotShiftLaterSignature(t *testing.T) {
|
||||
rawSig1 := testAntigravityResponsesClaudeSignatureForModel(t, "claude-sonnet-4-6")
|
||||
rawSig2 := testAntigravityResponsesClaudeSignatureForModel(t, "claude-opus-4-6")
|
||||
expectedSig2, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSig2)
|
||||
if !ok {
|
||||
t.Fatal("second Claude signature should be compatible")
|
||||
}
|
||||
raw := []byte(`{
|
||||
"model":"claude-opus-4-6-thinking",
|
||||
"input":[
|
||||
{"type":"reasoning","encrypted_content":"` + rawSig1 + `","summary":[]},
|
||||
{"role":"user","content":[{"type":"input_text","text":"boundary"}]},
|
||||
{"type":"reasoning","encrypted_content":"` + rawSig2 + `","summary":[{"type":"summary_text","text":"second reasoning"}]},
|
||||
{"role":"user","content":[{"type":"input_text","text":"continue"}]}
|
||||
]
|
||||
}`)
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
|
||||
var thoughts []gjson.Result
|
||||
for _, content := range gjson.GetBytes(out, "request.contents").Array() {
|
||||
for _, part := range content.Get("parts").Array() {
|
||||
if part.Get("thought").Bool() {
|
||||
thoughts = append(thoughts, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(thoughts) != 1 {
|
||||
t.Fatalf("thought count = %d, want only the non-empty reasoning item. Output: %s", len(thoughts), out)
|
||||
}
|
||||
if got := thoughts[0].Get("text").String(); got != "second reasoning" {
|
||||
t.Fatalf("thought text = %q, want second reasoning. Output: %s", got, out)
|
||||
}
|
||||
if got := thoughts[0].Get("thoughtSignature").String(); got != expectedSig2 {
|
||||
t.Fatalf("later thought received the wrong signature prefix/len = %q/%d, want %q/%d. Output: %s", firstByte(got), len(got), firstByte(expectedSig2), len(expectedSig2), out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_EmptyClaudeReasoningBeforeFunctionDoesNotShiftLaterSignature(t *testing.T) {
|
||||
rawSig1 := testAntigravityResponsesClaudeSignatureForModel(t, "claude-sonnet-4-6")
|
||||
rawSig2 := testAntigravityResponsesClaudeSignatureForModel(t, "claude-opus-4-6")
|
||||
expectedSig2, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSig2)
|
||||
if !ok {
|
||||
t.Fatal("second Claude signature should be compatible")
|
||||
}
|
||||
raw := []byte(`{
|
||||
"model":"claude-opus-4-6-thinking",
|
||||
"input":[
|
||||
{"type":"reasoning","encrypted_content":"` + rawSig1 + `","summary":[]},
|
||||
{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call-1","output":"ok"},
|
||||
{"type":"reasoning","encrypted_content":"` + rawSig2 + `","summary":[{"type":"summary_text","text":"second reasoning"}]},
|
||||
{"role":"user","content":[{"type":"input_text","text":"continue"}]}
|
||||
]
|
||||
}`)
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
|
||||
var thoughts []gjson.Result
|
||||
for _, content := range gjson.GetBytes(out, "request.contents").Array() {
|
||||
for _, part := range content.Get("parts").Array() {
|
||||
if part.Get("thought").Bool() {
|
||||
thoughts = append(thoughts, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(thoughts) != 1 || thoughts[0].Get("text").String() != "second reasoning" {
|
||||
t.Fatalf("later reasoning placement malformed. Output: %s", out)
|
||||
}
|
||||
if got := thoughts[0].Get("thoughtSignature").String(); got != expectedSig2 {
|
||||
t.Fatalf("later thought received the wrong signature prefix/len = %q/%d, want %q/%d. Output: %s", firstByte(got), len(got), firstByte(expectedSig2), len(expectedSig2), out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_GeminiReasoningUsesNativeThoughtSignaturePlacement(t *testing.T) {
|
||||
sig := "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
|
||||
raw := []byte(`{"model":"gemini-3.5-flash","input":[{"type":"reasoning","encrypted_content":"gemini#` + sig + `","summary":[{"type":"summary_text","text":"reasoning summary"}]}]}`)
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash-agent", raw, false)
|
||||
parts := gjson.GetBytes(out, "request.contents.0.parts").Array()
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("parts length = %d, want 1. Output: %s", len(parts), out)
|
||||
}
|
||||
if got := parts[0].Get("thought").Bool(); !got {
|
||||
t.Fatalf("parts[0] should be thought. Output: %s", out)
|
||||
}
|
||||
if got := parts[0].Get("thoughtSignature").String(); got != sig {
|
||||
t.Fatalf("parts[0].thoughtSignature = %q, want preserved Gemini signature. Output: %s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_PreservesToolResultImage(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3-flash",
|
||||
"input": [
|
||||
{"role": "user", "content": [{"type": "input_text", "text": "请帮我读取分析这张图片"}]},
|
||||
{"type": "function_call", "id": "fc_read", "call_id": "call_read_1", "name": "read", "arguments": "{\"path\":\"/path/to/image.png\"}"},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_read_1",
|
||||
"output": [
|
||||
{"type": "input_text", "text": "Read image file [image/png]"},
|
||||
{"type": "input_image", "detail": "auto", "image_url": "data:image/png;base64,QUJD"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
|
||||
contents := gjson.GetBytes(out, "request.contents").Array()
|
||||
if len(contents) != 3 {
|
||||
t.Fatalf("expected 3 contents, got %d. Output: %s", len(contents), out)
|
||||
}
|
||||
funcContent := contents[2]
|
||||
if got := funcContent.Get("role").String(); got != "user" {
|
||||
t.Fatalf("role = %q, want user. Output: %s", got, out)
|
||||
}
|
||||
funcResp := funcContent.Get("parts.0.functionResponse")
|
||||
if !funcResp.Exists() {
|
||||
t.Fatalf("functionResponse should exist. Output: %s", out)
|
||||
}
|
||||
if got := funcResp.Get("id").String(); got != "call_read_1" {
|
||||
t.Fatalf("id = %q, want call_read_1", got)
|
||||
}
|
||||
if got := funcResp.Get("name").String(); got != "read" {
|
||||
t.Fatalf("name = %q, want read", got)
|
||||
}
|
||||
inlineData := funcResp.Get("parts.0.inlineData")
|
||||
if !inlineData.Exists() {
|
||||
t.Fatalf("expected functionResponse.parts.0.inlineData to exist, got: %s", out)
|
||||
}
|
||||
if got := inlineData.Get("mimeType").String(); got != "image/png" {
|
||||
t.Errorf("expected mimeType image/png, got %q", got)
|
||||
}
|
||||
if got := inlineData.Get("data").String(); got != "QUJD" {
|
||||
t.Errorf("expected data QUJD, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_AttachesParallelToolImagesToNearestResponse(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3-flash",
|
||||
"input": [
|
||||
{"role": "user", "content": [{"type": "input_text", "text": "read both"}]},
|
||||
{"type": "function_call", "id": "fc_a", "call_id": "call_a", "name": "read", "arguments": "{\"path\":\"/tmp/a.png\"}"},
|
||||
{"type": "function_call", "id": "fc_b", "call_id": "call_b", "name": "read", "arguments": "{\"path\":\"/tmp/b.png\"}"},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_a",
|
||||
"output": [
|
||||
{"type": "input_text", "text": "file A"},
|
||||
{"type": "input_image", "image_url": "data:image/png;base64,AAA"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_b",
|
||||
"output": [
|
||||
{"type": "input_text", "text": "file B"},
|
||||
{"type": "input_image", "image_url": "data:image/jpeg;base64,BBB"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
|
||||
parts := gjson.GetBytes(out, "request.contents.2.parts").Array()
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("function parts = %d, want 2. Output: %s", len(parts), out)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, part := range parts {
|
||||
fr := part.Get("functionResponse")
|
||||
got[fr.Get("id").String()] = fr.Get("parts.0.inlineData.data").String()
|
||||
}
|
||||
if got["call_a"] != "AAA" {
|
||||
t.Fatalf("call_a image = %q, want AAA. Output: %s", got["call_a"], out)
|
||||
}
|
||||
if got["call_b"] != "BBB" {
|
||||
t.Fatalf("call_b image = %q, want BBB. Output: %s", got["call_b"], out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_PreservesAdditionalToolsAndToolConfig(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3-flash",
|
||||
"input": [
|
||||
{
|
||||
"type": "additional_tools",
|
||||
"tools": [
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "functions",
|
||||
"tools": [
|
||||
{"type": "custom", "name": "exec", "description": "Execute a command"},
|
||||
{"type": "function", "name": "continuity_probe", "description": "Probe", "parameters": {"type": "object", "properties": {"value": {"type": "string"}}, "required": ["value"]}}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{"role": "user", "content": [{"type": "input_text", "text": "test"}]}
|
||||
],
|
||||
"tool_choice": {
|
||||
"type": "function",
|
||||
"name": "continuity_probe",
|
||||
"namespace": "functions"
|
||||
}
|
||||
}`
|
||||
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
|
||||
if !gjson.ValidBytes(out) {
|
||||
t.Fatalf("invalid JSON output: %s", out)
|
||||
}
|
||||
|
||||
decls := gjson.GetBytes(out, "request.tools.0.functionDeclarations").Array()
|
||||
if len(decls) != 2 {
|
||||
t.Fatalf("expected 2 functionDeclarations in request.tools, got %d; raw: %s", len(decls), out)
|
||||
}
|
||||
|
||||
mode := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String()
|
||||
if mode != "ANY" {
|
||||
t.Fatalf("mode = %q, want ANY", mode)
|
||||
}
|
||||
allowed := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String()
|
||||
if allowed != "functions__continuity_probe" {
|
||||
t.Fatalf("allowedFunctionNames.0 = %q, want functions__continuity_probe", allowed)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func ConvertAntigravityResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
responseResult := gjson.GetBytes(rawJSON, "response")
|
||||
if responseResult.Exists() {
|
||||
rawJSON = []byte(responseResult.Raw)
|
||||
}
|
||||
return ConvertGeminiResponseToOpenAIResponses(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)
|
||||
}
|
||||
|
||||
func ConvertAntigravityResponseToOpenAIResponsesNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
|
||||
responseResult := gjson.GetBytes(rawJSON, "response")
|
||||
if responseResult.Exists() {
|
||||
rawJSON = []byte(responseResult.Raw)
|
||||
}
|
||||
|
||||
requestResult := gjson.GetBytes(originalRequestRawJSON, "request")
|
||||
if requestResult.Exists() {
|
||||
originalRequestRawJSON = []byte(requestResult.Raw)
|
||||
}
|
||||
|
||||
requestResult = gjson.GetBytes(requestRawJSON, "request")
|
||||
if requestResult.Exists() {
|
||||
requestRawJSON = []byte(requestResult.Raw)
|
||||
}
|
||||
|
||||
return ConvertGeminiResponseToOpenAIResponsesNonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertAntigravityResponseToOpenAIResponsesNonStream_PreservesOpenAITools(t *testing.T) {
|
||||
originalRequest := []byte(`{
|
||||
"model": "gemini-3.5-flash-low",
|
||||
"input": "Call get_weather for Tokyo.",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "get_weather",
|
||||
"description": "Get weather for a city",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"]
|
||||
}
|
||||
}],
|
||||
"tool_choice": "required"
|
||||
}`)
|
||||
translatedRequest := []byte(`{
|
||||
"request": {
|
||||
"model": "gemini-3.5-flash-low",
|
||||
"tools": [{
|
||||
"functionDeclarations": [{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather for a city",
|
||||
"parameters": {
|
||||
"type": "OBJECT",
|
||||
"properties": {"city": {"type": "STRING"}},
|
||||
"required": ["city"]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}`)
|
||||
rawResponse := []byte(`{
|
||||
"response": {
|
||||
"responseId": "antigravity-tool-response",
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [{
|
||||
"functionCall": {
|
||||
"name": "get_weather",
|
||||
"args": {"city": "Tokyo"}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}]
|
||||
}
|
||||
}`)
|
||||
|
||||
output := ConvertAntigravityResponseToOpenAIResponsesNonStream(
|
||||
context.Background(),
|
||||
"gemini-3.5-flash-low",
|
||||
originalRequest,
|
||||
translatedRequest,
|
||||
rawResponse,
|
||||
nil,
|
||||
)
|
||||
|
||||
if !gjson.ValidBytes(output) {
|
||||
t.Fatalf("converter returned invalid JSON: %s", output)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "tools.0.type").String(); got != "function" {
|
||||
t.Fatalf("tools.0.type = %q, want function; output=%s", got, output)
|
||||
}
|
||||
if gjson.GetBytes(output, "tools.0.functionDeclarations").Exists() {
|
||||
t.Fatalf("OpenAI response contains Gemini-native functionDeclarations: %s", output)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "output.0.type").String(); got != "function_call" {
|
||||
t.Fatalf("output.0.type = %q, want function_call; output=%s", got, output)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "output.0.name").String(); got != "get_weather" {
|
||||
t.Fatalf("output.0.name = %q, want get_weather; output=%s", got, output)
|
||||
}
|
||||
arguments := gjson.GetBytes(output, "output.0.arguments").String()
|
||||
if !gjson.Valid(arguments) || gjson.Get(arguments, "city").String() != "Tokyo" {
|
||||
t.Fatalf("output.0.arguments = %q, want JSON arguments with city Tokyo; output=%s", arguments, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAntigravityResponseToOpenAIResponses_RestoresAdditionalNamespaceCustomToolCall(t *testing.T) {
|
||||
originalRequest := []byte(`{
|
||||
"model": "gemini-3.5-flash-low",
|
||||
"input": [{
|
||||
"type": "additional_tools",
|
||||
"tools": [{
|
||||
"type": "namespace",
|
||||
"name": "functions",
|
||||
"tools": [{"type": "custom", "name": "exec"}]
|
||||
}]
|
||||
}]
|
||||
}`)
|
||||
rawResponse := []byte(`{
|
||||
"response": {
|
||||
"responseId": "antigravity-custom-response",
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [{
|
||||
"functionCall": {
|
||||
"name": "functions__exec",
|
||||
"args": {"input": "pwd"}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}]
|
||||
}
|
||||
}`)
|
||||
|
||||
output := ConvertAntigravityResponseToOpenAIResponsesNonStream(
|
||||
context.Background(),
|
||||
"gemini-3.5-flash-low",
|
||||
originalRequest,
|
||||
nil,
|
||||
rawResponse,
|
||||
nil,
|
||||
)
|
||||
|
||||
if !gjson.ValidBytes(output) {
|
||||
t.Fatalf("invalid JSON output: %s", output)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "output.0.type").String(); got != "custom_tool_call" {
|
||||
t.Fatalf("output.0.type = %q, want custom_tool_call; output=%s", got, output)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "output.0.name").String(); got != "exec" {
|
||||
t.Fatalf("output.0.name = %q, want exec", got)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "output.0.namespace").String(); got != "functions" {
|
||||
t.Fatalf("output.0.namespace = %q, want functions", got)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "output.0.input").String(); got != "pwd" {
|
||||
t.Fatalf("output.0.input = %q, want pwd", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
OpenaiResponse,
|
||||
Antigravity,
|
||||
ConvertOpenAIResponsesRequestToAntigravity,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertAntigravityResponseToOpenAIResponses,
|
||||
NonStream: ConvertAntigravityResponseToOpenAIResponsesNonStream,
|
||||
},
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue