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

View file

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

View file

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

View file

@ -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...), &param)
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, &param)...)
}
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, &param)
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, &param)
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}`), &param)
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="}}`), &param)
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"}}`), &param)
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\"}"}}`), &param)
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}}}`), &param)
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))
}
}

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,
Codex,
ConvertGeminiRequestToCodex,
interfaces.TranslateResponse{
Stream: ConvertCodexResponseToGemini,
NonStream: ConvertCodexResponseToGeminiNonStream,
TokenCount: GeminiTokenCount,
},
)
}

View file

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