Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View 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,
},
)
}

View file

@ -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"
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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\"}"}}]}}]}`), &param)
out := ConvertOpenAIResponseToGemini(context.Background(), "gpt-test", nil, nil, []byte(`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`), &param)
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())
}
}