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 TestConvertOpenAIRequestToGeminiNormalizesFileDataURL(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 := ConvertOpenAIRequestToGemini("gemini-2.5-pro", input, false)
|
||||
inlineData := gjson.GetBytes(out, "contents.0.parts.0.inlineData")
|
||||
if got := inlineData.Get("mime_type").String(); got != "application/pdf" {
|
||||
t.Fatalf("inlineData.mime_type = %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,502 @@
|
|||
// Package openai provides request translation functionality for OpenAI to Gemini API compatibility.
|
||||
// It converts OpenAI Chat Completions requests into Gemini compatible JSON using gjson/sjson only.
|
||||
package chat_completions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
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 geminiFunctionThoughtSignature = "skip_thought_signature_validator"
|
||||
|
||||
// ConvertOpenAIRequestToGemini converts an OpenAI Chat Completions request (raw JSON)
|
||||
// into a complete Gemini 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 Gemini API format
|
||||
func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) []byte {
|
||||
rawJSON := inputRawJSON
|
||||
// Base envelope (no default thinkingConfig)
|
||||
out := []byte(`{"contents":[]}`)
|
||||
|
||||
// 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, "generationConfig", []byte(genConfig.Raw))
|
||||
}
|
||||
|
||||
// Apply thinking configuration: convert OpenAI reasoning_effort to Gemini 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 := "generationConfig.thinkingConfig"
|
||||
if effort == "auto" {
|
||||
out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1)
|
||||
} else {
|
||||
out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Temperature/top_p/top_k
|
||||
if tr := gjson.GetBytes(rawJSON, "temperature"); tr.Exists() && tr.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.temperature", tr.Num)
|
||||
}
|
||||
if tpr := gjson.GetBytes(rawJSON, "top_p"); tpr.Exists() && tpr.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.topP", tpr.Num)
|
||||
}
|
||||
if tkr := gjson.GetBytes(rawJSON, "top_k"); tkr.Exists() && tkr.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.topK", tkr.Num)
|
||||
}
|
||||
|
||||
// OpenAI max_tokens / max_completion_tokens -> Gemini generationConfig.maxOutputTokens
|
||||
if mt := gjson.GetBytes(rawJSON, "max_tokens"); mt.Exists() && mt.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.maxOutputTokens", mt.Num)
|
||||
} else if mct := gjson.GetBytes(rawJSON, "max_completion_tokens"); mct.Exists() && mct.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.maxOutputTokens", mct.Num)
|
||||
}
|
||||
|
||||
// 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, "generationConfig.candidateCount", val)
|
||||
}
|
||||
}
|
||||
|
||||
// Map OpenAI response_format to Gemini structured output settings.
|
||||
out = applyOpenAIResponseFormatToGemini(out, rawJSON)
|
||||
|
||||
// Map OpenAI modalities -> Gemini 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, "generationConfig.responseModalities", responseMods)
|
||||
}
|
||||
}
|
||||
|
||||
// OpenRouter-style image_config support
|
||||
// If the input uses top-level image_config.aspect_ratio, map it into 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, "generationConfig.imageConfig.aspectRatio", ar.Str)
|
||||
}
|
||||
if size := imgCfg.Get("image_size"); size.Exists() && size.Type == gjson.String {
|
||||
out, _ = sjson.SetBytes(out, "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 -> systemInstruction as a user message style
|
||||
if content.Type == gjson.String {
|
||||
systemParts = append(systemParts, geminiTextPart(content.String()))
|
||||
} else if content.IsObject() && content.Get("type").String() == "text" {
|
||||
systemParts = append(systemParts, geminiTextPart(content.Get("text").String()))
|
||||
} else if content.IsArray() {
|
||||
contents := content.Array()
|
||||
for j := 0; j < len(contents); j++ {
|
||||
systemParts = append(systemParts, geminiTextPart(contents[j].Get("text").String()))
|
||||
}
|
||||
}
|
||||
} else if role == "user" || ((role == "system" || role == "developer") && len(arr) == 1) {
|
||||
// Build single user content node to avoid splitting into multiple contents.
|
||||
partItems := make([][]byte, 0, 4)
|
||||
if content.Type == gjson.String {
|
||||
partItems = append(partItems, geminiTextPart(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, geminiTextPart(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 {
|
||||
partItems = append(partItems, geminiInlineDataPart(pieces[0], pieces[1][7:], geminiFunctionThoughtSignature))
|
||||
}
|
||||
}
|
||||
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, geminiInlineDataPart(pieces[0], pieces[1][7:], ""))
|
||||
}
|
||||
}
|
||||
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, geminiInlineDataPart(mimeType, data, ""))
|
||||
} 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 := openAIInputAudioMimeType(item.Get("input_audio.format").String())
|
||||
partItems = append(partItems, geminiInlineDataPart(mimeType, audioData, ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
contentItems = append(contentItems, geminiContentNode("user", partItems))
|
||||
} else if role == "assistant" {
|
||||
partItems := make([][]byte, 0, 4)
|
||||
if reasoningContent := m.Get("reasoning_content"); reasoningContent.Type == gjson.String && reasoningContent.String() != "" {
|
||||
part := geminiTextPart(reasoningContent.String())
|
||||
part, _ = sjson.SetBytes(part, "thought", true)
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", geminiFunctionThoughtSignature)
|
||||
partItems = append(partItems, part)
|
||||
}
|
||||
if content.Type == gjson.String && content.String() != "" {
|
||||
partItems = append(partItems, geminiTextPart(content.String()))
|
||||
} else if content.IsArray() {
|
||||
// Assistant multimodal content (e.g. text + image) -> single model content with parts.
|
||||
for _, item := range content.Array() {
|
||||
switch item.Get("type").String() {
|
||||
case "text":
|
||||
if text := item.Get("text").String(); text != "" {
|
||||
partItems = append(partItems, geminiTextPart(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 {
|
||||
partItems = append(partItems, geminiInlineDataPart(pieces[0], pieces[1][7:], geminiFunctionThoughtSignature))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool calls -> single model content with functionCall parts.
|
||||
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.SanitizeFunctionName(tc.Get("function.name").String())
|
||||
if functionName == "" {
|
||||
continue
|
||||
}
|
||||
part := []byte(`{"functionCall":{"name":""}}`)
|
||||
part, _ = sjson.SetBytes(part, "functionCall.name", functionName)
|
||||
part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(tc.Get("function.arguments").String()))
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", openAIToolCallGeminiThoughtSignature(tc))
|
||||
partItems = append(partItems, part)
|
||||
if functionID != "" {
|
||||
functionIDs = append(functionIDs, functionID)
|
||||
}
|
||||
}
|
||||
if len(partItems) > 0 {
|
||||
contentItems = append(contentItems, geminiContentNode("model", partItems))
|
||||
}
|
||||
|
||||
// Append a single tool content combining name + response per function.
|
||||
responseParts := make([][]byte, 0, len(functionIDs))
|
||||
for _, functionID := range functionIDs {
|
||||
if name, ok := tcID2Name[functionID]; ok {
|
||||
part := []byte(`{"functionResponse":{"name":"","response":{"result":""}}}`)
|
||||
part, _ = sjson.SetBytes(part, "functionResponse.name", util.SanitizeFunctionName(name))
|
||||
response := toolResponses[functionID]
|
||||
if response == "" {
|
||||
response = "{}"
|
||||
}
|
||||
part, _ = sjson.SetBytes(part, "functionResponse.response.result", []byte(response))
|
||||
responseParts = append(responseParts, part)
|
||||
}
|
||||
}
|
||||
if len(responseParts) > 0 {
|
||||
contentItems = append(contentItems, geminiContentNode("user", responseParts))
|
||||
}
|
||||
} else if len(partItems) > 0 {
|
||||
contentItems = append(contentItems, geminiContentNode("model", partItems))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(systemParts) > 0 {
|
||||
systemInstruction := geminiContentNode("user", systemParts)
|
||||
out, _ = sjson.SetRawBytes(out, "systemInstruction", systemInstruction)
|
||||
}
|
||||
if len(contentItems) > 0 && gjson.GetBytes(contentItems[len(contentItems)-1], "role").String() == "model" {
|
||||
contentItems = contentItems[:len(contentItems)-1]
|
||||
}
|
||||
out = translatorcommon.SetRawArrayItems(out, "contents", contentItems)
|
||||
}
|
||||
|
||||
// tools -> tools[].functionDeclarations + 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 := []byte(fnRaw)
|
||||
fnRawBytes, errSet = sjson.SetBytes(fnRawBytes, "parametersJsonSchema.type", "object")
|
||||
if errSet != nil {
|
||||
log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
|
||||
continue
|
||||
}
|
||||
fnRawBytes, errSet = sjson.SetRawBytes(fnRawBytes, "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 := []byte(fnRaw)
|
||||
fnRawBytes, errSet = sjson.SetBytes(fnRawBytes, "parametersJsonSchema.type", "object")
|
||||
if errSet != nil {
|
||||
log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
|
||||
continue
|
||||
}
|
||||
fnRawBytes, errSet = sjson.SetRawBytes(fnRawBytes, "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()
|
||||
sanitizedName := util.SanitizeFunctionName(originalName)
|
||||
if nameResult.Type != gjson.String || sanitizedName != originalName {
|
||||
fnRawBytes, _ = sjson.SetBytes(fnRawBytes, "name", sanitizedName)
|
||||
}
|
||||
if parameters := gjson.GetBytes(fnRawBytes, "parametersJsonSchema"); parameters.Exists() {
|
||||
cleanedParameters := util.CleanJSONSchemaForGemini(parameters.Raw)
|
||||
if cleanedParameters != parameters.Raw {
|
||||
fnRawBytes, _ = sjson.SetRawBytes(fnRawBytes, "parametersJsonSchema", []byte(cleanedParameters))
|
||||
}
|
||||
}
|
||||
if gjson.GetBytes(fnRawBytes, "strict").Exists() {
|
||||
fnRawBytes, _ = sjson.DeleteBytes(fnRawBytes, "strict")
|
||||
}
|
||||
functionDeclarations = append(functionDeclarations, fnRawBytes)
|
||||
}
|
||||
}
|
||||
if gs := t.Get("google_search"); gs.Exists() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
if len(functionDeclarations) > 0 || len(googleSearchNodes) > 0 || len(codeExecutionNodes) > 0 || len(urlContextNodes) > 0 {
|
||||
toolItems := make([][]byte, 0, 1+len(googleSearchNodes)+len(codeExecutionNodes)+len(urlContextNodes))
|
||||
if len(functionDeclarations) > 0 {
|
||||
functionToolNode := []byte(`{"functionDeclarations":[]}`)
|
||||
functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", translatorcommon.JoinRawArray(functionDeclarations))
|
||||
toolItems = append(toolItems, functionToolNode)
|
||||
}
|
||||
toolItems = append(toolItems, googleSearchNodes...)
|
||||
toolItems = append(toolItems, codeExecutionNodes...)
|
||||
toolItems = append(toolItems, urlContextNodes...)
|
||||
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
|
||||
}
|
||||
}
|
||||
|
||||
out = common.AttachDefaultSafetySettings(out, "safetySettings")
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func geminiTextPart(text string) []byte {
|
||||
part := []byte(`{"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", text)
|
||||
return part
|
||||
}
|
||||
|
||||
func geminiInlineDataPart(mimeType, data, thoughtSignature string) []byte {
|
||||
part := []byte(`{"inlineData":{"mime_type":"","data":""}}`)
|
||||
part, _ = sjson.SetBytes(part, "inlineData.mime_type", mimeType)
|
||||
part, _ = sjson.SetBytes(part, "inlineData.data", data)
|
||||
if thoughtSignature != "" {
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", thoughtSignature)
|
||||
}
|
||||
return part
|
||||
}
|
||||
|
||||
func geminiContentNode(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 openAIToolCallGeminiThoughtSignature(toolCall gjson.Result) string {
|
||||
for _, path := range []string{
|
||||
"extra_content.google.thought_signature",
|
||||
"function.extra_content.google.thought_signature",
|
||||
"thoughtSignature",
|
||||
"thought_signature",
|
||||
} {
|
||||
if signatureResult := toolCall.Get(path); signatureResult.Exists() {
|
||||
return sigcompat.GeminiReplaySignatureOrBypass(signatureResult.String(), sigcompat.SignatureBlockKindGeminiFunctionCall)
|
||||
}
|
||||
}
|
||||
return geminiFunctionThoughtSignature
|
||||
}
|
||||
|
||||
func openAIInputAudioMimeType(audioFormat string) string {
|
||||
switch audioFormat {
|
||||
case "", "wav":
|
||||
return "audio/wav"
|
||||
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"
|
||||
default:
|
||||
return "audio/" + audioFormat
|
||||
}
|
||||
}
|
||||
|
||||
// applyOpenAIResponseFormatToGemini maps OpenAI Chat Completions structured output settings to Gemini.
|
||||
// Response schemas pass through unchanged because the tool schema cleaner removes supported response fields.
|
||||
func applyOpenAIResponseFormatToGemini(out []byte, rawJSON []byte) []byte {
|
||||
responseFormat := gjson.GetBytes(rawJSON, "response_format")
|
||||
if !responseFormat.Exists() {
|
||||
return out
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(responseFormat.Get("type").String())) {
|
||||
case "json_object":
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json")
|
||||
case "json_schema":
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json")
|
||||
out, _ = sjson.DeleteBytes(out, "generationConfig.responseSchema")
|
||||
if schema := responseFormat.Get("json_schema.schema"); schema.Exists() {
|
||||
out, _ = sjson.SetRawBytes(out, "generationConfig.responseJsonSchema", []byte(schema.Raw))
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertOpenAIRequestToGemini_StripsTrailingAssistantPrefill(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gpt-5.4",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "previous answer"}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
contents := resultJSON.Get("contents").Array()
|
||||
|
||||
if len(contents) != 1 {
|
||||
t.Fatalf("contents length = %d, want 1. contents=%s", len(contents), resultJSON.Get("contents").Raw)
|
||||
}
|
||||
if got := contents[0].Get("role").String(); got != "user" {
|
||||
t.Fatalf("final remaining role = %q, want %q", got, "user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiPreservesInputAudio(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gpt-5.5",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Transcribe this audio verbatim."},
|
||||
{"type": "input_audio", "input_audio": {"data": "SUQzBA==", "format": "mp3"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
parts := resultJSON.Get("contents.0.parts").Array()
|
||||
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("parts length = %d, want 2. parts=%s", len(parts), resultJSON.Get("contents.0.parts").Raw)
|
||||
}
|
||||
if got := parts[0].Get("text").String(); got != "Transcribe this audio verbatim." {
|
||||
t.Fatalf("text part = %q, want prompt text", got)
|
||||
}
|
||||
if got := parts[1].Get("inlineData.mime_type").String(); got != "audio/mpeg" {
|
||||
t.Fatalf("audio mime_type = %q, want %q", got, "audio/mpeg")
|
||||
}
|
||||
if got := parts[1].Get("inlineData.data").String(); got != "SUQzBA==" {
|
||||
t.Fatalf("audio data = %q, want %q", got, "SUQzBA==")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiPreservesVideoURL(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3-flash",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "video_url", "video_url": {"url": "data:video/mp4;base64,AAAAIGZ0eXBtcDQy"}},
|
||||
{"type": "text", "text": "Describe the video"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
parts := resultJSON.Get("contents.0.parts").Array()
|
||||
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("parts length = %d, want 2. parts=%s", len(parts), resultJSON.Get("contents.0.parts").Raw)
|
||||
}
|
||||
if got := parts[0].Get("inlineData.mime_type").String(); got != "video/mp4" {
|
||||
t.Fatalf("video mime_type = %q, want %q", got, "video/mp4")
|
||||
}
|
||||
if got := parts[0].Get("inlineData.data").String(); got != "AAAAIGZ0eXBtcDQy" {
|
||||
t.Fatalf("video data = %q, want %q", got, "AAAAIGZ0eXBtcDQy")
|
||||
}
|
||||
if got := parts[1].Get("text").String(); got != "Describe the video" {
|
||||
t.Fatalf("text part = %q, want prompt text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiSkipsEmptyTextPartsWithoutNulls(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 := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), false)
|
||||
userParts := gjson.GetBytes(result, "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, "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 TestConvertOpenAIRequestToGeminiPreservesReasoningContent(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 := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), true)
|
||||
contents := gjson.GetBytes(result, "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 != geminiFunctionThoughtSignature {
|
||||
t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiPreservesReasoningBeforeVisibleContentAndToolCall(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 := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), true)
|
||||
contents := gjson.GetBytes(result, "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 != geminiFunctionThoughtSignature {
|
||||
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 TestConvertOpenAIRequestToGeminiSkipsEmptyAssistantMessages(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 := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), true)
|
||||
contents := gjson.GetBytes(result, "contents").Array()
|
||||
if len(contents) != 2 {
|
||||
t.Fatalf("contents length = %d, want 2. Output: %s", len(contents), result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiMapsMaxTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want int64
|
||||
}{
|
||||
{
|
||||
name: "max_tokens",
|
||||
body: `{"model":"gemini-2.0-flash","messages":[{"role":"user","content":"hi"}],"max_tokens":30}`,
|
||||
want: 30,
|
||||
},
|
||||
{
|
||||
name: "max_completion_tokens",
|
||||
body: `{"model":"gemini-2.0-flash","messages":[{"role":"user","content":"hi"}],"max_completion_tokens":40}`,
|
||||
want: 40,
|
||||
},
|
||||
{
|
||||
name: "max_tokens preferred over max_completion_tokens",
|
||||
body: `{"model":"gemini-2.0-flash","messages":[{"role":"user","content":"hi"}],"max_tokens":30,"max_completion_tokens":40}`,
|
||||
want: 30,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
out := ConvertOpenAIRequestToGemini("gemini-2.0-flash", []byte(tt.body), false)
|
||||
if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != tt.want {
|
||||
t.Fatalf("generationConfig.maxOutputTokens = %d, want %d. Output: %s", got, tt.want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiCleansToolSchemaRequiredFields(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-2.0-flash",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_company",
|
||||
"description": "Search",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"title": "SearchCompany",
|
||||
"properties": {
|
||||
"country": {"type": "string"},
|
||||
"industry": {"type": "string"}
|
||||
},
|
||||
"required": ["country", "industry", "stale_field", "another_stale"]
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`
|
||||
|
||||
output := ConvertOpenAIRequestToGemini("gemini-2.0-flash", []byte(inputJSON), false)
|
||||
schema := gjson.GetBytes(output, "tools.0.functionDeclarations.0.parametersJsonSchema")
|
||||
|
||||
if !schema.Exists() {
|
||||
t.Fatalf("parametersJsonSchema missing. Output: %s", output)
|
||||
}
|
||||
if schema.Get("title").Exists() {
|
||||
t.Fatalf("schema title should be removed. Output: %s", output)
|
||||
}
|
||||
required := schema.Get("required").Array()
|
||||
if len(required) != 2 {
|
||||
t.Fatalf("required length = %d, want 2. Schema: %s", len(required), schema.Raw)
|
||||
}
|
||||
if got := required[0].String(); got != "country" {
|
||||
t.Fatalf("required[0] = %q, want country. Schema: %s", got, schema.Raw)
|
||||
}
|
||||
if got := required[1].String(); got != "industry" {
|
||||
t.Fatalf("required[1] = %q, want industry. Schema: %s", got, schema.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiResponseFormatJSONSchema(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3.1-flash-lite",
|
||||
"generationConfig": {
|
||||
"temperature": 0.2,
|
||||
"responseSchema": {"type": "string"}
|
||||
},
|
||||
"messages": [{"role": "user", "content": "Return structured JSON."}],
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "response",
|
||||
"strict": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"cleanedContent": {"type": "string"}},
|
||||
"required": ["cleanedContent"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
|
||||
generationConfig := gjson.GetBytes(output, "generationConfig")
|
||||
|
||||
if got := generationConfig.Get("responseMimeType").String(); got != "application/json" {
|
||||
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output)
|
||||
}
|
||||
schema := generationConfig.Get("responseJsonSchema")
|
||||
if !schema.Exists() {
|
||||
t.Fatalf("responseJsonSchema missing. Output: %s", output)
|
||||
}
|
||||
if generationConfig.Get("responseSchema").Exists() {
|
||||
t.Fatalf("responseSchema should be removed. Output: %s", output)
|
||||
}
|
||||
if additionalProperties := schema.Get("additionalProperties"); !additionalProperties.Exists() || additionalProperties.Bool() {
|
||||
t.Fatalf("additionalProperties = %s, want false. Output: %s", additionalProperties.Raw, output)
|
||||
}
|
||||
if got := generationConfig.Get("temperature").Float(); got != 0.2 {
|
||||
t.Fatalf("temperature = %v, want 0.2. Output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiResponseFormatJSONObject(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3.1-flash-lite",
|
||||
"generationConfig": {"temperature": 0.6},
|
||||
"messages": [{"role": "user", "content": "Return a JSON object."}],
|
||||
"response_format": {"type": "json_object"}
|
||||
}`
|
||||
|
||||
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
|
||||
generationConfig := gjson.GetBytes(output, "generationConfig")
|
||||
|
||||
if got := generationConfig.Get("responseMimeType").String(); got != "application/json" {
|
||||
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output)
|
||||
}
|
||||
if generationConfig.Get("responseJsonSchema").Exists() {
|
||||
t.Fatalf("responseJsonSchema should not be set for json_object. Output: %s", output)
|
||||
}
|
||||
if got := generationConfig.Get("temperature").Float(); got != 0.6 {
|
||||
t.Fatalf("temperature = %v, want 0.6. Output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiResponseFormatJSONSchemaWithoutSchema(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3.1-flash-lite",
|
||||
"messages": [{"role": "user", "content": "Return structured JSON."}],
|
||||
"response_format": {"type": "json_schema", "json_schema": {"name": "response"}}
|
||||
}`
|
||||
|
||||
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
|
||||
generationConfig := gjson.GetBytes(output, "generationConfig")
|
||||
|
||||
if got := generationConfig.Get("responseMimeType").String(); got != "application/json" {
|
||||
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output)
|
||||
}
|
||||
if generationConfig.Get("responseJsonSchema").Exists() {
|
||||
t.Fatalf("responseJsonSchema should not be set without a schema. Output: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiResponseFormatNoOp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "absent",
|
||||
body: `{"model":"gemini-3.1-flash-lite","messages":[{"role":"user","content":"plain text"}],"temperature":0.5}`,
|
||||
},
|
||||
{
|
||||
name: "unknown type",
|
||||
body: `{"model":"gemini-3.1-flash-lite","messages":[{"role":"user","content":"plain text"}],"temperature":0.5,"response_format":{"type":"text"}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(tt.body), false)
|
||||
generationConfig := gjson.GetBytes(output, "generationConfig")
|
||||
if generationConfig.Get("responseMimeType").Exists() {
|
||||
t.Fatalf("responseMimeType should not be set. Output: %s", output)
|
||||
}
|
||||
if generationConfig.Get("responseJsonSchema").Exists() {
|
||||
t.Fatalf("responseJsonSchema should not be set. Output: %s", output)
|
||||
}
|
||||
if got := generationConfig.Get("temperature").Float(); got != 0.5 {
|
||||
t.Fatalf("temperature = %v, want 0.5. Output: %s", got, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
// Package openai provides response translation functionality for Gemini to OpenAI API compatibility.
|
||||
// This package handles the conversion of Gemini 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"
|
||||
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// convertGeminiResponseToOpenAIChatParams holds parameters for response conversion.
|
||||
type convertGeminiResponseToOpenAIChatParams struct {
|
||||
UnixTimestamp int64
|
||||
// FunctionIndex tracks tool call indices per candidate index to support multiple candidates.
|
||||
FunctionIndex map[int]int
|
||||
SawToolCall map[int]bool
|
||||
UpstreamFinishReason map[int]string
|
||||
SanitizedNameMap map[string]string
|
||||
}
|
||||
|
||||
// functionCallIDCounter provides a process-wide unique counter for function call identifiers.
|
||||
var functionCallIDCounter uint64
|
||||
|
||||
// ConvertGeminiResponseToOpenAI translates a single chunk of a streaming response from the
|
||||
// Gemini API format to the OpenAI Chat Completions streaming format.
|
||||
// It processes various Gemini 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 Gemini API
|
||||
// - param: A pointer to a parameter object for maintaining state between calls
|
||||
//
|
||||
// Returns:
|
||||
// - [][]byte: A slice of OpenAI-compatible JSON responses
|
||||
func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
// Initialize parameters if nil.
|
||||
if *param == nil {
|
||||
*param = &convertGeminiResponseToOpenAIChatParams{
|
||||
UnixTimestamp: 0,
|
||||
FunctionIndex: make(map[int]int),
|
||||
SawToolCall: make(map[int]bool),
|
||||
UpstreamFinishReason: make(map[int]string),
|
||||
SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON),
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the Map is initialized (handling cases where param might be reused from older context).
|
||||
p := (*param).(*convertGeminiResponseToOpenAIChatParams)
|
||||
if p.FunctionIndex == nil {
|
||||
p.FunctionIndex = make(map[int]int)
|
||||
}
|
||||
if p.SawToolCall == nil {
|
||||
p.SawToolCall = make(map[int]bool)
|
||||
}
|
||||
if p.UpstreamFinishReason == nil {
|
||||
p.UpstreamFinishReason = make(map[int]string)
|
||||
}
|
||||
if p.SanitizedNameMap == nil {
|
||||
p.SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON)
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(rawJSON, []byte("data:")) {
|
||||
rawJSON = bytes.TrimSpace(rawJSON[5:])
|
||||
}
|
||||
|
||||
if bytes.Equal(rawJSON, []byte("[DONE]")) {
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
// Initialize the OpenAI SSE base template.
|
||||
// We use a base template and clone it for each candidate to support multiple candidates.
|
||||
baseTemplate := []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, "modelVersion"); modelVersionResult.Exists() {
|
||||
baseTemplate, _ = sjson.SetBytes(baseTemplate, "model", modelVersionResult.String())
|
||||
}
|
||||
|
||||
// Extract and set the creation timestamp.
|
||||
if createTimeResult := gjson.GetBytes(rawJSON, "createTime"); createTimeResult.Exists() {
|
||||
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
|
||||
if err == nil {
|
||||
p.UnixTimestamp = t.Unix()
|
||||
}
|
||||
baseTemplate, _ = sjson.SetBytes(baseTemplate, "created", p.UnixTimestamp)
|
||||
} else {
|
||||
baseTemplate, _ = sjson.SetBytes(baseTemplate, "created", p.UnixTimestamp)
|
||||
}
|
||||
|
||||
// Extract and set the response ID.
|
||||
if responseIDResult := gjson.GetBytes(rawJSON, "responseId"); responseIDResult.Exists() {
|
||||
baseTemplate, _ = sjson.SetBytes(baseTemplate, "id", responseIDResult.String())
|
||||
}
|
||||
|
||||
// Extract and set usage metadata (token counts).
|
||||
// Usage is applied to the base template so it appears in the chunks.
|
||||
if usageResult := gjson.GetBytes(rawJSON, "usageMetadata"); usageResult.Exists() {
|
||||
cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int()
|
||||
baseTemplate, _ = sjson.SetBytes(baseTemplate, "usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int())
|
||||
if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
|
||||
baseTemplate, _ = sjson.SetBytes(baseTemplate, "usage.total_tokens", totalTokenCountResult.Int())
|
||||
}
|
||||
promptTokenCount := usageResult.Get("promptTokenCount").Int()
|
||||
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
|
||||
baseTemplate, _ = sjson.SetBytes(baseTemplate, "usage.prompt_tokens", promptTokenCount)
|
||||
if thoughtsTokenCount > 0 {
|
||||
baseTemplate, _ = sjson.SetBytes(baseTemplate, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
|
||||
}
|
||||
// Include cached token count if present (indicates prompt caching is working)
|
||||
if cachedTokenCount > 0 {
|
||||
var err error
|
||||
baseTemplate, err = sjson.SetBytes(baseTemplate, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount)
|
||||
if err != nil {
|
||||
log.Warnf("gemini openai response: failed to set cached_tokens in streaming: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var responseStrings [][]byte
|
||||
candidates := gjson.GetBytes(rawJSON, "candidates")
|
||||
|
||||
// Iterate over all candidates to support candidate_count > 1.
|
||||
if candidates.IsArray() {
|
||||
candidates.ForEach(func(_, candidate gjson.Result) bool {
|
||||
// Clone the template for the current candidate.
|
||||
template := append([]byte(nil), baseTemplate...)
|
||||
|
||||
// Set the specific index for this candidate.
|
||||
candidateIndex := int(candidate.Get("index").Int())
|
||||
template, _ = sjson.SetBytes(template, "choices.0.index", candidateIndex)
|
||||
|
||||
if finishReasonResult := candidate.Get("finishReason"); finishReasonResult.Exists() {
|
||||
p.UpstreamFinishReason[candidateIndex] = strings.ToUpper(finishReasonResult.String())
|
||||
}
|
||||
|
||||
partsResult := candidate.Get("content.parts")
|
||||
assistantRoleSet := false
|
||||
setAssistantRole := func() {
|
||||
if assistantRoleSet {
|
||||
return
|
||||
}
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
|
||||
assistantRoleSet = true
|
||||
}
|
||||
|
||||
if partsResult.IsArray() {
|
||||
partResults := partsResult.Array()
|
||||
for i := 0; i < len(partResults); i++ {
|
||||
partResult := partResults[i]
|
||||
partTextResult := partResult.Get("text")
|
||||
functionCallResult := partResult.Get("functionCall")
|
||||
inlineDataResult := partResult.Get("inlineData")
|
||||
if !inlineDataResult.Exists() {
|
||||
inlineDataResult = partResult.Get("inline_data")
|
||||
}
|
||||
thoughtSignatureResult := partResult.Get("thoughtSignature")
|
||||
if !thoughtSignatureResult.Exists() {
|
||||
thoughtSignatureResult = partResult.Get("thought_signature")
|
||||
}
|
||||
|
||||
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""
|
||||
hasContentPayload := partTextResult.Exists() || functionCallResult.Exists() || inlineDataResult.Exists()
|
||||
|
||||
// Skip pure thoughtSignature parts but keep any actual payload in the same part.
|
||||
if hasThoughtSignature && !hasContentPayload {
|
||||
continue
|
||||
}
|
||||
|
||||
if partTextResult.Exists() {
|
||||
text := partTextResult.String()
|
||||
setAssistantRole()
|
||||
// Handle text content, distinguishing between regular content and reasoning/thoughts.
|
||||
if partResult.Get("thought").Bool() {
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.reasoning_content", text)
|
||||
} else {
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.content", text)
|
||||
}
|
||||
} else if functionCallResult.Exists() {
|
||||
// Handle function call content.
|
||||
p.SawToolCall[candidateIndex] = true
|
||||
toolCallsResult := gjson.GetBytes(template, "choices.0.delta.tool_calls")
|
||||
|
||||
// Retrieve the function index for this specific candidate.
|
||||
functionCallIndex := p.FunctionIndex[candidateIndex]
|
||||
p.FunctionIndex[candidateIndex]++
|
||||
|
||||
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(p.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)
|
||||
}
|
||||
setAssistantRole()
|
||||
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)
|
||||
setAssistantRole()
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
upstreamFinishReason := p.UpstreamFinishReason[candidateIndex]
|
||||
sawToolCall := p.SawToolCall[candidateIndex]
|
||||
usageExists := gjson.GetBytes(rawJSON, "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))
|
||||
}
|
||||
|
||||
responseStrings = append(responseStrings, template)
|
||||
return true // continue loop
|
||||
})
|
||||
} else {
|
||||
// If there are no candidates (e.g., a pure usageMetadata chunk), return the usage chunk if present.
|
||||
if gjson.GetBytes(rawJSON, "usageMetadata").Exists() && len(responseStrings) == 0 {
|
||||
responseStrings = append(responseStrings, append([]byte(nil), baseTemplate...))
|
||||
}
|
||||
}
|
||||
|
||||
return responseStrings
|
||||
}
|
||||
|
||||
// ConvertGeminiResponseToOpenAINonStream converts a non-streaming Gemini response to a non-streaming OpenAI response.
|
||||
// This function processes the complete Gemini 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 (unused in current implementation)
|
||||
// - rawJSON: The raw JSON response from the Gemini API
|
||||
// - param: A pointer to a parameter object for the conversion (unused in current implementation)
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: An OpenAI-compatible JSON response containing all message content and metadata
|
||||
func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
|
||||
sanitizedNameMap := util.SanitizedToolNameMap(originalRequestRawJSON)
|
||||
var unixTimestamp int64
|
||||
// Initialize template with an empty choices array to support multiple candidates.
|
||||
template := []byte(`{"id":"","object":"chat.completion","created":123456,"model":"model","choices":[]}`)
|
||||
|
||||
if modelVersionResult := gjson.GetBytes(rawJSON, "modelVersion"); modelVersionResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "model", modelVersionResult.String())
|
||||
}
|
||||
|
||||
if createTimeResult := gjson.GetBytes(rawJSON, "createTime"); createTimeResult.Exists() {
|
||||
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
|
||||
if err == nil {
|
||||
unixTimestamp = t.Unix()
|
||||
}
|
||||
template, _ = sjson.SetBytes(template, "created", unixTimestamp)
|
||||
} else {
|
||||
template, _ = sjson.SetBytes(template, "created", unixTimestamp)
|
||||
}
|
||||
|
||||
if responseIDResult := gjson.GetBytes(rawJSON, "responseId"); responseIDResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "id", responseIDResult.String())
|
||||
}
|
||||
|
||||
if usageResult := gjson.GetBytes(rawJSON, "usageMetadata"); usageResult.Exists() {
|
||||
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()
|
||||
cachedTokenCount := usageResult.Get("cachedContentTokenCount").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("gemini openai response: failed to set cached_tokens in non-streaming: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process the main content part of the response for all candidates.
|
||||
candidates := gjson.GetBytes(rawJSON, "candidates")
|
||||
if candidates.IsArray() {
|
||||
var choicesList [][]byte
|
||||
candidates.ForEach(func(_, candidate gjson.Result) bool {
|
||||
// Construct a single Choice object.
|
||||
choiceTemplate := []byte(`{"index":0,"message":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}`)
|
||||
|
||||
// Set the index for this choice.
|
||||
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "index", candidate.Get("index").Int())
|
||||
|
||||
// Set finish reason.
|
||||
if finishReasonResult := candidate.Get("finishReason"); finishReasonResult.Exists() {
|
||||
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "finish_reason", strings.ToLower(finishReasonResult.String()))
|
||||
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "native_finish_reason", strings.ToLower(finishReasonResult.String()))
|
||||
}
|
||||
|
||||
partsResult := candidate.Get("content.parts")
|
||||
hasFunctionCall := false
|
||||
if partsResult.IsArray() {
|
||||
partsResults := partsResult.Array()
|
||||
var toolCalls [][]byte
|
||||
var images [][]byte
|
||||
var textContent strings.Builder
|
||||
var reasoningContent strings.Builder
|
||||
hasTextContent := false
|
||||
hasReasoningContent := false
|
||||
|
||||
for i := 0; i < len(partsResults); i++ {
|
||||
partResult := partsResults[i]
|
||||
partTextResult := partResult.Get("text")
|
||||
functionCallResult := partResult.Get("functionCall")
|
||||
inlineDataResult := partResult.Get("inlineData")
|
||||
if !inlineDataResult.Exists() {
|
||||
inlineDataResult = partResult.Get("inline_data")
|
||||
}
|
||||
|
||||
if partTextResult.Exists() {
|
||||
// Append text content, distinguishing between regular content and reasoning.
|
||||
if partResult.Get("thought").Bool() {
|
||||
hasReasoningContent = true
|
||||
reasoningContent.WriteString(partTextResult.String())
|
||||
} else {
|
||||
hasTextContent = true
|
||||
textContent.WriteString(partTextResult.String())
|
||||
}
|
||||
} else if functionCallResult.Exists() {
|
||||
// Append function call content to the tool_calls array.
|
||||
hasFunctionCall = true
|
||||
functionCallItemTemplate := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
|
||||
fcName := util.RestoreSanitizedToolName(sanitizedNameMap, functionCallResult.Get("name").String())
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)))
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.name", fcName)
|
||||
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", fcArgsResult.Raw)
|
||||
}
|
||||
toolCalls = append(toolCalls, functionCallItemTemplate)
|
||||
} else if inlineDataResult.Exists() {
|
||||
data := inlineDataResult.Get("data").String()
|
||||
if data != "" {
|
||||
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)
|
||||
imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
|
||||
imagePayload, _ = sjson.SetBytes(imagePayload, "index", len(images))
|
||||
imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
|
||||
images = append(images, imagePayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasTextContent {
|
||||
if !hasReasoningContent && len(partsResults) == 1 && len(toolCalls) == 0 && len(images) == 0 {
|
||||
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "message.content", partsResults[0].Get("text").String())
|
||||
} else {
|
||||
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "message.content", textContent.String())
|
||||
}
|
||||
}
|
||||
if hasReasoningContent {
|
||||
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "message.reasoning_content", reasoningContent.String())
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
choiceTemplate, _ = sjson.SetRawBytes(choiceTemplate, "message.tool_calls", translatorcommon.JoinRawArray(toolCalls))
|
||||
}
|
||||
if len(images) > 0 {
|
||||
choiceTemplate, _ = sjson.SetRawBytes(choiceTemplate, "message.images", translatorcommon.JoinRawArray(images))
|
||||
}
|
||||
}
|
||||
|
||||
if hasFunctionCall {
|
||||
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "finish_reason", "tool_calls")
|
||||
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "native_finish_reason", "tool_calls")
|
||||
}
|
||||
|
||||
// Append the constructed choice to the main choices array.
|
||||
choicesList = append(choicesList, choiceTemplate)
|
||||
return true
|
||||
})
|
||||
if len(choicesList) > 0 {
|
||||
template = translatorcommon.SetRawArrayItems(template, "choices", choicesList)
|
||||
}
|
||||
}
|
||||
|
||||
return template
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertGeminiResponseToOpenAIIncludesZeroCompletionTokensWhenMissing(t *testing.T) {
|
||||
var param any
|
||||
chunk := []byte(`{"usageMetadata":{"promptTokenCount":16,"thoughtsTokenCount":42,"totalTokenCount":58}}`)
|
||||
|
||||
result := ConvertGeminiResponseToOpenAI(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 TestConvertGeminiResponseToOpenAINonStreamIncludesZeroCompletionTokensWhenMissing(t *testing.T) {
|
||||
response := []byte(`{"usageMetadata":{"promptTokenCount":16,"thoughtsTokenCount":42,"totalTokenCount":58}}`)
|
||||
|
||||
result := ConvertGeminiResponseToOpenAINonStream(context.Background(), "model", nil, nil, response, nil)
|
||||
completionTokens := gjson.GetBytes(result, "usage.completion_tokens")
|
||||
if !completionTokens.Exists() || completionTokens.Int() != 0 {
|
||||
t.Fatalf("completion_tokens = %s, want present with value 0. Output: %s", completionTokens.Raw, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiFinishReasonOnlyOnFinalChunk(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
chunk1 := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_dir","args":{"path":"C:/"}}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"}}`)
|
||||
result1 := ConvertGeminiResponseToOpenAI(ctx, "model", nil, nil, chunk1, ¶m)
|
||||
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.Fatalf("expected null finish_reason on tool chunk, got %v", fr1.String())
|
||||
}
|
||||
|
||||
chunk2 := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_dir","args":{"path":"D:/"}}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"}}`)
|
||||
ConvertGeminiResponseToOpenAI(ctx, "model", nil, nil, chunk2, ¶m)
|
||||
|
||||
chunk3 := []byte(`{"candidates":[{"content":{"parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}`)
|
||||
result3 := ConvertGeminiResponseToOpenAI(ctx, "model", nil, nil, chunk3, ¶m)
|
||||
if len(result3) != 1 {
|
||||
t.Fatalf("expected 1 result from chunk3, got %d", len(result3))
|
||||
}
|
||||
fr3 := gjson.GetBytes(result3[0], "choices.0.finish_reason").String()
|
||||
if fr3 != "tool_calls" {
|
||||
t.Fatalf("expected finish_reason tool_calls, got %s", fr3)
|
||||
}
|
||||
nfr3 := gjson.GetBytes(result3[0], "choices.0.native_finish_reason").String()
|
||||
if nfr3 != "stop" {
|
||||
t.Fatalf("expected native_finish_reason stop, got %s", nfr3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToOpenAINonStream_EmptyTextProducesEmptyString(t *testing.T) {
|
||||
response := []byte(`{"candidates":[{"content":{"parts":[{"text":""},{"text":"","thought":true}]},"finishReason":"STOP"}]}`)
|
||||
result := ConvertGeminiResponseToOpenAINonStream(context.Background(), "model", nil, nil, response, nil)
|
||||
|
||||
content := gjson.GetBytes(result, "choices.0.message.content")
|
||||
if !content.Exists() || content.String() != "" || content.Type == gjson.Null {
|
||||
t.Fatalf("expected content to be empty string \"\", got %v (type %v)", content.Value(), content.Type)
|
||||
}
|
||||
|
||||
reasoning := gjson.GetBytes(result, "choices.0.message.reasoning_content")
|
||||
if !reasoning.Exists() || reasoning.String() != "" || reasoning.Type == gjson.Null {
|
||||
t.Fatalf("expected reasoning_content to be empty string \"\", got %v (type %v)", reasoning.Value(), reasoning.Type)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const capturedGeminiToolCallThoughtSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
|
||||
|
||||
func TestConvertOpenAIRequestToGemini_ToolCallSignatureCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawSignature string
|
||||
wantSignature string
|
||||
}{
|
||||
{
|
||||
name: "Gemini signature is preserved",
|
||||
rawSignature: "gemini#" + capturedGeminiToolCallThoughtSignature,
|
||||
wantSignature: capturedGeminiToolCallThoughtSignature,
|
||||
},
|
||||
{
|
||||
name: "unknown signature uses bypass",
|
||||
rawSignature: "not-a-provider-signature",
|
||||
wantSignature: signature.GeminiSkipThoughtSignatureValidator,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"model": "gemini-3.5-flash",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {"name": "lookup", "arguments": "{\"q\":\"Paris\"}"},
|
||||
"extra_content": {"google": {"thought_signature": "` + tt.rawSignature + `"}}
|
||||
}]
|
||||
}]
|
||||
}`)
|
||||
|
||||
output := ConvertOpenAIRequestToGemini("gemini-3.5-flash", input, false)
|
||||
if got := gjson.GetBytes(output, "contents.0.parts.0.thoughtSignature").String(); got != tt.wantSignature {
|
||||
t.Fatalf("thoughtSignature = %q, want %q. Output: %s", got, tt.wantSignature, 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,
|
||||
Gemini,
|
||||
ConvertOpenAIRequestToGemini,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertGeminiResponseToOpenAI,
|
||||
NonStream: ConvertGeminiResponseToOpenAINonStream,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiNormalizesToolNameAndStrict(t *testing.T) {
|
||||
input := []byte(`{"messages":[],"tools":[{"type":"function","function":{"name":true,"strict":true,"parameters":{"type":"object"}}}]}`)
|
||||
|
||||
output := ConvertOpenAIRequestToGemini("gemini-test", input, false)
|
||||
|
||||
name := gjson.GetBytes(output, "tools.0.functionDeclarations.0.name")
|
||||
if name.Type != gjson.String || name.String() != "true" {
|
||||
t.Fatalf("tool name = %s, want string true", name.Raw)
|
||||
}
|
||||
if gjson.GetBytes(output, "tools.0.functionDeclarations.0.strict").Exists() {
|
||||
t.Fatal("strict should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToOpenAINonStreamKeepsAssistantRole(t *testing.T) {
|
||||
input := []byte(`{"candidates":[{"index":0,"content":{"parts":[{"text":"hello"}]},"finishReason":"STOP"}]}`)
|
||||
|
||||
output := ConvertGeminiResponseToOpenAINonStream(context.Background(), "", nil, nil, input, nil)
|
||||
|
||||
if role := gjson.GetBytes(output, "choices.0.message.role").String(); role != "assistant" {
|
||||
t.Fatalf("role = %q, want assistant", role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToOpenAIStreamingSetsAssistantRoleOnce(t *testing.T) {
|
||||
input := []byte(`{"candidates":[{"index":0,"content":{"parts":[{"text":"hello"},{"functionCall":{"name":"lookup","args":{}}},{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}}]}`)
|
||||
var param any
|
||||
|
||||
outputs := ConvertGeminiResponseToOpenAI(context.Background(), "", nil, nil, input, ¶m)
|
||||
|
||||
if len(outputs) != 1 {
|
||||
t.Fatalf("output count = %d, want 1", len(outputs))
|
||||
}
|
||||
if role := gjson.GetBytes(outputs[0], "choices.0.delta.role").String(); role != "assistant" {
|
||||
t.Fatalf("role = %q, want assistant", role)
|
||||
}
|
||||
if got := gjson.GetBytes(outputs[0], "choices.0.delta.content").String(); got != "hello" {
|
||||
t.Fatalf("content = %q, want hello", got)
|
||||
}
|
||||
if !gjson.GetBytes(outputs[0], "choices.0.delta.tool_calls.0").Exists() {
|
||||
t.Fatal("tool call should be present")
|
||||
}
|
||||
if !gjson.GetBytes(outputs[0], "choices.0.delta.images.0").Exists() {
|
||||
t.Fatal("image should be present")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
19
backend/internal/translator/gemini/openai/responses/init.go
Normal file
19
backend/internal/translator/gemini/openai/responses/init.go
Normal file
|
|
@ -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,
|
||||
Gemini,
|
||||
ConvertOpenAIResponsesRequestToGemini,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertGeminiResponseToOpenAIResponses,
|
||||
NonStream: ConvertGeminiResponseToOpenAIResponsesNonStream,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGeminiBuildsGenerationConfigWithoutIntermediateObject(t *testing.T) {
|
||||
input := []byte(`{"input":"hello","temperature":0.5,"top_p":0.9,"stop_sequences":["done"],"text":{"format":{"type":"json_schema","schema":{"type":"object"}}}}`)
|
||||
|
||||
output := ConvertOpenAIResponsesRequestToGemini("gemini-test", input, false)
|
||||
|
||||
if got := gjson.GetBytes(output, "generationConfig.temperature").Float(); got != 0.5 {
|
||||
t.Fatalf("temperature = %v, want 0.5", got)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "generationConfig.topP").Float(); got != 0.9 {
|
||||
t.Fatalf("topP = %v, want 0.9", got)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "generationConfig.stopSequences.0").String(); got != "done" {
|
||||
t.Fatalf("stop sequence = %q, want done", got)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "generationConfig.responseMimeType").String(); got != "application/json" {
|
||||
t.Fatalf("responseMimeType = %q, want application/json", got)
|
||||
}
|
||||
if !gjson.GetBytes(output, "generationConfig.responseJsonSchema").Exists() {
|
||||
t.Fatal("responseJsonSchema should be present")
|
||||
}
|
||||
if gjson.GetBytes(output, "generationConfig.responseSchema").Exists() {
|
||||
t.Fatal("responseSchema should not be present")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const (
|
||||
geminiResponsesCarrierPrefix = "cpa-gemini-responses-carrier-v1:"
|
||||
geminiResponsesCarrierNext = "next"
|
||||
geminiResponsesCarrierPrevious = "previous"
|
||||
geminiResponsesCarrierStandalone = "standalone"
|
||||
geminiResponsesCarrierText = "text"
|
||||
geminiResponsesCarrierFunction = "function"
|
||||
geminiResponsesCarrierAny = "any"
|
||||
|
||||
geminiResponsesCarrierDirectionField = "_cpa_reasoning_direction"
|
||||
geminiResponsesCarrierTargetField = "_cpa_reasoning_target"
|
||||
geminiResponsesCarrierSignatureField = "_cpa_reasoning_signature"
|
||||
geminiResponsesCarrierSummaryField = "_cpa_reasoning_summary"
|
||||
)
|
||||
|
||||
func encodeGeminiResponsesCarrier(rawSignature, direction, targetKind string) string {
|
||||
rawSignature = strings.TrimSpace(rawSignature)
|
||||
if rawSignature == "" {
|
||||
return ""
|
||||
}
|
||||
return geminiResponsesCarrierPrefix + direction + ":" + targetKind + ":" + base64.RawStdEncoding.EncodeToString([]byte(rawSignature))
|
||||
}
|
||||
|
||||
func decodeGeminiResponsesCarrier(rawSignature string) (signatureValue, direction, targetKind string, marked, ok bool) {
|
||||
rawSignature = strings.TrimSpace(rawSignature)
|
||||
if !strings.HasPrefix(rawSignature, geminiResponsesCarrierPrefix) {
|
||||
return rawSignature, "", "", false, true
|
||||
}
|
||||
marked = true
|
||||
if len(rawSignature) > (sigcompat.MaxGeminiThoughtSignatureLen*4/3)+1024 {
|
||||
return "", "", "", true, false
|
||||
}
|
||||
fields := strings.SplitN(strings.TrimPrefix(rawSignature, geminiResponsesCarrierPrefix), ":", 3)
|
||||
if len(fields) != 3 {
|
||||
return "", "", "", true, false
|
||||
}
|
||||
direction, targetKind = fields[0], fields[1]
|
||||
switch direction {
|
||||
case geminiResponsesCarrierNext, geminiResponsesCarrierPrevious, geminiResponsesCarrierStandalone:
|
||||
default:
|
||||
return "", "", "", true, false
|
||||
}
|
||||
switch targetKind {
|
||||
case geminiResponsesCarrierText, geminiResponsesCarrierFunction, geminiResponsesCarrierAny:
|
||||
default:
|
||||
return "", "", "", true, false
|
||||
}
|
||||
decoded, errDecode := base64.RawStdEncoding.DecodeString(fields[2])
|
||||
if errDecode != nil || len(decoded) == 0 || strings.HasPrefix(string(decoded), geminiResponsesCarrierPrefix) {
|
||||
return "", "", "", true, false
|
||||
}
|
||||
return string(decoded), direction, targetKind, true, true
|
||||
}
|
||||
|
||||
func compatibleGeminiResponsesCarrierSignature(rawSignature, targetKind string) (string, bool) {
|
||||
blockKind := sigcompat.SignatureBlockKindGeminiModelPart
|
||||
if targetKind == geminiResponsesCarrierFunction {
|
||||
blockKind = sigcompat.SignatureBlockKindGeminiFunctionCall
|
||||
}
|
||||
normalized, compatible := sigcompat.CompatibleSignatureForProviderBlock(sigcompat.SignatureProviderGemini, rawSignature, blockKind)
|
||||
if !compatible || sigcompat.IsGeminiThoughtSignatureBypass(sigcompat.SignaturePayloadWithoutProviderPrefix(normalized)) {
|
||||
return "", false
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func geminiResponsesCarrierSemanticTarget(item gjson.Result) string {
|
||||
switch item.Get("type").String() {
|
||||
case "function_call", "custom_tool_call":
|
||||
return geminiResponsesCarrierFunction
|
||||
case "reasoning":
|
||||
if strings.TrimSpace(item.Get("summary.0.text").String()) != "" {
|
||||
return geminiResponsesCarrierText
|
||||
}
|
||||
}
|
||||
if _, ok := openAIResponsesAssistantVisibleText(item); ok {
|
||||
return geminiResponsesCarrierText
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func geminiResponsesCarrierMatchesAdjacent(items []gjson.Result, index int, direction, targetKind string) bool {
|
||||
step := 1
|
||||
if direction == geminiResponsesCarrierPrevious {
|
||||
step = -1
|
||||
}
|
||||
for adjacent := index + step; adjacent >= 0 && adjacent < len(items); adjacent += step {
|
||||
if kind := geminiResponsesCarrierSemanticTarget(items[adjacent]); kind != "" {
|
||||
return targetKind == geminiResponsesCarrierAny || targetKind == kind
|
||||
}
|
||||
if !isOpenAIResponsesDetachedCarrier(items[adjacent]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasInternalCarrierFields(item gjson.Result) bool {
|
||||
return item.Get(geminiResponsesCarrierDirectionField).Exists() ||
|
||||
item.Get(geminiResponsesCarrierTargetField).Exists() ||
|
||||
item.Get(geminiResponsesCarrierSignatureField).Exists() ||
|
||||
item.Get(geminiResponsesCarrierSummaryField).Exists()
|
||||
}
|
||||
|
||||
func stripGeminiResponsesCarrierMetadata(rawJSON string) ([]byte, bool) {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(rawJSON), &fields); err != nil {
|
||||
return []byte(rawJSON), false
|
||||
}
|
||||
delete(fields, geminiResponsesCarrierDirectionField)
|
||||
delete(fields, geminiResponsesCarrierTargetField)
|
||||
delete(fields, geminiResponsesCarrierSignatureField)
|
||||
delete(fields, geminiResponsesCarrierSummaryField)
|
||||
stripped, errMarshal := json.Marshal(fields)
|
||||
if errMarshal != nil {
|
||||
return []byte(rawJSON), false
|
||||
}
|
||||
return stripped, true
|
||||
}
|
||||
|
||||
func normalizeGeminiResponsesCarriers(items []gjson.Result) ([]gjson.Result, bool) {
|
||||
normalized := make([]gjson.Result, 0, len(items))
|
||||
hasValidCarrier := false
|
||||
for itemIndex, originalItem := range items {
|
||||
item := originalItem
|
||||
var itemJSON []byte
|
||||
if hasInternalCarrierFields(originalItem) {
|
||||
stripped, ok := stripGeminiResponsesCarrierMetadata(originalItem.Raw)
|
||||
if ok {
|
||||
itemJSON = stripped
|
||||
item = gjson.ParseBytes(itemJSON)
|
||||
}
|
||||
}
|
||||
if item.Get("type").String() != "reasoning" {
|
||||
normalized = append(normalized, item)
|
||||
continue
|
||||
}
|
||||
if len(itemJSON) == 0 {
|
||||
itemJSON = []byte(item.Raw)
|
||||
}
|
||||
rawSignature := strings.TrimSpace(item.Get("encrypted_content").String())
|
||||
signature, direction, targetKind, marked, ok := decodeGeminiResponsesCarrier(rawSignature)
|
||||
if !marked {
|
||||
if rawSignature != "" {
|
||||
_, hasCompatibleRawCarrier := compatibleGeminiResponsesCarrierSignature(rawSignature, geminiResponsesCarrierAny)
|
||||
hasValidCarrier = hasValidCarrier || hasCompatibleRawCarrier
|
||||
}
|
||||
normalized = append(normalized, item)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
signature, ok = compatibleGeminiResponsesCarrierSignature(signature, targetKind)
|
||||
}
|
||||
if ok && direction != geminiResponsesCarrierStandalone {
|
||||
ok = geminiResponsesCarrierMatchesAdjacent(items, itemIndex, direction, targetKind)
|
||||
}
|
||||
isDetached := isOpenAIResponsesDetachedCarrier(item)
|
||||
hasSummary := strings.TrimSpace(item.Get("summary.0.text").String()) != ""
|
||||
validSummaryCarrier := hasSummary && ((direction == geminiResponsesCarrierStandalone && (targetKind == geminiResponsesCarrierText || targetKind == geminiResponsesCarrierAny)) || direction == geminiResponsesCarrierNext)
|
||||
if !ok || (!isDetached && !validSummaryCarrier) {
|
||||
if strings.TrimSpace(item.Get("summary.0.text").String()) == "" {
|
||||
continue
|
||||
}
|
||||
itemJSON, _ = sjson.DeleteBytes(itemJSON, "encrypted_content")
|
||||
normalized = append(normalized, gjson.ParseBytes(itemJSON))
|
||||
continue
|
||||
}
|
||||
hasValidCarrier = true
|
||||
itemJSON, _ = sjson.SetBytes(itemJSON, "encrypted_content", signature)
|
||||
itemJSON, _ = sjson.SetBytes(itemJSON, geminiResponsesCarrierDirectionField, direction)
|
||||
itemJSON, _ = sjson.SetBytes(itemJSON, geminiResponsesCarrierTargetField, targetKind)
|
||||
normalized = append(normalized, gjson.ParseBytes(itemJSON))
|
||||
}
|
||||
return normalized, hasValidCarrier
|
||||
}
|
||||
|
||||
func geminiResponsesCarrierDirection(item gjson.Result) string {
|
||||
return item.Get(geminiResponsesCarrierDirectionField).String()
|
||||
}
|
||||
|
||||
func geminiResponsesCarrierTarget(item gjson.Result) string {
|
||||
return item.Get(geminiResponsesCarrierTargetField).String()
|
||||
}
|
||||
|
||||
func isOpenAIResponsesDetachedCarrier(item gjson.Result) bool {
|
||||
return item.Get("type").String() == "reasoning" && strings.TrimSpace(item.Get("encrypted_content").String()) != "" && strings.TrimSpace(item.Get("summary.0.text").String()) == ""
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
func TestGeminiResponsesCarrierRoundTrip(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
direction string
|
||||
targetKind string
|
||||
}{
|
||||
{geminiResponsesCarrierNext, geminiResponsesCarrierText},
|
||||
{geminiResponsesCarrierPrevious, geminiResponsesCarrierFunction},
|
||||
{geminiResponsesCarrierStandalone, geminiResponsesCarrierAny},
|
||||
} {
|
||||
encoded := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, testCase.direction, testCase.targetKind)
|
||||
signature, direction, targetKind, marked, ok := decodeGeminiResponsesCarrier(encoded)
|
||||
if !marked || !ok || signature != testResponsesGeminiThoughtSignature || direction != testCase.direction || targetKind != testCase.targetKind {
|
||||
t.Fatalf("carrier round-trip = %q/%q/%q marked=%v ok=%v", signature, direction, targetKind, marked, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeGeminiResponsesCarriersDropsMalformedEnvelope(t *testing.T) {
|
||||
items := gjson.Parse(`[{"type":"reasoning","encrypted_content":"` + geminiResponsesCarrierPrefix + `previous:text:not-base64!","summary":[]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"safe"}]}]`).Array()
|
||||
normalized, hasCarrier := normalizeGeminiResponsesCarriers(items)
|
||||
if hasCarrier || len(normalized) != 1 || normalized[0].Get("type").String() != "message" || strings.Contains(normalized[0].Raw, geminiResponsesCarrierPrefix) {
|
||||
t.Fatalf("malformed carrier was preserved: %v", normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGemini_DecodesCarrierForAliasModel(t *testing.T) {
|
||||
carrier := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierText)
|
||||
request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"reasoning","encrypted_content":"` + carrier + `","summary":[]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}]}`)
|
||||
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
|
||||
part := gjson.GetBytes(translated, "contents.0.parts.0")
|
||||
if part.Get("text").String() != "answer" || part.Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || strings.Contains(string(translated), geminiResponsesCarrierPrefix) {
|
||||
t.Fatalf("alias model did not decode carrier: %s", translated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiResponsesWrappedUUIDFunctionSignatureRoundTrip(t *testing.T) {
|
||||
const providerUUID = "e24830a7-5cd6-42fe-998b-ee539e72b9c3"
|
||||
inner := protowire.AppendTag(nil, 1, protowire.BytesType)
|
||||
inner = protowire.AppendBytes(inner, []byte(providerUUID))
|
||||
outer := protowire.AppendTag(nil, 2, protowire.BytesType)
|
||||
outer = protowire.AppendBytes(outer, inner)
|
||||
signature := base64.StdEncoding.EncodeToString(outer)
|
||||
|
||||
providerResponse := `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"` + signature + `","functionCall":{"id":"native-call","name":"run","args":{"command":"true"}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"wrapped-uuid"}}`
|
||||
var state any
|
||||
chunks := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash", []byte(`{"model":"alias-without-provider-name"}`), nil, []byte(providerResponse), &state)
|
||||
clientItems := make([]string, 0, 2)
|
||||
callID := ""
|
||||
for _, chunk := range chunks {
|
||||
event, data := parseSSEEvent(t, chunk)
|
||||
if event != "response.output_item.done" {
|
||||
continue
|
||||
}
|
||||
item := data.Get("item")
|
||||
switch item.Get("type").String() {
|
||||
case "reasoning":
|
||||
decoded, direction, targetKind, marked, ok := decodeGeminiResponsesCarrier(item.Get("encrypted_content").String())
|
||||
if !marked || !ok || decoded != signature || direction != geminiResponsesCarrierNext || targetKind != geminiResponsesCarrierFunction {
|
||||
t.Fatalf("provider signature carrier = marked:%v ok:%v direction:%q target:%q", marked, ok, direction, targetKind)
|
||||
}
|
||||
clientItems = append(clientItems, item.Raw)
|
||||
case "function_call":
|
||||
callID = item.Get("call_id").String()
|
||||
clientItems = append(clientItems, item.Raw)
|
||||
}
|
||||
}
|
||||
if len(clientItems) != 2 || callID == "" {
|
||||
t.Fatalf("Responses client items = %v, call ID present=%v", clientItems, callID != "")
|
||||
}
|
||||
clientItems = append(clientItems, `{"type":"function_call_output","call_id":`+strconv.Quote(callID)+`,"output":"ok"}`)
|
||||
request := []byte(`{"model":"alias-without-provider-name","input":[` + strings.Join(clientItems, ",") + `]}`)
|
||||
|
||||
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
|
||||
var functionPart gjson.Result
|
||||
gjson.GetBytes(translated, "contents").ForEach(func(_, content gjson.Result) bool {
|
||||
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
||||
if part.Get("functionCall").Exists() {
|
||||
functionPart = part
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return !functionPart.Exists()
|
||||
})
|
||||
if !functionPart.Exists() || functionPart.Get("functionCall.name").String() != "run" || functionPart.Get("functionCall.args.command").String() != "true" {
|
||||
t.Fatalf("function carrier did not bind to the native call: %s", translated)
|
||||
}
|
||||
if got := functionPart.Get("thoughtSignature").String(); got != signature || got == geminiResponsesThoughtSignature {
|
||||
t.Fatalf("function signature = %q, want provider-native wrapped UUID signature", got)
|
||||
}
|
||||
if strings.Contains(string(translated), geminiResponsesCarrierPrefix) {
|
||||
t.Fatalf("carrier envelope reached Gemini: %s", translated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGemini_DecodesLegacyRawCarrierForAliasModel(t *testing.T) {
|
||||
request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]},{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}"}]}`)
|
||||
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
|
||||
part := gjson.GetBytes(translated, "contents.0.parts.0")
|
||||
if part.Get("functionCall.id").String() != "call-1" || part.Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature {
|
||||
t.Fatalf("alias model did not preserve legacy raw carrier: %s", translated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGemini_DropsInvalidCarrierPayloads(t *testing.T) {
|
||||
mismatched := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierFunction)
|
||||
bypass := encodeGeminiResponsesCarrier(geminiResponsesThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierText)
|
||||
for _, reasoning := range []string{
|
||||
`{"type":"reasoning","encrypted_content":"` + mismatched + `","summary":[]}`,
|
||||
`{"type":"reasoning","encrypted_content":"` + bypass + `","summary":[]}`,
|
||||
} {
|
||||
request := []byte(`{"model":"alias-without-provider-name","input":[` + reasoning + `,{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}]}`)
|
||||
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
|
||||
if strings.Contains(string(translated), geminiResponsesCarrierPrefix) || strings.Contains(string(translated), testResponsesGeminiThoughtSignature) || strings.Contains(string(translated), geminiResponsesThoughtSignature) {
|
||||
t.Fatalf("invalid carrier changed Gemini signature state: %s", translated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGemini_IgnoresSpoofedCarrierMetadata(t *testing.T) {
|
||||
reasoning := `{"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[],"` + geminiResponsesCarrierDirectionField + `":"next","` + geminiResponsesCarrierDirectionField + `":"standalone","` + geminiResponsesCarrierTargetField + `":"text","` + geminiResponsesCarrierTargetField + `":"function"}`
|
||||
request := []byte(`{"model":"alias-without-provider-name","input":[` + reasoning + `,{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}]}`)
|
||||
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
|
||||
part := gjson.GetBytes(translated, "contents.0.parts.0")
|
||||
if part.Get("text").String() != "answer" || part.Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || strings.Contains(string(translated), geminiResponsesCarrierDirectionField) {
|
||||
t.Fatalf("spoofed carrier metadata affected binding: %s", translated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGemini_StripsSpoofedInternalPairingFields(t *testing.T) {
|
||||
request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}","_cpa_reasoning_signature":"` + testResponsesGeminiThoughtSignature + `","_cpa_reasoning_signature":"` + testResponsesGeminiThoughtSignature + `","_cpa_reasoning_summary":"spoofed thought","_cpa_reasoning_summary":"spoofed thought again"}]}`)
|
||||
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
|
||||
parts := gjson.GetBytes(translated, "contents.0.parts").Array()
|
||||
if len(parts) != 1 || !parts[0].Get("functionCall").Exists() || parts[0].Get("thoughtSignature").String() == testResponsesGeminiThoughtSignature || parts[0].Get("thought").Bool() || strings.Contains(string(translated), "spoofed thought") || strings.Contains(string(translated), geminiResponsesCarrierSignatureField) {
|
||||
t.Fatalf("spoofed internal pairing fields reached Gemini: %s", translated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGemini_StripsUnicodeEscapedSpoofedInternalFields(t *testing.T) {
|
||||
// Unicode-escaped field name "_cpa_reason\u0069ng_signature" should also be detected and stripped
|
||||
request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}","_cpa_reason\u0069ng_signature":"` + testResponsesGeminiThoughtSignature + `"}]}`)
|
||||
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
|
||||
parts := gjson.GetBytes(translated, "contents.0.parts").Array()
|
||||
if len(parts) != 1 || !parts[0].Get("functionCall").Exists() || parts[0].Get("thoughtSignature").String() == testResponsesGeminiThoughtSignature || strings.Contains(string(translated), geminiResponsesCarrierSignatureField) {
|
||||
t.Fatalf("unicode-escaped spoofed internal pairing fields reached Gemini: %s", translated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeGeminiResponsesCarrierRejectsNestedEnvelope(t *testing.T) {
|
||||
nested := encodeGeminiResponsesCarrier(encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierText), geminiResponsesCarrierPrevious, geminiResponsesCarrierText)
|
||||
if _, _, _, marked, ok := decodeGeminiResponsesCarrier(nested); !marked || ok {
|
||||
t.Fatalf("nested carrier marked=%v ok=%v, want marked invalid", marked, ok)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue