Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
20
backend/internal/translator/openai/gemini/init.go
Normal file
20
backend/internal/translator/openai/gemini/init.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package gemini
|
||||
|
||||
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(
|
||||
Gemini,
|
||||
OpenAI,
|
||||
ConvertGeminiRequestToOpenAI,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertOpenAIResponseToGemini,
|
||||
NonStream: ConvertOpenAIResponseToGeminiNonStream,
|
||||
TokenCount: GeminiTokenCount,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,511 @@
|
|||
// Package gemini provides request translation functionality for Gemini to OpenAI API.
|
||||
// It handles parsing and transforming Gemini API requests into OpenAI Chat Completions API format,
|
||||
// extracting model information, generation config, message contents, and tool declarations.
|
||||
// The package performs JSON data transformation to ensure compatibility
|
||||
// between Gemini API format and OpenAI API's expected format.
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// ConvertGeminiRequestToOpenAI parses and transforms a Gemini API request into OpenAI Chat Completions API format.
|
||||
// It extracts the model name, generation config, message contents, and tool declarations
|
||||
// from the raw JSON request and returns them in the format expected by the OpenAI API.
|
||||
func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
rawJSON := inputRawJSON
|
||||
// Base OpenAI Chat Completions API template
|
||||
out := []byte(`{"model":"","messages":[]}`)
|
||||
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
|
||||
// Model mapping
|
||||
out, _ = sjson.SetBytes(out, "model", modelName)
|
||||
|
||||
// Generation config mapping
|
||||
if genConfig := root.Get("generationConfig"); genConfig.Exists() {
|
||||
// Temperature
|
||||
if temp := genConfig.Get("temperature"); temp.Exists() {
|
||||
out, _ = sjson.SetBytes(out, "temperature", temp.Float())
|
||||
}
|
||||
|
||||
// Max tokens
|
||||
if maxTokens := genConfig.Get("maxOutputTokens"); maxTokens.Exists() {
|
||||
out, _ = sjson.SetBytes(out, "max_tokens", maxTokens.Int())
|
||||
}
|
||||
|
||||
// Top P
|
||||
if topP := genConfig.Get("topP"); topP.Exists() {
|
||||
out, _ = sjson.SetBytes(out, "top_p", topP.Float())
|
||||
}
|
||||
|
||||
// Top K (OpenAI doesn't have direct equivalent, but we can map it)
|
||||
if topK := genConfig.Get("topK"); topK.Exists() {
|
||||
// Store as custom parameter for potential use
|
||||
out, _ = sjson.SetBytes(out, "top_k", topK.Int())
|
||||
}
|
||||
|
||||
// Stop sequences
|
||||
if stopSequences := genConfig.Get("stopSequences"); stopSequences.Exists() && stopSequences.IsArray() {
|
||||
var stops []string
|
||||
stopSequences.ForEach(func(_, value gjson.Result) bool {
|
||||
stops = append(stops, value.String())
|
||||
return true
|
||||
})
|
||||
if len(stops) > 0 {
|
||||
out, _ = sjson.SetBytes(out, "stop", stops)
|
||||
}
|
||||
}
|
||||
|
||||
// Candidate count (OpenAI 'n' parameter)
|
||||
if candidateCount := genConfig.Get("candidateCount"); candidateCount.Exists() {
|
||||
out, _ = sjson.SetBytes(out, "n", candidateCount.Int())
|
||||
}
|
||||
|
||||
if responseModalities := genConfig.Get("responseModalities"); responseModalities.Exists() && responseModalities.IsArray() {
|
||||
var modalities []string
|
||||
responseModalities.ForEach(func(_, value gjson.Result) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value.String())) {
|
||||
case "text":
|
||||
modalities = append(modalities, "text")
|
||||
case "image":
|
||||
modalities = append(modalities, "image")
|
||||
case "audio":
|
||||
modalities = append(modalities, "audio")
|
||||
}
|
||||
return true
|
||||
})
|
||||
if len(modalities) > 0 {
|
||||
out, _ = sjson.SetBytes(out, "modalities", modalities)
|
||||
}
|
||||
}
|
||||
|
||||
// Map Gemini thinkingConfig to OpenAI reasoning_effort.
|
||||
// Always perform conversion to support allowCompat models that may not be in registry.
|
||||
// Note: Google official Python SDK sends snake_case fields (thinking_level/thinking_budget).
|
||||
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
|
||||
thinkingLevel := thinkingConfig.Get("thinkingLevel")
|
||||
if !thinkingLevel.Exists() {
|
||||
thinkingLevel = thinkingConfig.Get("thinking_level")
|
||||
}
|
||||
if thinkingLevel.Exists() {
|
||||
effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
|
||||
if effort != "" {
|
||||
out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
|
||||
}
|
||||
} else {
|
||||
thinkingBudget := thinkingConfig.Get("thinkingBudget")
|
||||
if !thinkingBudget.Exists() {
|
||||
thinkingBudget = thinkingConfig.Get("thinking_budget")
|
||||
}
|
||||
if thinkingBudget.Exists() {
|
||||
if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {
|
||||
out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream parameter
|
||||
out, _ = sjson.SetBytes(out, "stream", stream)
|
||||
if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String {
|
||||
out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String())
|
||||
}
|
||||
|
||||
// Process contents (Gemini messages) -> OpenAI messages
|
||||
messageCapacity := root.Get("contents.#").Int()
|
||||
if root.Get("systemInstruction").Exists() || root.Get("system_instruction").Exists() {
|
||||
messageCapacity++
|
||||
}
|
||||
messageItems := translatorcommon.NewRawArrayItems(messageCapacity)
|
||||
toolCallIDsByName := make(map[string][]string) // Track tool call IDs per function name for matching
|
||||
|
||||
// System instruction -> OpenAI system message
|
||||
// Gemini may provide `systemInstruction` or `system_instruction`; support both keys.
|
||||
systemInstruction := root.Get("systemInstruction")
|
||||
if !systemInstruction.Exists() {
|
||||
systemInstruction = root.Get("system_instruction")
|
||||
}
|
||||
if systemInstruction.Exists() {
|
||||
parts := systemInstruction.Get("parts")
|
||||
contentItems := make([][]byte, 0, 2)
|
||||
|
||||
if parts.Exists() && parts.IsArray() {
|
||||
parts.ForEach(func(_, part gjson.Result) bool {
|
||||
if translatorcommon.IsGeminiThoughtPart(part) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle text parts
|
||||
if text := part.Get("text"); text.Exists() {
|
||||
contentPart := []byte(`{"type":"text","text":""}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "text", text.String())
|
||||
contentItems = append(contentItems, contentPart)
|
||||
}
|
||||
|
||||
// Handle inline data (e.g., images)
|
||||
if contentPart, ok := openAIContentPartFromGeminiInlineData(part); ok {
|
||||
contentItems = append(contentItems, contentPart)
|
||||
}
|
||||
if contentPart, ok := openAIContentPartFromGeminiFileData(part); ok {
|
||||
contentItems = append(contentItems, contentPart)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if len(contentItems) > 0 {
|
||||
msg := []byte(`{"role":"system","content":[]}`)
|
||||
msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
|
||||
messageItems = append(messageItems, msg)
|
||||
}
|
||||
}
|
||||
|
||||
if contents := root.Get("contents"); contents.Exists() && contents.IsArray() {
|
||||
msgIdx := 0
|
||||
contents.ForEach(func(_, content gjson.Result) bool {
|
||||
role := content.Get("role").String()
|
||||
parts := content.Get("parts")
|
||||
|
||||
// Convert role: model -> assistant
|
||||
if role == "model" {
|
||||
role = "assistant"
|
||||
}
|
||||
|
||||
msg := []byte(`{"role":"","content":""}`)
|
||||
msg, _ = sjson.SetBytes(msg, "role", role)
|
||||
|
||||
var textBuilder strings.Builder
|
||||
contentItems := make([][]byte, 0, 4)
|
||||
onlyTextContent := true
|
||||
toolCallItems := make([][]byte, 0, 2)
|
||||
droppedThought := false
|
||||
|
||||
if parts.Exists() && parts.IsArray() {
|
||||
partIdx := 0
|
||||
parts.ForEach(func(_, part gjson.Result) bool {
|
||||
currentPartIdx := partIdx
|
||||
partIdx++
|
||||
|
||||
if translatorcommon.IsGeminiThoughtPart(part) {
|
||||
droppedThought = true
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle text parts
|
||||
if text := part.Get("text"); text.Exists() {
|
||||
formattedText := text.String()
|
||||
textBuilder.WriteString(formattedText)
|
||||
contentPart := []byte(`{"type":"text","text":""}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "text", formattedText)
|
||||
contentItems = append(contentItems, contentPart)
|
||||
}
|
||||
|
||||
// Handle inline data (e.g., images)
|
||||
if contentPart, ok := openAIContentPartFromGeminiInlineData(part); ok {
|
||||
onlyTextContent = false
|
||||
contentItems = append(contentItems, contentPart)
|
||||
}
|
||||
if contentPart, ok := openAIContentPartFromGeminiFileData(part); ok {
|
||||
onlyTextContent = false
|
||||
contentItems = append(contentItems, contentPart)
|
||||
}
|
||||
|
||||
// Handle function calls (Gemini) -> tool calls (OpenAI)
|
||||
if functionCall := part.Get("functionCall"); functionCall.Exists() {
|
||||
funcName := functionCall.Get("name").String()
|
||||
argsRaw := ""
|
||||
if args := functionCall.Get("args"); args.Exists() {
|
||||
argsRaw = args.Raw
|
||||
}
|
||||
toolCallID := explicitGeminiToolID(functionCall)
|
||||
if toolCallID == "" {
|
||||
toolCallID = deterministicToolCallID("call", msgIdx, currentPartIdx, funcName, argsRaw)
|
||||
}
|
||||
toolCallIDsByName[funcName] = append(toolCallIDsByName[funcName], toolCallID)
|
||||
|
||||
toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
|
||||
toolCall, _ = sjson.SetBytes(toolCall, "id", toolCallID)
|
||||
toolCall, _ = sjson.SetBytes(toolCall, "function.name", funcName)
|
||||
|
||||
// Convert args to arguments JSON string
|
||||
if argsRaw != "" {
|
||||
toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", argsRaw)
|
||||
} else {
|
||||
toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", "{}")
|
||||
}
|
||||
|
||||
toolCallItems = append(toolCallItems, toolCall)
|
||||
}
|
||||
|
||||
// Handle function responses (Gemini) -> tool role messages (OpenAI)
|
||||
if functionResponse := part.Get("functionResponse"); functionResponse.Exists() {
|
||||
funcName := functionResponse.Get("name").String()
|
||||
// Create tool message for function response
|
||||
toolMsg := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
|
||||
|
||||
responseRaw := ""
|
||||
// Convert response.content to JSON string
|
||||
if response := functionResponse.Get("response"); response.Exists() {
|
||||
if contentField := response.Get("content"); contentField.Exists() {
|
||||
responseRaw = contentField.Raw
|
||||
toolMsg, _ = sjson.SetBytes(toolMsg, "content", responseRaw)
|
||||
} else {
|
||||
responseRaw = response.Raw
|
||||
toolMsg, _ = sjson.SetBytes(toolMsg, "content", responseRaw)
|
||||
}
|
||||
}
|
||||
|
||||
if toolCallID := explicitGeminiToolID(functionResponse); toolCallID != "" {
|
||||
toolMsg, _ = sjson.SetBytes(toolMsg, "tool_call_id", toolCallID)
|
||||
if queue := toolCallIDsByName[funcName]; len(queue) > 0 {
|
||||
for i, id := range queue {
|
||||
if id == toolCallID {
|
||||
toolCallIDsByName[funcName] = append(queue[:i], queue[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if queue := toolCallIDsByName[funcName]; len(queue) > 0 {
|
||||
toolCallID := queue[0]
|
||||
toolCallIDsByName[funcName] = queue[1:]
|
||||
toolMsg, _ = sjson.SetBytes(toolMsg, "tool_call_id", toolCallID)
|
||||
} else {
|
||||
// Generate a deterministic tool call ID fallback if none available
|
||||
fallbackID := deterministicToolCallID("response", msgIdx, currentPartIdx, funcName, responseRaw)
|
||||
toolMsg, _ = sjson.SetBytes(toolMsg, "tool_call_id", fallbackID)
|
||||
}
|
||||
|
||||
messageItems = append(messageItems, toolMsg)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Set content
|
||||
if len(contentItems) > 0 {
|
||||
if onlyTextContent {
|
||||
msg, _ = sjson.SetBytes(msg, "content", textBuilder.String())
|
||||
} else {
|
||||
msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
|
||||
}
|
||||
}
|
||||
|
||||
// Set tool calls if any.
|
||||
if len(toolCallItems) > 0 {
|
||||
msg, _ = sjson.SetRawBytes(msg, "tool_calls", translatorcommon.JoinRawArray(toolCallItems))
|
||||
}
|
||||
|
||||
if droppedThought && len(contentItems) == 0 && len(toolCallItems) == 0 {
|
||||
msgIdx++
|
||||
return true
|
||||
}
|
||||
|
||||
messageItems = append(messageItems, msg)
|
||||
msgIdx++
|
||||
return true
|
||||
})
|
||||
}
|
||||
out = translatorcommon.SetRawArrayItems(out, "messages", messageItems)
|
||||
|
||||
// Tools mapping: Gemini tools -> OpenAI tools
|
||||
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
|
||||
var toolItems [][]byte
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
if functionDeclarations := tool.Get("functionDeclarations"); functionDeclarations.Exists() && functionDeclarations.IsArray() {
|
||||
functionDeclarations.ForEach(func(_, funcDecl gjson.Result) bool {
|
||||
openAITool := []byte(`{"type":"function","function":{"name":"","description":""}}`)
|
||||
openAITool, _ = sjson.SetBytes(openAITool, "function.name", funcDecl.Get("name").String())
|
||||
openAITool, _ = sjson.SetBytes(openAITool, "function.description", funcDecl.Get("description").String())
|
||||
|
||||
// Convert parameters schema
|
||||
if parameters := funcDecl.Get("parameters"); parameters.Exists() {
|
||||
openAITool, _ = sjson.SetRawBytes(openAITool, "function.parameters", []byte(parameters.Raw))
|
||||
} else if parameters := funcDecl.Get("parametersJsonSchema"); parameters.Exists() {
|
||||
openAITool, _ = sjson.SetRawBytes(openAITool, "function.parameters", []byte(parameters.Raw))
|
||||
}
|
||||
|
||||
toolItems = append(toolItems, openAITool)
|
||||
return true
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
if len(toolItems) > 0 {
|
||||
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
|
||||
}
|
||||
}
|
||||
|
||||
// Tool choice mapping (Gemini doesn't have direct equivalent, but we can handle it)
|
||||
if toolConfig := root.Get("toolConfig"); toolConfig.Exists() {
|
||||
if functionCallingConfig := toolConfig.Get("functionCallingConfig"); functionCallingConfig.Exists() {
|
||||
mode := functionCallingConfig.Get("mode").String()
|
||||
allowedNames := functionCallingConfig.Get("allowedFunctionNames")
|
||||
switch mode {
|
||||
case "NONE":
|
||||
out, _ = sjson.SetBytes(out, "tool_choice", "none")
|
||||
case "AUTO":
|
||||
out, _ = sjson.SetBytes(out, "tool_choice", "auto")
|
||||
case "ANY":
|
||||
allowedNameItems := allowedNames.Array()
|
||||
if allowedNames.IsArray() && len(allowedNameItems) == 1 {
|
||||
choice := []byte(`{"type":"function","function":{"name":""}}`)
|
||||
choice, _ = sjson.SetBytes(choice, "function.name", allowedNameItems[0].String())
|
||||
out, _ = sjson.SetRawBytes(out, "tool_choice", choice)
|
||||
} else {
|
||||
out, _ = sjson.SetBytes(out, "tool_choice", "required")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func deterministicToolCallID(kind string, msgIdx, partIdx int, name, payload string) string {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%d|%d|%s|%s", kind, msgIdx, partIdx, name, payload)))
|
||||
return "call_" + hex.EncodeToString(sum[:12])
|
||||
}
|
||||
|
||||
func explicitGeminiToolID(node gjson.Result) string {
|
||||
if id := strings.TrimSpace(node.Get("id").String()); id != "" {
|
||||
return id
|
||||
}
|
||||
if callID := strings.TrimSpace(node.Get("call_id").String()); callID != "" {
|
||||
return callID
|
||||
}
|
||||
return strings.TrimSpace(node.Get("callId").String())
|
||||
}
|
||||
|
||||
func openAIContentPartFromGeminiInlineData(part gjson.Result) ([]byte, bool) {
|
||||
inlineData := part.Get("inlineData")
|
||||
if !inlineData.Exists() {
|
||||
inlineData = part.Get("inline_data")
|
||||
}
|
||||
if !inlineData.Exists() {
|
||||
return nil, false
|
||||
}
|
||||
mimeType := inlineData.Get("mimeType").String()
|
||||
if mimeType == "" {
|
||||
mimeType = inlineData.Get("mime_type").String()
|
||||
}
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
data := inlineData.Get("data").String()
|
||||
if data == "" {
|
||||
return nil, false
|
||||
}
|
||||
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
|
||||
lowerMimeType := strings.ToLower(mimeType)
|
||||
switch {
|
||||
case strings.HasPrefix(lowerMimeType, "image/"):
|
||||
contentPart := []byte(`{"type":"image_url","image_url":{"url":""}}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "image_url.url", dataURL)
|
||||
return contentPart, true
|
||||
case strings.HasPrefix(lowerMimeType, "audio/"):
|
||||
contentPart := []byte(`{"type":"input_audio","input_audio":{"data":"","format":""}}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "input_audio.data", data)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "input_audio.format", openAIInputAudioFormatFromMIME(mimeType))
|
||||
return contentPart, true
|
||||
case strings.HasPrefix(lowerMimeType, "video/"):
|
||||
contentPart := []byte(`{"type":"video_url","video_url":{"url":""}}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "video_url.url", dataURL)
|
||||
return contentPart, true
|
||||
default:
|
||||
contentPart := []byte(`{"type":"file","file":{"filename":"","file_data":""}}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "file.filename", openAIFileNameFromMIME(mimeType))
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "file.file_data", data)
|
||||
return contentPart, true
|
||||
}
|
||||
}
|
||||
|
||||
func openAIContentPartFromGeminiFileData(part gjson.Result) ([]byte, bool) {
|
||||
fileData := part.Get("fileData")
|
||||
if !fileData.Exists() {
|
||||
fileData = part.Get("file_data")
|
||||
}
|
||||
if !fileData.Exists() {
|
||||
return nil, false
|
||||
}
|
||||
fileURI := fileData.Get("fileUri").String()
|
||||
if fileURI == "" {
|
||||
fileURI = fileData.Get("file_uri").String()
|
||||
}
|
||||
if fileURI == "" {
|
||||
return nil, false
|
||||
}
|
||||
mimeType := fileData.Get("mimeType").String()
|
||||
if mimeType == "" {
|
||||
mimeType = fileData.Get("mime_type").String()
|
||||
}
|
||||
lowerMimeType := strings.ToLower(mimeType)
|
||||
if strings.HasPrefix(lowerMimeType, "image/") {
|
||||
contentPart := []byte(`{"type":"image_url","image_url":{"url":""}}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "image_url.url", fileURI)
|
||||
return contentPart, true
|
||||
}
|
||||
if strings.HasPrefix(lowerMimeType, "video/") {
|
||||
contentPart := []byte(`{"type":"video_url","video_url":{"url":""}}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "video_url.url", fileURI)
|
||||
return contentPart, true
|
||||
}
|
||||
if strings.HasPrefix(lowerMimeType, "application/") || strings.HasPrefix(lowerMimeType, "text/") {
|
||||
contentPart := []byte(`{"type":"file","file":{"filename":"","file_url":""}}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "file.filename", openAIFileNameFromMIME(mimeType))
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "file.file_url", fileURI)
|
||||
return contentPart, true
|
||||
}
|
||||
fileInfo := "File: " + fileURI
|
||||
if mimeType != "" {
|
||||
fileInfo += " (Type: " + mimeType + ")"
|
||||
}
|
||||
contentPart := []byte(`{"type":"text","text":""}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "text", fileInfo)
|
||||
return contentPart, true
|
||||
}
|
||||
|
||||
func openAIInputAudioFormatFromMIME(mimeType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mimeType)) {
|
||||
case "audio/wav", "audio/wave", "audio/x-wav":
|
||||
return "wav"
|
||||
case "audio/flac":
|
||||
return "flac"
|
||||
case "audio/opus", "audio/ogg":
|
||||
return "opus"
|
||||
case "audio/pcm", "audio/l16":
|
||||
return "pcm16"
|
||||
default:
|
||||
return "mp3"
|
||||
}
|
||||
}
|
||||
|
||||
func openAIFileNameFromMIME(mimeType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mimeType)) {
|
||||
case "application/pdf":
|
||||
return "document.pdf"
|
||||
case "text/plain":
|
||||
return "document.txt"
|
||||
case "text/csv":
|
||||
return "document.csv"
|
||||
case "application/json":
|
||||
return "document.json"
|
||||
case "application/xml", "text/xml":
|
||||
return "document.xml"
|
||||
default:
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(mimeType)), "video/") {
|
||||
return "video"
|
||||
}
|
||||
return "document"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertGeminiRequestToOpenAI_FunctionResponsesConsumeToolCallIDsFIFO(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "read_file", "args": {"path": "a.txt"}}},
|
||||
{"functionCall": {"name": "grep", "args": {"pattern": "needle"}}},
|
||||
{"functionCall": {"name": "list_dir", "args": {"path": "."}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "function",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "read_file", "response": {"result": "a"}}},
|
||||
{"functionResponse": {"name": "grep", "response": {"result": "b"}}},
|
||||
{"functionResponse": {"name": "list_dir", "response": {"result": "c"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
|
||||
firstID := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String()
|
||||
secondID := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String()
|
||||
thirdID := gjson.GetBytes(out, "messages.0.tool_calls.2.id").String()
|
||||
|
||||
if firstID == "" || secondID == "" || thirdID == "" {
|
||||
t.Fatalf("expected all assistant tool call IDs to be set. Output: %s", string(out))
|
||||
}
|
||||
if firstID == secondID || secondID == thirdID || firstID == thirdID {
|
||||
t.Fatalf("expected distinct assistant tool call IDs, got %q, %q, %q", firstID, secondID, thirdID)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != firstID {
|
||||
t.Fatalf("messages.1.tool_call_id = %q, want %q. Output: %s", got, firstID, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != secondID {
|
||||
t.Fatalf("messages.2.tool_call_id = %q, want %q. Output: %s", got, secondID, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.3.tool_call_id").String(); got != thirdID {
|
||||
t.Fatalf("messages.3.tool_call_id = %q, want %q. Output: %s", got, thirdID, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToOpenAI_FunctionResponseWithoutPriorCallGetsFallbackID(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "function",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "read_file", "response": {"result": "ok"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
|
||||
toolCallID := gjson.GetBytes(out, "messages.0.tool_call_id").String()
|
||||
if !strings.HasPrefix(toolCallID, "call_") {
|
||||
t.Fatalf("fallback tool_call_id = %q, want call_ prefix. Output: %s", toolCallID, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToOpenAI_ExtraFunctionResponsesUseFallbackID(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "read_file", "args": {"path": "a.txt"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "function",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "read_file", "response": {"result": "a"}}},
|
||||
{"functionResponse": {"name": "read_file", "response": {"result": "extra"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
|
||||
callID := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String()
|
||||
firstResponseID := gjson.GetBytes(out, "messages.1.tool_call_id").String()
|
||||
extraResponseID := gjson.GetBytes(out, "messages.2.tool_call_id").String()
|
||||
|
||||
if firstResponseID != callID {
|
||||
t.Fatalf("messages.1.tool_call_id = %q, want %q. Output: %s", firstResponseID, callID, string(out))
|
||||
}
|
||||
if !strings.HasPrefix(extraResponseID, "call_") {
|
||||
t.Fatalf("extra response fallback tool_call_id = %q, want call_ prefix. Output: %s", extraResponseID, string(out))
|
||||
}
|
||||
if extraResponseID == callID {
|
||||
t.Fatalf("extra response reused consumed tool_call_id %q. Output: %s", extraResponseID, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToOpenAI_PreservesExplicitFunctionCallIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
callField string
|
||||
responseField string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "id",
|
||||
callField: `"id":"call_gateway_id"`,
|
||||
responseField: `"id":"call_gateway_id"`,
|
||||
want: "call_gateway_id",
|
||||
},
|
||||
{
|
||||
name: "call_id",
|
||||
callField: `"call_id":"call_gateway_call_id"`,
|
||||
responseField: `"call_id":"call_gateway_call_id"`,
|
||||
want: "call_gateway_call_id",
|
||||
},
|
||||
{
|
||||
name: "callId",
|
||||
callField: `"callId":"call_gateway_camel_id"`,
|
||||
responseField: `"callId":"call_gateway_camel_id"`,
|
||||
want: "call_gateway_camel_id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"contents": [
|
||||
{"role": "model", "parts": [{"functionCall": {"name": "lookup", ` + tt.callField + `, "args": {"q": "x"}}}]},
|
||||
{"role": "function", "parts": [{"functionResponse": {"name": "lookup", ` + tt.responseField + `, "response": {"result": "ok"}}}]}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
|
||||
if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != tt.want {
|
||||
t.Fatalf("tool call id = %q, want %q. Output: %s", got, tt.want, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != tt.want {
|
||||
t.Fatalf("tool response id = %q, want %q. Output: %s", got, tt.want, string(out))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToOpenAI_AcceptsSnakeInlineData(t *testing.T) {
|
||||
out := ConvertGeminiRequestToOpenAI("gpt-test", []byte(`{"contents":[{"role":"user","parts":[{"inline_data":{"mime_type":"image/png","data":"aGVsbG8="}}]}]}`), false)
|
||||
if got := gjson.GetBytes(out, "messages.0.content.0.image_url.url").String(); got != "data:image/png;base64,aGVsbG8=" {
|
||||
t.Fatalf("image url = %q, want data:image/png;base64,aGVsbG8=. Output: %s", got, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToOpenAI_SplitsNonImageInlineDataByMIME(t *testing.T) {
|
||||
out := ConvertGeminiRequestToOpenAI("gpt-test", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"audio/wav","data":"UklGRg=="}},{"inlineData":{"mimeType":"video/mp4","data":"AAAAIGZ0eXA="}},{"inlineData":{"mimeType":"application/pdf","data":"JVBERi0="}}]}]}`), false)
|
||||
|
||||
if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "input_audio" {
|
||||
t.Fatalf("audio content type = %q, want input_audio. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.0.content.1.type").String(); got != "video_url" {
|
||||
t.Fatalf("video content type = %q, want video_url. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.0.content.2.type").String(); got != "file" {
|
||||
t.Fatalf("document content type = %q, want file. Output: %s", got, string(out))
|
||||
}
|
||||
if gjson.GetBytes(out, "messages.0.content.#(type==\"image_url\")").Exists() {
|
||||
t.Fatalf("non-image inlineData must not be converted to image_url. Output: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToOpenAI_DropsHiddenThoughtParts(t *testing.T) {
|
||||
t.Run("thought-only turn", func(t *testing.T) {
|
||||
out := ConvertGeminiRequestToOpenAI("openai-test", []byte(`{
|
||||
"contents":[
|
||||
{"role":"model","parts":[{"thought":true,"text":"internal reasoning","thoughtSignature":"opaque-provider-state"}]},
|
||||
{"role":"user","parts":[{"text":"continue"}]}
|
||||
]
|
||||
}`), false)
|
||||
|
||||
messages := gjson.GetBytes(out, "messages").Array()
|
||||
if len(messages) != 1 || messages[0].Get("role").String() != "user" || messages[0].Get("content").String() != "continue" {
|
||||
t.Fatalf("hidden thought turn was not dropped. Output: %s", string(out))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mixed turn", func(t *testing.T) {
|
||||
out := ConvertGeminiRequestToOpenAI("openai-test", []byte(`{
|
||||
"contents":[{"role":"model","parts":[
|
||||
{"thought":true,"text":"internal reasoning","thoughtSignature":"opaque-provider-state"},
|
||||
{"text":"visible answer"}
|
||||
]}]
|
||||
}`), false)
|
||||
|
||||
messages := gjson.GetBytes(out, "messages").Array()
|
||||
if len(messages) != 1 || messages[0].Get("role").String() != "assistant" || messages[0].Get("content").String() != "visible answer" {
|
||||
t.Fatalf("hidden thought was not dropped independently of visible text. Output: %s", string(out))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToOpenAI_DeterministicToolCallIDs(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "read_file", "args": {"path": "main.go"}}},
|
||||
{"functionCall": {"name": "grep", "args": {"pattern": "TODO"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "function",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "read_file", "response": {"result": "code"}}},
|
||||
{"functionResponse": {"name": "grep", "response": {"result": "matches"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
firstOut := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
|
||||
firstCall0 := gjson.GetBytes(firstOut, "messages.0.tool_calls.0.id").String()
|
||||
firstCall1 := gjson.GetBytes(firstOut, "messages.0.tool_calls.1.id").String()
|
||||
firstResp0 := gjson.GetBytes(firstOut, "messages.1.tool_call_id").String()
|
||||
firstResp1 := gjson.GetBytes(firstOut, "messages.2.tool_call_id").String()
|
||||
|
||||
if !strings.HasPrefix(firstCall0, "call_") || !strings.HasPrefix(firstCall1, "call_") {
|
||||
t.Fatalf("expected tool call IDs to have call_ prefix, got %q, %q", firstCall0, firstCall1)
|
||||
}
|
||||
if firstResp0 != firstCall0 {
|
||||
t.Fatalf("expected first response ID %q to match first call ID %q", firstResp0, firstCall0)
|
||||
}
|
||||
if firstResp1 != firstCall1 {
|
||||
t.Fatalf("expected second response ID %q to match second call ID %q", firstResp1, firstCall1)
|
||||
}
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
|
||||
if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != firstCall0 {
|
||||
t.Fatalf("iteration %d: tool_calls.0.id = %q, want %q", i, got, firstCall0)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String(); got != firstCall1 {
|
||||
t.Fatalf("iteration %d: tool_calls.1.id = %q, want %q", i, got, firstCall1)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != firstResp0 {
|
||||
t.Fatalf("iteration %d: messages.1.tool_call_id = %q, want %q", i, got, firstResp0)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != firstResp1 {
|
||||
t.Fatalf("iteration %d: messages.2.tool_call_id = %q, want %q", i, got, firstResp1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToOpenAI_SameNameCallsInSameMessageDistinct(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "read_file", "args": {"path": "a.txt"}}},
|
||||
{"functionCall": {"name": "read_file", "args": {"path": "a.txt"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "function",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "read_file", "response": {"result": "first"}}},
|
||||
{"functionResponse": {"name": "read_file", "response": {"result": "second"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
|
||||
id0 := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String()
|
||||
id1 := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String()
|
||||
|
||||
if id0 == id1 {
|
||||
t.Fatalf("expected distinct IDs for same-name calls in same message, got both %q", id0)
|
||||
}
|
||||
|
||||
resp0 := gjson.GetBytes(out, "messages.1.tool_call_id").String()
|
||||
resp1 := gjson.GetBytes(out, "messages.2.tool_call_id").String()
|
||||
|
||||
if resp0 != id0 {
|
||||
t.Fatalf("expected first response to match first call ID %q, got %q", id0, resp0)
|
||||
}
|
||||
if resp1 != id1 {
|
||||
t.Fatalf("expected second response to match second call ID %q, got %q", id1, resp1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToOpenAI_InterleavedPerNameFIFOMatching(t *testing.T) {
|
||||
// Interleaved calls: toolA, toolB, toolA, toolB
|
||||
// Responses returned grouped by tool: toolB, toolA, toolB, toolA
|
||||
inputJSON := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "tool_a", "args": {"step": 1}}},
|
||||
{"functionCall": {"name": "tool_b", "args": {"step": 1}}},
|
||||
{"functionCall": {"name": "tool_a", "args": {"step": 2}}},
|
||||
{"functionCall": {"name": "tool_b", "args": {"step": 2}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "function",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "tool_b", "response": {"step": 1}}},
|
||||
{"functionResponse": {"name": "tool_a", "response": {"step": 1}}},
|
||||
{"functionResponse": {"name": "tool_b", "response": {"step": 2}}},
|
||||
{"functionResponse": {"name": "tool_a", "response": {"step": 2}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
|
||||
callA1 := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String()
|
||||
callB1 := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String()
|
||||
callA2 := gjson.GetBytes(out, "messages.0.tool_calls.2.id").String()
|
||||
callB2 := gjson.GetBytes(out, "messages.0.tool_calls.3.id").String()
|
||||
|
||||
// Responses:
|
||||
// messages[1] = tool_b (step 1) -> should match callB1
|
||||
// messages[2] = tool_a (step 1) -> should match callA1
|
||||
// messages[3] = tool_b (step 2) -> should match callB2
|
||||
// messages[4] = tool_a (step 2) -> should match callA2
|
||||
if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != callB1 {
|
||||
t.Fatalf("first response (tool_b) = %q, want callB1 %q", got, callB1)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != callA1 {
|
||||
t.Fatalf("second response (tool_a) = %q, want callA1 %q", got, callA1)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.3.tool_call_id").String(); got != callB2 {
|
||||
t.Fatalf("third response (tool_b) = %q, want callB2 %q", got, callB2)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.4.tool_call_id").String(); got != callA2 {
|
||||
t.Fatalf("fourth response (tool_a) = %q, want callA2 %q", got, callA2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToOpenAI_DeterministicFallbackOrphanResponse(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "function",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "orphan_tool", "response": {"result": "standalone"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
firstOut := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
|
||||
firstID := gjson.GetBytes(firstOut, "messages.0.tool_call_id").String()
|
||||
if !strings.HasPrefix(firstID, "call_") {
|
||||
t.Fatalf("expected fallback tool_call_id with call_ prefix, got %q", firstID)
|
||||
}
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
|
||||
if got := gjson.GetBytes(out, "messages.0.tool_call_id").String(); got != firstID {
|
||||
t.Fatalf("iteration %d: orphan fallback tool_call_id = %q, want %q", i, got, firstID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToOpenAI_ExplicitCallInheritedByImplicitResponse(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "lookup", "id": "explicit_call_1", "args": {"q": "foo"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "function",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "lookup", "response": {"result": "bar"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
|
||||
if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != "explicit_call_1" {
|
||||
t.Fatalf("tool call ID = %q, want explicit_call_1", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "explicit_call_1" {
|
||||
t.Fatalf("tool response ID = %q, want explicit_call_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToOpenAI_OutOrderExplicitResponseDoesNotDuplicateID(t *testing.T) {
|
||||
// Calls: foo (id=call_1), foo (id=call_2), foo (id=call_3)
|
||||
// Responses: 1st response has explicit id=call_2, 2nd and 3rd are implicit.
|
||||
// Expected responses order: call_2, call_1, call_3.
|
||||
inputJSON := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "foo", "id": "call_1", "args": {"n": 1}}},
|
||||
{"functionCall": {"name": "foo", "id": "call_2", "args": {"n": 2}}},
|
||||
{"functionCall": {"name": "foo", "id": "call_3", "args": {"n": 3}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "function",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "foo", "id": "call_2", "response": {"r": 2}}},
|
||||
{"functionResponse": {"name": "foo", "response": {"r": 1}}},
|
||||
{"functionResponse": {"name": "foo", "response": {"r": 3}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
|
||||
resp1 := gjson.GetBytes(out, "messages.1.tool_call_id").String()
|
||||
resp2 := gjson.GetBytes(out, "messages.2.tool_call_id").String()
|
||||
resp3 := gjson.GetBytes(out, "messages.3.tool_call_id").String()
|
||||
|
||||
if resp1 != "call_2" {
|
||||
t.Fatalf("first response = %q, want call_2", resp1)
|
||||
}
|
||||
if resp2 != "call_1" {
|
||||
t.Fatalf("second response = %q, want call_1", resp2)
|
||||
}
|
||||
if resp3 != "call_3" {
|
||||
t.Fatalf("third response = %q, want call_3", resp3)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,720 @@
|
|||
// Package gemini provides response translation functionality for OpenAI to Gemini API.
|
||||
// This package handles the conversion of OpenAI Chat Completions API responses into Gemini API-compatible
|
||||
// JSON format, transforming streaming events and non-streaming responses into the format
|
||||
// expected by Gemini API clients. It supports both streaming and non-streaming modes,
|
||||
// handling text content, tool calls, and usage metadata appropriately.
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// ConvertOpenAIResponseToGeminiParams holds parameters for response conversion
|
||||
type ConvertOpenAIResponseToGeminiParams struct {
|
||||
// Tool calls accumulator for streaming
|
||||
ToolCallsAccumulator map[int]*ToolCallAccumulator
|
||||
// Content accumulator for streaming
|
||||
ContentAccumulator strings.Builder
|
||||
// Track if this is the first chunk
|
||||
IsFirstChunk bool
|
||||
}
|
||||
|
||||
// ToolCallAccumulator holds the state for accumulating tool call data
|
||||
type ToolCallAccumulator struct {
|
||||
ID string
|
||||
Name string
|
||||
Arguments strings.Builder
|
||||
}
|
||||
|
||||
// ConvertOpenAIResponseToGemini converts OpenAI Chat Completions streaming response format to Gemini API format.
|
||||
// This function processes OpenAI streaming chunks and transforms them into Gemini-compatible JSON responses.
|
||||
// It handles text content, tool calls, and usage metadata, outputting responses that match the Gemini API format.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request.
|
||||
// - modelName: The name of the model.
|
||||
// - rawJSON: The raw JSON response from the OpenAI API.
|
||||
// - param: A pointer to a parameter object for the conversion.
|
||||
//
|
||||
// Returns:
|
||||
// - [][]byte: A slice of Gemini-compatible JSON responses.
|
||||
func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
if *param == nil {
|
||||
*param = &ConvertOpenAIResponseToGeminiParams{
|
||||
ToolCallsAccumulator: nil,
|
||||
ContentAccumulator: strings.Builder{},
|
||||
IsFirstChunk: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle [DONE] marker
|
||||
if bytes.Equal(bytes.TrimSpace(rawJSON), []byte("[DONE]")) {
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(rawJSON, []byte("data:")) {
|
||||
rawJSON = bytes.TrimSpace(rawJSON[5:])
|
||||
}
|
||||
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
|
||||
// Initialize accumulators if needed
|
||||
if (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator == nil {
|
||||
(*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
|
||||
}
|
||||
|
||||
// Process choices
|
||||
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() {
|
||||
// Handle empty choices array (usage-only chunk)
|
||||
if len(choices.Array()) == 0 {
|
||||
// This is a usage-only chunk, handle usage and return
|
||||
if usage := root.Get("usage"); usage.Exists() {
|
||||
template := []byte(`{"candidates":[],"usageMetadata":{}}`)
|
||||
|
||||
// Set model if available
|
||||
if model := root.Get("model"); model.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "model", model.String())
|
||||
}
|
||||
|
||||
template = setGeminiUsageMetadataFromOpenAIUsage(template, usage)
|
||||
return [][]byte{template}
|
||||
}
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
var results [][]byte
|
||||
|
||||
choices.ForEach(func(choiceIndex, choice gjson.Result) bool {
|
||||
// Base Gemini response template without finishReason; set when known
|
||||
template := []byte(`{"candidates":[{"content":{"parts":[],"role":"model"},"index":0}]}`)
|
||||
|
||||
// Set model if available
|
||||
if model := root.Get("model"); model.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "model", model.String())
|
||||
}
|
||||
|
||||
_ = int(choice.Get("index").Int()) // choiceIdx not used in streaming
|
||||
delta := choice.Get("delta")
|
||||
baseTemplate := append([]byte(nil), template...)
|
||||
|
||||
// Handle role (only in first chunk)
|
||||
if role := delta.Get("role"); role.Exists() && (*param).(*ConvertOpenAIResponseToGeminiParams).IsFirstChunk {
|
||||
// OpenAI assistant -> Gemini model
|
||||
if role.String() == "assistant" {
|
||||
template, _ = sjson.SetBytes(template, "candidates.0.content.role", "model")
|
||||
}
|
||||
(*param).(*ConvertOpenAIResponseToGeminiParams).IsFirstChunk = false
|
||||
results = append(results, template)
|
||||
return true
|
||||
}
|
||||
|
||||
var chunkOutputs [][]byte
|
||||
|
||||
// Handle reasoning/thinking delta
|
||||
if reasoning := delta.Get("reasoning_content"); reasoning.Exists() {
|
||||
for _, reasoningText := range extractReasoningTexts(reasoning) {
|
||||
if reasoningText == "" {
|
||||
continue
|
||||
}
|
||||
reasoningTemplate := append([]byte(nil), baseTemplate...)
|
||||
reasoningTemplate, _ = sjson.SetBytes(reasoningTemplate, "candidates.0.content.parts.0.thought", true)
|
||||
reasoningTemplate, _ = sjson.SetBytes(reasoningTemplate, "candidates.0.content.parts.0.text", reasoningText)
|
||||
chunkOutputs = append(chunkOutputs, reasoningTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle content delta
|
||||
if content := delta.Get("content"); content.Exists() && content.String() != "" {
|
||||
contentText := content.String()
|
||||
(*param).(*ConvertOpenAIResponseToGeminiParams).ContentAccumulator.WriteString(contentText)
|
||||
|
||||
// Create text part for this delta
|
||||
contentTemplate := append([]byte(nil), baseTemplate...)
|
||||
contentTemplate, _ = sjson.SetBytes(contentTemplate, "candidates.0.content.parts.0.text", contentText)
|
||||
chunkOutputs = append(chunkOutputs, contentTemplate)
|
||||
}
|
||||
|
||||
if len(chunkOutputs) > 0 {
|
||||
results = append(results, chunkOutputs...)
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle tool calls delta
|
||||
if toolCalls := delta.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
|
||||
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
|
||||
toolIndex := int(toolCall.Get("index").Int())
|
||||
toolID := toolCall.Get("id").String()
|
||||
toolType := toolCall.Get("type").String()
|
||||
function := toolCall.Get("function")
|
||||
|
||||
// Skip non-function tool calls explicitly marked as other types.
|
||||
if toolType != "" && toolType != "function" {
|
||||
return true
|
||||
}
|
||||
|
||||
// OpenAI streaming deltas may omit the type field while still carrying function data.
|
||||
if !function.Exists() {
|
||||
return true
|
||||
}
|
||||
|
||||
functionName := function.Get("name").String()
|
||||
functionArgs := function.Get("arguments").String()
|
||||
|
||||
// Initialize accumulator if needed so later deltas without type can append arguments.
|
||||
if _, exists := (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator[toolIndex]; !exists {
|
||||
(*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator[toolIndex] = &ToolCallAccumulator{
|
||||
ID: toolID,
|
||||
Name: functionName,
|
||||
}
|
||||
}
|
||||
|
||||
acc := (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator[toolIndex]
|
||||
|
||||
// Update ID if provided
|
||||
if toolID != "" {
|
||||
acc.ID = toolID
|
||||
}
|
||||
|
||||
// Update name if provided
|
||||
if functionName != "" {
|
||||
acc.Name = functionName
|
||||
}
|
||||
|
||||
// Accumulate arguments
|
||||
if functionArgs != "" {
|
||||
acc.Arguments.WriteString(functionArgs)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Don't output anything for tool call deltas - wait for completion
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle finish reason
|
||||
if finishReason := choice.Get("finish_reason"); finishReason.Exists() {
|
||||
geminiFinishReason := mapOpenAIFinishReasonToGemini(finishReason.String())
|
||||
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", geminiFinishReason)
|
||||
|
||||
// If we have accumulated tool calls, output them now
|
||||
if len((*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator) > 0 {
|
||||
partIndex := 0
|
||||
for _, accumulator := range (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator {
|
||||
idPath := fmt.Sprintf("candidates.0.content.parts.%d.functionCall.id", partIndex)
|
||||
namePath := fmt.Sprintf("candidates.0.content.parts.%d.functionCall.name", partIndex)
|
||||
argsPath := fmt.Sprintf("candidates.0.content.parts.%d.functionCall.args", partIndex)
|
||||
if accumulator.ID != "" {
|
||||
template, _ = sjson.SetBytes(template, idPath, accumulator.ID)
|
||||
}
|
||||
template, _ = sjson.SetBytes(template, namePath, accumulator.Name)
|
||||
template, _ = sjson.SetRawBytes(template, argsPath, []byte(parseArgsToObjectRaw(accumulator.Arguments.String())))
|
||||
partIndex++
|
||||
}
|
||||
|
||||
// Clear accumulators
|
||||
(*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
|
||||
}
|
||||
|
||||
results = append(results, template)
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle usage information
|
||||
if usage := root.Get("usage"); usage.Exists() {
|
||||
template = setGeminiUsageMetadataFromOpenAIUsage(template, usage)
|
||||
results = append(results, template)
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
return results
|
||||
}
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
// mapOpenAIFinishReasonToGemini maps OpenAI finish reasons to Gemini finish reasons
|
||||
func mapOpenAIFinishReasonToGemini(openAIReason string) string {
|
||||
switch openAIReason {
|
||||
case "stop":
|
||||
return "STOP"
|
||||
case "length":
|
||||
return "MAX_TOKENS"
|
||||
case "tool_calls":
|
||||
return "STOP" // Gemini doesn't have a specific tool_calls finish reason
|
||||
case "content_filter":
|
||||
return "SAFETY"
|
||||
default:
|
||||
return "STOP"
|
||||
}
|
||||
}
|
||||
|
||||
// parseArgsToObjectRaw safely parses a JSON string of function arguments into an object JSON string.
|
||||
// It returns "{}" if the input is empty or cannot be parsed as a JSON object.
|
||||
func parseArgsToObjectRaw(argsStr string) string {
|
||||
trimmed := strings.TrimSpace(argsStr)
|
||||
if trimmed == "" || trimmed == "{}" {
|
||||
return "{}"
|
||||
}
|
||||
|
||||
// First try strict JSON
|
||||
if gjson.Valid(trimmed) {
|
||||
strict := gjson.Parse(trimmed)
|
||||
if strict.IsObject() {
|
||||
return strict.Raw
|
||||
}
|
||||
}
|
||||
|
||||
// Tolerant parse: handle streams where values are barewords (e.g., 北京, celsius)
|
||||
tolerant := tolerantParseJSONObjectRaw(trimmed)
|
||||
if tolerant != "{}" {
|
||||
return tolerant
|
||||
}
|
||||
|
||||
// Fallback: return empty object when parsing fails
|
||||
return "{}"
|
||||
}
|
||||
|
||||
func escapeSjsonPathKey(key string) string {
|
||||
key = strings.ReplaceAll(key, `\`, `\\`)
|
||||
key = strings.ReplaceAll(key, `.`, `\.`)
|
||||
return key
|
||||
}
|
||||
|
||||
// tolerantParseJSONObjectRaw attempts to parse a JSON-like object string into a JSON object string, tolerating
|
||||
// bareword values (unquoted strings) commonly seen during streamed tool calls.
|
||||
// Example input: {"location": 北京, "unit": celsius}
|
||||
func tolerantParseJSONObjectRaw(s string) string {
|
||||
// Ensure we operate within the outermost braces if present
|
||||
start := strings.Index(s, "{")
|
||||
end := strings.LastIndex(s, "}")
|
||||
if start == -1 || end == -1 || start >= end {
|
||||
return "{}"
|
||||
}
|
||||
content := s[start+1 : end]
|
||||
|
||||
runes := []rune(content)
|
||||
n := len(runes)
|
||||
i := 0
|
||||
result := []byte(`{}`)
|
||||
|
||||
for i < n {
|
||||
// Skip whitespace and commas
|
||||
for i < n && (runes[i] == ' ' || runes[i] == '\n' || runes[i] == '\r' || runes[i] == '\t' || runes[i] == ',') {
|
||||
i++
|
||||
}
|
||||
if i >= n {
|
||||
break
|
||||
}
|
||||
|
||||
// Expect quoted key
|
||||
if runes[i] != '"' {
|
||||
// Unable to parse this segment reliably; skip to next comma
|
||||
for i < n && runes[i] != ',' {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse JSON string for key
|
||||
keyToken, nextIdx := parseJSONStringRunes(runes, i)
|
||||
if nextIdx == -1 {
|
||||
break
|
||||
}
|
||||
keyName := jsonStringTokenToRawString(keyToken)
|
||||
sjsonKey := escapeSjsonPathKey(keyName)
|
||||
i = nextIdx
|
||||
|
||||
// Skip whitespace
|
||||
for i < n && (runes[i] == ' ' || runes[i] == '\n' || runes[i] == '\r' || runes[i] == '\t') {
|
||||
i++
|
||||
}
|
||||
if i >= n || runes[i] != ':' {
|
||||
break
|
||||
}
|
||||
i++ // skip ':'
|
||||
// Skip whitespace
|
||||
for i < n && (runes[i] == ' ' || runes[i] == '\n' || runes[i] == '\r' || runes[i] == '\t') {
|
||||
i++
|
||||
}
|
||||
if i >= n {
|
||||
break
|
||||
}
|
||||
|
||||
// Parse value (string, number, object/array, bareword)
|
||||
switch runes[i] {
|
||||
case '"':
|
||||
// JSON string
|
||||
valToken, ni := parseJSONStringRunes(runes, i)
|
||||
if ni == -1 {
|
||||
// Malformed; treat as empty string
|
||||
result, _ = sjson.SetBytes(result, sjsonKey, "")
|
||||
i = n
|
||||
} else {
|
||||
result, _ = sjson.SetBytes(result, sjsonKey, jsonStringTokenToRawString(valToken))
|
||||
i = ni
|
||||
}
|
||||
case '{', '[':
|
||||
// Bracketed value: attempt to capture balanced structure
|
||||
seg, ni := captureBracketed(runes, i)
|
||||
if ni == -1 {
|
||||
i = n
|
||||
} else {
|
||||
if gjson.Valid(seg) {
|
||||
result, _ = sjson.SetRawBytes(result, sjsonKey, []byte(seg))
|
||||
} else {
|
||||
result, _ = sjson.SetBytes(result, sjsonKey, seg)
|
||||
}
|
||||
i = ni
|
||||
}
|
||||
default:
|
||||
// Bare token until next comma or end
|
||||
j := i
|
||||
for j < n && runes[j] != ',' {
|
||||
j++
|
||||
}
|
||||
token := strings.TrimSpace(string(runes[i:j]))
|
||||
// Interpret common JSON atoms and numbers; otherwise treat as string
|
||||
if token == "true" {
|
||||
result, _ = sjson.SetBytes(result, sjsonKey, true)
|
||||
} else if token == "false" {
|
||||
result, _ = sjson.SetBytes(result, sjsonKey, false)
|
||||
} else if token == "null" {
|
||||
result, _ = sjson.SetBytes(result, sjsonKey, nil)
|
||||
} else if numVal, ok := tryParseNumber(token); ok {
|
||||
result, _ = sjson.SetBytes(result, sjsonKey, numVal)
|
||||
} else {
|
||||
result, _ = sjson.SetBytes(result, sjsonKey, token)
|
||||
}
|
||||
i = j
|
||||
}
|
||||
|
||||
// Skip trailing whitespace and optional comma before next pair
|
||||
for i < n && (runes[i] == ' ' || runes[i] == '\n' || runes[i] == '\r' || runes[i] == '\t') {
|
||||
i++
|
||||
}
|
||||
if i < n && runes[i] == ',' {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// parseJSONStringRunes returns the JSON string token (including quotes) and the index just after it.
|
||||
func parseJSONStringRunes(runes []rune, start int) (string, int) {
|
||||
if start >= len(runes) || runes[start] != '"' {
|
||||
return "", -1
|
||||
}
|
||||
i := start + 1
|
||||
escaped := false
|
||||
for i < len(runes) {
|
||||
r := runes[i]
|
||||
if r == '\\' && !escaped {
|
||||
escaped = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if r == '"' && !escaped {
|
||||
return string(runes[start : i+1]), i + 1
|
||||
}
|
||||
escaped = false
|
||||
i++
|
||||
}
|
||||
return string(runes[start:]), -1
|
||||
}
|
||||
|
||||
// jsonStringTokenToRawString converts a JSON string token (including quotes) to a raw Go string value.
|
||||
func jsonStringTokenToRawString(token string) string {
|
||||
r := gjson.Parse(token)
|
||||
if r.Type == gjson.String {
|
||||
return r.String()
|
||||
}
|
||||
// Fallback: strip surrounding quotes if present
|
||||
if len(token) >= 2 && token[0] == '"' && token[len(token)-1] == '"' {
|
||||
return token[1 : len(token)-1]
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// captureBracketed captures a balanced JSON object/array starting at index i.
|
||||
// Returns the segment string and the index just after it; -1 if malformed.
|
||||
func captureBracketed(runes []rune, i int) (string, int) {
|
||||
if i >= len(runes) {
|
||||
return "", -1
|
||||
}
|
||||
startRune := runes[i]
|
||||
var endRune rune
|
||||
if startRune == '{' {
|
||||
endRune = '}'
|
||||
} else if startRune == '[' {
|
||||
endRune = ']'
|
||||
} else {
|
||||
return "", -1
|
||||
}
|
||||
depth := 0
|
||||
j := i
|
||||
inStr := false
|
||||
escaped := false
|
||||
for j < len(runes) {
|
||||
r := runes[j]
|
||||
if inStr {
|
||||
if r == '\\' && !escaped {
|
||||
escaped = true
|
||||
j++
|
||||
continue
|
||||
}
|
||||
if r == '"' && !escaped {
|
||||
inStr = false
|
||||
} else {
|
||||
escaped = false
|
||||
}
|
||||
j++
|
||||
continue
|
||||
}
|
||||
if r == '"' {
|
||||
inStr = true
|
||||
j++
|
||||
continue
|
||||
}
|
||||
if r == startRune {
|
||||
depth++
|
||||
} else if r == endRune {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return string(runes[i : j+1]), j + 1
|
||||
}
|
||||
}
|
||||
j++
|
||||
}
|
||||
return string(runes[i:]), -1
|
||||
}
|
||||
|
||||
// tryParseNumber attempts to parse a string as an int or float.
|
||||
func tryParseNumber(s string) (interface{}, bool) {
|
||||
if s == "" {
|
||||
return nil, false
|
||||
}
|
||||
// Try integer
|
||||
if i64, errParseInt := strconv.ParseInt(s, 10, 64); errParseInt == nil {
|
||||
return i64, true
|
||||
}
|
||||
if u64, errParseUInt := strconv.ParseUint(s, 10, 64); errParseUInt == nil {
|
||||
return u64, true
|
||||
}
|
||||
if f64, errParseFloat := strconv.ParseFloat(s, 64); errParseFloat == nil {
|
||||
return f64, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ConvertOpenAIResponseToGeminiNonStream converts a non-streaming OpenAI response to a non-streaming Gemini response.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request.
|
||||
// - modelName: The name of the model.
|
||||
// - rawJSON: The raw JSON response from the OpenAI API.
|
||||
// - param: A pointer to a parameter object for the conversion.
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: A Gemini-compatible JSON response.
|
||||
func ConvertOpenAIResponseToGeminiNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
|
||||
// Base Gemini response template without finishReason; set when known
|
||||
out := []byte(`{"candidates":[{"content":{"parts":[],"role":"model"},"index":0}]}`)
|
||||
|
||||
// Set model if available
|
||||
if model := root.Get("model"); model.Exists() {
|
||||
out, _ = sjson.SetBytes(out, "model", model.String())
|
||||
}
|
||||
|
||||
var allParts [][]byte
|
||||
|
||||
// Process choices
|
||||
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() {
|
||||
choices.ForEach(func(choiceIndex, choice gjson.Result) bool {
|
||||
choiceIdx := int(choice.Get("index").Int())
|
||||
message := choice.Get("message")
|
||||
|
||||
// Set role
|
||||
if role := message.Get("role"); role.Exists() {
|
||||
if role.String() == "assistant" {
|
||||
out, _ = sjson.SetBytes(out, "candidates.0.content.role", "model")
|
||||
}
|
||||
}
|
||||
|
||||
partIndex := 0
|
||||
ensurePart := func(idx int) []byte {
|
||||
for len(allParts) <= idx {
|
||||
allParts = append(allParts, []byte(`{}`))
|
||||
}
|
||||
return allParts[idx]
|
||||
}
|
||||
|
||||
// Handle reasoning content before visible text
|
||||
if reasoning := message.Get("reasoning_content"); reasoning.Exists() {
|
||||
for _, reasoningText := range extractReasoningTexts(reasoning) {
|
||||
if reasoningText == "" {
|
||||
continue
|
||||
}
|
||||
part := ensurePart(partIndex)
|
||||
part, _ = sjson.SetBytes(part, "thought", true)
|
||||
part, _ = sjson.SetBytes(part, "text", reasoningText)
|
||||
allParts[partIndex] = part
|
||||
partIndex++
|
||||
}
|
||||
}
|
||||
|
||||
// Handle content first
|
||||
if content := message.Get("content"); content.Exists() && content.String() != "" {
|
||||
part := ensurePart(partIndex)
|
||||
part, _ = sjson.SetBytes(part, "text", content.String())
|
||||
allParts[partIndex] = part
|
||||
partIndex++
|
||||
}
|
||||
|
||||
// Handle tool calls
|
||||
if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
|
||||
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
|
||||
if toolCall.Get("type").String() == "function" {
|
||||
function := toolCall.Get("function")
|
||||
functionName := function.Get("name").String()
|
||||
functionArgs := function.Get("arguments").String()
|
||||
functionID := toolCall.Get("id").String()
|
||||
|
||||
part := ensurePart(partIndex)
|
||||
if functionID != "" {
|
||||
part, _ = sjson.SetBytes(part, "functionCall.id", functionID)
|
||||
}
|
||||
part, _ = sjson.SetBytes(part, "functionCall.name", functionName)
|
||||
part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(parseArgsToObjectRaw(functionArgs)))
|
||||
allParts[partIndex] = part
|
||||
partIndex++
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Handle finish reason
|
||||
if finishReason := choice.Get("finish_reason"); finishReason.Exists() {
|
||||
geminiFinishReason := mapOpenAIFinishReasonToGemini(finishReason.String())
|
||||
out, _ = sjson.SetBytes(out, "candidates.0.finishReason", geminiFinishReason)
|
||||
}
|
||||
|
||||
// Set index
|
||||
out, _ = sjson.SetBytes(out, "candidates.0.index", choiceIdx)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if len(allParts) > 0 {
|
||||
out, _ = sjson.SetRawBytes(out, "candidates.0.content.parts", translatorcommon.JoinRawArray(allParts))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle usage information
|
||||
if usage := root.Get("usage"); usage.Exists() {
|
||||
out = setGeminiUsageMetadataFromOpenAIUsage(out, usage)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func GeminiTokenCount(ctx context.Context, count int64) []byte {
|
||||
return translatorcommon.GeminiTokenCountJSON(count)
|
||||
}
|
||||
|
||||
func reasoningTokensFromUsage(usage gjson.Result) int64 {
|
||||
if usage.Exists() {
|
||||
if v := usage.Get("completion_tokens_details.reasoning_tokens"); v.Exists() {
|
||||
return v.Int()
|
||||
}
|
||||
if v := usage.Get("output_tokens_details.reasoning_tokens"); v.Exists() {
|
||||
return v.Int()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func setGeminiUsageMetadataFromOpenAIUsage(out []byte, usage gjson.Result) []byte {
|
||||
promptTokens, hasPromptTokens := tokenCountFromUsage(usage, "prompt_tokens", "input_tokens")
|
||||
completionTokens, hasCompletionTokens := tokenCountFromUsage(usage, "completion_tokens", "output_tokens")
|
||||
totalTokens, hasTotalTokens := tokenCountFromUsage(usage, "total_tokens")
|
||||
if hasPromptTokens {
|
||||
out, _ = sjson.SetBytes(out, "usageMetadata.promptTokenCount", promptTokens)
|
||||
}
|
||||
if hasCompletionTokens {
|
||||
out, _ = sjson.SetBytes(out, "usageMetadata.candidatesTokenCount", completionTokens)
|
||||
}
|
||||
if hasTotalTokens {
|
||||
out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", totalTokens)
|
||||
} else if hasPromptTokens || hasCompletionTokens {
|
||||
out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", promptTokens+completionTokens)
|
||||
}
|
||||
if reasoningTokens := reasoningTokensFromUsage(usage); reasoningTokens > 0 {
|
||||
out, _ = sjson.SetBytes(out, "usageMetadata.thoughtsTokenCount", reasoningTokens)
|
||||
}
|
||||
if cachedTokens := cachedTokensFromUsage(usage); cachedTokens > 0 {
|
||||
out, _ = sjson.SetBytes(out, "usageMetadata.cachedContentTokenCount", cachedTokens)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tokenCountFromUsage(usage gjson.Result, paths ...string) (int64, bool) {
|
||||
for _, path := range paths {
|
||||
if v := usage.Get(path); v.Exists() {
|
||||
return v.Int(), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func cachedTokensFromUsage(usage gjson.Result) int64 {
|
||||
if usage.Exists() {
|
||||
if v := usage.Get("prompt_tokens_details.cached_tokens"); v.Exists() {
|
||||
return v.Int()
|
||||
}
|
||||
if v := usage.Get("input_tokens_details.cached_tokens"); v.Exists() {
|
||||
return v.Int()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func extractReasoningTexts(node gjson.Result) []string {
|
||||
var texts []string
|
||||
if !node.Exists() {
|
||||
return texts
|
||||
}
|
||||
|
||||
if node.IsArray() {
|
||||
node.ForEach(func(_, value gjson.Result) bool {
|
||||
texts = append(texts, extractReasoningTexts(value)...)
|
||||
return true
|
||||
})
|
||||
return texts
|
||||
}
|
||||
|
||||
switch node.Type {
|
||||
case gjson.String:
|
||||
texts = append(texts, node.String())
|
||||
case gjson.JSON:
|
||||
if text := node.Get("text"); text.Exists() {
|
||||
texts = append(texts, text.String())
|
||||
} else if raw := strings.TrimSpace(node.Raw); raw != "" && !strings.HasPrefix(raw, "{") && !strings.HasPrefix(raw, "[") {
|
||||
texts = append(texts, raw)
|
||||
}
|
||||
}
|
||||
|
||||
return texts
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertOpenAIResponseToGeminiNonStreamPreservesToolCallID(t *testing.T) {
|
||||
raw := []byte(`{"choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_chat_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]}}]}`)
|
||||
out := ConvertOpenAIResponseToGeminiNonStream(context.Background(), "gpt-test", nil, nil, raw, nil)
|
||||
if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.id").String(); got != "call_chat_1" {
|
||||
t.Fatalf("functionCall.id = %q, want call_chat_1", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.args.q").String(); got != "x" {
|
||||
t.Fatalf("functionCall.args.q = %q, want x", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponseToGeminiStreamPreservesToolCallID(t *testing.T) {
|
||||
var param any
|
||||
ConvertOpenAIResponseToGemini(context.Background(), "gpt-test", nil, nil, []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_stream_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]}}]}`), ¶m)
|
||||
out := ConvertOpenAIResponseToGemini(context.Background(), "gpt-test", nil, nil, []byte(`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`), ¶m)
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("stream output is empty")
|
||||
}
|
||||
if got := gjson.GetBytes(out[len(out)-1], "candidates.0.content.parts.0.functionCall.id").String(); got != "call_stream_1" {
|
||||
t.Fatalf("functionCall.id = %q, want call_stream_1", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out[len(out)-1], "candidates.0.content.parts.0.functionCall.args.q").String(); got != "x" {
|
||||
t.Fatalf("functionCall.args.q = %q, want x", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponseToGeminiNonStream_MultiChoicePartsOverlay(t *testing.T) {
|
||||
// Scenario 1: First choice has tool call, second choice has text on part 0 -> fields merge
|
||||
raw1 := []byte(`{"choices":[
|
||||
{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{}"}}]}},
|
||||
{"index":1,"message":{"role":"assistant","content":"choice 1 text"}}
|
||||
]}`)
|
||||
out1 := ConvertOpenAIResponseToGeminiNonStream(context.Background(), "gpt-test", nil, nil, raw1, nil)
|
||||
parts1 := gjson.GetBytes(out1, "candidates.0.content.parts").Array()
|
||||
if len(parts1) != 1 {
|
||||
t.Fatalf("expected 1 merged part, got %d. Output: %s", len(parts1), out1)
|
||||
}
|
||||
if parts1[0].Get("text").String() != "choice 1 text" {
|
||||
t.Fatalf("expected text to be 'choice 1 text', got %q", parts1[0].Get("text").String())
|
||||
}
|
||||
if parts1[0].Get("functionCall.id").String() != "call_1" {
|
||||
t.Fatalf("expected functionCall.id to be preserved as 'call_1', got %q", parts1[0].Get("functionCall.id").String())
|
||||
}
|
||||
|
||||
// Scenario 2: Reasoning in choice 0, text in choice 1 on part 0 -> thought preserved, text updated
|
||||
raw2 := []byte(`{"choices":[
|
||||
{"index":0,"message":{"role":"assistant","reasoning_content":"initial thought"}},
|
||||
{"index":1,"message":{"role":"assistant","content":"final text"}}
|
||||
]}`)
|
||||
out2 := ConvertOpenAIResponseToGeminiNonStream(context.Background(), "gpt-test", nil, nil, raw2, nil)
|
||||
parts2 := gjson.GetBytes(out2, "candidates.0.content.parts").Array()
|
||||
if len(parts2) != 1 {
|
||||
t.Fatalf("expected 1 merged part, got %d. Output: %s", len(parts2), out2)
|
||||
}
|
||||
if !parts2[0].Get("thought").Bool() {
|
||||
t.Fatalf("expected thought: true to be preserved")
|
||||
}
|
||||
if parts2[0].Get("text").String() != "final text" {
|
||||
t.Fatalf("expected text to be 'final text', got %q", parts2[0].Get("text").String())
|
||||
}
|
||||
|
||||
// Scenario 3: Text in choice 0, functionCall in choice 1 on part 0 -> text preserved, functionCall added
|
||||
raw3 := []byte(`{"choices":[
|
||||
{"index":0,"message":{"role":"assistant","content":"original text"}},
|
||||
{"index":1,"message":{"role":"assistant","tool_calls":[{"id":"call_2","type":"function","function":{"name":"search","arguments":"{}"}}]}}
|
||||
]}`)
|
||||
out3 := ConvertOpenAIResponseToGeminiNonStream(context.Background(), "gpt-test", nil, nil, raw3, nil)
|
||||
parts3 := gjson.GetBytes(out3, "candidates.0.content.parts").Array()
|
||||
if len(parts3) != 1 {
|
||||
t.Fatalf("expected 1 merged part, got %d. Output: %s", len(parts3), out3)
|
||||
}
|
||||
if parts3[0].Get("text").String() != "original text" {
|
||||
t.Fatalf("expected text to be 'original text', got %q", parts3[0].Get("text").String())
|
||||
}
|
||||
if parts3[0].Get("functionCall.id").String() != "call_2" {
|
||||
t.Fatalf("expected functionCall.id to be 'call_2', got %q", parts3[0].Get("functionCall.id").String())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue