Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
584
backend/internal/translator/codex/gemini/codex_gemini_request.go
Normal file
584
backend/internal/translator/codex/gemini/codex_gemini_request.go
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
// Package gemini provides request translation functionality for Codex to Gemini API compatibility.
|
||||
// It handles parsing and transforming Codex API requests into Gemini API format,
|
||||
// extracting model information, system instructions, message contents, and tool declarations.
|
||||
// The package performs JSON data transformation to ensure compatibility
|
||||
// between Codex API format and Gemini API's expected format.
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// ConvertGeminiRequestToCodex parses and transforms a Gemini API request into Codex API format.
|
||||
// It extracts the model name, system instruction, message contents, and tool declarations
|
||||
// from the raw JSON request and returns them in the format expected by the Codex API.
|
||||
// The function performs comprehensive transformation including:
|
||||
// 1. Model name mapping and generation configuration extraction
|
||||
// 2. System instruction conversion to Codex format
|
||||
// 3. Message content conversion with proper role mapping
|
||||
// 4. Tool call and tool result handling with FIFO queue for ID matching
|
||||
// 5. Tool declaration and tool choice configuration mapping
|
||||
//
|
||||
// Parameters:
|
||||
// - modelName: The name of the model to use for the request
|
||||
// - rawJSON: The raw JSON request data from the Gemini API
|
||||
// - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: The transformed request data in Codex API format
|
||||
func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte {
|
||||
rawJSON := inputRawJSON
|
||||
// Base template
|
||||
out := []byte(`{"model":"","instructions":"","input":[]}`)
|
||||
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
inputItems := translatorcommon.NewRawArrayItems(root.Get("contents.#").Int())
|
||||
|
||||
// Pre-compute tool name shortening map from declared functionDeclarations
|
||||
shortMap := map[string]string{}
|
||||
if tools := root.Get("tools"); tools.IsArray() {
|
||||
var names []string
|
||||
tarr := tools.Array()
|
||||
for i := 0; i < len(tarr); i++ {
|
||||
fns := tarr[i].Get("functionDeclarations")
|
||||
if !fns.IsArray() {
|
||||
continue
|
||||
}
|
||||
for _, fn := range fns.Array() {
|
||||
if v := fn.Get("name"); v.Exists() {
|
||||
names = append(names, v.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
shortMap = buildShortNameMap(names)
|
||||
}
|
||||
}
|
||||
|
||||
// helper for generating paired call IDs in the form: call_gemini_<seq>
|
||||
// Gemini uses sequential pairing across possibly multiple in-flight
|
||||
// functionCalls, so we keep a FIFO queue of generated call IDs and
|
||||
// consume them in order when functionResponses arrive.
|
||||
var pendingCallIDs []string
|
||||
callCounter := 0
|
||||
|
||||
getGeminiCallID := func(value gjson.Result) string {
|
||||
if callID := strings.TrimSpace(value.Get("id").String()); callID != "" {
|
||||
return callID
|
||||
}
|
||||
return strings.TrimSpace(value.Get("call_id").String())
|
||||
}
|
||||
|
||||
removePendingCallID := func(ids []string, callID string) []string {
|
||||
if callID == "" {
|
||||
return ids
|
||||
}
|
||||
for idx, pendingID := range ids {
|
||||
if pendingID == callID {
|
||||
return append(ids[:idx], ids[idx+1:]...)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// Model
|
||||
out, _ = sjson.SetBytes(out, "model", modelName)
|
||||
if serviceTier := normalizeGeminiCodexServiceTier(root.Get("service_tier")); serviceTier != "" {
|
||||
out, _ = sjson.SetBytes(out, "service_tier", serviceTier)
|
||||
}
|
||||
|
||||
// System instruction -> as a user message with input_text parts
|
||||
sysParts := root.Get("system_instruction.parts")
|
||||
if !sysParts.Exists() {
|
||||
sysParts = root.Get("systemInstruction.parts")
|
||||
}
|
||||
if sysParts.IsArray() {
|
||||
contentItems := make([][]byte, 0, 2)
|
||||
arr := sysParts.Array()
|
||||
for i := 0; i < len(arr); i++ {
|
||||
p := arr[i]
|
||||
if translatorcommon.IsGeminiThoughtPart(p) {
|
||||
continue
|
||||
}
|
||||
if t := p.Get("text"); t.Exists() {
|
||||
part := []byte(`{}`)
|
||||
part, _ = sjson.SetBytes(part, "type", "input_text")
|
||||
part, _ = sjson.SetBytes(part, "text", t.String())
|
||||
contentItems = append(contentItems, part)
|
||||
}
|
||||
}
|
||||
if len(contentItems) > 0 {
|
||||
msg := []byte(`{"type":"message","role":"developer","content":[]}`)
|
||||
msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
|
||||
inputItems = append(inputItems, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Contents -> messages and function calls/results
|
||||
contents := root.Get("contents")
|
||||
if contents.IsArray() {
|
||||
items := contents.Array()
|
||||
for i := 0; i < len(items); i++ {
|
||||
item := items[i]
|
||||
role := item.Get("role").String()
|
||||
if role == "model" {
|
||||
role = "assistant"
|
||||
}
|
||||
|
||||
parts := item.Get("parts")
|
||||
if !parts.IsArray() {
|
||||
continue
|
||||
}
|
||||
parr := parts.Array()
|
||||
for j := 0; j < len(parr); j++ {
|
||||
p := parr[j]
|
||||
if translatorcommon.IsGeminiThoughtPart(p) {
|
||||
continue
|
||||
}
|
||||
|
||||
// text part
|
||||
if t := p.Get("text"); t.Exists() {
|
||||
partType := "input_text"
|
||||
if role == "assistant" {
|
||||
partType = "output_text"
|
||||
}
|
||||
part := []byte(`{}`)
|
||||
part, _ = sjson.SetBytes(part, "type", partType)
|
||||
part, _ = sjson.SetBytes(part, "text", t.String())
|
||||
inputItems = append(inputItems, codexMessageWithPart(role, part))
|
||||
continue
|
||||
}
|
||||
|
||||
if contentPart, ok := codexContentPartFromGeminiInlineData(p); ok {
|
||||
inputItems = append(inputItems, codexMessageWithPart(role, contentPart))
|
||||
continue
|
||||
}
|
||||
|
||||
if contentPart, ok := codexContentPartFromGeminiFileData(p); ok {
|
||||
inputItems = append(inputItems, codexMessageWithPart(role, contentPart))
|
||||
continue
|
||||
}
|
||||
|
||||
// function call from model
|
||||
if fc := p.Get("functionCall"); fc.Exists() {
|
||||
fn := []byte(`{"type":"function_call"}`)
|
||||
if name := fc.Get("name"); name.Exists() {
|
||||
n := name.String()
|
||||
if short, ok := shortMap[n]; ok {
|
||||
n = short
|
||||
} else {
|
||||
n = shortenNameIfNeeded(n)
|
||||
}
|
||||
fn, _ = sjson.SetBytes(fn, "name", n)
|
||||
}
|
||||
if args := fc.Get("args"); args.Exists() {
|
||||
fn, _ = sjson.SetBytes(fn, "arguments", args.Raw)
|
||||
}
|
||||
// Reuse gateway-provided IDs when present, otherwise generate one for pairing.
|
||||
id := getGeminiCallID(fc)
|
||||
if id == "" {
|
||||
callCounter++
|
||||
id = fmt.Sprintf("call_gemini_%016d", callCounter)
|
||||
}
|
||||
fn, _ = sjson.SetBytes(fn, "call_id", id)
|
||||
pendingCallIDs = append(pendingCallIDs, id)
|
||||
inputItems = append(inputItems, fn)
|
||||
continue
|
||||
}
|
||||
|
||||
// function response from user
|
||||
if fr := p.Get("functionResponse"); fr.Exists() {
|
||||
fno := []byte(`{"type":"function_call_output"}`)
|
||||
// Prefer a string result if present; otherwise embed the raw response as a string
|
||||
if res := fr.Get("response.result"); res.Exists() {
|
||||
fno, _ = sjson.SetBytes(fno, "output", res.String())
|
||||
} else if resp := fr.Get("response"); resp.Exists() {
|
||||
fno, _ = sjson.SetBytes(fno, "output", resp.Raw)
|
||||
}
|
||||
// fno, _ = sjson.SetBytes(fno, "call_id", "call_W6nRJzFXyPM2LFBbfo98qAbq")
|
||||
// attach the oldest queued call_id to pair the response
|
||||
// with its call. If the queue is empty, generate a new id.
|
||||
var id string
|
||||
if customID := getGeminiCallID(fr); customID != "" {
|
||||
id = customID
|
||||
pendingCallIDs = removePendingCallID(pendingCallIDs, id)
|
||||
} else if len(pendingCallIDs) > 0 {
|
||||
id = pendingCallIDs[0]
|
||||
// pop the first element
|
||||
pendingCallIDs = pendingCallIDs[1:]
|
||||
} else {
|
||||
callCounter++
|
||||
id = fmt.Sprintf("call_gemini_%016d", callCounter)
|
||||
}
|
||||
fno, _ = sjson.SetBytes(fno, "call_id", id)
|
||||
inputItems = append(inputItems, fno)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out = translatorcommon.SetRawArrayItems(out, "input", inputItems)
|
||||
|
||||
// Tools mapping: Gemini functionDeclarations -> Codex tools
|
||||
tools := root.Get("tools")
|
||||
if tools.IsArray() {
|
||||
var toolItems [][]byte
|
||||
out, _ = sjson.SetBytes(out, "tool_choice", "auto")
|
||||
tarr := tools.Array()
|
||||
for i := 0; i < len(tarr); i++ {
|
||||
td := tarr[i]
|
||||
fns := td.Get("functionDeclarations")
|
||||
if !fns.IsArray() {
|
||||
continue
|
||||
}
|
||||
farr := fns.Array()
|
||||
for j := 0; j < len(farr); j++ {
|
||||
fn := farr[j]
|
||||
tool := []byte(`{}`)
|
||||
tool, _ = sjson.SetBytes(tool, "type", "function")
|
||||
if v := fn.Get("name"); v.Exists() {
|
||||
name := v.String()
|
||||
if short, ok := shortMap[name]; ok {
|
||||
name = short
|
||||
} else {
|
||||
name = shortenNameIfNeeded(name)
|
||||
}
|
||||
tool, _ = sjson.SetBytes(tool, "name", name)
|
||||
}
|
||||
if v := fn.Get("description"); v.Exists() {
|
||||
tool, _ = sjson.SetBytes(tool, "description", v.String())
|
||||
}
|
||||
if prm := fn.Get("parameters"); prm.Exists() {
|
||||
cleaned := cleanGeminiCodexToolParameters(prm)
|
||||
tool, _ = sjson.SetRawBytes(tool, "parameters", cleaned)
|
||||
} else if prm = fn.Get("parametersJsonSchema"); prm.Exists() {
|
||||
cleaned := cleanGeminiCodexToolParameters(prm)
|
||||
tool, _ = sjson.SetRawBytes(tool, "parameters", cleaned)
|
||||
}
|
||||
tool, _ = sjson.SetBytes(tool, "strict", false)
|
||||
toolItems = append(toolItems, tool)
|
||||
}
|
||||
}
|
||||
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
|
||||
}
|
||||
|
||||
// Fixed flags aligning with Codex expectations
|
||||
out, _ = sjson.SetBytes(out, "parallel_tool_calls", true)
|
||||
out = setCodexToolChoiceFromGeminiToolConfig(out, root.Get("toolConfig.functionCallingConfig"))
|
||||
|
||||
// Convert Gemini thinkingConfig to Codex reasoning.effort.
|
||||
// Note: Google official Python SDK sends snake_case fields (thinking_level/thinking_budget).
|
||||
effortSet := false
|
||||
if genConfig := root.Get("generationConfig"); genConfig.Exists() {
|
||||
thinkingLevel := genConfig.Get("thinkingLevel")
|
||||
if !thinkingLevel.Exists() {
|
||||
thinkingLevel = genConfig.Get("thinking_level")
|
||||
}
|
||||
if thinkingLevel.Exists() {
|
||||
effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
|
||||
if effort != "" {
|
||||
out, _ = sjson.SetBytes(out, "reasoning.effort", effort)
|
||||
effortSet = true
|
||||
}
|
||||
} else 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)
|
||||
effortSet = true
|
||||
}
|
||||
} 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)
|
||||
effortSet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !effortSet {
|
||||
// No thinking config, set default effort
|
||||
out, _ = sjson.SetBytes(out, "reasoning.effort", "medium")
|
||||
}
|
||||
// OpenAI documents reasoning summaries as explicit opt-in output. Leave
|
||||
// reasoning.summary to the source request's canonical summary intent instead
|
||||
// of coupling it to reasoning effort.
|
||||
out, _ = sjson.SetBytes(out, "stream", true)
|
||||
out, _ = sjson.SetBytes(out, "store", false)
|
||||
out, _ = sjson.SetBytes(out, "include", []string{"reasoning.encrypted_content"})
|
||||
|
||||
var pathsToLower []string
|
||||
toolsResult := gjson.GetBytes(out, "tools")
|
||||
util.Walk(toolsResult, "", "type", &pathsToLower)
|
||||
for _, p := range pathsToLower {
|
||||
fullPath := fmt.Sprintf("tools.%s", p)
|
||||
typeValue := gjson.GetBytes(out, fullPath)
|
||||
if typeValue.Type != gjson.String {
|
||||
continue
|
||||
}
|
||||
normalizedType := strings.ToLower(typeValue.String())
|
||||
if normalizedType == typeValue.String() {
|
||||
continue
|
||||
}
|
||||
out, _ = sjson.SetBytes(out, fullPath, normalizedType)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func setCodexToolChoiceFromGeminiToolConfig(out []byte, functionCallingConfig gjson.Result) []byte {
|
||||
if !functionCallingConfig.Exists() {
|
||||
return out
|
||||
}
|
||||
mode := functionCallingConfig.Get("mode").String()
|
||||
switch mode {
|
||||
case "NONE":
|
||||
out, _ = sjson.SetBytes(out, "tool_choice", "none")
|
||||
case "AUTO":
|
||||
current := gjson.GetBytes(out, "tool_choice")
|
||||
if current.Type != gjson.String || current.String() != "auto" {
|
||||
out, _ = sjson.SetBytes(out, "tool_choice", "auto")
|
||||
}
|
||||
case "ANY":
|
||||
allowedNames := functionCallingConfig.Get("allowedFunctionNames")
|
||||
allowedNameItems := allowedNames.Array()
|
||||
if allowedNames.IsArray() && len(allowedNameItems) == 1 {
|
||||
choice := []byte(`{"type":"function","name":""}`)
|
||||
choice, _ = sjson.SetBytes(choice, "name", shortenNameIfNeeded(allowedNameItems[0].String()))
|
||||
out, _ = sjson.SetRawBytes(out, "tool_choice", choice)
|
||||
} else {
|
||||
out, _ = sjson.SetBytes(out, "tool_choice", "required")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cleanGeminiCodexToolParameters(parameters gjson.Result) []byte {
|
||||
cleaned := []byte(parameters.Raw)
|
||||
if parameters.Get("$schema").Exists() {
|
||||
cleaned, _ = sjson.DeleteBytes(cleaned, "$schema")
|
||||
}
|
||||
if additionalProperties := parameters.Get("additionalProperties"); additionalProperties.Type != gjson.False {
|
||||
cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func codexMessageWithPart(role string, part []byte) []byte {
|
||||
msg := []byte(`{"type":"message","role":"","content":[]}`)
|
||||
msg, _ = sjson.SetBytes(msg, "role", role)
|
||||
msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray([][]byte{part}))
|
||||
return msg
|
||||
}
|
||||
|
||||
func normalizeGeminiCodexServiceTier(serviceTier gjson.Result) string {
|
||||
if !serviceTier.Exists() || serviceTier.Type != gjson.String {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(serviceTier.String())) {
|
||||
case "priority", "fast":
|
||||
return "priority"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func codexContentPartFromGeminiInlineData(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()
|
||||
}
|
||||
data := inlineData.Get("data").String()
|
||||
if mimeType == "" || data == "" {
|
||||
return nil, false
|
||||
}
|
||||
lowerMimeType := strings.ToLower(mimeType)
|
||||
switch {
|
||||
case strings.HasPrefix(lowerMimeType, "image/"):
|
||||
contentPart := []byte(`{"type":"input_image","image_url":""}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "image_url", fmt.Sprintf("data:%s;base64,%s", mimeType, data))
|
||||
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", codexInputAudioFormatFromMIME(mimeType))
|
||||
return contentPart, true
|
||||
default:
|
||||
contentPart := []byte(`{"type":"input_file","file_data":"","filename":""}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "file_data", data)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "filename", codexFileNameFromMIME(mimeType))
|
||||
return contentPart, true
|
||||
}
|
||||
}
|
||||
|
||||
func codexContentPartFromGeminiFileData(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":"input_image","image_url":""}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "image_url", fileURI)
|
||||
return contentPart, true
|
||||
}
|
||||
if strings.HasPrefix(lowerMimeType, "video/") || strings.HasPrefix(lowerMimeType, "application/") || strings.HasPrefix(lowerMimeType, "text/") {
|
||||
contentPart := []byte(`{"type":"input_file","file_url":"","filename":""}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "file_url", fileURI)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "filename", codexFileNameFromMIME(mimeType))
|
||||
return contentPart, true
|
||||
}
|
||||
fileInfo := "File: " + fileURI
|
||||
if mimeType != "" {
|
||||
fileInfo += " (Type: " + mimeType + ")"
|
||||
}
|
||||
contentPart := []byte(`{"type":"input_text","text":""}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "text", fileInfo)
|
||||
return contentPart, true
|
||||
}
|
||||
|
||||
func codexInputAudioFormatFromMIME(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 codexFileNameFromMIME(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"
|
||||
}
|
||||
}
|
||||
|
||||
// shortenNameIfNeeded applies the simple shortening rule for a single name.
|
||||
func shortenNameIfNeeded(name string) string {
|
||||
const limit = 64
|
||||
if len(name) <= limit {
|
||||
return name
|
||||
}
|
||||
if strings.HasPrefix(name, "mcp__") {
|
||||
idx := strings.LastIndex(name, "__")
|
||||
if idx > 0 {
|
||||
cand := "mcp__" + name[idx+2:]
|
||||
if len(cand) > limit {
|
||||
return cand[:limit]
|
||||
}
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return name[:limit]
|
||||
}
|
||||
|
||||
// buildShortNameMap ensures uniqueness of shortened names within a request.
|
||||
func buildShortNameMap(names []string) map[string]string {
|
||||
const limit = 64
|
||||
used := map[string]struct{}{}
|
||||
m := map[string]string{}
|
||||
|
||||
baseCandidate := func(n string) string {
|
||||
if len(n) <= limit {
|
||||
return n
|
||||
}
|
||||
if strings.HasPrefix(n, "mcp__") {
|
||||
idx := strings.LastIndex(n, "__")
|
||||
if idx > 0 {
|
||||
cand := "mcp__" + n[idx+2:]
|
||||
if len(cand) > limit {
|
||||
cand = cand[:limit]
|
||||
}
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return n[:limit]
|
||||
}
|
||||
|
||||
makeUnique := func(cand string) string {
|
||||
if _, ok := used[cand]; !ok {
|
||||
return cand
|
||||
}
|
||||
base := cand
|
||||
for i := 1; ; i++ {
|
||||
suffix := "_" + strconv.Itoa(i)
|
||||
allowed := limit - len(suffix)
|
||||
if allowed < 0 {
|
||||
allowed = 0
|
||||
}
|
||||
tmp := base
|
||||
if len(tmp) > allowed {
|
||||
tmp = tmp[:allowed]
|
||||
}
|
||||
tmp = tmp + suffix
|
||||
if _, ok := used[tmp]; !ok {
|
||||
return tmp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, n := range names {
|
||||
cand := baseCandidate(n)
|
||||
uniq := makeUnique(cand)
|
||||
used[uniq] = struct{}{}
|
||||
m[n] = uniq
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertGeminiRequestToCodex_PreservesCustomCallIDs(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",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
raw := []byte(fmt.Sprintf(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "lookup", %s, "args": {"query": "status"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "lookup", %s, "response": {"result": "ok"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, tt.callField, tt.responseField))
|
||||
|
||||
out := ConvertGeminiRequestToCodex("gpt-5.1-codex", raw, false)
|
||||
|
||||
gotCallID := gjson.GetBytes(out, "input.0.call_id").String()
|
||||
if gotCallID != tt.want {
|
||||
t.Fatalf("expected function_call call_id %q, got %q; output=%s", tt.want, gotCallID, string(out))
|
||||
}
|
||||
|
||||
gotOutputID := gjson.GetBytes(out, "input.1.call_id").String()
|
||||
if gotOutputID != tt.want {
|
||||
t.Fatalf("expected function_call_output call_id %q, got %q; output=%s", tt.want, gotOutputID, string(out))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToCodex_AcceptsInlineData(t *testing.T) {
|
||||
out := ConvertGeminiRequestToCodex("gpt-5.1-codex", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}]}`), false)
|
||||
if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "input_image" {
|
||||
t.Fatalf("content type = %q, want input_image. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "input.0.content.0.image_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 TestConvertGeminiRequestToCodex_SplitsNonImageInlineDataByMIME(t *testing.T) {
|
||||
out := ConvertGeminiRequestToCodex("gpt-5.1-codex", []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, "input.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, "input.1.content.0.type").String(); got != "input_file" {
|
||||
t.Fatalf("video content type = %q, want input_file. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "input.2.content.0.type").String(); got != "input_file" {
|
||||
t.Fatalf("document content type = %q, want input_file. Output: %s", got, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToCodex_DropsHiddenThoughtParts(t *testing.T) {
|
||||
t.Run("thought-only turn", func(t *testing.T) {
|
||||
out := ConvertGeminiRequestToCodex("codex-test", []byte(`{
|
||||
"contents":[
|
||||
{"role":"model","parts":[{"thought":true,"text":"internal reasoning","thoughtSignature":"opaque-provider-state"}]},
|
||||
{"role":"user","parts":[{"text":"continue"}]}
|
||||
]
|
||||
}`), false)
|
||||
|
||||
input := gjson.GetBytes(out, "input").Array()
|
||||
if len(input) != 1 || input[0].Get("role").String() != "user" || input[0].Get("content.0.text").String() != "continue" {
|
||||
t.Fatalf("hidden thought turn was not dropped. Output: %s", string(out))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mixed turn", func(t *testing.T) {
|
||||
out := ConvertGeminiRequestToCodex("codex-test", []byte(`{
|
||||
"contents":[{"role":"model","parts":[
|
||||
{"thought":true,"text":"internal reasoning","thoughtSignature":"opaque-provider-state"},
|
||||
{"text":"visible answer"}
|
||||
]}]
|
||||
}`), false)
|
||||
|
||||
input := gjson.GetBytes(out, "input").Array()
|
||||
if len(input) != 1 || input[0].Get("content.0.type").String() != "output_text" || input[0].Get("content.0.text").String() != "visible answer" {
|
||||
t.Fatalf("hidden thought was not dropped independently of visible text. Output: %s", string(out))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToCodex_DeterministicCallIDs(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "first_tool", "args": {"q": "one"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "first_tool", "response": {"result": "ok1"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "second_tool", "args": {"q": "two"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "second_tool", "response": {"result": "ok2"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out1 := ConvertGeminiRequestToCodex("gpt-5.1-codex", raw, false)
|
||||
out2 := ConvertGeminiRequestToCodex("gpt-5.1-codex", raw, false)
|
||||
|
||||
if string(out1) != string(out2) {
|
||||
t.Fatalf("expected deterministic output across multiple conversions, got different outputs:\nout1=%s\nout2=%s", string(out1), string(out2))
|
||||
}
|
||||
|
||||
wantID1 := "call_gemini_0000000000000001"
|
||||
wantID2 := "call_gemini_0000000000000002"
|
||||
|
||||
gotCall1 := gjson.GetBytes(out1, "input.0.call_id").String()
|
||||
gotResp1 := gjson.GetBytes(out1, "input.1.call_id").String()
|
||||
gotCall2 := gjson.GetBytes(out1, "input.2.call_id").String()
|
||||
gotResp2 := gjson.GetBytes(out1, "input.3.call_id").String()
|
||||
|
||||
if gotCall1 != wantID1 || gotResp1 != wantID1 {
|
||||
t.Fatalf("expected first tool pair to have id %q, got call=%q, resp=%q", wantID1, gotCall1, gotResp1)
|
||||
}
|
||||
if gotCall2 != wantID2 || gotResp2 != wantID2 {
|
||||
t.Fatalf("expected second tool pair to have id %q, got call=%q, resp=%q", wantID2, gotCall2, gotResp2)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,461 @@
|
|||
// Package gemini provides response translation functionality for Codex to Gemini API compatibility.
|
||||
// This package handles the conversion of Codex API responses into Gemini-compatible
|
||||
// JSON format, transforming streaming events and non-streaming responses into the format
|
||||
// expected by Gemini API clients.
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
var (
|
||||
dataTag = []byte("data:")
|
||||
)
|
||||
|
||||
// ConvertCodexResponseToGeminiParams holds parameters for response conversion.
|
||||
type ConvertCodexResponseToGeminiParams struct {
|
||||
Model string
|
||||
CreatedAt int64
|
||||
ResponseID string
|
||||
LastStorageOutput []byte
|
||||
HasOutputTextDelta bool
|
||||
LastImageHashByID map[string][32]byte
|
||||
}
|
||||
|
||||
// ConvertCodexResponseToGemini converts Codex streaming response format to Gemini format.
|
||||
// This function processes various Codex event types 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.
|
||||
// The function maintains state across multiple calls to ensure proper response sequencing.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request, used for cancellation and timeout handling
|
||||
// - modelName: The name of the model being used for the response
|
||||
// - rawJSON: The raw JSON response from the Codex API
|
||||
// - param: A pointer to a parameter object for maintaining state between calls
|
||||
//
|
||||
// Returns:
|
||||
// - [][]byte: A slice of Gemini-compatible JSON responses
|
||||
func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
if *param == nil {
|
||||
*param = &ConvertCodexResponseToGeminiParams{
|
||||
Model: modelName,
|
||||
CreatedAt: 0,
|
||||
ResponseID: "",
|
||||
LastStorageOutput: nil,
|
||||
HasOutputTextDelta: false,
|
||||
LastImageHashByID: make(map[string][32]byte),
|
||||
}
|
||||
}
|
||||
|
||||
if !bytes.HasPrefix(rawJSON, dataTag) {
|
||||
return [][]byte{}
|
||||
}
|
||||
rawJSON = bytes.TrimSpace(rawJSON[5:])
|
||||
|
||||
rootResult := gjson.ParseBytes(rawJSON)
|
||||
typeResult := rootResult.Get("type")
|
||||
typeStr := typeResult.String()
|
||||
|
||||
params := (*param).(*ConvertCodexResponseToGeminiParams)
|
||||
|
||||
// Base Gemini response template
|
||||
template := []byte(`{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-2.5-pro","createTime":"2025-08-15T02:52:03.884209Z","responseId":"06CeaPH7NaCU48APvNXDyA4"}`)
|
||||
{
|
||||
template, _ = sjson.SetBytes(template, "modelVersion", params.Model)
|
||||
createdAtResult := rootResult.Get("response.created_at")
|
||||
if createdAtResult.Exists() {
|
||||
params.CreatedAt = createdAtResult.Int()
|
||||
template, _ = sjson.SetBytes(template, "createTime", time.Unix(params.CreatedAt, 0).Format(time.RFC3339Nano))
|
||||
}
|
||||
template, _ = sjson.SetBytes(template, "responseId", params.ResponseID)
|
||||
}
|
||||
|
||||
if typeStr == "response.image_generation_call.partial_image" {
|
||||
itemID := rootResult.Get("item_id").String()
|
||||
b64 := rootResult.Get("partial_image_b64").String()
|
||||
if b64 == "" {
|
||||
return [][]byte{}
|
||||
}
|
||||
if itemID != "" {
|
||||
if params.LastImageHashByID == nil {
|
||||
params.LastImageHashByID = make(map[string][32]byte)
|
||||
}
|
||||
hash := sha256.Sum256([]byte(b64))
|
||||
if last, ok := params.LastImageHashByID[itemID]; ok && last == hash {
|
||||
return [][]byte{}
|
||||
}
|
||||
params.LastImageHashByID[itemID] = hash
|
||||
}
|
||||
|
||||
outputFormat := rootResult.Get("output_format").String()
|
||||
mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
|
||||
|
||||
part := []byte(`{"inlineData":{"data":"","mimeType":""}}`)
|
||||
part, _ = sjson.SetBytes(part, "inlineData.data", b64)
|
||||
part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
|
||||
template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
|
||||
return [][]byte{template}
|
||||
}
|
||||
|
||||
// Handle function call completion
|
||||
if typeStr == "response.output_item.done" {
|
||||
itemResult := rootResult.Get("item")
|
||||
itemType := itemResult.Get("type").String()
|
||||
if itemType == "image_generation_call" {
|
||||
itemID := itemResult.Get("id").String()
|
||||
b64 := itemResult.Get("result").String()
|
||||
if b64 == "" {
|
||||
return [][]byte{}
|
||||
}
|
||||
if itemID != "" {
|
||||
if params.LastImageHashByID == nil {
|
||||
params.LastImageHashByID = make(map[string][32]byte)
|
||||
}
|
||||
hash := sha256.Sum256([]byte(b64))
|
||||
if last, ok := params.LastImageHashByID[itemID]; ok && last == hash {
|
||||
return [][]byte{}
|
||||
}
|
||||
params.LastImageHashByID[itemID] = hash
|
||||
}
|
||||
|
||||
outputFormat := itemResult.Get("output_format").String()
|
||||
mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
|
||||
|
||||
part := []byte(`{"inlineData":{"data":"","mimeType":""}}`)
|
||||
part, _ = sjson.SetBytes(part, "inlineData.data", b64)
|
||||
part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
|
||||
template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
|
||||
return [][]byte{template}
|
||||
}
|
||||
if itemType == "function_call" {
|
||||
// Create function call part
|
||||
functionCall := []byte(`{"functionCall":{"name":"","args":{}}}`)
|
||||
{
|
||||
// Restore original tool name if shortened
|
||||
n := itemResult.Get("name").String()
|
||||
rev := buildReverseMapFromGeminiOriginal(originalRequestRawJSON)
|
||||
if orig, ok := rev[n]; ok {
|
||||
n = orig
|
||||
}
|
||||
functionCall, _ = sjson.SetBytes(functionCall, "functionCall.name", n)
|
||||
}
|
||||
|
||||
// Parse and set arguments
|
||||
argsStr := itemResult.Get("arguments").String()
|
||||
if argsStr != "" {
|
||||
argsResult := gjson.Parse(argsStr)
|
||||
if argsResult.IsObject() {
|
||||
functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsStr))
|
||||
}
|
||||
}
|
||||
functionCall = setGeminiFunctionCallID(functionCall, itemResult)
|
||||
|
||||
template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{functionCall})
|
||||
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
|
||||
|
||||
params.LastStorageOutput = append([]byte(nil), template...)
|
||||
|
||||
// Use this return to storage message
|
||||
return [][]byte{}
|
||||
}
|
||||
}
|
||||
|
||||
if typeStr == "response.created" { // Handle response creation - set model and response ID
|
||||
template, _ = sjson.SetBytes(template, "modelVersion", rootResult.Get("response.model").String())
|
||||
template, _ = sjson.SetBytes(template, "responseId", rootResult.Get("response.id").String())
|
||||
params.ResponseID = rootResult.Get("response.id").String()
|
||||
} else if typeStr == "response.reasoning_summary_text.delta" { // Handle reasoning/thinking content delta
|
||||
part := []byte(`{"thought":true,"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String())
|
||||
template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
|
||||
} else if typeStr == "response.output_text.delta" { // Handle regular text content delta
|
||||
params.HasOutputTextDelta = true
|
||||
part := []byte(`{"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String())
|
||||
template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
|
||||
} else if typeStr == "response.output_item.done" { // Fallback: emit final message text when no delta chunks were received
|
||||
itemResult := rootResult.Get("item")
|
||||
if itemResult.Get("type").String() != "message" || params.HasOutputTextDelta {
|
||||
return [][]byte{}
|
||||
}
|
||||
contentResult := itemResult.Get("content")
|
||||
if !contentResult.Exists() || !contentResult.IsArray() {
|
||||
return [][]byte{}
|
||||
}
|
||||
wroteText := false
|
||||
contentResult.ForEach(func(_, partResult gjson.Result) bool {
|
||||
if partResult.Get("type").String() != "output_text" {
|
||||
return true
|
||||
}
|
||||
text := partResult.Get("text").String()
|
||||
if text == "" {
|
||||
return true
|
||||
}
|
||||
part := []byte(`{"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", text)
|
||||
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part)
|
||||
wroteText = true
|
||||
return true
|
||||
})
|
||||
if wroteText {
|
||||
params.HasOutputTextDelta = true
|
||||
return [][]byte{template}
|
||||
}
|
||||
return [][]byte{}
|
||||
} else if typeStr == "response.completed" || typeStr == "response.incomplete" { // Handle response completion with usage metadata
|
||||
template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", rootResult.Get("response.usage.input_tokens").Int())
|
||||
template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", rootResult.Get("response.usage.output_tokens").Int())
|
||||
totalTokens := rootResult.Get("response.usage.input_tokens").Int() + rootResult.Get("response.usage.output_tokens").Int()
|
||||
template, _ = sjson.SetBytes(template, "usageMetadata.totalTokenCount", totalTokens)
|
||||
if typeStr == "response.incomplete" {
|
||||
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", codexGeminiIncompleteFinishReason(rootResult.Get("response.incomplete_details.reason").String()))
|
||||
}
|
||||
} else {
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
if len(params.LastStorageOutput) > 0 {
|
||||
stored := append([]byte(nil), params.LastStorageOutput...)
|
||||
params.LastStorageOutput = nil
|
||||
return [][]byte{stored, template}
|
||||
}
|
||||
return [][]byte{template}
|
||||
}
|
||||
|
||||
// ConvertCodexResponseToGeminiNonStream converts a non-streaming Codex response to a non-streaming Gemini response.
|
||||
// This function processes the complete Codex response and transforms it into a single Gemini-compatible
|
||||
// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all
|
||||
// the information into a single response that matches the Gemini API format.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request, used for cancellation and timeout handling
|
||||
// - modelName: The name of the model being used for the response
|
||||
// - rawJSON: The raw JSON response from the Codex API
|
||||
// - param: A pointer to a parameter object for the conversion (unused in current implementation)
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: A Gemini-compatible JSON response containing all message content and metadata
|
||||
func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
|
||||
rootResult := gjson.ParseBytes(rawJSON)
|
||||
|
||||
// Verify this is a terminal response event.
|
||||
responseType := rootResult.Get("type").String()
|
||||
if responseType != "response.completed" && responseType != "response.incomplete" {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
// Base Gemini response template for non-streaming
|
||||
template := []byte(`{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"STOP"}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"","createTime":"","responseId":""}`)
|
||||
|
||||
// Set model version
|
||||
template, _ = sjson.SetBytes(template, "modelVersion", modelName)
|
||||
|
||||
// Set response metadata from the completed response
|
||||
responseData := rootResult.Get("response")
|
||||
if responseData.Exists() {
|
||||
if responseType == "response.incomplete" {
|
||||
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", codexGeminiIncompleteFinishReason(responseData.Get("incomplete_details.reason").String()))
|
||||
}
|
||||
// Set response ID
|
||||
if responseId := responseData.Get("id"); responseId.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "responseId", responseId.String())
|
||||
}
|
||||
|
||||
// Set creation time
|
||||
if createdAt := responseData.Get("created_at"); createdAt.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "createTime", time.Unix(createdAt.Int(), 0).Format(time.RFC3339Nano))
|
||||
}
|
||||
|
||||
// Set usage metadata
|
||||
if usage := responseData.Get("usage"); usage.Exists() {
|
||||
inputTokens := usage.Get("input_tokens").Int()
|
||||
outputTokens := usage.Get("output_tokens").Int()
|
||||
totalTokens := inputTokens + outputTokens
|
||||
|
||||
template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", inputTokens)
|
||||
template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", outputTokens)
|
||||
template, _ = sjson.SetBytes(template, "usageMetadata.totalTokenCount", totalTokens)
|
||||
}
|
||||
|
||||
// Process output content to build parts array
|
||||
var parts [][]byte
|
||||
var pendingFunctionCalls [][]byte
|
||||
|
||||
flushPendingFunctionCalls := func() {
|
||||
if len(pendingFunctionCalls) == 0 {
|
||||
return
|
||||
}
|
||||
// Add all pending function calls as individual parts
|
||||
// This maintains the original Gemini API format while ensuring consecutive calls are grouped together
|
||||
parts = append(parts, pendingFunctionCalls...)
|
||||
pendingFunctionCalls = nil
|
||||
}
|
||||
|
||||
if output := responseData.Get("output"); output.Exists() && output.IsArray() {
|
||||
output.ForEach(func(key, value gjson.Result) bool {
|
||||
itemType := value.Get("type").String()
|
||||
|
||||
switch itemType {
|
||||
case "reasoning":
|
||||
// Flush any pending function calls before adding non-function content
|
||||
flushPendingFunctionCalls()
|
||||
|
||||
// Add thinking content
|
||||
if content := value.Get("content"); content.Exists() {
|
||||
part := []byte(`{"text":"","thought":true}`)
|
||||
part, _ = sjson.SetBytes(part, "text", content.String())
|
||||
parts = append(parts, part)
|
||||
}
|
||||
|
||||
case "message":
|
||||
// Flush any pending function calls before adding non-function content
|
||||
flushPendingFunctionCalls()
|
||||
|
||||
// Add regular text content
|
||||
if content := value.Get("content"); content.Exists() && content.IsArray() {
|
||||
content.ForEach(func(_, contentItem gjson.Result) bool {
|
||||
if contentItem.Get("type").String() == "output_text" {
|
||||
if text := contentItem.Get("text"); text.Exists() {
|
||||
part := []byte(`{"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", text.String())
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
case "image_generation_call":
|
||||
flushPendingFunctionCalls()
|
||||
b64 := value.Get("result").String()
|
||||
if b64 == "" {
|
||||
break
|
||||
}
|
||||
outputFormat := value.Get("output_format").String()
|
||||
mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
|
||||
|
||||
part := []byte(`{"inlineData":{"data":"","mimeType":""}}`)
|
||||
part, _ = sjson.SetBytes(part, "inlineData.data", b64)
|
||||
part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
|
||||
parts = append(parts, part)
|
||||
|
||||
case "function_call":
|
||||
// Collect function call for potential merging with consecutive ones
|
||||
functionCall := []byte(`{"functionCall":{"args":{},"name":""}}`)
|
||||
{
|
||||
n := value.Get("name").String()
|
||||
rev := buildReverseMapFromGeminiOriginal(originalRequestRawJSON)
|
||||
if orig, ok := rev[n]; ok {
|
||||
n = orig
|
||||
}
|
||||
functionCall, _ = sjson.SetBytes(functionCall, "functionCall.name", n)
|
||||
}
|
||||
|
||||
// Parse and set arguments
|
||||
if argsStr := value.Get("arguments").String(); argsStr != "" {
|
||||
argsResult := gjson.Parse(argsStr)
|
||||
if argsResult.IsObject() {
|
||||
functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsStr))
|
||||
}
|
||||
}
|
||||
functionCall = setGeminiFunctionCallID(functionCall, value)
|
||||
|
||||
pendingFunctionCalls = append(pendingFunctionCalls, functionCall)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Handle any remaining pending function calls at the end
|
||||
flushPendingFunctionCalls()
|
||||
|
||||
if len(parts) > 0 {
|
||||
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts", translatorcommon.JoinRawArray(parts))
|
||||
}
|
||||
}
|
||||
}
|
||||
return template
|
||||
}
|
||||
|
||||
// buildReverseMapFromGeminiOriginal builds a map[short]original from original Gemini request tools.
|
||||
func buildReverseMapFromGeminiOriginal(original []byte) map[string]string {
|
||||
tools := gjson.GetBytes(original, "tools")
|
||||
rev := map[string]string{}
|
||||
if !tools.IsArray() {
|
||||
return rev
|
||||
}
|
||||
var names []string
|
||||
tarr := tools.Array()
|
||||
for i := 0; i < len(tarr); i++ {
|
||||
fns := tarr[i].Get("functionDeclarations")
|
||||
if !fns.IsArray() {
|
||||
continue
|
||||
}
|
||||
for _, fn := range fns.Array() {
|
||||
if v := fn.Get("name"); v.Exists() {
|
||||
names = append(names, v.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
m := buildShortNameMap(names)
|
||||
for orig, short := range m {
|
||||
rev[short] = orig
|
||||
}
|
||||
}
|
||||
return rev
|
||||
}
|
||||
|
||||
func setGeminiFunctionCallID(functionCall []byte, item gjson.Result) []byte {
|
||||
if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" {
|
||||
functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", callID)
|
||||
return functionCall
|
||||
}
|
||||
if id := strings.TrimSpace(item.Get("id").String()); id != "" {
|
||||
functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", id)
|
||||
}
|
||||
return functionCall
|
||||
}
|
||||
|
||||
func codexGeminiIncompleteFinishReason(reason string) string {
|
||||
switch reason {
|
||||
case "max_tokens", "max_output_tokens":
|
||||
return "MAX_TOKENS"
|
||||
case "content_filter":
|
||||
return "SAFETY"
|
||||
default:
|
||||
return "OTHER"
|
||||
}
|
||||
}
|
||||
|
||||
func GeminiTokenCount(ctx context.Context, count int64) []byte {
|
||||
return translatorcommon.GeminiTokenCountJSON(count)
|
||||
}
|
||||
|
||||
func mimeTypeFromCodexOutputFormat(outputFormat string) string {
|
||||
if outputFormat == "" {
|
||||
return "image/png"
|
||||
}
|
||||
if strings.Contains(outputFormat, "/") {
|
||||
return outputFormat
|
||||
}
|
||||
switch strings.ToLower(outputFormat) {
|
||||
case "png":
|
||||
return "image/png"
|
||||
case "jpg", "jpeg":
|
||||
return "image/jpeg"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
case "gif":
|
||||
return "image/gif"
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertCodexResponseToGemini_IncompleteTerminal(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
terminal := []byte(`{"type":"response.incomplete","response":{"id":"resp_1","model":"gpt-5.5","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`)
|
||||
|
||||
var param any
|
||||
streamOut := ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, append([]byte("data: "), terminal...), ¶m)
|
||||
if len(streamOut) != 1 {
|
||||
t.Fatalf("expected 1 streaming terminal chunk, got %d", len(streamOut))
|
||||
}
|
||||
if got := gjson.GetBytes(streamOut[0], "candidates.0.finishReason").String(); got != "MAX_TOKENS" {
|
||||
t.Fatalf("stream finishReason = %q, want MAX_TOKENS; payload=%s", got, streamOut[0])
|
||||
}
|
||||
|
||||
nonStreamOut := ConvertCodexResponseToGeminiNonStream(ctx, "gemini-2.5-pro", nil, nil, terminal, nil)
|
||||
if got := gjson.GetBytes(nonStreamOut, "candidates.0.finishReason").String(); got != "MAX_TOKENS" {
|
||||
t.Fatalf("non-stream finishReason = %q, want MAX_TOKENS; payload=%s", got, nonStreamOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToGemini_StreamEmptyOutputUsesOutputItemDoneMessageFallback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
originalRequest := []byte(`{"tools":[]}`)
|
||||
var param any
|
||||
|
||||
chunks := [][]byte{
|
||||
[]byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]},\"output_index\":0}"),
|
||||
[]byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"),
|
||||
}
|
||||
|
||||
var outputs [][]byte
|
||||
for _, chunk := range chunks {
|
||||
outputs = append(outputs, ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, chunk, ¶m)...)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, out := range outputs {
|
||||
if gjson.GetBytes(out, "candidates.0.content.parts.0.text").String() == "ok" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected fallback content from response.output_item.done message; outputs=%q", outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToGemini_StreamPartialImageEmitsInlineData(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
originalRequest := []byte(`{"tools":[]}`)
|
||||
var param any
|
||||
|
||||
chunk := []byte(`data: {"type":"response.image_generation_call.partial_image","item_id":"ig_123","output_format":"png","partial_image_b64":"aGVsbG8=","partial_image_index":0}`)
|
||||
out := ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, chunk, ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.data").String()
|
||||
if got != "aGVsbG8=" {
|
||||
t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "aGVsbG8=", got, string(out[0]))
|
||||
}
|
||||
|
||||
gotMime := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.mimeType").String()
|
||||
if gotMime != "image/png" {
|
||||
t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/png", gotMime, string(out[0]))
|
||||
}
|
||||
|
||||
out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, chunk, ¶m)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected duplicate image chunk to be suppressed, got %d", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToGemini_StreamImageGenerationCallDoneEmitsInlineData(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
originalRequest := []byte(`{"tools":[]}`)
|
||||
var param any
|
||||
|
||||
out := ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.image_generation_call.partial_image","item_id":"ig_123","output_format":"png","partial_image_b64":"aGVsbG8=","partial_image_index":0}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","output_format":"png","result":"aGVsbG8="}}`), ¶m)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected output_item.done to be suppressed when identical to last partial image, got %d", len(out))
|
||||
}
|
||||
|
||||
out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","output_format":"jpeg","result":"Ymll"}}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.data").String()
|
||||
if got != "Ymll" {
|
||||
t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "Ymll", got, string(out[0]))
|
||||
}
|
||||
|
||||
gotMime := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.mimeType").String()
|
||||
if gotMime != "image/jpeg" {
|
||||
t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/jpeg", gotMime, string(out[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToGemini_NonStreamImageGenerationCallAddsInlineDataPart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
originalRequest := []byte(`{"tools":[]}`)
|
||||
|
||||
raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]},{"type":"image_generation_call","output_format":"png","result":"aGVsbG8="}]}}`)
|
||||
out := ConvertCodexResponseToGeminiNonStream(ctx, "gemini-2.5-pro", originalRequest, nil, raw, nil)
|
||||
|
||||
got := gjson.GetBytes(out, "candidates.0.content.parts.1.inlineData.data").String()
|
||||
if got != "aGVsbG8=" {
|
||||
t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "aGVsbG8=", got, string(out))
|
||||
}
|
||||
|
||||
gotMime := gjson.GetBytes(out, "candidates.0.content.parts.1.inlineData.mimeType").String()
|
||||
if gotMime != "image/png" {
|
||||
t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/png", gotMime, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToGemini_StreamPreservesFunctionCallID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
originalRequest := []byte(`{"tools":[]}`)
|
||||
var param any
|
||||
|
||||
out := ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_gateway","name":"lookup","arguments":"{\"query\":\"status\"}"}}`), ¶m)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected function call output to be buffered, got %d chunks", len(out))
|
||||
}
|
||||
|
||||
out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}`), ¶m)
|
||||
if len(out) == 0 {
|
||||
t.Fatal("expected buffered function call to be emitted on completion")
|
||||
}
|
||||
|
||||
got := ""
|
||||
for _, chunk := range out {
|
||||
if value := gjson.GetBytes(chunk, "candidates.0.content.parts.0.functionCall.id").String(); value != "" {
|
||||
got = value
|
||||
break
|
||||
}
|
||||
}
|
||||
if got != "call_gateway" {
|
||||
t.Fatalf("expected functionCall.id %q, got %q; chunks=%q", "call_gateway", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToGeminiNonStreamPreservesFunctionCallID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
originalRequest := []byte(`{"tools":[]}`)
|
||||
|
||||
raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"function_call","call_id":"call_gateway","name":"lookup","arguments":"{\"query\":\"status\"}"}]}}`)
|
||||
out := ConvertCodexResponseToGeminiNonStream(ctx, "gemini-2.5-pro", originalRequest, nil, raw, nil)
|
||||
|
||||
got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.id").String()
|
||||
if got != "call_gateway" {
|
||||
t.Fatalf("expected functionCall.id %q, got %q; chunk=%s", "call_gateway", got, string(out))
|
||||
}
|
||||
}
|
||||
20
backend/internal/translator/codex/gemini/init.go
Normal file
20
backend/internal/translator/codex/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,
|
||||
Codex,
|
||||
ConvertGeminiRequestToCodex,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertCodexResponseToGemini,
|
||||
NonStream: ConvertCodexResponseToGeminiNonStream,
|
||||
TokenCount: GeminiTokenCount,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestCleanGeminiCodexToolParametersPreservesCanonicalSchema(t *testing.T) {
|
||||
input := []byte(`{"type":"object","properties":{"value":{"type":"string"}},"additionalProperties":false}`)
|
||||
|
||||
output := cleanGeminiCodexToolParameters(gjson.ParseBytes(input))
|
||||
|
||||
if string(output) != string(input) {
|
||||
t.Fatalf("canonical schema changed:\n got: %s\nwant: %s", output, input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCodexToolChoiceFromGeminiToolConfigReusesAutoChoice(t *testing.T) {
|
||||
input := []byte(`{"tool_choice":"auto","input":[]}`)
|
||||
config := gjson.Parse(`{"mode":"AUTO"}`)
|
||||
|
||||
output := setCodexToolChoiceFromGeminiToolConfig(input, config)
|
||||
|
||||
if &output[0] != &input[0] {
|
||||
t.Fatal("AUTO tool choice caused a payload copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanGeminiCodexToolParametersNormalizesSchema(t *testing.T) {
|
||||
input := []byte(`{"type":"object","$schema":"draft","additionalProperties":true}`)
|
||||
|
||||
output := cleanGeminiCodexToolParameters(gjson.ParseBytes(input))
|
||||
|
||||
if gjson.GetBytes(output, "$schema").Exists() {
|
||||
t.Fatal("$schema should be removed")
|
||||
}
|
||||
if additionalProperties := gjson.GetBytes(output, "additionalProperties"); additionalProperties.Type != gjson.False {
|
||||
t.Fatalf("additionalProperties = %s, want false", additionalProperties.Raw)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue