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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue