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,66 @@
package claude
import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/tidwall/gjson"
)
const capturedGeminiThinkingSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
func TestConvertClaudeRequestToGeminiWithCompat_SignatureCompatibility(t *testing.T) {
tests := []struct {
name string
signature string
wantSignature string
}{
{
name: "preserves valid gemini signature",
signature: "gemini#" + capturedGeminiThinkingSignature,
wantSignature: capturedGeminiThinkingSignature,
},
{
name: "foreign claude signature maps to bypass sentinel",
signature: "claude#opaque-signature-12345",
wantSignature: signature.GeminiSkipThoughtSignatureValidator,
},
{
name: "empty signature maps to bypass sentinel",
signature: "",
wantSignature: signature.GeminiSkipThoughtSignatureValidator,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"` + tt.signature + `"}]}]}`)
withCompat := ConvertClaudeRequestToGeminiWithCompat("deepseek-v4", payload, false)
part := gjson.GetBytes(withCompat, "contents.0.parts.0")
if !part.Get("thought").Bool() || part.Get("text").String() != "reason" {
t.Fatalf("compat translation missing thought part: %s", withCompat)
}
if got := part.Get("thoughtSignature").String(); got != tt.wantSignature {
t.Fatalf("thoughtSignature = %q, want %q; output: %s", got, tt.wantSignature, withCompat)
}
})
}
}
func TestConvertClaudeRequestToGeminiWithCompatPreservesEmptyThinking(t *testing.T) {
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""}]}]}`)
withoutCompat := ConvertClaudeRequestToGemini("deepseek-v4", payload, false)
if gjson.GetBytes(withoutCompat, "contents.0.parts.#").Int() != 0 {
t.Fatalf("default translation preserved thinking: %s", withoutCompat)
}
withCompat := ConvertClaudeRequestToGeminiWithCompat("deepseek-v4", payload, false)
part := gjson.GetBytes(withCompat, "contents.0.parts.0")
if !part.Get("thought").Bool() || part.Get("text").String() != "reason" {
t.Fatalf("compat translation missing thought part: %s", withCompat)
}
if !part.Get("thoughtSignature").Exists() || part.Get("thoughtSignature").String() != signature.GeminiSkipThoughtSignatureValidator {
t.Fatalf("compat translation did not preserve bypass signature: %s", withCompat)
}
}

View file

@ -0,0 +1,348 @@
// Package claude provides request translation functionality for Claude API.
// It handles parsing and transforming Claude API requests into the internal client format,
// extracting model information, system instructions, message contents, and tool declarations.
// The package also performs JSON data cleaning and transformation to ensure compatibility
// between Claude API format and the internal client's expected format.
package claude
import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const geminiClaudeThoughtSignature = "skip_thought_signature_validator"
// ConvertClaudeRequestToGemini parses a Claude API request and returns a complete
// Gemini request body (as JSON bytes) ready to be sent via SendRawMessageStream.
// All JSON transformations are performed using gjson/sjson.
//
// Parameters:
// - modelName: The name of the model.
// - rawJSON: The raw JSON request from the Claude API.
// - stream: A boolean indicating if the request is for a streaming response.
//
// Returns:
// - []byte: The transformed request in Gemini format.
func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, stream bool) []byte {
return convertClaudeRequestToGemini(modelName, inputRawJSON, stream, false)
}
// ConvertClaudeRequestToGeminiWithCompat preserves assistant thinking blocks
// with empty signatures for configured compatibility endpoints.
func ConvertClaudeRequestToGeminiWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte {
return convertClaudeRequestToGemini(modelName, inputRawJSON, stream, true)
}
func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, preserveEmptyThinkingBlocks bool) []byte {
rawJSON := inputRawJSON
// Build output Gemini request JSON
out := []byte(`{"contents":[]}`)
out, _ = sjson.SetBytes(out, "model", modelName)
// system instruction
if systemResult := gjson.GetBytes(rawJSON, "system"); systemResult.IsArray() {
systemParts := make([][]byte, 0, 2)
systemResult.ForEach(func(_, systemPromptResult gjson.Result) bool {
if systemPromptResult.Get("type").String() == "text" {
textResult := systemPromptResult.Get("text")
if textResult.Type == gjson.String {
if util.IsClaudeCodeAttributionSystemText(textResult.String()) {
return true
}
part := []byte(`{"text":""}`)
part, _ = sjson.SetBytes(part, "text", textResult.String())
systemParts = append(systemParts, part)
}
}
return true
})
if len(systemParts) > 0 {
systemInstruction := []byte(`{"role":"user","parts":[]}`)
systemInstruction, _ = sjson.SetRawBytes(systemInstruction, "parts", translatorcommon.JoinRawArray(systemParts))
out, _ = sjson.SetRawBytes(out, "systemInstruction", systemInstruction)
}
} else if systemResult.Type == gjson.String && !util.IsClaudeCodeAttributionSystemText(systemResult.String()) {
part := []byte(`{"text":""}`)
part, _ = sjson.SetBytes(part, "text", systemResult.String())
systemInstruction := []byte(`{"parts":[]}`)
systemInstruction = translatorcommon.SetRawArrayItems(systemInstruction, "parts", [][]byte{part})
out, _ = sjson.SetRawBytes(out, "systemInstruction", systemInstruction)
}
// contents
if messagesResult := gjson.GetBytes(rawJSON, "messages"); messagesResult.IsArray() {
contentItems := translatorcommon.NewRawArrayItems(messagesResult.Get("#").Int())
messagesResult.ForEach(func(_, messageResult gjson.Result) bool {
roleResult := messageResult.Get("role")
if roleResult.Type != gjson.String {
return true
}
role := roleResult.String()
if role == "assistant" {
role = "model"
} else if role == "system" {
role = "user"
}
partItems := make([][]byte, 0, 4)
contentsResult := messageResult.Get("content")
if roleResult.String() == "system" {
if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(contentsResult); ok {
part := []byte(`{"text":""}`)
part, _ = sjson.SetBytes(part, "text", reminderText)
partItems = append(partItems, part)
contentItems = append(contentItems, geminiContentWithParts(role, partItems))
}
return true
}
if contentsResult.IsArray() {
contentsResult.ForEach(func(_, contentResult gjson.Result) bool {
switch contentResult.Get("type").String() {
case "text":
text := contentResult.Get("text").String()
if text == "" {
return true
}
part := []byte(`{"text":""}`)
part, _ = sjson.SetBytes(part, "text", text)
partItems = append(partItems, part)
case "thinking":
if !preserveEmptyThinkingBlocks {
return true
}
part := []byte(`{"text":"","thought":true,"thoughtSignature":""}`)
part, _ = sjson.SetBytes(part, "text", contentResult.Get("thinking").String())
signature := sigcompat.GeminiReplaySignatureOrBypass(contentResult.Get("signature").String(), sigcompat.SignatureBlockKindGeminiModelPart)
part, _ = sjson.SetBytes(part, "thoughtSignature", signature)
partItems = append(partItems, part)
case "tool_use":
functionName := contentResult.Get("name").String()
if toolUseID := contentResult.Get("id").String(); toolUseID != "" {
if derived := toolNameFromClaudeToolUseID(toolUseID); derived != "" {
functionName = derived
}
}
functionName = util.SanitizeFunctionName(functionName)
functionArgs := contentResult.Get("input").String()
argsResult := gjson.Parse(functionArgs)
if argsResult.IsObject() && gjson.Valid(functionArgs) {
part := []byte(`{"thoughtSignature":"","functionCall":{"name":"","args":{}}}`)
part, _ = sjson.SetBytes(part, "thoughtSignature", geminiClaudeThoughtSignature)
part, _ = sjson.SetBytes(part, "functionCall.name", functionName)
part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(functionArgs))
partItems = append(partItems, part)
}
case "tool_result":
toolCallID := contentResult.Get("tool_use_id").String()
if toolCallID == "" {
return true
}
funcName := toolNameFromClaudeToolUseID(toolCallID)
if funcName == "" {
funcName = toolCallID
}
funcName = util.SanitizeFunctionName(funcName)
toolResult := util.ConvertClaudeToolResultContent(contentResult.Get("content"))
part := []byte(`{"functionResponse":{"name":"","response":{"result":""}}}`)
part, _ = sjson.SetBytes(part, "functionResponse.name", funcName)
if toolResult.ResultIsRaw {
part, _ = sjson.SetRawBytes(part, "functionResponse.response.result", []byte(toolResult.Result))
} else {
part, _ = sjson.SetBytes(part, "functionResponse.response.result", toolResult.Result)
}
partItems = append(partItems, part)
for _, img := range toolResult.Images {
imagePart := []byte(`{"inline_data":{"mime_type":"","data":""}}`)
imagePart, _ = sjson.SetBytes(imagePart, "inline_data.mime_type", img.MimeType)
imagePart, _ = sjson.SetBytes(imagePart, "inline_data.data", img.Data)
partItems = append(partItems, imagePart)
}
case "image":
source := contentResult.Get("source")
if source.Get("type").String() != "base64" {
return true
}
mimeType := source.Get("media_type").String()
data := source.Get("data").String()
if mimeType == "" || data == "" {
return true
}
part := []byte(`{"inline_data":{"mime_type":"","data":""}}`)
part, _ = sjson.SetBytes(part, "inline_data.mime_type", mimeType)
part, _ = sjson.SetBytes(part, "inline_data.data", data)
partItems = append(partItems, part)
}
return true
})
contentItems = append(contentItems, geminiContentWithParts(role, partItems))
} else if contentsResult.Type == gjson.String {
part := []byte(`{"text":""}`)
part, _ = sjson.SetBytes(part, "text", contentsResult.String())
partItems = append(partItems, part)
contentItems = append(contentItems, geminiContentWithParts(role, partItems))
}
return true
})
// Strip a trailing model turn with unanswered function calls.
if len(contentItems) > 0 {
last := gjson.ParseBytes(contentItems[len(contentItems)-1])
if last.Get("role").String() == "model" {
hasFunctionCall := false
last.Get("parts").ForEach(func(_, part gjson.Result) bool {
if part.Get("functionCall").Exists() {
hasFunctionCall = true
return false
}
return true
})
if hasFunctionCall {
contentItems = contentItems[:len(contentItems)-1]
}
}
}
out = translatorcommon.SetRawArrayItems(out, "contents", contentItems)
}
// tools
if toolsResult := gjson.GetBytes(rawJSON, "tools"); toolsResult.IsArray() {
var toolItems [][]byte
toolsResult.ForEach(func(_, toolResult gjson.Result) bool {
inputSchemaResult := toolResult.Get("input_schema")
if inputSchemaResult.Exists() && inputSchemaResult.IsObject() {
inputSchema := util.CleanJSONSchemaForGemini(inputSchemaResult.Raw)
tool := []byte(toolResult.Raw)
var err error
tool, err = sjson.DeleteBytes(tool, "input_schema")
if err != nil {
return true
}
tool, err = sjson.SetRawBytes(tool, "parametersJsonSchema", []byte(inputSchema))
if err != nil {
return true
}
for _, path := range []string{"strict", "input_examples", "type", "cache_control", "defer_loading", "eager_input_streaming"} {
if toolResult.Get(path).Exists() {
tool, _ = sjson.DeleteBytes(tool, path)
}
}
nameResult := toolResult.Get("name")
originalName := nameResult.String()
sanitizedName := util.SanitizeFunctionName(originalName)
if nameResult.Type != gjson.String || sanitizedName != originalName {
tool, _ = sjson.SetBytes(tool, "name", sanitizedName)
}
if gjson.ValidBytes(tool) && gjson.ParseBytes(tool).IsObject() {
toolItems = append(toolItems, tool)
}
}
return true
})
if len(toolItems) > 0 {
tools := []byte(`[{"functionDeclarations":[]}]`)
tools, _ = sjson.SetRawBytes(tools, "0.functionDeclarations", translatorcommon.JoinRawArray(toolItems))
out, _ = sjson.SetRawBytes(out, "tools", tools)
}
}
// tool_choice
toolChoiceResult := gjson.GetBytes(rawJSON, "tool_choice")
if toolChoiceResult.Exists() {
toolChoiceType := ""
toolChoiceName := ""
if toolChoiceResult.IsObject() {
toolChoiceType = toolChoiceResult.Get("type").String()
toolChoiceName = toolChoiceResult.Get("name").String()
} else if toolChoiceResult.Type == gjson.String {
toolChoiceType = toolChoiceResult.String()
}
switch toolChoiceType {
case "auto":
out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "AUTO")
case "none":
out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "NONE")
case "any":
out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "ANY")
case "tool":
out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "ANY")
if toolChoiceName != "" {
out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames", []string{util.SanitizeFunctionName(toolChoiceName)})
}
}
}
// Map Anthropic thinking -> Gemini thinking config when enabled
// Translator only does format conversion, ApplyThinking handles model capability validation.
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
switch t.Get("type").String() {
case "enabled":
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
budget := int(b.Int())
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", budget)
}
case "adaptive", "auto":
// For adaptive thinking:
// - If output_config.effort is explicitly present, pass through as thinkingLevel.
// - Otherwise, treat it as "enabled with target-model maximum" and emit thinkingBudget=max.
// ApplyThinking handles clamping to target model's supported levels.
effort := ""
if v := gjson.GetBytes(rawJSON, "output_config.effort"); v.Exists() && v.Type == gjson.String {
effort = strings.ToLower(strings.TrimSpace(v.String()))
}
if effort != "" {
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingLevel", effort)
} else {
maxBudget := 0
if mi := registry.LookupModelInfo(modelName, "gemini"); mi != nil && mi.Thinking != nil {
maxBudget = mi.Thinking.Max
}
if maxBudget > 0 {
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", maxBudget)
} else {
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingLevel", "high")
}
}
}
}
if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "generationConfig.temperature", v.Num)
}
if v := gjson.GetBytes(rawJSON, "top_p"); v.Exists() && v.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "generationConfig.topP", v.Num)
}
if v := gjson.GetBytes(rawJSON, "top_k"); v.Exists() && v.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "generationConfig.topK", v.Num)
}
result := out
result = common.AttachDefaultSafetySettings(result, "safetySettings")
return result
}
func geminiContentWithParts(role string, parts [][]byte) []byte {
content := []byte(`{"role":"","parts":[]}`)
content, _ = sjson.SetBytes(content, "role", role)
content, _ = sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(parts))
return content
}
func toolNameFromClaudeToolUseID(toolUseID string) string {
parts := strings.Split(toolUseID, "-")
if len(parts) <= 1 {
return ""
}
return strings.Join(parts[0:len(parts)-1], "-")
}

View file

@ -0,0 +1,277 @@
package claude
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertClaudeRequestToGemini_ToolChoice_SpecificTool(t *testing.T) {
inputJSON := []byte(`{
"model": "gemini-3-flash-preview",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "hi"}
]
}
],
"tools": [
{
"name": "json",
"description": "A JSON tool",
"input_schema": {
"type": "object",
"properties": {}
}
}
],
"tool_choice": {"type": "tool", "name": "json"}
}`)
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
if got := gjson.GetBytes(output, "toolConfig.functionCallingConfig.mode").String(); got != "ANY" {
t.Fatalf("Expected toolConfig.functionCallingConfig.mode 'ANY', got '%s'", got)
}
allowed := gjson.GetBytes(output, "toolConfig.functionCallingConfig.allowedFunctionNames").Array()
if len(allowed) != 1 || allowed[0].String() != "json" {
t.Fatalf("Expected allowedFunctionNames ['json'], got %s", gjson.GetBytes(output, "toolConfig.functionCallingConfig.allowedFunctionNames").Raw)
}
}
func TestConvertClaudeRequestToGemini_StringSystemInstruction(t *testing.T) {
inputJSON := []byte(`{
"model": "gemini-3-flash-preview",
"system": "Be concise",
"messages": [{"role": "user", "content": "Hello"}]
}`)
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
if got := gjson.GetBytes(output, "systemInstruction.parts.0.text").String(); got != "Be concise" {
t.Fatalf("Expected systemInstruction text %q, got %q", "Be concise", got)
}
if gjson.GetBytes(output, "systemInstruction.role").Exists() {
t.Fatalf("Expected systemInstruction.role to not exist, got %q", gjson.GetBytes(output, "systemInstruction.role").String())
}
if gjson.GetBytes(output, "system_instruction").Exists() {
t.Fatalf("Legacy system_instruction field should not be emitted: %s", output)
}
}
func TestConvertClaudeRequestToGemini_ImageContent(t *testing.T) {
inputJSON := []byte(`{
"model": "gemini-3-flash-preview",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "describe this image"},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "aGVsbG8="
}
}
]
}
]
}`)
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
parts := gjson.GetBytes(output, "contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("Expected 2 parts, got %d", len(parts))
}
if got := parts[0].Get("text").String(); got != "describe this image" {
t.Fatalf("Expected first part text 'describe this image', got '%s'", got)
}
if got := parts[1].Get("inline_data.mime_type").String(); got != "image/png" {
t.Fatalf("Expected image mime type 'image/png', got '%s'", got)
}
if got := parts[1].Get("inline_data.data").String(); got != "aGVsbG8=" {
t.Fatalf("Expected image data 'aGVsbG8=', got '%s'", got)
}
}
func TestConvertClaudeRequestToGemini_StripsClaudeCodeAttribution(t *testing.T) {
inputJSON := []byte(`{
"model": "claude-sonnet-4-5",
"system": [
{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.63.abc; cc_entrypoint=cli; cch=12345;"},
{"type": "text", "text": "You are a Claude agent, built on Anthropic's Claude Agent SDK."},
{"type": "text", "text": "User system prompt"}
],
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
}`)
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
parts := gjson.GetBytes(output, "systemInstruction.parts").Array()
if len(parts) != 2 {
t.Fatalf("Expected 2 system parts after attribution strip, got %d: %s", len(parts), gjson.GetBytes(output, "systemInstruction.parts").Raw)
}
if got := parts[0].Get("text").String(); got != "You are a Claude agent, built on Anthropic's Claude Agent SDK." {
t.Fatalf("Unexpected first system part: %q", got)
}
if got := parts[1].Get("text").String(); got != "User system prompt" {
t.Fatalf("Unexpected second system part: %q", got)
}
if gjson.GetBytes(output, `systemInstruction.parts.#(text%"x-anthropic-billing-header:*")`).Exists() {
t.Fatalf("Claude Code attribution block was forwarded: %s", gjson.GetBytes(output, "systemInstruction.parts").Raw)
}
}
func TestConvertClaudeRequestToGemini_ConvertsMessageSystemRoleToUserContent(t *testing.T) {
inputJSON := []byte(`{
"model": "gemini-3-flash-preview",
"system": [{"type": "text", "text": "Top-level rules"}],
"messages": [
{"role": "user", "content": [{"type": "text", "text": "Hello"}]},
{"role": "system", "content": "String mid-conversation rule"},
{"role": "system", "content": [{"type": "text", "text": "Array mid-conversation rule"}]}
]
}`)
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
if systemContent := gjson.GetBytes(output, `contents.#(role=="system")`); systemContent.Exists() {
t.Fatalf("system role should not be emitted in contents: %s", systemContent.Raw)
}
contents := gjson.GetBytes(output, "contents").Array()
if len(contents) != 3 {
t.Fatalf("Expected the user and message-level system turns in contents, got %d: %s", len(contents), gjson.GetBytes(output, "contents").Raw)
}
if got := contents[0].Get("role").String(); got != "user" {
t.Fatalf("Expected first content role user, got %q", got)
}
if got := contents[1].Get("role").String(); got != "user" {
t.Fatalf("Expected message-level string system content to be downgraded to user role, got %q", got)
}
if got := contents[1].Get("parts.0.text").String(); got != "<system-reminder>\nString mid-conversation rule\n</system-reminder>" {
t.Fatalf("Unexpected string message-level system content text: %q", got)
}
if got := contents[2].Get("role").String(); got != "user" {
t.Fatalf("Expected message-level array system content to be downgraded to user role, got %q", got)
}
if got := contents[2].Get("parts.0.text").String(); got != "<system-reminder>\nArray mid-conversation rule\n</system-reminder>" {
t.Fatalf("Unexpected array message-level system content text: %q", got)
}
parts := gjson.GetBytes(output, "systemInstruction.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected only top-level system parts, got %d: %s", len(parts), gjson.GetBytes(output, "systemInstruction.parts").Raw)
}
if got := parts[0].Get("text").String(); got != "Top-level rules" {
t.Fatalf("Unexpected first system part: %q", got)
}
}
func TestConvertClaudeRequestToGemini_SkipsEmptyTextParts(t *testing.T) {
inputJSON := []byte(`{
"model": "claude-3-5-sonnet",
"messages": [
{
"role": "assistant",
"content": [
{"type": "text", "text": ""},
{"type": "text", "text": "hello"},
{"type": "text", "text": ""}
]
}
]
}`)
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
parts := gjson.GetBytes(output, "contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected 1 part after skipping empty text, got %d: %s", len(parts), output)
}
if got := parts[0].Get("text").String(); got != "hello" {
t.Fatalf("Expected part text 'hello', got '%s'", got)
}
}
func TestConvertClaudeRequestToGemini_StructuredToolResult(t *testing.T) {
inputJSON := []byte(`{
"model": "gemini-3-flash-preview",
"messages": [
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "json-call-1", "name": "json", "input": {"ok": true}}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "json-call-1",
"content": [
{"type": "text", "text": "alpha"},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "aGVsbG8="}}
]
}
]
}
]
}`)
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
fr := gjson.GetBytes(output, "contents.1.parts.0.functionResponse")
if !fr.Exists() {
t.Fatalf("expected functionResponse part, contents=%s", gjson.GetBytes(output, "contents").Raw)
}
// The text block must remain structured JSON, not a double-encoded string blob.
if got := fr.Get("response.result.text").String(); got != "alpha" {
t.Fatalf("expected structured result text 'alpha', got result=%s", fr.Get("response.result").Raw)
}
// The image block must be emitted as a separate inline_data part, not embedded in result.
img := gjson.GetBytes(output, "contents.1.parts.1.inline_data")
if got := img.Get("mime_type").String(); got != "image/png" {
t.Fatalf("expected image mime type 'image/png', got '%s'", got)
}
if got := img.Get("data").String(); got != "aGVsbG8=" {
t.Fatalf("expected image data 'aGVsbG8=', got '%s'", got)
}
}
func TestConvertClaudeRequestToGemini_StringToolResult(t *testing.T) {
inputJSON := []byte(`{
"model": "gemini-3-flash-preview",
"messages": [
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "json-call-1", "name": "json", "input": {"ok": true}}
]
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "json-call-1", "content": "alpha"}
]
}
]
}`)
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
fr := gjson.GetBytes(output, "contents.1.parts.0.functionResponse")
if !fr.Exists() {
t.Fatalf("expected functionResponse part, contents=%s", gjson.GetBytes(output, "contents").Raw)
}
// String content must not be double-encoded: result should be exactly "alpha".
if got := fr.Get("response.result").String(); got != "alpha" {
t.Fatalf("expected result 'alpha', got '%s' (raw=%s)", got, fr.Get("response.result").Raw)
}
}

View file

@ -0,0 +1,421 @@
// Package claude provides response translation functionality for Claude API.
// This package handles the conversion of backend client responses into Claude-compatible
// Server-Sent Events (SSE) format, implementing a sophisticated state machine that manages
// different response types including text content, thinking processes, and function calls.
// The translation ensures proper sequencing of SSE events and maintains state across
// multiple response chunks to provide a seamless streaming experience.
package claude
import (
"bytes"
"context"
"fmt"
"strings"
"sync/atomic"
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"
)
// Params holds parameters for response conversion.
type Params struct {
IsGlAPIKey bool
HasFirstResponse bool
ResponseType int
ResponseIndex int
HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output
ToolNameMap map[string]string
SanitizedNameMap map[string]string
SawToolCall bool
HasFinalEvents bool
}
// toolUseIDCounter provides a process-wide unique counter for tool use identifiers.
var toolUseIDCounter uint64
// ConvertGeminiResponseToClaude performs sophisticated streaming response format conversion.
// This function implements a complex state machine that translates backend client responses
// into Claude-compatible Server-Sent Events (SSE) format. It manages different response types
// and handles state transitions between content blocks, thinking processes, and function calls.
//
// Response type states: 0=none, 1=content, 2=thinking, 3=function
// The function maintains state across multiple calls to ensure proper SSE event sequencing.
//
// Parameters:
// - ctx: The context for the request.
// - modelName: The name of the model.
// - rawJSON: The raw JSON response from the Gemini API.
// - param: A pointer to a parameter object for the conversion.
//
// Returns:
// - [][]byte: A slice of bytes, each containing a Claude-compatible SSE payload.
func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
if *param == nil {
*param = &Params{
IsGlAPIKey: false,
HasFirstResponse: false,
ResponseType: 0,
ResponseIndex: 0,
ToolNameMap: util.ToolNameMapFromClaudeRequest(originalRequestRawJSON),
SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON),
SawToolCall: false,
}
}
if bytes.Equal(rawJSON, []byte("[DONE]")) {
// Only send message_stop if we have actually output content
if (*param).(*Params).HasContent {
return [][]byte{translatorcommon.AppendSSEEventString(nil, "message_stop", `{"type":"message_stop"}`, 3)}
}
return [][]byte{}
}
output := make([]byte, 0, 1024)
appendEvent := func(event, payload string) {
output = translatorcommon.AppendSSEEventString(output, event, payload, 3)
}
appendSignatureDelta := func(signature string) {
if signature == "" || (*param).(*Params).ResponseType != 2 {
return
}
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, (*param).(*Params).ResponseIndex)), "delta.signature", signature)
appendEvent("content_block_delta", string(data))
(*param).(*Params).HasContent = true
}
// Initialize the streaming session with a message_start event
// This is only sent for the very first response chunk
if !(*param).(*Params).HasFirstResponse {
// Create the initial message structure with default values
// This follows the Claude API specification for streaming message initialization
messageStartTemplate := []byte(`{"type":"message_start","message":{"id":"msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY","type":"message","role":"assistant","content":[],"model":"claude-3-5-sonnet-20241022","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`)
// Override default values with actual response metadata if available
if modelVersionResult := gjson.GetBytes(rawJSON, "modelVersion"); modelVersionResult.Exists() {
messageStartTemplate, _ = sjson.SetBytes(messageStartTemplate, "message.model", modelVersionResult.String())
}
if responseIDResult := gjson.GetBytes(rawJSON, "responseId"); responseIDResult.Exists() {
messageStartTemplate, _ = sjson.SetBytes(messageStartTemplate, "message.id", responseIDResult.String())
}
appendEvent("message_start", string(messageStartTemplate))
(*param).(*Params).HasFirstResponse = true
}
// Process the response parts array from the backend client
// Each part can contain text content, thinking content, or function calls
partsResult := gjson.GetBytes(rawJSON, "candidates.0.content.parts")
if partsResult.IsArray() {
partResults := partsResult.Array()
for i := 0; i < len(partResults); i++ {
partResult := partResults[i]
// Extract the different types of content from each part
partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall")
thoughtSignatureResult := partResult.Get("thoughtSignature")
if !thoughtSignatureResult.Exists() {
thoughtSignatureResult = partResult.Get("thought_signature")
}
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""
if hasThoughtSignature && !partTextResult.Exists() && !functionCallResult.Exists() {
appendSignatureDelta(thoughtSignatureResult.String())
continue
}
// Handle text content (both regular content and thinking)
if partTextResult.Exists() {
// Process thinking content (internal reasoning)
if partResult.Get("thought").Bool() || hasThoughtSignature {
if hasThoughtSignature && partTextResult.String() == "" {
appendSignatureDelta(thoughtSignatureResult.String())
continue
}
// Continue existing thinking block
if (*param).(*Params).ResponseType == 2 {
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partTextResult.String())
appendEvent("content_block_delta", string(data))
(*param).(*Params).HasContent = true
} else {
// Transition from another state to thinking
// First, close any existing content block
if (*param).(*Params).ResponseType != 0 {
if (*param).(*Params).ResponseType == 2 {
// output = output + "event: content_block_delta\n"
// output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, (*param).(*Params).ResponseIndex)
// output = output + "\n\n\n"
}
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
(*param).(*Params).ResponseIndex++
}
// Start a new thinking content block
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, (*param).(*Params).ResponseIndex))
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partTextResult.String())
appendEvent("content_block_delta", string(data))
(*param).(*Params).ResponseType = 2 // Set state to thinking
(*param).(*Params).HasContent = true
}
appendSignatureDelta(thoughtSignatureResult.String())
} else {
// Process regular text content (user-visible output)
// Continue existing text block
if (*param).(*Params).ResponseType == 1 {
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partTextResult.String())
appendEvent("content_block_delta", string(data))
(*param).(*Params).HasContent = true
} else {
// Transition from another state to text content
// First, close any existing content block
if (*param).(*Params).ResponseType != 0 {
if (*param).(*Params).ResponseType == 2 {
// output = output + "event: content_block_delta\n"
// output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, (*param).(*Params).ResponseIndex)
// output = output + "\n\n\n"
}
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
(*param).(*Params).ResponseIndex++
}
// Start a new text content block
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, (*param).(*Params).ResponseIndex))
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partTextResult.String())
appendEvent("content_block_delta", string(data))
(*param).(*Params).ResponseType = 1 // Set state to content
(*param).(*Params).HasContent = true
}
}
} else if functionCallResult.Exists() {
// Handle function/tool calls from the AI model
// This processes tool usage requests and formats them for Claude API compatibility
(*param).(*Params).SawToolCall = true
upstreamToolName := functionCallResult.Get("name").String()
upstreamToolName = util.RestoreSanitizedToolName((*param).(*Params).SanitizedNameMap, upstreamToolName)
clientToolName := util.MapToolName((*param).(*Params).ToolNameMap, upstreamToolName)
// FIX: Handle streaming split/delta where name might be empty in subsequent chunks.
// If we are already in tool use mode and name is empty, treat as continuation (delta).
if (*param).(*Params).ResponseType == 3 && upstreamToolName == "" {
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", fcArgsResult.Raw)
appendEvent("content_block_delta", string(data))
}
// Continue to next part without closing/opening logic
continue
}
// Handle state transitions when switching to function calls
// Close any existing function call block first
if (*param).(*Params).ResponseType == 3 {
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
(*param).(*Params).ResponseIndex++
(*param).(*Params).ResponseType = 0
}
// Special handling for thinking state transition
if (*param).(*Params).ResponseType == 2 {
// output = output + "event: content_block_delta\n"
// output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, (*param).(*Params).ResponseIndex)
// output = output + "\n\n\n"
}
// Close any other existing content block
if (*param).(*Params).ResponseType != 0 {
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
(*param).(*Params).ResponseIndex++
}
// Start a new tool use content block
// This creates the structure for a function call in Claude format
// Create the tool use block with unique ID and function details
data := []byte(fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`, (*param).(*Params).ResponseIndex))
data, _ = sjson.SetBytes(data, "content_block.id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, atomic.AddUint64(&toolUseIDCounter, 1))))
data, _ = sjson.SetBytes(data, "content_block.name", clientToolName)
appendEvent("content_block_start", string(data))
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
data, _ = sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", fcArgsResult.Raw)
appendEvent("content_block_delta", string(data))
}
(*param).(*Params).ResponseType = 3
(*param).(*Params).HasContent = true
}
}
}
usageResult := gjson.GetBytes(rawJSON, "usageMetadata")
if usageResult.Exists() && bytes.Contains(rawJSON, []byte(`"finishReason"`)) && !(*param).(*Params).HasFinalEvents {
// Only send final events if we have actually output content
if (*param).(*Params).HasContent {
if (*param).(*Params).ResponseType != 0 {
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
(*param).(*Params).ResponseType = 0
}
template := []byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
if (*param).(*Params).SawToolCall {
template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
} else if finish := gjson.GetBytes(rawJSON, "candidates.0.finishReason"); finish.Exists() && finish.String() == "MAX_TOKENS" {
template = []byte(`{"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
}
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
candidatesTokenCount := usageResult.Get("candidatesTokenCount").Int()
template, _ = sjson.SetBytes(template, "usage.output_tokens", candidatesTokenCount+thoughtsTokenCount)
template, _ = sjson.SetBytes(template, "usage.input_tokens", usageResult.Get("promptTokenCount").Int())
appendEvent("message_delta", string(template))
(*param).(*Params).HasFinalEvents = true
}
}
return [][]byte{output}
}
// ConvertGeminiResponseToClaudeNonStream converts a non-streaming Gemini response to a non-streaming Claude response.
//
// Parameters:
// - ctx: The context for the request.
// - modelName: The name of the model.
// - rawJSON: The raw JSON response from the Gemini API.
// - param: A pointer to a parameter object for the conversion.
//
// Returns:
// - []byte: A Claude-compatible JSON response.
func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
_ = requestRawJSON
root := gjson.ParseBytes(rawJSON)
toolNameMap := util.ToolNameMapFromClaudeRequest(originalRequestRawJSON)
sanitizedNameMap := util.SanitizedToolNameMap(originalRequestRawJSON)
out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
out, _ = sjson.SetBytes(out, "id", root.Get("responseId").String())
out, _ = sjson.SetBytes(out, "model", root.Get("modelVersion").String())
inputTokens := root.Get("usageMetadata.promptTokenCount").Int()
outputTokens := root.Get("usageMetadata.candidatesTokenCount").Int() + root.Get("usageMetadata.thoughtsTokenCount").Int()
out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens)
out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens)
parts := root.Get("candidates.0.content.parts")
textBuilder := strings.Builder{}
thinkingBuilder := strings.Builder{}
var thinkingSignature string
toolIDCounter := 0
hasToolCall := false
var blocks [][]byte
flushText := func() {
if textBuilder.Len() == 0 {
return
}
block := []byte(`{"type":"text","text":""}`)
block, _ = sjson.SetBytes(block, "text", textBuilder.String())
blocks = append(blocks, block)
textBuilder.Reset()
}
flushThinking := func() {
if thinkingBuilder.Len() == 0 && thinkingSignature == "" {
return
}
block := []byte(`{"type":"thinking","thinking":""}`)
block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
if thinkingSignature != "" {
block, _ = sjson.SetBytes(block, "signature", thinkingSignature)
}
blocks = append(blocks, block)
thinkingBuilder.Reset()
thinkingSignature = ""
}
if parts.IsArray() {
for _, part := range parts.Array() {
thoughtSignatureResult := part.Get("thoughtSignature")
if !thoughtSignatureResult.Exists() {
thoughtSignatureResult = part.Get("thought_signature")
}
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""
if hasThoughtSignature {
thinkingSignature = thoughtSignatureResult.String()
}
text := part.Get("text")
functionCall := part.Get("functionCall")
if hasThoughtSignature && (!text.Exists() || text.String() == "") && !functionCall.Exists() {
continue
}
if text.Exists() && text.String() != "" {
if part.Get("thought").Bool() || hasThoughtSignature {
flushText()
thinkingBuilder.WriteString(text.String())
continue
}
flushThinking()
textBuilder.WriteString(text.String())
continue
}
if functionCall.Exists() {
flushThinking()
flushText()
hasToolCall = true
upstreamToolName := functionCall.Get("name").String()
upstreamToolName = util.RestoreSanitizedToolName(sanitizedNameMap, upstreamToolName)
clientToolName := util.MapToolName(toolNameMap, upstreamToolName)
toolIDCounter++
toolBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
toolBlock, _ = sjson.SetBytes(toolBlock, "id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, toolIDCounter)))
toolBlock, _ = sjson.SetBytes(toolBlock, "name", clientToolName)
inputRaw := "{}"
if args := functionCall.Get("args"); args.Exists() && gjson.Valid(args.Raw) && args.IsObject() {
inputRaw = args.Raw
}
toolBlock, _ = sjson.SetRawBytes(toolBlock, "input", []byte(inputRaw))
blocks = append(blocks, toolBlock)
continue
}
}
}
flushThinking()
flushText()
if len(blocks) > 0 {
out, _ = sjson.SetRawBytes(out, "content", translatorcommon.JoinRawArray(blocks))
}
stopReason := "end_turn"
if hasToolCall {
stopReason = "tool_use"
} else {
if finish := root.Get("candidates.0.finishReason"); finish.Exists() {
switch finish.String() {
case "MAX_TOKENS":
stopReason = "max_tokens"
case "STOP", "FINISH_REASON_UNSPECIFIED", "UNKNOWN":
stopReason = "end_turn"
default:
stopReason = "end_turn"
}
}
}
out, _ = sjson.SetBytes(out, "stop_reason", stopReason)
if inputTokens == int64(0) && outputTokens == int64(0) && !root.Get("usageMetadata").Exists() {
out, _ = sjson.DeleteBytes(out, "usage")
}
return out
}
func ClaudeTokenCount(ctx context.Context, count int64) []byte {
return translatorcommon.ClaudeInputTokensJSON(count)
}

View file

@ -0,0 +1,204 @@
package claude
import (
"bytes"
"context"
"strings"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertGeminiResponseToClaude_SignatureOnlyPartDoesNotOpenEmptyTextBlock(t *testing.T) {
requestJSON := []byte(`{"model":"gemini-test","messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
thinkingChunk := []byte(`{
"candidates": [{
"content": {
"parts": [{"text": "thinking text", "thought": true}]
}
}],
"modelVersion": "gemini-test",
"responseId": "resp-test"
}`)
signatureChunk := []byte(`{
"candidates": [{
"content": {
"parts": [{"text": "", "thoughtSignature": "sig-test"}]
},
"finishReason": "STOP"
}],
"usageMetadata": {
"promptTokenCount": 10,
"thoughtsTokenCount": 2,
"totalTokenCount": 12
},
"modelVersion": "gemini-test",
"responseId": "resp-test"
}`)
var param any
ctx := context.Background()
output := bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-test", requestJSON, requestJSON, thinkingChunk, &param), nil)
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-test", requestJSON, requestJSON, signatureChunk, &param), nil)...)
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-test", requestJSON, requestJSON, []byte("[DONE]"), &param), nil)...)
outputText := string(output)
if strings.Contains(outputText, `"content_block":{"type":"text"`) {
t.Fatalf("signature-only part must not open an empty text block: %s", outputText)
}
if strings.Contains(outputText, `"type":"content_block_stop","index":1`) {
t.Fatalf("signature-only part must not produce a stop for unopened index 1: %s", outputText)
}
if !strings.Contains(outputText, `"type":"signature_delta"`) || !strings.Contains(outputText, `"signature":"sig-test"`) {
t.Fatalf("signature-only part must be emitted as a thinking signature delta: %s", outputText)
}
if got := strings.Count(outputText, `"type":"content_block_stop","index":0`); got != 1 {
t.Fatalf("expected exactly one stop for thinking index 0, got %d: %s", got, outputText)
}
if !strings.Contains(outputText, `"type":"message_delta"`) || !strings.Contains(outputText, `"output_tokens":2`) {
t.Fatalf("finish chunk without candidatesTokenCount must still emit final message_delta: %s", outputText)
}
if !strings.Contains(outputText, `"type":"message_stop"`) {
t.Fatalf("DONE chunk must still emit message_stop after final events: %s", outputText)
}
}
func TestConvertGeminiResponseToClaudeNonStream_PreservesThoughtSignature(t *testing.T) {
requestJSON := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}]}`)
geminiResponse := []byte(`{
"candidates": [{
"content": {
"parts": [
{"text": "thinking step 1\n", "thought": true},
{"text": "thinking step 2", "thought": true, "thoughtSignature": "sig-xyz-123"},
{"text": "visible answer"}
]
},
"finishReason": "STOP"
}],
"usageMetadata": {
"promptTokenCount": 10,
"candidatesTokenCount": 5
},
"modelVersion": "gemini-2.5-pro",
"responseId": "resp-non-stream"
}`)
ctx := context.Background()
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
outputJSON := gjson.ParseBytes(output)
blocks := outputJSON.Get("content").Array()
if len(blocks) != 2 {
t.Fatalf("expected 2 content blocks (thinking + text), got %d: %s", len(blocks), string(output))
}
thinkingBlock := blocks[0]
if thinkingBlock.Get("type").String() != "thinking" {
t.Fatalf("expected first block to be thinking, got %s", thinkingBlock.Get("type").String())
}
if thinkingBlock.Get("thinking").String() != "thinking step 1\nthinking step 2" {
t.Fatalf("unexpected thinking content: %s", thinkingBlock.Get("thinking").String())
}
if thinkingBlock.Get("signature").String() != "sig-xyz-123" {
t.Fatalf("expected signature 'sig-xyz-123', got %q. Output: %s", thinkingBlock.Get("signature").String(), string(output))
}
textBlock := blocks[1]
if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "visible answer" {
t.Fatalf("unexpected text block: %s", textBlock.Raw)
}
}
func TestConvertGeminiResponseToClaudeNonStream_PartWithThoughtSignatureWithoutThoughtBool(t *testing.T) {
requestJSON := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}]}`)
geminiResponse := []byte(`{
"candidates": [{
"content": {
"parts": [
{"text": "inferred reasoning", "thought_signature": "sig-snake-case"},
{"text": "final answer"}
]
},
"finishReason": "STOP"
}],
"usageMetadata": {
"promptTokenCount": 10,
"candidatesTokenCount": 5
},
"modelVersion": "gemini-2.5-pro",
"responseId": "resp-non-stream-2"
}`)
ctx := context.Background()
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
outputJSON := gjson.ParseBytes(output)
blocks := outputJSON.Get("content").Array()
if len(blocks) != 2 {
t.Fatalf("expected 2 content blocks (thinking + text), got %d: %s", len(blocks), string(output))
}
thinkingBlock := blocks[0]
if thinkingBlock.Get("type").String() != "thinking" {
t.Fatalf("expected first block to be thinking, got %s", thinkingBlock.Get("type").String())
}
if thinkingBlock.Get("thinking").String() != "inferred reasoning" {
t.Fatalf("unexpected thinking content: %s", thinkingBlock.Get("thinking").String())
}
if thinkingBlock.Get("signature").String() != "sig-snake-case" {
t.Fatalf("expected signature 'sig-snake-case', got %q. Output: %s", thinkingBlock.Get("signature").String(), string(output))
}
textBlock := blocks[1]
if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "final answer" {
t.Fatalf("unexpected text block: %s", textBlock.Raw)
}
}
func TestConvertGeminiResponseToClaudeNonStream_TrailingSignatureOnlyPart(t *testing.T) {
requestJSON := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}]}`)
geminiResponse := []byte(`{
"candidates": [{
"content": {
"parts": [
{"text": "thinking step 1\n", "thought": true},
{"text": "", "thoughtSignature": "sig-trailing"},
{"text": "visible answer"}
]
},
"finishReason": "STOP"
}],
"usageMetadata": {
"promptTokenCount": 10,
"candidatesTokenCount": 5
},
"modelVersion": "gemini-2.5-pro",
"responseId": "resp-non-stream-trailing"
}`)
ctx := context.Background()
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
outputJSON := gjson.ParseBytes(output)
blocks := outputJSON.Get("content").Array()
if len(blocks) != 2 {
t.Fatalf("expected 2 content blocks (thinking + text), got %d: %s", len(blocks), string(output))
}
thinkingBlock := blocks[0]
if thinkingBlock.Get("type").String() != "thinking" {
t.Fatalf("expected first block to be thinking, got %s", thinkingBlock.Get("type").String())
}
if thinkingBlock.Get("thinking").String() != "thinking step 1\n" {
t.Fatalf("unexpected thinking content: %s", thinkingBlock.Get("thinking").String())
}
if thinkingBlock.Get("signature").String() != "sig-trailing" {
t.Fatalf("expected signature 'sig-trailing', got %q. Output: %s", thinkingBlock.Get("signature").String(), string(output))
}
textBlock := blocks[1]
if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "visible answer" {
t.Fatalf("unexpected text block: %s", textBlock.Raw)
}
}

View file

@ -0,0 +1,20 @@
package claude
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(
Claude,
Gemini,
ConvertClaudeRequestToGemini,
interfaces.TranslateResponse{
Stream: ConvertGeminiResponseToClaude,
NonStream: ConvertGeminiResponseToClaudeNonStream,
TokenCount: ClaudeTokenCount,
},
)
}

View file

@ -0,0 +1,47 @@
package common
import (
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// DefaultSafetySettings returns the default Gemini safety configuration we attach to requests.
func DefaultSafetySettings() []map[string]string {
return []map[string]string{
{
"category": "HARM_CATEGORY_HARASSMENT",
"threshold": "OFF",
},
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"threshold": "OFF",
},
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"threshold": "OFF",
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"threshold": "OFF",
},
{
"category": "HARM_CATEGORY_CIVIC_INTEGRITY",
"threshold": "BLOCK_NONE",
},
}
}
// AttachDefaultSafetySettings ensures the default safety settings are present when absent.
// The caller must provide the target JSON path (e.g. "safetySettings" or "request.safetySettings").
func AttachDefaultSafetySettings(rawJSON []byte, path string) []byte {
if gjson.GetBytes(rawJSON, path).Exists() {
return rawJSON
}
out, err := sjson.SetBytes(rawJSON, path, DefaultSafetySettings())
if err != nil {
return rawJSON
}
return out
}

View file

@ -0,0 +1,310 @@
// Package gemini provides in-provider request normalization for Gemini API.
// It ensures incoming v1beta requests meet minimal schema requirements
// expected by Google's Generative Language API.
package gemini
import (
"fmt"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertGeminiRequestToGemini normalizes Gemini v1beta requests.
// - Adds a default role for each content if missing or invalid.
// The first message defaults to "user", then alternates user/model when needed.
//
// It keeps the payload otherwise unchanged.
func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) []byte {
rawJSON := inputRawJSON
// Fast path: if no contents field, only attach safety settings
contents := util.GetGJSONBytesNoCopy(rawJSON, "contents")
if !contents.Exists() {
return common.AttachDefaultSafetySettings(rawJSON, "safetySettings")
}
toolsResult := gjson.GetBytes(rawJSON, "tools")
if toolsResult.Exists() && toolsResult.IsArray() {
var toolItems [][]byte
toolsChanged := false
toolsResult.ForEach(func(_, toolResult gjson.Result) bool {
tool := []byte(toolResult.Raw)
toolChanged := false
if declarations := toolResult.Get("functionDeclarations"); declarations.Exists() {
tool, _ = sjson.SetRawBytes(tool, "function_declarations", []byte(declarations.Raw))
tool, _ = sjson.DeleteBytes(tool, "functionDeclarations")
toolChanged = true
}
declarations := gjson.GetBytes(tool, "function_declarations")
if declarations.IsArray() {
var declarationItems [][]byte
declarationsChanged := false
declarations.ForEach(func(_, declarationResult gjson.Result) bool {
declaration := []byte(declarationResult.Raw)
if parameters := declarationResult.Get("parameters"); parameters.Exists() {
declaration, _ = sjson.SetRawBytes(declaration, "parametersJsonSchema", []byte(parameters.Raw))
declaration, _ = sjson.DeleteBytes(declaration, "parameters")
declarationsChanged = true
}
declarationItems = append(declarationItems, declaration)
return true
})
if declarationsChanged {
tool, _ = sjson.SetRawBytes(tool, "function_declarations", translatorcommon.JoinRawArray(declarationItems))
toolChanged = true
}
}
toolsChanged = toolsChanged || toolChanged
toolItems = append(toolItems, tool)
return true
})
if toolsChanged {
rawJSON, _ = sjson.SetRawBytes(rawJSON, "tools", translatorcommon.JoinRawArray(toolItems))
}
}
// Walk contents and fix roles
out := rawJSON
prevRole := ""
if contents.IsArray() {
rolesChanged := false
contents.ForEach(func(_, value gjson.Result) bool {
role := value.Get("role").String()
if role != "user" && role != "model" {
role = nextGeminiRole(prevRole)
rolesChanged = true
}
prevRole = role
return true
})
if rolesChanged {
prevRole = ""
contentItems := translatorcommon.NewRawArrayItems(contents.Get("#").Int())
contents.ForEach(func(_, value gjson.Result) bool {
role := value.Get("role").String()
item := []byte(value.Raw)
if role != "user" && role != "model" {
role = nextGeminiRole(prevRole)
item, _ = sjson.SetBytes(item, "role", role)
}
prevRole = role
contentItems = append(contentItems, item)
return true
})
out, _ = sjson.SetRawBytes(out, "contents", translatorcommon.JoinRawArray(contentItems))
}
} else {
idx := 0
contents.ForEach(func(_ gjson.Result, value gjson.Result) bool {
role := value.Get("role").String()
if role != "user" && role != "model" {
role = nextGeminiRole(prevRole)
out, _ = sjson.SetBytes(out, fmt.Sprintf("contents.%d.role", idx), role)
}
prevRole = role
idx++
return true
})
}
out = signature.SanitizeGeminiRequestThoughtSignatures(out, "contents")
if gjson.GetBytes(rawJSON, "generationConfig.responseSchema").Exists() {
strJson, _ := util.RenameKey(string(out), "generationConfig.responseSchema", "generationConfig.responseJsonSchema")
out = []byte(strJson)
}
// Backfill empty functionResponse.name from the preceding functionCall.name.
// Some clients send function responses with empty names; the Gemini API rejects these.
out = backfillEmptyFunctionResponseNames(out)
out = common.AttachDefaultSafetySettings(out, "safetySettings")
return out
}
// backfillEmptyFunctionResponseNames walks the contents array and for each
// model turn containing functionCall parts, records the call names in order.
// For the immediately following user/function turn containing functionResponse
// parts, any empty name is replaced with the corresponding call name.
func backfillEmptyFunctionResponseNames(data []byte) []byte {
contents := util.GetGJSONBytesNoCopy(data, "contents")
if !contents.Exists() {
return data
}
canBatch := contents.IsArray()
if canBatch {
contents.ForEach(func(_, content gjson.Result) bool {
parts := content.Get("parts")
if parts.Exists() && !parts.IsArray() {
canBatch = false
return false
}
return true
})
}
if !canBatch {
return backfillEmptyFunctionResponseNamesLegacy(data, contents)
}
needsBackfill, excessResponseIndexes := geminiFunctionResponseNamesNeedBackfill(contents)
if !needsBackfill {
for _, contentIndex := range excessResponseIndexes {
log.Debugf("more function responses than calls at contents[%d], skipping name backfill", contentIndex)
}
return data
}
changed := false
contentItems := translatorcommon.NewRawArrayItems(contents.Get("#").Int())
var pendingCallNames []string
contents.ForEach(func(contentIdx, content gjson.Result) bool {
role := content.Get("role").String()
contentRaw := []byte(content.Raw)
// Collect functionCall names from model turns.
if role == "model" {
var names []string
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
if part.Get("functionCall").Exists() {
names = append(names, part.Get("functionCall.name").String())
}
return true
})
pendingCallNames = names
contentItems = append(contentItems, contentRaw)
return true
}
// Backfill empty functionResponse names from pending call names.
if len(pendingCallNames) > 0 {
responseIndex := 0
partsChanged := false
partItems := make([][]byte, 0, 4)
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
partRaw := []byte(part.Raw)
if part.Get("functionResponse").Exists() {
name := part.Get("functionResponse.name").String()
if strings.TrimSpace(name) == "" {
if responseIndex < len(pendingCallNames) {
partRaw, _ = sjson.SetBytes(partRaw, "functionResponse.name", pendingCallNames[responseIndex])
partsChanged = true
} else {
log.Debugf("more function responses than calls at contents[%d], skipping name backfill", contentIdx.Int())
}
}
responseIndex++
}
partItems = append(partItems, partRaw)
return true
})
if partsChanged {
contentRaw, _ = sjson.SetRawBytes(contentRaw, "parts", translatorcommon.JoinRawArray(partItems))
changed = true
}
pendingCallNames = nil
}
contentItems = append(contentItems, contentRaw)
return true
})
if !changed {
return data
}
out, errSetContents := sjson.SetRawBytes(data, "contents", translatorcommon.JoinRawArray(contentItems))
if errSetContents != nil {
return data
}
return out
}
func geminiFunctionResponseNamesNeedBackfill(contents gjson.Result) (bool, []int64) {
var pendingCallNames []string
var excessResponseIndexes []int64
needsBackfill := false
contents.ForEach(func(contentIdx, content gjson.Result) bool {
if content.Get("role").String() == "model" {
var names []string
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
if part.Get("functionCall").Exists() {
names = append(names, part.Get("functionCall.name").String())
}
return true
})
pendingCallNames = names
return true
}
if len(pendingCallNames) == 0 {
return true
}
responseIndex := 0
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
if part.Get("functionResponse").Exists() {
if strings.TrimSpace(part.Get("functionResponse.name").String()) == "" {
if responseIndex < len(pendingCallNames) {
needsBackfill = true
return false
}
excessResponseIndexes = append(excessResponseIndexes, contentIdx.Int())
}
responseIndex++
}
return true
})
pendingCallNames = nil
return !needsBackfill
})
return needsBackfill, excessResponseIndexes
}
func backfillEmptyFunctionResponseNamesLegacy(data []byte, contents gjson.Result) []byte {
out := data
var pendingCallNames []string
contents.ForEach(func(contentIdx, content gjson.Result) bool {
if content.Get("role").String() == "model" {
var names []string
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
if part.Get("functionCall").Exists() {
names = append(names, part.Get("functionCall.name").String())
}
return true
})
pendingCallNames = names
return true
}
if len(pendingCallNames) > 0 {
responseIndex := 0
content.Get("parts").ForEach(func(partIdx, part gjson.Result) bool {
if part.Get("functionResponse").Exists() {
if strings.TrimSpace(part.Get("functionResponse.name").String()) == "" {
if responseIndex < len(pendingCallNames) {
path := fmt.Sprintf("contents.%d.parts.%d.functionResponse.name", contentIdx.Int(), partIdx.Int())
out, _ = sjson.SetBytes(out, path, pendingCallNames[responseIndex])
} else {
log.Debugf("more function responses than calls at contents[%d], skipping name backfill", contentIdx.Int())
}
}
responseIndex++
}
return true
})
pendingCallNames = nil
}
return true
})
return out
}
func nextGeminiRole(previousRole string) string {
if previousRole == "" || previousRole == "model" {
return "user"
}
return "model"
}

View file

@ -0,0 +1,255 @@
package gemini
import (
"strings"
"testing"
"github.com/tidwall/gjson"
)
const largeInlineDataSize = 20 << 20
var largeInlineDataBenchmarkOutput []byte
func TestConvertGeminiRequestToGeminiReusesLargeNormalizedPayload(t *testing.T) {
input := largeInlineDataGeminiRequest(true)
// Assert the reuse invariant with t.Fatal rather than inside testing.Benchmark:
// a failing benchmark aborts before any iteration completes and yields a zero
// BenchmarkResult, so AllocedBytesPerOp would report 0 and silently satisfy the
// allocation check below exactly when the payload is being copied.
output := ConvertGeminiRequestToGemini("gemini-test", input, false)
if &output[0] != &input[0] {
t.Fatal("normalized request should reuse the input payload")
}
largeInlineDataBenchmarkOutput = output
result := testing.Benchmark(func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
largeInlineDataBenchmarkOutput = ConvertGeminiRequestToGemini("gemini-test", input, false)
}
})
if result.N == 0 {
t.Fatal("allocation benchmark did not complete an iteration")
}
if allocated := result.AllocedBytesPerOp(); allocated >= 1<<20 {
t.Fatalf("normalized 20 MiB inlineData request allocated %d bytes/op, want less than 1 MiB", allocated)
}
}
func BenchmarkConvertGeminiRequestToGeminiLargeInlineData(b *testing.B) {
for _, test := range []struct {
name string
includeSafetySettings bool
}{
{name: "normalized_passthrough", includeSafetySettings: true},
{name: "attach_default_safety", includeSafetySettings: false},
} {
b.Run(test.name, func(b *testing.B) {
input := largeInlineDataGeminiRequest(test.includeSafetySettings)
b.ReportAllocs()
b.SetBytes(int64(len(input)))
b.ResetTimer()
for b.Loop() {
largeInlineDataBenchmarkOutput = ConvertGeminiRequestToGemini("gemini-test", input, false)
}
})
}
}
func largeInlineDataGeminiRequest(includeSafetySettings bool) []byte {
prefix := `{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"video/mp4","data":"`
suffix := `"}}]}]`
if includeSafetySettings {
suffix += `,"safetySettings":[]`
}
return []byte(prefix + strings.Repeat("A", largeInlineDataSize) + suffix + `}`)
}
func TestBackfillEmptyFunctionResponseNames_Single(t *testing.T) {
input := []byte(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "Bash", "args": {"cmd": "ls"}}}
]
},
{
"role": "user",
"parts": [
{"functionResponse": {"name": "", "response": {"output": "file1.txt"}}}
]
}
]
}`)
out := backfillEmptyFunctionResponseNames(input)
name := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
if name != "Bash" {
t.Errorf("Expected backfilled name 'Bash', got '%s'", name)
}
}
func TestBackfillEmptyFunctionResponseNames_Parallel(t *testing.T) {
input := []byte(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "Read", "args": {"path": "/a"}}},
{"functionCall": {"name": "Grep", "args": {"pattern": "x"}}}
]
},
{
"role": "user",
"parts": [
{"functionResponse": {"name": "", "response": {"result": "content a"}}},
{"functionResponse": {"name": "", "response": {"result": "match x"}}}
]
}
]
}`)
out := backfillEmptyFunctionResponseNames(input)
name0 := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
name1 := gjson.GetBytes(out, "contents.1.parts.1.functionResponse.name").String()
if name0 != "Read" {
t.Errorf("Expected first name 'Read', got '%s'", name0)
}
if name1 != "Grep" {
t.Errorf("Expected second name 'Grep', got '%s'", name1)
}
}
func TestBackfillEmptyFunctionResponseNames_PreservesExisting(t *testing.T) {
input := []byte(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "Bash", "args": {}}}
]
},
{
"role": "user",
"parts": [
{"functionResponse": {"name": "Bash", "response": {"result": "ok"}}}
]
}
]
}`)
out := backfillEmptyFunctionResponseNames(input)
name := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
if name != "Bash" {
t.Errorf("Expected preserved name 'Bash', got '%s'", name)
}
}
func TestConvertGeminiRequestToGemini_BackfillsEmptyName(t *testing.T) {
input := []byte(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "Bash", "args": {"cmd": "ls"}}}
]
},
{
"role": "user",
"parts": [
{"functionResponse": {"name": "", "response": {"output": "file1.txt"}}}
]
}
]
}`)
out := ConvertGeminiRequestToGemini("", input, false)
name := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
if name != "Bash" {
t.Errorf("Expected backfilled name 'Bash', got '%s'", name)
}
}
func TestBackfillEmptyFunctionResponseNames_MoreResponsesThanCalls(t *testing.T) {
// Extra responses beyond the call count should not panic and should be left unchanged.
input := []byte(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "Bash", "args": {}}}
]
},
{
"role": "user",
"parts": [
{"functionResponse": {"name": "", "response": {"result": "ok"}}},
{"functionResponse": {"name": "", "response": {"result": "extra"}}}
]
}
]
}`)
out := backfillEmptyFunctionResponseNames(input)
name0 := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
if name0 != "Bash" {
t.Errorf("Expected first name 'Bash', got '%s'", name0)
}
// Second response has no matching call, should remain empty
name1 := gjson.GetBytes(out, "contents.1.parts.1.functionResponse.name").String()
if name1 != "" {
t.Errorf("Expected second name to remain empty, got '%s'", name1)
}
}
func TestBackfillEmptyFunctionResponseNames_MultipleGroups(t *testing.T) {
// Two sequential call/response groups should each get correct names.
input := []byte(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "Read", "args": {}}}
]
},
{
"role": "user",
"parts": [
{"functionResponse": {"name": "", "response": {"result": "content"}}}
]
},
{
"role": "model",
"parts": [
{"functionCall": {"name": "Grep", "args": {}}}
]
},
{
"role": "user",
"parts": [
{"functionResponse": {"name": "", "response": {"result": "match"}}}
]
}
]
}`)
out := backfillEmptyFunctionResponseNames(input)
name0 := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
name1 := gjson.GetBytes(out, "contents.3.parts.0.functionResponse.name").String()
if name0 != "Read" {
t.Errorf("Expected first group name 'Read', got '%s'", name0)
}
if name1 != "Grep" {
t.Errorf("Expected second group name 'Grep', got '%s'", name1)
}
}

View file

@ -0,0 +1,30 @@
package gemini
import (
"bytes"
"context"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
)
// PassthroughGeminiResponseStream forwards Gemini responses unchanged.
func PassthroughGeminiResponseStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) [][]byte {
if bytes.HasPrefix(rawJSON, []byte("data:")) {
rawJSON = bytes.TrimSpace(rawJSON[5:])
}
if bytes.Equal(rawJSON, []byte("[DONE]")) {
return [][]byte{}
}
return [][]byte{rawJSON}
}
// PassthroughGeminiResponseNonStream forwards Gemini responses unchanged.
func PassthroughGeminiResponseNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
return rawJSON
}
func GeminiTokenCount(ctx context.Context, count int64) []byte {
return translatorcommon.GeminiTokenCountJSON(count)
}

View file

@ -0,0 +1,22 @@
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"
)
// Register a no-op response translator and a request normalizer for Gemini→Gemini.
// The request converter ensures missing or invalid roles are normalized to valid values.
func init() {
translator.Register(
Gemini,
Gemini,
ConvertGeminiRequestToGemini,
interfaces.TranslateResponse{
Stream: PassthroughGeminiResponseStream,
NonStream: PassthroughGeminiResponseNonStream,
TokenCount: GeminiTokenCount,
},
)
}

View file

@ -0,0 +1,37 @@
package interactions
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(
Interactions,
Interactions,
ConvertInteractionsRequestToInteractions,
interfaces.TranslateResponse{
Stream: ConvertInteractionsResponsePassthrough,
NonStream: ConvertInteractionsResponsePassthroughNonStream,
},
)
translator.Register(
Interactions,
Gemini,
ConvertInteractionsRequestToGemini,
interfaces.TranslateResponse{
Stream: ConvertGeminiResponseToInteractions,
NonStream: ConvertGeminiResponseToInteractionsNonStream,
},
)
translator.Register(
Gemini,
Interactions,
ConvertGeminiRequestToInteractions,
interfaces.TranslateResponse{
Stream: ConvertInteractionsResponseToGemini,
NonStream: ConvertInteractionsResponseToGeminiNonStream,
},
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,756 @@
package interactions
import (
"bytes"
"context"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertInteractionsRequestToGeminiStringInput(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":"hello"}`), false)
if got := gjson.GetBytes(out, "contents.0.role").String(); got != "user" {
t.Fatalf("role = %q, want user", got)
}
if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hello" {
t.Fatalf("text = %q, want hello", got)
}
}
func TestConvertInteractionsRequestToGeminiSystemAndGenerationConfig(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","system_instruction":{"text":"be brief"},"generation_config":{"max_output_tokens":32,"top_p":0.8},"input":"hi"}`), false)
if got := gjson.GetBytes(out, "systemInstruction.parts.0.text").String(); got != "be brief" {
t.Fatalf("systemInstruction = %q, want be brief", got)
}
if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != 32 {
t.Fatalf("maxOutputTokens = %d, want 32", got)
}
if got := gjson.GetBytes(out, "generationConfig.topP").Float(); got != 0.8 {
t.Fatalf("topP = %v, want 0.8", got)
}
}
func TestConvertInteractionsRequestToGeminiStringSystemInstruction(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","system_instruction":"be brief","input":"hi"}`), false)
if got := gjson.GetBytes(out, "systemInstruction.parts.0.text").String(); got != "be brief" {
t.Fatalf("systemInstruction.parts.0.text = %q, want be brief. Output: %s", got, string(out))
}
}
func TestConvertGeminiRequestToInteractionsStringSystemInstruction(t *testing.T) {
out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","systemInstruction":{"parts":[{"text":"be brief"},{"text":"answer directly"}]},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`), false)
sys := gjson.GetBytes(out, "system_instruction")
if sys.Type != gjson.String {
t.Fatalf("system_instruction type = %v, want string. Output: %s", sys.Type, string(out))
}
if got := sys.String(); got != "be brief\nanswer directly" {
t.Fatalf("system_instruction = %q, want merged text. Output: %s", got, string(out))
}
if gjson.GetBytes(out, "system_instruction.parts").Exists() {
t.Fatalf("system_instruction.parts should not be forwarded. Output: %s", string(out))
}
}
func TestConvertGeminiResponseToInteractionsNonStream(t *testing.T) {
out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2,"totalTokenCount":3}}`))
if got := gjson.GetBytes(out, "steps.0.type").String(); got != "model_output" {
t.Fatalf("step type = %q, want model_output", got)
}
if got := gjson.GetBytes(out, "steps.0.content.0.text").String(); got != "ok" {
t.Fatalf("text = %q, want ok", got)
}
if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 3 {
t.Fatalf("total tokens = %d, want 3", got)
}
}
func TestConvertGeminiResponseToInteractionsNonStreamSnakeCaseUsage(t *testing.T) {
out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_snake","candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usage_metadata":{"prompt_token_count":11,"candidates_token_count":22,"total_token_count":33,"thoughts_token_count":44,"cached_content_token_count":55}}`))
for _, test := range []struct {
path string
want int64
}{
{"usage.input_tokens", 11},
{"usage.output_tokens", 22},
{"usage.reasoning_tokens", 44},
{"usage.total_tokens", 33},
{"usage.cached_tokens", 55},
} {
if got := gjson.GetBytes(out, test.path).Int(); got != test.want {
t.Fatalf("%s = %d, want %d. Output: %s", test.path, got, test.want, string(out))
}
}
}
func TestConvertInteractionsResponseToGeminiStreamFunctionCall(t *testing.T) {
var param any
created := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`data: {"interaction":{"id":"i1","model":"gemini-3.1-flash-lite"},"event_type":"interaction.created"}`), &param)
if len(created) != 0 {
t.Fatalf("created output count = %d, want 0", len(created))
}
start := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`data: {"index":0,"step":{"type":"function_call","id":"call_1","signature":"sig_1","name":"get_weather","arguments":{}},"event_type":"step.start"}`), &param)
if len(start) != 0 {
t.Fatalf("start output count = %d, want 0", len(start))
}
delta := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`data: {"index":0,"delta":{"type":"arguments_delta","arguments":"{\"location\":\"北京\"}"},"event_type":"step.delta"}`), &param)
if len(delta) != 1 {
t.Fatalf("delta output count = %d, want 1", len(delta))
}
if got := gjson.GetBytes(delta[0], "candidates.0.content.parts.0.functionCall.name").String(); got != "get_weather" {
t.Fatalf("functionCall.name = %q, want get_weather. Payload: %s", got, string(delta[0]))
}
if got := gjson.GetBytes(delta[0], "candidates.0.content.parts.0.functionCall.args.location").String(); got != "北京" {
t.Fatalf("functionCall.args.location = %q, want 北京. Payload: %s", got, string(delta[0]))
}
if got := gjson.GetBytes(delta[0], "candidates.0.content.parts.0.functionCall.id").String(); got != "call_1" {
t.Fatalf("functionCall.id = %q, want call_1. Payload: %s", got, string(delta[0]))
}
if got := gjson.GetBytes(delta[0], "candidates.0.content.parts.0.thoughtSignature").String(); got != "sig_1" {
t.Fatalf("thoughtSignature = %q, want sig_1. Payload: %s", got, string(delta[0]))
}
completed := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`data: {"interaction":{"id":"i1","status":"requires_action","usage":{"total_input_tokens":2,"total_output_tokens":3,"total_tokens":5,"total_thought_tokens":1,"total_cached_tokens":4},"service_tier":"standard","model":"gemini-3.1-flash-lite"},"event_type":"interaction.completed"}`), &param)
if len(completed) != 1 {
t.Fatalf("completed output count = %d, want 1", len(completed))
}
if got := gjson.GetBytes(completed[0], "candidates.0.finishReason").String(); got != "STOP" {
t.Fatalf("finishReason = %q, want STOP. Payload: %s", got, string(completed[0]))
}
if got := gjson.GetBytes(completed[0], "usageMetadata.promptTokenCount").Int(); got != 2 {
t.Fatalf("promptTokenCount = %d, want 2. Payload: %s", got, string(completed[0]))
}
if got := gjson.GetBytes(completed[0], "usageMetadata.candidatesTokenCount").Int(); got != 3 {
t.Fatalf("candidatesTokenCount = %d, want 3. Payload: %s", got, string(completed[0]))
}
if got := gjson.GetBytes(completed[0], "usageMetadata.totalTokenCount").Int(); got != 5 {
t.Fatalf("totalTokenCount = %d, want 5. Payload: %s", got, string(completed[0]))
}
if got := gjson.GetBytes(completed[0], "usageMetadata.promptTokensDetails.0.tokenCount").Int(); got != 2 {
t.Fatalf("promptTokensDetails.0.tokenCount = %d, want 2. Payload: %s", got, string(completed[0]))
}
done := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`event: done
data: [DONE]`), &param)
if len(done) != 0 {
t.Fatalf("done output count = %d, want 0", len(done))
}
}
func TestConvertInteractionsResponseToGeminiStreamFinishMetadataUsage(t *testing.T) {
var param any
out := ConvertInteractionsResponseToGemini(context.Background(), "gemini-test", nil, nil, []byte(`data: {"event_type":"finish","metadata":{"total_usage":{"total_input_tokens":2,"total_output_tokens":6,"total_thought_tokens":3,"total_cached_tokens":1,"total_tokens":11}}}`), &param)
if len(out) != 1 {
t.Fatalf("output count = %d, want 1", len(out))
}
if got := gjson.GetBytes(out[0], "candidates.0.finishReason").String(); got != "STOP" {
t.Fatalf("finishReason = %q, want STOP. Payload: %s", got, string(out[0]))
}
if got := gjson.GetBytes(out[0], "usageMetadata.promptTokenCount").Int(); got != 2 {
t.Fatalf("promptTokenCount = %d, want 2. Payload: %s", got, string(out[0]))
}
if got := gjson.GetBytes(out[0], "usageMetadata.candidatesTokenCount").Int(); got != 6 {
t.Fatalf("candidatesTokenCount = %d, want 6. Payload: %s", got, string(out[0]))
}
if got := gjson.GetBytes(out[0], "usageMetadata.thoughtsTokenCount").Int(); got != 3 {
t.Fatalf("thoughtsTokenCount = %d, want 3. Payload: %s", got, string(out[0]))
}
if got := gjson.GetBytes(out[0], "usageMetadata.cachedContentTokenCount").Int(); got != 1 {
t.Fatalf("cachedContentTokenCount = %d, want 1. Payload: %s", got, string(out[0]))
}
if got := gjson.GetBytes(out[0], "usageMetadata.totalTokenCount").Int(); got != 11 {
t.Fatalf("totalTokenCount = %d, want 11. Payload: %s", got, string(out[0]))
}
}
func TestConvertInteractionsResponseToGeminiNonStreamFunctionCall(t *testing.T) {
raw := []byte(`{"id":"i1","model":"gemini-3.1-flash-lite","steps":[{"type":"function_call","call_id":"call_1","signature":"sig_1","name":"get_weather","arguments":{"location":"北京"}}],"usage":{"total_input_tokens":2,"total_output_tokens":3,"total_tokens":5}}`)
out := ConvertInteractionsResponseToGeminiNonStream(context.Background(), "gemini-3.1-flash-lite", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.name").String(); got != "get_weather" {
t.Fatalf("functionCall.name = %q, want get_weather. Payload: %s", got, string(out))
}
if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.args.location").String(); got != "北京" {
t.Fatalf("functionCall.args.location = %q, want 北京. Payload: %s", got, string(out))
}
if got := gjson.GetBytes(out, "candidates.0.content.parts.0.thoughtSignature").String(); got != "sig_1" {
t.Fatalf("thoughtSignature = %q, want sig_1. Payload: %s", got, string(out))
}
if got := gjson.GetBytes(out, "usageMetadata.totalTokenCount").Int(); got != 5 {
t.Fatalf("totalTokenCount = %d, want 5. Payload: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToGeminiTurnInput(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":{"role":"user","steps":[{"type":"user_input","content":[{"text":"hi"}]}]}}`), false)
if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hi" {
t.Fatalf("text = %q, want hi", got)
}
}
func TestConvertInteractionsRequestToGeminiTurnArrayInput(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"role":"user","steps":[{"type":"user_input","content":[{"text":"hi"}]}]},{"role":"assistant","steps":[{"type":"model_output","content":[{"text":"ok"}]}]}]}`), false)
if got := gjson.GetBytes(out, "contents.0.role").String(); got != "user" {
t.Fatalf("contents.0.role = %q, want user. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hi" {
t.Fatalf("contents.0.parts.0.text = %q, want hi. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "contents.1.role").String(); got != "model" {
t.Fatalf("contents.1.role = %q, want model. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "contents.1.parts.0.text").String(); got != "ok" {
t.Fatalf("contents.1.parts.0.text = %q, want ok. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToGeminiPreservesExpressibleTopLevelFields(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","tool_choice":{"type":"function","function":{"name":"lookup"}},"response_modalities":["text","image"],"service_tier":"priority","input":"hi"}`), false)
if got := gjson.GetBytes(out, "toolConfig.functionCallingConfig.mode").String(); got != "ANY" {
t.Fatalf("toolConfig.functionCallingConfig.mode = %q, want ANY. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != "lookup" {
t.Fatalf("allowedFunctionNames.0 = %q, want lookup. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "generationConfig.responseModalities.0").String(); got != "TEXT" {
t.Fatalf("responseModalities.0 = %q, want TEXT. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "generationConfig.responseModalities.1").String(); got != "IMAGE" {
t.Fatalf("responseModalities.1 = %q, want IMAGE. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "service_tier").String(); got != "priority" {
t.Fatalf("service_tier = %q, want priority. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToGeminiContentInput(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":{"role":"user","parts":[{"text":"hi"}]}}`), false)
if got := gjson.GetBytes(out, "contents.0.role").String(); got != "user" {
t.Fatalf("contents.0.role = %q, want user", got)
}
if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hi" {
t.Fatalf("contents.0.parts.0.text = %q, want hi", got)
}
}
func TestConvertInteractionsRequestToGeminiContentArrayInput(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"role":"user","parts":[{"text":"hi"}]},{"role":"assistant","parts":[{"text":"ok"}]}]}`), false)
if got := gjson.GetBytes(out, "contents.0.role").String(); got != "user" {
t.Fatalf("contents.0.role = %q, want user", got)
}
if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hi" {
t.Fatalf("contents.0.parts.0.text = %q, want hi", got)
}
if got := gjson.GetBytes(out, "contents.1.role").String(); got != "model" {
t.Fatalf("contents.1.role = %q, want model", got)
}
if got := gjson.GetBytes(out, "contents.1.parts.0.text").String(); got != "ok" {
t.Fatalf("contents.1.parts.0.text = %q, want ok", got)
}
}
func TestConvertGeminiResponseToInteractionsNonStreamFunctionCall(t *testing.T) {
out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","args":{"q":"x"}}}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2,"totalTokenCount":3,"cachedContentTokenCount":4}}`))
if got := gjson.GetBytes(out, "steps.0.type").String(); got != "function_call" {
t.Fatalf("step type = %q, want function_call", got)
}
if got := gjson.GetBytes(out, "steps.0.name").String(); got != "lookup" {
t.Fatalf("name = %q, want lookup", got)
}
if got := gjson.GetBytes(out, "usage.cached_tokens").Int(); got != 4 {
t.Fatalf("cached tokens = %d, want 4", got)
}
}
func TestConvertGeminiResponseToInteractionsNonStreamFunctionCallPreservesCallID(t *testing.T) {
out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","call_id":"call_response_1","args":{"q":"x"}}}]}}]}`))
if got := gjson.GetBytes(out, "steps.0.call_id").String(); got != "call_response_1" {
t.Fatalf("steps.0.call_id = %q, want call_response_1", got)
}
}
func TestConvertGeminiResponseToInteractionsStreamFunctionCallCallID(t *testing.T) {
var param any
out := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","call_id":"call_stream_1","args":{"q":"x"}}}]}}]}`), &param)
payload := findStepDeltaPayload(out)
if len(payload) == 0 {
t.Fatalf("step.delta payload not found")
}
startPayload := findEventPayload(out, "step.start")
if got := gjson.GetBytes(startPayload, "step.id").String(); got != "call_stream_1" {
t.Fatalf("step.id = %q, want call_stream_1", got)
}
if got := gjson.GetBytes(payload, "delta.arguments").String(); got != `{"q":"x"}` {
t.Fatalf("delta.arguments = %q, want JSON string", got)
}
}
func TestConvertGeminiResponseToInteractionsStreamFunctionCallThoughtSignature(t *testing.T) {
var param any
thoughtOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"thinking","thought":true}]}}]}`), &param)
textOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"I will call the tool."}]}}]}`), &param)
callOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig-call","functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]}}]}`), &param)
out := append(append(thoughtOut, textOut...), callOut...)
signaturePayload := findStepDeltaPayloadByType(out, "thought_signature")
if len(signaturePayload) == 0 {
t.Fatalf("thought_signature step.delta payload not found. Events: %s", eventTypes(out))
}
if got := gjson.GetBytes(signaturePayload, "delta.signature").String(); got != "sig-call" {
t.Fatalf("delta.signature = %q, want sig-call. Payload: %s", got, string(signaturePayload))
}
if got := gjson.GetBytes(signaturePayload, "index").Int(); got != 2 {
t.Fatalf("signature index = %d, want 2. Events: %s", got, eventTypes(out))
}
functionStartPayload := findNthEventPayload(out, "step.start", 3)
if got := gjson.GetBytes(functionStartPayload, "step.type").String(); got != "function_call" {
t.Fatalf("fourth step type = %q, want function_call. Events: %s", got, eventTypes(out))
}
if got := gjson.GetBytes(functionStartPayload, "step.id").String(); got != "call_1" {
t.Fatalf("function call id = %q, want call_1. Payload: %s", got, string(functionStartPayload))
}
argumentsPayload := findStepDeltaPayloadByType(out, "arguments_delta")
if got := gjson.GetBytes(argumentsPayload, "delta.arguments").String(); got != `{"q":"x"}` {
t.Fatalf("delta.arguments = %q, want JSON string. Payload: %s", got, string(argumentsPayload))
}
}
func TestConvertGeminiResponseToInteractionsStreamStepLifecycle(t *testing.T) {
var param any
thoughtOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"thinking","thought":true}]}}]}`), &param)
textOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"answer"}]}}]}`), &param)
callOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":4,"totalTokenCount":7,"thoughtsTokenCount":2}}`), &param)
out := append(append(thoughtOut, textOut...), callOut...)
if got := eventTypes(out); !bytes.Equal(got, []byte("interaction.created,interaction.status_update,step.start,step.delta,step.stop,step.start,step.delta,step.stop,step.start,step.delta,step.stop,interaction.completed")) {
t.Fatalf("event sequence = %s", got)
}
if got := gjson.GetBytes(findNthEventPayload(out, "step.start", 0), "step.type").String(); got != "thought" {
t.Fatalf("first step type = %q, want thought", got)
}
if got := gjson.GetBytes(findNthEventPayload(out, "step.start", 1), "step.type").String(); got != "model_output" {
t.Fatalf("second step type = %q, want model_output", got)
}
if got := gjson.GetBytes(findNthEventPayload(out, "step.start", 2), "step.type").String(); got != "function_call" {
t.Fatalf("third step type = %q, want function_call", got)
}
if got := gjson.GetBytes(findNthEventPayload(out, "step.delta", 0), "delta.type").String(); got != "thought_summary" {
t.Fatalf("thought delta type = %q, want thought_summary", got)
}
if got := gjson.GetBytes(findNthEventPayload(out, "step.delta", 2), "delta.type").String(); got != "arguments_delta" {
t.Fatalf("function delta type = %q, want arguments_delta", got)
}
completed := findCompletedPayload(out)
if got := gjson.GetBytes(completed, "interaction.usage.total_input_tokens").Int(); got != 3 {
t.Fatalf("total_input_tokens = %d, want 3. Payload: %s", got, string(completed))
}
if got := gjson.GetBytes(completed, "interaction.usage.total_output_tokens").Int(); got != 4 {
t.Fatalf("total_output_tokens = %d, want 4. Payload: %s", got, string(completed))
}
if got := gjson.GetBytes(completed, "interaction.usage.total_thought_tokens").Int(); got != 2 {
t.Fatalf("total_thought_tokens = %d, want 2. Payload: %s", got, string(completed))
}
}
func TestConvertGeminiResponseToInteractionsStreamSnakeCaseUsage(t *testing.T) {
var param any
out := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usage_metadata":{"prompt_token_count":11,"candidates_token_count":22,"total_token_count":33,"thoughts_token_count":44,"cached_content_token_count":55}}`), &param)
if got := countEventType(out, "interaction.completed"); got != 1 {
t.Fatalf("interaction.completed count = %d, want 1. Events: %s", got, eventTypes(out))
}
completed := findCompletedPayload(out)
for _, test := range []struct {
path string
want int64
}{
{"interaction.usage.total_input_tokens", 11},
{"interaction.usage.total_output_tokens", 22},
{"interaction.usage.total_thought_tokens", 44},
{"interaction.usage.total_tokens", 33},
{"interaction.usage.total_cached_tokens", 55},
} {
if got := gjson.GetBytes(completed, test.path).Int(); got != test.want {
t.Fatalf("%s = %d, want %d. Payload: %s", test.path, got, test.want, string(completed))
}
}
}
func TestConvertGeminiResponseToInteractionsStreamEmitsTerminalOnce(t *testing.T) {
var param any
finishOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"finishReason":"STOP"}]}`), &param)
usageOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2,"totalTokenCount":3}}`), &param)
doneOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`[DONE]`), &param)
if got := countEventType(finishOut, "step.stop"); got != 0 {
t.Fatalf("finish step.stop count = %d, want 0", got)
}
if got := countEventType(finishOut, "interaction.completed"); got != 0 {
t.Fatalf("finish interaction.completed count = %d, want 0", got)
}
if got := countEventType(usageOut, "step.stop"); got != 0 {
t.Fatalf("usage step.stop count = %d, want 0", got)
}
if got := countEventType(usageOut, "interaction.completed"); got != 1 {
t.Fatalf("usage interaction.completed count = %d, want 1", got)
}
if got := countEventType(doneOut, "interaction.completed"); got != 0 {
t.Fatalf("done interaction.completed count = %d, want 0", got)
}
if got := countEventType(doneOut, "done"); got != 1 {
t.Fatalf("done event count = %d, want 1", got)
}
if payload := findEventPayload(doneOut, "done"); string(payload) != "[DONE]" {
t.Fatalf("done payload = %q, want [DONE]", string(payload))
}
payload := findCompletedPayload(usageOut)
if got := gjson.GetBytes(payload, "interaction.usage.total_tokens").Int(); got != 3 {
t.Fatalf("completed total_tokens = %d, want 3. Payload: %s", got, string(payload))
}
}
func TestConvertGeminiResponseToInteractionsStreamDoesNotCompleteOnNonTerminalUsage(t *testing.T) {
var param any
thoughtOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash-low", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"thought":true,"text":"thinking"}]}}],"usageMetadata":{"promptTokenCount":124,"totalTokenCount":124}}`), &param)
if got := countEventType(thoughtOut, "interaction.completed"); got != 0 {
t.Fatalf("thought interaction.completed count = %d, want 0. Events: %s", got, eventTypes(thoughtOut))
}
if got := countEventType(thoughtOut, "step.stop"); got != 0 {
t.Fatalf("thought step.stop count = %d, want 0. Events: %s", got, eventTypes(thoughtOut))
}
textOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash-low", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"好的,我将为您调用天气查询工具。"}]}}],"usageMetadata":{"promptTokenCount":124,"candidatesTokenCount":17,"totalTokenCount":452,"thoughtsTokenCount":311}}`), &param)
callOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash-low", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"get_weather","args":{"location":"北京"},"id":"nriii75p"}}]}}],"usageMetadata":{"promptTokenCount":124,"candidatesTokenCount":33,"totalTokenCount":468,"thoughtsTokenCount":311}}`), &param)
finishOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash-low", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":124,"candidatesTokenCount":33,"totalTokenCount":468,"thoughtsTokenCount":311}}`), &param)
out := append(append(append(thoughtOut, textOut...), callOut...), finishOut...)
if got := countEventType(out, "interaction.completed"); got != 1 {
t.Fatalf("interaction.completed count = %d, want 1. Events: %s", got, eventTypes(out))
}
if got := eventTypes(out); !bytes.Equal(got, []byte("interaction.created,interaction.status_update,step.start,step.delta,step.stop,step.start,step.delta,step.stop,step.start,step.delta,step.stop,interaction.completed")) {
t.Fatalf("event sequence = %s", got)
}
payload := findCompletedPayload(out)
if got := gjson.GetBytes(payload, "interaction.usage.total_tokens").Int(); got != 468 {
t.Fatalf("completed total_tokens = %d, want 468. Payload: %s", got, string(payload))
}
}
func TestConvertGeminiResponseToInteractionsStreamIgnoresTrafficOnlyUsageMetadata(t *testing.T) {
var param any
out := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"}}`), &param)
if got := countEventType(out, "interaction.completed"); got != 0 {
t.Fatalf("interaction.completed count = %d, want 0. Events: %q", got, out)
}
if got := countEventType(out, "done"); got != 0 {
t.Fatalf("done count = %d, want 0. Events: %q", got, out)
}
}
func TestConvertGeminiResponseToInteractionsStreamCompletesOnDoneWithoutUsage(t *testing.T) {
var param any
finishOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"finishReason":"STOP"}]}`), &param)
doneOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`[DONE]`), &param)
if got := countEventType(finishOut, "interaction.completed"); got != 0 {
t.Fatalf("finish interaction.completed count = %d, want 0", got)
}
if got := countEventType(doneOut, "interaction.completed"); got != 1 {
t.Fatalf("done interaction.completed count = %d, want 1", got)
}
if got := countEventType(doneOut, "done"); got != 1 {
t.Fatalf("done event count = %d, want 1", got)
}
}
func TestConvertInteractionsRequestToGeminiImageContent(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"user_input","content":[{"type":"image","mime_type":"image/png","data":"aGVsbG8="}]}]}`), false)
if got := gjson.GetBytes(out, "contents.0.parts.0.inlineData.mimeType").String(); got != "image/png" {
t.Fatalf("mimeType = %q, want image/png", got)
}
if got := gjson.GetBytes(out, "contents.0.parts.0.inlineData.data").String(); got != "aGVsbG8=" {
t.Fatalf("data = %q, want aGVsbG8=", got)
}
}
func TestConvertInteractionsRequestToGeminiModelOutputTypedContent(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"model_output","content":[{"type":"image","mime_type":"image/png","data":"aGVsbG8="},{"type":"document","mime_type":"application/pdf","file_uri":"gs://bucket/doc.pdf"}]}]}`), false)
if got := gjson.GetBytes(out, "contents.0.role").String(); got != "model" {
t.Fatalf("contents.0.role = %q, want model. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "contents.0.parts.0.inlineData.mimeType").String(); got != "image/png" {
t.Fatalf("image mimeType = %q, want image/png. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "contents.0.parts.0.inlineData.data").String(); got != "aGVsbG8=" {
t.Fatalf("image data = %q, want aGVsbG8=. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "contents.0.parts.1.fileData.mimeType").String(); got != "application/pdf" {
t.Fatalf("document mimeType = %q, want application/pdf. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "contents.0.parts.1.fileData.fileUri").String(); got != "gs://bucket/doc.pdf" {
t.Fatalf("document fileUri = %q, want gs://bucket/doc.pdf. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToGeminiThoughtTypedContent(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"thought","content":[{"type":"text","text":"thinking"},{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="}]}]}`), false)
if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "thinking" {
t.Fatalf("thought text = %q, want thinking. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "contents.0.parts.0.thought").Bool(); !got {
t.Fatalf("thought flag = false, want true. Output: %s", string(out))
}
if got := gjson.GetBytes(out, "contents.0.parts.1.inlineData.mimeType").String(); got != "audio/wav" {
t.Fatalf("audio mimeType = %q, want audio/wav. Output: %s", got, string(out))
}
}
func TestConvertGeminiResponseToInteractionsNonStreamImage(t *testing.T) {
out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}}]}`))
if got := gjson.GetBytes(out, "steps.0.content.0.type").String(); got != "image" {
t.Fatalf("content type = %q, want image", got)
}
}
func TestConvertInteractionsRequestToGeminiGenerationConfigAllFields(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","generation_config":{"max_output_tokens":32,"response_schema":{"type":"object"},"seed":42,"thinking_config":{"thinking_budget":1024,"include_thoughts":true},"context_window_compression":{"trigger_tokens":1000}},"input":"hi"}`), false)
if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != 32 {
t.Fatalf("maxOutputTokens = %d, want 32", got)
}
if got := gjson.GetBytes(out, "generationConfig.responseSchema.type").String(); got != "object" {
t.Fatalf("responseSchema.type = %q, want object", got)
}
if got := gjson.GetBytes(out, "generationConfig.seed").Int(); got != 42 {
t.Fatalf("seed = %d, want 42", got)
}
if got := gjson.GetBytes(out, "generationConfig.thinkingConfig.thinkingBudget").Int(); got != 1024 {
t.Fatalf("thinkingBudget = %d, want 1024", got)
}
if got := gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts").Bool(); !got {
t.Fatalf("includeThoughts = false, want true")
}
if got := gjson.GetBytes(out, "generationConfig.contextWindowCompression.triggerTokens").Int(); got != 1000 {
t.Fatalf("triggerTokens = %d, want 1000", got)
}
}
func TestConvertInteractionsRequestToGeminiGenerationConfigProtocolFields(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","generation_config":{"tool_choice":"auto","thinking_level":"high","thinking_summaries":"auto"},"stream":true,"input":"hi"}`), true)
for _, path := range []string{
"stream",
"generationConfig.toolChoice",
"generationConfig.thinkingLevel",
"generationConfig.thinkingSummaries",
} {
if gjson.GetBytes(out, path).Exists() {
t.Fatalf("%s exists, want omitted. Output: %s", path, string(out))
}
}
if got := gjson.GetBytes(out, "toolConfig.functionCallingConfig.mode").String(); got != "AUTO" {
t.Fatalf("toolConfig.functionCallingConfig.mode = %q, want AUTO. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" {
t.Fatalf("thinkingLevel = %q, want high. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts").Bool(); !got {
t.Fatalf("includeThoughts = false, want true. Output: %s", string(out))
}
}
func TestConvertGeminiRequestToInteractionsFunctionCall(t *testing.T) {
out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","args":{"q":"x"}}}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","response":{"ok":true}}}]}]}`), false)
if got := gjson.GetBytes(out, "input.0.type").String(); got != "function_call" {
t.Fatalf("input.0.type = %q, want function_call", got)
}
if got := gjson.GetBytes(out, "input.0.name").String(); got != "lookup" {
t.Fatalf("input.0.name = %q, want lookup", got)
}
if got := gjson.GetBytes(out, "input.1.type").String(); got != "function_result" {
t.Fatalf("input.1.type = %q, want function_result", got)
}
}
func TestConvertGeminiRequestToInteractionsTextContentType(t *testing.T) {
out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`), false)
if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "text" {
t.Fatalf("content.0.type = %q, want text. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hi" {
t.Fatalf("content.0.text = %q, want hi. Output: %s", got, string(out))
}
}
func TestConvertGeminiRequestToInteractionsMultimodal(t *testing.T) {
out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"audio/wav","data":"aGVsbG8="}}]}]}`), false)
if got := gjson.GetBytes(out, "input.0.type").String(); got != "user_input" {
t.Fatalf("input.0.type = %q, want user_input", got)
}
if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "audio" {
t.Fatalf("content.0.type = %q, want audio", got)
}
if got := gjson.GetBytes(out, "input.0.content.0.mime_type").String(); got != "audio/wav" {
t.Fatalf("mime_type = %q, want audio/wav", got)
}
}
func TestConvertGeminiRequestToInteractionsThought(t *testing.T) {
out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"model","parts":[{"text":"thinking","thought":true}]}]}`), false)
if got := gjson.GetBytes(out, "input.0.type").String(); got != "thought" {
t.Fatalf("input.0.type = %q, want thought", got)
}
}
func TestConvertInteractionsRequestToGeminiTurnWithModelRole(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":{"role":"model","steps":[{"type":"user_input","content":[{"text":"hi"}]},{"type":"model_output","content":[{"text":"ok"}]}]}}`), false)
if got := gjson.GetBytes(out, "contents.0.role").String(); got != "model" {
t.Fatalf("contents.0.role = %q, want model", got)
}
if got := gjson.GetBytes(out, "contents.1.role").String(); got != "model" {
t.Fatalf("contents.1.role = %q, want model", got)
}
}
func TestConvertInteractionsRequestToGeminiGenerationConfigPreservesLargeIntegers(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","generation_config":{"max_output_tokens":32,"large_identity":9223372036854775807},"input":"hi"}`), false)
if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != 32 {
t.Fatalf("maxOutputTokens = %d, want 32", got)
}
if got := gjson.GetBytes(out, "generationConfig.largeIdentity").String(); got != "9223372036854775807" {
t.Fatalf("largeIdentity = %q, want 9223372036854775807", got)
}
}
func TestConvertInteractionsRequestToGeminiFunctionCallPreservesCallID(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}}]}`), false)
if got := gjson.GetBytes(out, "contents.0.parts.0.functionCall.id").String(); got != "call_1" {
t.Fatalf("functionCall.id = %q, want call_1", got)
}
if got := gjson.GetBytes(out, "contents.0.parts.0.functionCall.name").String(); got != "lookup" {
t.Fatalf("functionCall.name = %q, want lookup", got)
}
if got := gjson.GetBytes(out, "contents.0.parts.0.functionCall.args.q").String(); got != "x" {
t.Fatalf("functionCall.args.q = %q, want x", got)
}
}
func TestConvertInteractionsRequestToGeminiFunctionResultPreservesCallID(t *testing.T) {
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}]}`), false)
if got := gjson.GetBytes(out, "contents.0.parts.0.functionResponse.id").String(); got != "call_1" {
t.Fatalf("functionResponse.id = %q, want call_1", got)
}
if got := gjson.GetBytes(out, "contents.0.parts.0.functionResponse.name").String(); got != "lookup" {
t.Fatalf("functionResponse.name = %q, want lookup", got)
}
}
func TestConvertGeminiRequestToInteractionsFunctionCallPreservesID(t *testing.T) {
out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","id":"call_1","response":{"ok":true}}}]}]}`), false)
if got := gjson.GetBytes(out, "input.0.call_id").String(); got != "call_1" {
t.Fatalf("input.0.call_id = %q, want call_1", got)
}
if got := gjson.GetBytes(out, "input.1.call_id").String(); got != "call_1" {
t.Fatalf("input.1.call_id = %q, want call_1", got)
}
}
func TestConvertGeminiRequestToInteractionsFunctionCallPreservesCallID(t *testing.T) {
out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","call_id":"call_request_1","args":{"q":"x"}}}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","call_id":"call_request_1","response":{"ok":true}}}]}]}`), false)
if got := gjson.GetBytes(out, "input.0.call_id").String(); got != "call_request_1" {
t.Fatalf("input.0.call_id = %q, want call_request_1", got)
}
if got := gjson.GetBytes(out, "input.1.call_id").String(); got != "call_request_1" {
t.Fatalf("input.1.call_id = %q, want call_request_1", got)
}
}
func TestConvertGeminiRequestToInteractionsGenerationConfig(t *testing.T) {
out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","generationConfig":{"maxOutputTokens":32,"topP":0.8,"thinkingConfig":{"thinkingBudget":1024}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`), false)
if got := gjson.GetBytes(out, "generation_config.max_output_tokens").Int(); got != 32 {
t.Fatalf("max_output_tokens = %d, want 32", got)
}
if got := gjson.GetBytes(out, "generation_config.top_p").Float(); got != 0.8 {
t.Fatalf("top_p = %v, want 0.8", got)
}
if got := gjson.GetBytes(out, "generation_config.thinking_config.thinking_budget").Int(); got != 1024 {
t.Fatalf("thinking_budget = %d, want 1024", got)
}
}
func findStepDeltaPayload(events [][]byte) []byte {
return findEventPayload(events, "step.delta")
}
func findStepDeltaPayloadByType(events [][]byte, deltaType string) []byte {
for _, event := range events {
payload := ssePayload(event)
if eventName(event, payload) == "step.delta" && gjson.GetBytes(payload, "delta.type").String() == deltaType {
return payload
}
}
return nil
}
func findCompletedPayload(events [][]byte) []byte {
return findEventPayload(events, "interaction.completed")
}
func findEventPayload(events [][]byte, eventType string) []byte {
return findNthEventPayload(events, eventType, 0)
}
func findNthEventPayload(events [][]byte, eventType string, n int) []byte {
for _, event := range events {
payload := ssePayload(event)
if eventName(event, payload) == eventType {
if n == 0 {
return payload
}
n--
}
}
return nil
}
func eventTypes(events [][]byte) []byte {
var out []byte
for _, event := range events {
payload := ssePayload(event)
eventType := eventName(event, payload)
if eventType == "" {
continue
}
if len(out) > 0 {
out = append(out, ',')
}
out = append(out, eventType...)
}
return out
}
func countEventType(events [][]byte, eventType string) int {
count := 0
for _, event := range events {
payload := ssePayload(event)
if eventName(event, payload) == eventType {
count++
}
}
return count
}
func eventName(event, payload []byte) string {
if eventType := gjson.GetBytes(payload, "event_type").String(); eventType != "" {
return eventType
}
const prefix = "event: "
lineEnd := bytes.IndexByte(event, '\n')
if lineEnd < 0 || !bytes.HasPrefix(event, []byte(prefix)) {
return ""
}
return string(event[len(prefix):lineEnd])
}
func ssePayload(event []byte) []byte {
const prefix = "\ndata: "
idx := bytes.Index(event, []byte(prefix))
if idx < 0 {
return nil
}
return event[idx+len(prefix):]
}

View file

@ -0,0 +1,20 @@
package interactions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertInteractionsRequestToGeminiNormalizesOpenAIFileDataURL(t *testing.T) {
input := []byte(`{"model":"gemini-3.5-flash","input":[{"type":"user_input","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", input, false)
inlineData := gjson.GetBytes(out, "contents.0.parts.0.inlineData")
if got := inlineData.Get("mimeType").String(); got != "application/pdf" {
t.Fatalf("inlineData.mimeType = %q, want application/pdf. Output: %s", got, out)
}
if got := inlineData.Get("data").String(); got != "JVBERi0xLjQK" {
t.Fatalf("inlineData.data = %q, want raw base64 payload. Output: %s", got, out)
}
}

View file

@ -0,0 +1,367 @@
package interactions
import (
"bytes"
"context"
"fmt"
"strings"
"time"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type interactionsToGeminiStreamState struct {
ID string
Model string
ServiceTier string
StepNames map[int]string
StepIDs map[int]string
StepSignatures map[int]string
}
func ConvertGeminiResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
return ConvertGeminiResponseToInteractionsStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)
}
func ConvertGeminiResponseToInteractionsNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
return convertGeminiResponseToInteractionsNonStreamDirect(modelName, originalRequestRawJSON, requestRawJSON, rawJSON)
}
func ConvertInteractionsResponseToGemini(_ context.Context, modelName string, _, _, rawJSON []byte, param *any) [][]byte {
if param == nil {
var local any
param = &local
}
if *param == nil {
*param = &interactionsToGeminiStreamState{Model: modelName}
}
st := (*param).(*interactionsToGeminiStreamState)
st.ensureMaps()
return convertInteractionsEventToGemini(modelName, rawJSON, st)
}
func ConvertInteractionsResponseToGeminiNonStream(_ context.Context, modelName string, _, _, rawJSON []byte, _ *any) []byte {
root := gjson.ParseBytes(rawJSON)
interaction := root
if nested := root.Get("interaction"); nested.Exists() {
interaction = nested
}
st := &interactionsToGeminiStreamState{
ID: firstNonEmptyInteractionString(interaction.Get("id").String(), root.Get("id").String(), fmt.Sprintf("response_%d", time.Now().UnixNano())),
Model: firstNonEmptyInteractionString(interaction.Get("model").String(), root.Get("model").String(), modelName),
ServiceTier: firstNonEmptyInteractionString(interaction.Get("service_tier").String(), root.Get("service_tier").String()),
}
var parts [][]byte
steps := interaction.Get("steps")
if !steps.Exists() {
steps = root.Get("steps")
}
steps.ForEach(func(_, step gjson.Result) bool {
parts = append(parts, interactionsStepToGeminiParts(step)...)
return true
})
out := buildInteractionsGeminiChunk(st, modelName, parts, "STOP", translatorcommon.InteractionsUsage(root), true)
return out
}
func ConvertInteractionsRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) []byte {
_ = modelName
_ = stream
return inputRawJSON
}
func ConvertInteractionsResponsePassthrough(_ context.Context, _ string, _, _, rawJSON []byte, _ *any) [][]byte {
if len(rawJSON) == 0 {
return nil
}
return [][]byte{rawJSON}
}
func ConvertInteractionsResponsePassthroughNonStream(_ context.Context, _ string, _, _, rawJSON []byte, _ *any) []byte {
return rawJSON
}
func convertInteractionsEventToGemini(modelName string, rawJSON []byte, st *interactionsToGeminiStreamState) [][]byte {
payload := interactionsGeminiSSEPayload(rawJSON)
if len(payload) == 0 {
return nil
}
root := gjson.ParseBytes(payload)
if !root.Exists() {
return nil
}
switch root.Get("event_type").String() {
case "interaction.created":
interaction := root.Get("interaction")
st.ID = firstNonEmptyInteractionString(st.ID, interaction.Get("id").String())
st.Model = firstNonEmptyInteractionString(st.Model, interaction.Get("model").String(), modelName)
case "step.start":
rememberInteractionsGeminiStep(root, st)
case "step.delta":
if chunk := interactionsStepDeltaToGeminiChunk(modelName, root, st); len(chunk) > 0 {
return [][]byte{chunk}
}
case "interaction.completed", "finish":
interaction := root.Get("interaction")
st.ID = firstNonEmptyInteractionString(st.ID, interaction.Get("id").String())
st.Model = firstNonEmptyInteractionString(st.Model, interaction.Get("model").String(), modelName)
st.ServiceTier = firstNonEmptyInteractionString(st.ServiceTier, interaction.Get("service_tier").String())
chunk := buildInteractionsGeminiChunk(st, modelName, nil, "STOP", translatorcommon.InteractionsUsage(root), true)
return [][]byte{chunk}
}
return nil
}
func rememberInteractionsGeminiStep(root gjson.Result, st *interactionsToGeminiStreamState) {
index := int(root.Get("index").Int())
step := root.Get("step")
st.StepNames[index] = step.Get("name").String()
st.StepIDs[index] = firstNonEmptyInteractionString(step.Get("call_id").String(), step.Get("id").String())
st.StepSignatures[index] = firstNonEmptyInteractionString(step.Get("signature").String(), step.Get("thoughtSignature").String(), step.Get("thought_signature").String())
}
func interactionsStepDeltaToGeminiChunk(modelName string, root gjson.Result, st *interactionsToGeminiStreamState) []byte {
index := int(root.Get("index").Int())
delta := root.Get("delta")
switch delta.Get("type").String() {
case "arguments_delta":
part := []byte(`{"functionCall":{"name":"","args":{}}}`)
part, _ = sjson.SetBytes(part, "functionCall.name", firstNonEmptyInteractionString(st.StepNames[index], root.Get("step.name").String()))
if id := st.StepIDs[index]; id != "" {
part, _ = sjson.SetBytes(part, "functionCall.id", id)
}
if signature := st.StepSignatures[index]; signature != "" {
part, _ = sjson.SetBytes(part, "thoughtSignature", signature)
}
arguments := strings.TrimSpace(delta.Get("arguments").String())
if arguments != "" && gjson.Valid(arguments) {
part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(arguments))
}
return buildInteractionsGeminiChunk(st, modelName, [][]byte{part}, "", gjson.Result{}, false)
case "text":
text := firstNonEmptyInteractionString(delta.Get("text").String(), delta.Get("content.text").String())
if text == "" {
return nil
}
return buildInteractionsGeminiChunk(st, modelName, [][]byte{geminiTextPartJSON(text, false)}, "", gjson.Result{}, false)
case "thought_summary":
text := firstNonEmptyInteractionString(delta.Get("content.text").String(), delta.Get("text").String())
if text == "" {
return nil
}
return buildInteractionsGeminiChunk(st, modelName, [][]byte{geminiTextPartJSON(text, true)}, "", gjson.Result{}, false)
case "thought_signature":
signature := firstNonEmptyInteractionString(delta.Get("signature").String(), delta.Get("thought_signature").String(), delta.Get("thoughtSignature").String())
if signature == "" {
return nil
}
st.StepSignatures[index] = signature
part := geminiTextPartJSON("", true)
part, _ = sjson.SetBytes(part, "thoughtSignature", signature)
return buildInteractionsGeminiChunk(st, modelName, [][]byte{part}, "", gjson.Result{}, false)
}
return nil
}
func interactionsStepToGeminiParts(step gjson.Result) [][]byte {
switch step.Get("type").String() {
case "function_call":
return [][]byte{interactionsFunctionCallStepToGeminiPart(step)}
case "function_result":
return [][]byte{interactionsFunctionResponseStepToGeminiPart(step)}
case "thought":
return interactionsContentToGeminiParts(step.Get("content"), true)
default:
return interactionsContentToGeminiParts(step.Get("content"), false)
}
}
func interactionsContentToGeminiParts(content gjson.Result, thought bool) [][]byte {
var parts [][]byte
if !content.Exists() {
return parts
}
if content.Type == gjson.String {
return [][]byte{geminiTextPartJSON(content.String(), thought)}
}
if content.IsObject() {
if part := interactionsContentPartToGeminiPart(content, thought); len(part) > 0 {
parts = append(parts, part)
}
return parts
}
if content.IsArray() {
content.ForEach(func(_, item gjson.Result) bool {
if part := interactionsContentPartToGeminiPart(item, thought); len(part) > 0 {
parts = append(parts, part)
}
return true
})
}
return parts
}
func interactionsFunctionCallStepToGeminiPart(step gjson.Result) []byte {
part := []byte(`{"functionCall":{"name":"","args":{}}}`)
part, _ = sjson.SetBytes(part, "functionCall.name", step.Get("name").String())
if id := firstNonEmptyInteractionString(step.Get("call_id").String(), step.Get("id").String()); id != "" {
part, _ = sjson.SetBytes(part, "functionCall.id", id)
}
if signature := firstNonEmptyInteractionString(step.Get("signature").String(), step.Get("thoughtSignature").String(), step.Get("thought_signature").String()); signature != "" {
part, _ = sjson.SetBytes(part, "thoughtSignature", signature)
}
part = setInteractionsGeminiRawObject(part, "functionCall.args", firstExistingInteractionResult(step, "arguments", "args"))
return part
}
func interactionsFunctionResponseStepToGeminiPart(step gjson.Result) []byte {
part := []byte(`{"functionResponse":{"name":"","response":{}}}`)
part, _ = sjson.SetBytes(part, "functionResponse.name", step.Get("name").String())
if id := firstNonEmptyInteractionString(step.Get("call_id").String(), step.Get("id").String()); id != "" {
part, _ = sjson.SetBytes(part, "functionResponse.id", id)
}
part = setInteractionsGeminiRawObject(part, "functionResponse.response", firstExistingInteractionResult(step, "result", "response"))
return part
}
func buildInteractionsGeminiChunk(st *interactionsToGeminiStreamState, modelName string, parts [][]byte, finishReason string, usage gjson.Result, includeEmptyPart bool) []byte {
out := []byte(`{"candidates":[{"content":{"parts":[],"role":"model"},"index":0}]}`)
if len(parts) == 0 && includeEmptyPart {
parts = append(parts, geminiTextPartJSON("", false))
}
validParts := make([][]byte, 0, len(parts))
for _, part := range parts {
if len(part) > 0 {
validParts = append(validParts, part)
}
}
if len(validParts) > 0 {
out = translatorcommon.SetRawArrayItems(out, "candidates.0.content.parts", validParts)
}
if finishReason != "" {
out, _ = sjson.SetBytes(out, "candidates.0.finishReason", finishReason)
}
if model := firstNonEmptyInteractionString(st.Model, modelName); model != "" {
out, _ = sjson.SetBytes(out, "modelVersion", model)
}
if id := st.ID; id != "" {
out, _ = sjson.SetBytes(out, "responseId", id)
}
if st.ServiceTier != "" {
out, _ = sjson.SetBytes(out, "usageMetadata.serviceTier", st.ServiceTier)
}
return setGeminiUsageMetadataFromInteractionsUsage(out, usage)
}
func setGeminiUsageMetadataFromInteractionsUsage(out []byte, usage gjson.Result) []byte {
if !usage.Exists() {
return out
}
inputTokens, hasInputTokens := interactionsUsageInt(usage, "input_tokens", "total_input_tokens")
outputTokens, hasOutputTokens := interactionsUsageInt(usage, "output_tokens", "total_output_tokens")
totalTokens, hasTotalTokens := interactionsUsageInt(usage, "total_tokens")
if hasInputTokens {
out, _ = sjson.SetBytes(out, "usageMetadata.promptTokenCount", inputTokens)
out, _ = sjson.SetRawBytes(out, "usageMetadata.promptTokensDetails", []byte(fmt.Sprintf(`[{"modality":"TEXT","tokenCount":%d}]`, inputTokens)))
}
if hasOutputTokens {
out, _ = sjson.SetBytes(out, "usageMetadata.candidatesTokenCount", outputTokens)
}
if hasTotalTokens {
out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", totalTokens)
} else if hasInputTokens || hasOutputTokens {
out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", inputTokens+outputTokens)
}
if thoughtTokens, ok := interactionsUsageInt(usage, "reasoning_tokens", "total_thought_tokens"); ok {
out, _ = sjson.SetBytes(out, "usageMetadata.thoughtsTokenCount", thoughtTokens)
}
if cachedTokens, ok := interactionsUsageInt(usage, "cached_tokens", "total_cached_tokens"); ok {
out, _ = sjson.SetBytes(out, "usageMetadata.cachedContentTokenCount", cachedTokens)
}
return out
}
func interactionsGeminiSSEPayload(rawJSON []byte) []byte {
trimmed := bytes.TrimSpace(rawJSON)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("[DONE]")) {
return nil
}
if bytes.HasPrefix(trimmed, []byte("{")) {
return trimmed
}
var payload []byte
for _, line := range bytes.Split(trimmed, []byte{'\n'}) {
line = bytes.TrimSpace(bytes.TrimRight(line, "\r"))
if !bytes.HasPrefix(line, []byte("data:")) {
continue
}
data := bytes.TrimSpace(line[len("data:"):])
if len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) {
continue
}
if len(payload) > 0 {
payload = append(payload, '\n')
}
payload = append(payload, data...)
}
return payload
}
func interactionsUsageInt(usage gjson.Result, paths ...string) (int64, bool) {
for _, path := range paths {
if value := usage.Get(path); value.Exists() {
return value.Int(), true
}
}
return 0, false
}
func firstExistingInteractionResult(root gjson.Result, paths ...string) gjson.Result {
for _, path := range paths {
if value := root.Get(path); value.Exists() {
return value
}
}
return gjson.Result{}
}
func setInteractionsGeminiRawObject(out []byte, path string, value gjson.Result) []byte {
if !value.Exists() {
out, _ = sjson.SetRawBytes(out, path, []byte(`{}`))
return out
}
if value.Type == gjson.String {
raw := strings.TrimSpace(value.String())
if raw != "" && gjson.Valid(raw) {
out, _ = sjson.SetRawBytes(out, path, []byte(raw))
return out
}
}
if value.Raw != "" {
out, _ = sjson.SetRawBytes(out, path, []byte(value.Raw))
}
return out
}
func firstNonEmptyInteractionString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func (st *interactionsToGeminiStreamState) ensureMaps() {
if st.StepNames == nil {
st.StepNames = make(map[int]string)
}
if st.StepIDs == nil {
st.StepIDs = make(map[int]string)
}
if st.StepSignatures == nil {
st.StepSignatures = make(map[int]string)
}
}

View file

@ -0,0 +1,20 @@
package chat_completions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIRequestToGeminiNormalizesFileDataURL(t *testing.T) {
input := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
out := ConvertOpenAIRequestToGemini("gemini-2.5-pro", input, false)
inlineData := gjson.GetBytes(out, "contents.0.parts.0.inlineData")
if got := inlineData.Get("mime_type").String(); got != "application/pdf" {
t.Fatalf("inlineData.mime_type = %q, want application/pdf. Output: %s", got, out)
}
if got := inlineData.Get("data").String(); got != "JVBERi0xLjQK" {
t.Fatalf("inlineData.data = %q, want raw base64 payload. Output: %s", got, out)
}
}

View file

@ -0,0 +1,502 @@
// Package openai provides request translation functionality for OpenAI to Gemini API compatibility.
// It converts OpenAI Chat Completions requests into Gemini compatible JSON using gjson/sjson only.
package chat_completions
import (
"strings"
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const geminiFunctionThoughtSignature = "skip_thought_signature_validator"
// ConvertOpenAIRequestToGemini converts an OpenAI Chat Completions request (raw JSON)
// into a complete Gemini request JSON. All JSON construction uses sjson and lookups use gjson.
//
// Parameters:
// - modelName: The name of the model to use for the request
// - rawJSON: The raw JSON request data from the OpenAI API
// - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)
//
// Returns:
// - []byte: The transformed request data in Gemini API format
func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := inputRawJSON
// Base envelope (no default thinkingConfig)
out := []byte(`{"contents":[]}`)
// Model
out, _ = sjson.SetBytes(out, "model", modelName)
// Let user-provided generationConfig pass through
if genConfig := gjson.GetBytes(rawJSON, "generationConfig"); genConfig.Exists() {
out, _ = sjson.SetRawBytes(out, "generationConfig", []byte(genConfig.Raw))
}
// Apply thinking configuration: convert OpenAI reasoning_effort to Gemini thinkingConfig.
// Inline translation-only mapping; capability checks happen later in ApplyThinking.
re := gjson.GetBytes(rawJSON, "reasoning_effort")
if re.Exists() {
effort := strings.ToLower(strings.TrimSpace(re.String()))
if effort != "" {
thinkingPath := "generationConfig.thinkingConfig"
if effort == "auto" {
out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1)
} else {
out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort)
}
}
}
// Temperature/top_p/top_k
if tr := gjson.GetBytes(rawJSON, "temperature"); tr.Exists() && tr.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "generationConfig.temperature", tr.Num)
}
if tpr := gjson.GetBytes(rawJSON, "top_p"); tpr.Exists() && tpr.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "generationConfig.topP", tpr.Num)
}
if tkr := gjson.GetBytes(rawJSON, "top_k"); tkr.Exists() && tkr.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "generationConfig.topK", tkr.Num)
}
// OpenAI max_tokens / max_completion_tokens -> Gemini generationConfig.maxOutputTokens
if mt := gjson.GetBytes(rawJSON, "max_tokens"); mt.Exists() && mt.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "generationConfig.maxOutputTokens", mt.Num)
} else if mct := gjson.GetBytes(rawJSON, "max_completion_tokens"); mct.Exists() && mct.Type == gjson.Number {
out, _ = sjson.SetBytes(out, "generationConfig.maxOutputTokens", mct.Num)
}
// Candidate count (OpenAI 'n' parameter)
if n := gjson.GetBytes(rawJSON, "n"); n.Exists() && n.Type == gjson.Number {
if val := n.Int(); val > 1 {
out, _ = sjson.SetBytes(out, "generationConfig.candidateCount", val)
}
}
// Map OpenAI response_format to Gemini structured output settings.
out = applyOpenAIResponseFormatToGemini(out, rawJSON)
// Map OpenAI modalities -> Gemini generationConfig.responseModalities
// e.g. "modalities": ["image", "text"] -> ["IMAGE", "TEXT"]
if mods := gjson.GetBytes(rawJSON, "modalities"); mods.Exists() && mods.IsArray() {
var responseMods []string
for _, m := range mods.Array() {
switch strings.ToLower(m.String()) {
case "text":
responseMods = append(responseMods, "TEXT")
case "image":
responseMods = append(responseMods, "IMAGE")
}
}
if len(responseMods) > 0 {
out, _ = sjson.SetBytes(out, "generationConfig.responseModalities", responseMods)
}
}
// OpenRouter-style image_config support
// If the input uses top-level image_config.aspect_ratio, map it into generationConfig.imageConfig.aspectRatio.
if imgCfg := gjson.GetBytes(rawJSON, "image_config"); imgCfg.Exists() && imgCfg.IsObject() {
if ar := imgCfg.Get("aspect_ratio"); ar.Exists() && ar.Type == gjson.String {
out, _ = sjson.SetBytes(out, "generationConfig.imageConfig.aspectRatio", ar.Str)
}
if size := imgCfg.Get("image_size"); size.Exists() && size.Type == gjson.String {
out, _ = sjson.SetBytes(out, "generationConfig.imageConfig.imageSize", size.Str)
}
}
// messages -> systemInstruction + contents
messages := gjson.GetBytes(rawJSON, "messages")
if messages.IsArray() {
arr := messages.Array()
systemParts := make([][]byte, 0, 2)
contentItems := make([][]byte, 0, len(arr))
// First pass: assistant tool_calls id->name map
tcID2Name := map[string]string{}
for i := 0; i < len(arr); i++ {
m := arr[i]
if m.Get("role").String() == "assistant" {
tcs := m.Get("tool_calls")
if tcs.IsArray() {
for _, tc := range tcs.Array() {
if tc.Get("type").String() == "function" {
id := tc.Get("id").String()
name := tc.Get("function.name").String()
if id != "" && name != "" {
tcID2Name[id] = name
}
}
}
}
}
}
// Second pass build systemInstruction/tool responses cache
toolResponses := map[string]string{} // tool_call_id -> response text
for i := 0; i < len(arr); i++ {
m := arr[i]
role := m.Get("role").String()
if role == "tool" {
toolCallID := m.Get("tool_call_id").String()
if toolCallID != "" {
c := m.Get("content")
toolResponses[toolCallID] = c.Raw
}
}
}
for i := 0; i < len(arr); i++ {
m := arr[i]
role := m.Get("role").String()
content := m.Get("content")
if (role == "system" || role == "developer") && len(arr) > 1 {
// system -> systemInstruction as a user message style
if content.Type == gjson.String {
systemParts = append(systemParts, geminiTextPart(content.String()))
} else if content.IsObject() && content.Get("type").String() == "text" {
systemParts = append(systemParts, geminiTextPart(content.Get("text").String()))
} else if content.IsArray() {
contents := content.Array()
for j := 0; j < len(contents); j++ {
systemParts = append(systemParts, geminiTextPart(contents[j].Get("text").String()))
}
}
} else if role == "user" || ((role == "system" || role == "developer") && len(arr) == 1) {
// Build single user content node to avoid splitting into multiple contents.
partItems := make([][]byte, 0, 4)
if content.Type == gjson.String {
partItems = append(partItems, geminiTextPart(content.String()))
} else if content.IsArray() {
for _, item := range content.Array() {
switch item.Get("type").String() {
case "text":
if text := item.Get("text").String(); text != "" {
partItems = append(partItems, geminiTextPart(text))
}
case "image_url":
imageURL := item.Get("image_url.url").String()
if len(imageURL) > 5 {
pieces := strings.SplitN(imageURL[5:], ";", 2)
if len(pieces) == 2 && len(pieces[1]) > 7 {
partItems = append(partItems, geminiInlineDataPart(pieces[0], pieces[1][7:], geminiFunctionThoughtSignature))
}
}
case "video_url":
videoURL := item.Get("video_url.url").String()
if len(videoURL) > 5 {
pieces := strings.SplitN(videoURL[5:], ";", 2)
if len(pieces) == 2 && len(pieces[1]) > 7 {
partItems = append(partItems, geminiInlineDataPart(pieces[0], pieces[1][7:], ""))
}
}
case "file":
filename := item.Get("file.filename").String()
fileData := item.Get("file.file_data").String()
if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, "", fileData); ok {
partItems = append(partItems, geminiInlineDataPart(mimeType, data, ""))
} else {
log.Warn("Invalid file data or unknown file name extension in user message, skip")
}
case "input_audio":
audioData := item.Get("input_audio.data").String()
if audioData != "" {
mimeType := openAIInputAudioMimeType(item.Get("input_audio.format").String())
partItems = append(partItems, geminiInlineDataPart(mimeType, audioData, ""))
}
}
}
}
contentItems = append(contentItems, geminiContentNode("user", partItems))
} else if role == "assistant" {
partItems := make([][]byte, 0, 4)
if reasoningContent := m.Get("reasoning_content"); reasoningContent.Type == gjson.String && reasoningContent.String() != "" {
part := geminiTextPart(reasoningContent.String())
part, _ = sjson.SetBytes(part, "thought", true)
part, _ = sjson.SetBytes(part, "thoughtSignature", geminiFunctionThoughtSignature)
partItems = append(partItems, part)
}
if content.Type == gjson.String && content.String() != "" {
partItems = append(partItems, geminiTextPart(content.String()))
} else if content.IsArray() {
// Assistant multimodal content (e.g. text + image) -> single model content with parts.
for _, item := range content.Array() {
switch item.Get("type").String() {
case "text":
if text := item.Get("text").String(); text != "" {
partItems = append(partItems, geminiTextPart(text))
}
case "image_url":
imageURL := item.Get("image_url.url").String()
if len(imageURL) > 5 {
pieces := strings.SplitN(imageURL[5:], ";", 2)
if len(pieces) == 2 && len(pieces[1]) > 7 {
partItems = append(partItems, geminiInlineDataPart(pieces[0], pieces[1][7:], geminiFunctionThoughtSignature))
}
}
}
}
}
// Tool calls -> single model content with functionCall parts.
tcs := m.Get("tool_calls")
if tcs.IsArray() {
functionIDs := make([]string, 0)
for _, tc := range tcs.Array() {
if tc.Get("type").String() != "function" {
continue
}
functionID := tc.Get("id").String()
functionName := util.SanitizeFunctionName(tc.Get("function.name").String())
if functionName == "" {
continue
}
part := []byte(`{"functionCall":{"name":""}}`)
part, _ = sjson.SetBytes(part, "functionCall.name", functionName)
part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(tc.Get("function.arguments").String()))
part, _ = sjson.SetBytes(part, "thoughtSignature", openAIToolCallGeminiThoughtSignature(tc))
partItems = append(partItems, part)
if functionID != "" {
functionIDs = append(functionIDs, functionID)
}
}
if len(partItems) > 0 {
contentItems = append(contentItems, geminiContentNode("model", partItems))
}
// Append a single tool content combining name + response per function.
responseParts := make([][]byte, 0, len(functionIDs))
for _, functionID := range functionIDs {
if name, ok := tcID2Name[functionID]; ok {
part := []byte(`{"functionResponse":{"name":"","response":{"result":""}}}`)
part, _ = sjson.SetBytes(part, "functionResponse.name", util.SanitizeFunctionName(name))
response := toolResponses[functionID]
if response == "" {
response = "{}"
}
part, _ = sjson.SetBytes(part, "functionResponse.response.result", []byte(response))
responseParts = append(responseParts, part)
}
}
if len(responseParts) > 0 {
contentItems = append(contentItems, geminiContentNode("user", responseParts))
}
} else if len(partItems) > 0 {
contentItems = append(contentItems, geminiContentNode("model", partItems))
}
}
}
if len(systemParts) > 0 {
systemInstruction := geminiContentNode("user", systemParts)
out, _ = sjson.SetRawBytes(out, "systemInstruction", systemInstruction)
}
if len(contentItems) > 0 && gjson.GetBytes(contentItems[len(contentItems)-1], "role").String() == "model" {
contentItems = contentItems[:len(contentItems)-1]
}
out = translatorcommon.SetRawArrayItems(out, "contents", contentItems)
}
// tools -> tools[].functionDeclarations + tools[].googleSearch/codeExecution/urlContext passthrough
tools := gjson.GetBytes(rawJSON, "tools")
toolResults := tools.Array()
if tools.IsArray() && len(toolResults) > 0 {
functionDeclarations := make([][]byte, 0, len(toolResults))
googleSearchNodes := make([][]byte, 0)
codeExecutionNodes := make([][]byte, 0)
urlContextNodes := make([][]byte, 0)
for _, t := range toolResults {
if t.Get("type").String() == "function" {
fn := t.Get("function")
if fn.Exists() && fn.IsObject() {
fnRaw := fn.Raw
if fn.Get("parameters").Exists() {
renamed, errRename := util.RenameKey(fnRaw, "parameters", "parametersJsonSchema")
if errRename != nil {
log.Warnf("Failed to rename parameters for tool '%s': %v", fn.Get("name").String(), errRename)
var errSet error
fnRawBytes := []byte(fnRaw)
fnRawBytes, errSet = sjson.SetBytes(fnRawBytes, "parametersJsonSchema.type", "object")
if errSet != nil {
log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
fnRawBytes, errSet = sjson.SetRawBytes(fnRawBytes, "parametersJsonSchema.properties", []byte(`{}`))
if errSet != nil {
log.Warnf("Failed to set default schema properties for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
fnRaw = string(fnRawBytes)
} else {
fnRaw = renamed
}
} else {
var errSet error
fnRawBytes := []byte(fnRaw)
fnRawBytes, errSet = sjson.SetBytes(fnRawBytes, "parametersJsonSchema.type", "object")
if errSet != nil {
log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
fnRawBytes, errSet = sjson.SetRawBytes(fnRawBytes, "parametersJsonSchema.properties", []byte(`{}`))
if errSet != nil {
log.Warnf("Failed to set default schema properties for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
fnRaw = string(fnRawBytes)
}
fnRawBytes := []byte(fnRaw)
nameResult := fn.Get("name")
originalName := nameResult.String()
sanitizedName := util.SanitizeFunctionName(originalName)
if nameResult.Type != gjson.String || sanitizedName != originalName {
fnRawBytes, _ = sjson.SetBytes(fnRawBytes, "name", sanitizedName)
}
if parameters := gjson.GetBytes(fnRawBytes, "parametersJsonSchema"); parameters.Exists() {
cleanedParameters := util.CleanJSONSchemaForGemini(parameters.Raw)
if cleanedParameters != parameters.Raw {
fnRawBytes, _ = sjson.SetRawBytes(fnRawBytes, "parametersJsonSchema", []byte(cleanedParameters))
}
}
if gjson.GetBytes(fnRawBytes, "strict").Exists() {
fnRawBytes, _ = sjson.DeleteBytes(fnRawBytes, "strict")
}
functionDeclarations = append(functionDeclarations, fnRawBytes)
}
}
if gs := t.Get("google_search"); gs.Exists() {
googleToolNode := []byte(`{}`)
var errSet error
googleToolNode, errSet = sjson.SetRawBytes(googleToolNode, "googleSearch", []byte(gs.Raw))
if errSet != nil {
log.Warnf("Failed to set googleSearch tool: %v", errSet)
continue
}
googleSearchNodes = append(googleSearchNodes, googleToolNode)
}
if ce := t.Get("code_execution"); ce.Exists() {
codeToolNode := []byte(`{}`)
var errSet error
codeToolNode, errSet = sjson.SetRawBytes(codeToolNode, "codeExecution", []byte(ce.Raw))
if errSet != nil {
log.Warnf("Failed to set codeExecution tool: %v", errSet)
continue
}
codeExecutionNodes = append(codeExecutionNodes, codeToolNode)
}
if uc := t.Get("url_context"); uc.Exists() {
urlToolNode := []byte(`{}`)
var errSet error
urlToolNode, errSet = sjson.SetRawBytes(urlToolNode, "urlContext", []byte(uc.Raw))
if errSet != nil {
log.Warnf("Failed to set urlContext tool: %v", errSet)
continue
}
urlContextNodes = append(urlContextNodes, urlToolNode)
}
}
if len(functionDeclarations) > 0 || len(googleSearchNodes) > 0 || len(codeExecutionNodes) > 0 || len(urlContextNodes) > 0 {
toolItems := make([][]byte, 0, 1+len(googleSearchNodes)+len(codeExecutionNodes)+len(urlContextNodes))
if len(functionDeclarations) > 0 {
functionToolNode := []byte(`{"functionDeclarations":[]}`)
functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", translatorcommon.JoinRawArray(functionDeclarations))
toolItems = append(toolItems, functionToolNode)
}
toolItems = append(toolItems, googleSearchNodes...)
toolItems = append(toolItems, codeExecutionNodes...)
toolItems = append(toolItems, urlContextNodes...)
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
}
}
out = common.AttachDefaultSafetySettings(out, "safetySettings")
return out
}
func geminiTextPart(text string) []byte {
part := []byte(`{"text":""}`)
part, _ = sjson.SetBytes(part, "text", text)
return part
}
func geminiInlineDataPart(mimeType, data, thoughtSignature string) []byte {
part := []byte(`{"inlineData":{"mime_type":"","data":""}}`)
part, _ = sjson.SetBytes(part, "inlineData.mime_type", mimeType)
part, _ = sjson.SetBytes(part, "inlineData.data", data)
if thoughtSignature != "" {
part, _ = sjson.SetBytes(part, "thoughtSignature", thoughtSignature)
}
return part
}
func geminiContentNode(role string, parts [][]byte) []byte {
content := []byte(`{"role":"","parts":[]}`)
content, _ = sjson.SetBytes(content, "role", role)
content, _ = sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(parts))
return content
}
func openAIToolCallGeminiThoughtSignature(toolCall gjson.Result) string {
for _, path := range []string{
"extra_content.google.thought_signature",
"function.extra_content.google.thought_signature",
"thoughtSignature",
"thought_signature",
} {
if signatureResult := toolCall.Get(path); signatureResult.Exists() {
return sigcompat.GeminiReplaySignatureOrBypass(signatureResult.String(), sigcompat.SignatureBlockKindGeminiFunctionCall)
}
}
return geminiFunctionThoughtSignature
}
func openAIInputAudioMimeType(audioFormat string) string {
switch audioFormat {
case "", "wav":
return "audio/wav"
case "mp3":
return "audio/mpeg"
case "ogg":
return "audio/ogg"
case "flac":
return "audio/flac"
case "aac":
return "audio/aac"
case "webm":
return "audio/webm"
case "pcm16":
return "audio/pcm"
case "g711_ulaw", "g711_alaw":
return "audio/basic"
default:
return "audio/" + audioFormat
}
}
// applyOpenAIResponseFormatToGemini maps OpenAI Chat Completions structured output settings to Gemini.
// Response schemas pass through unchanged because the tool schema cleaner removes supported response fields.
func applyOpenAIResponseFormatToGemini(out []byte, rawJSON []byte) []byte {
responseFormat := gjson.GetBytes(rawJSON, "response_format")
if !responseFormat.Exists() {
return out
}
switch strings.ToLower(strings.TrimSpace(responseFormat.Get("type").String())) {
case "json_object":
out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json")
case "json_schema":
out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json")
out, _ = sjson.DeleteBytes(out, "generationConfig.responseSchema")
if schema := responseFormat.Get("json_schema.schema"); schema.Exists() {
out, _ = sjson.SetRawBytes(out, "generationConfig.responseJsonSchema", []byte(schema.Raw))
}
}
return out
}

View file

@ -0,0 +1,417 @@
package chat_completions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIRequestToGemini_StripsTrailingAssistantPrefill(t *testing.T) {
inputJSON := `{
"model": "gpt-5.4",
"messages": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "previous answer"}
]
}`
result := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
contents := resultJSON.Get("contents").Array()
if len(contents) != 1 {
t.Fatalf("contents length = %d, want 1. contents=%s", len(contents), resultJSON.Get("contents").Raw)
}
if got := contents[0].Get("role").String(); got != "user" {
t.Fatalf("final remaining role = %q, want %q", got, "user")
}
}
func TestConvertOpenAIRequestToGeminiPreservesInputAudio(t *testing.T) {
inputJSON := `{
"model": "gpt-5.5",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "Transcribe this audio verbatim."},
{"type": "input_audio", "input_audio": {"data": "SUQzBA==", "format": "mp3"}}
]
}
]
}`
result := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
parts := resultJSON.Get("contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("parts length = %d, want 2. parts=%s", len(parts), resultJSON.Get("contents.0.parts").Raw)
}
if got := parts[0].Get("text").String(); got != "Transcribe this audio verbatim." {
t.Fatalf("text part = %q, want prompt text", got)
}
if got := parts[1].Get("inlineData.mime_type").String(); got != "audio/mpeg" {
t.Fatalf("audio mime_type = %q, want %q", got, "audio/mpeg")
}
if got := parts[1].Get("inlineData.data").String(); got != "SUQzBA==" {
t.Fatalf("audio data = %q, want %q", got, "SUQzBA==")
}
}
func TestConvertOpenAIRequestToGeminiPreservesVideoURL(t *testing.T) {
inputJSON := `{
"model": "gemini-3-flash",
"messages": [
{
"role": "user",
"content": [
{"type": "video_url", "video_url": {"url": "data:video/mp4;base64,AAAAIGZ0eXBtcDQy"}},
{"type": "text", "text": "Describe the video"}
]
}
]
}`
result := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
parts := resultJSON.Get("contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("parts length = %d, want 2. parts=%s", len(parts), resultJSON.Get("contents.0.parts").Raw)
}
if got := parts[0].Get("inlineData.mime_type").String(); got != "video/mp4" {
t.Fatalf("video mime_type = %q, want %q", got, "video/mp4")
}
if got := parts[0].Get("inlineData.data").String(); got != "AAAAIGZ0eXBtcDQy" {
t.Fatalf("video data = %q, want %q", got, "AAAAIGZ0eXBtcDQy")
}
if got := parts[1].Get("text").String(); got != "Describe the video" {
t.Fatalf("text part = %q, want prompt text", got)
}
}
func TestConvertOpenAIRequestToGeminiSkipsEmptyTextPartsWithoutNulls(t *testing.T) {
inputJSON := `{
"model": "gemini-3-flash",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": ""},
{"type": "input_audio", "input_audio": {"data": "SUQzBA==", "format": "mp3"}}
]
},
{
"role": "assistant",
"content": [{"type": "text", "text": ""}],
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "read_file", "arguments": "{\"path\":\"a.txt\"}"}
}]
},
{"role": "tool", "tool_call_id": "call_1", "content": "{\"output\":\"ok\"}"},
{"role": "user", "content": "done"}
]
}`
result := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), false)
userParts := gjson.GetBytes(result, "contents.0.parts").Array()
if len(userParts) != 1 {
t.Fatalf("user parts length = %d, want 1. Output: %s", len(userParts), result)
}
if userParts[0].Type == gjson.Null {
t.Fatalf("user parts.0 is null. Output: %s", result)
}
if got := userParts[0].Get("inlineData.mime_type").String(); got != "audio/mpeg" {
t.Fatalf("audio mime_type = %q, want audio/mpeg. Output: %s", got, result)
}
assistantParts := gjson.GetBytes(result, "contents.1.parts").Array()
if len(assistantParts) != 1 {
t.Fatalf("assistant parts length = %d, want 1. Output: %s", len(assistantParts), result)
}
if assistantParts[0].Type == gjson.Null {
t.Fatalf("assistant parts.0 is null. Output: %s", result)
}
if !assistantParts[0].Get("functionCall").Exists() {
t.Fatalf("functionCall missing. Output: %s", result)
}
}
func TestConvertOpenAIRequestToGeminiPreservesReasoningContent(t *testing.T) {
inputJSON := `{
"model": "gemini-3-flash",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "", "reasoning_content": "thinking only"},
{"role": "user", "content": "say ok"}
]
}`
result := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), true)
contents := gjson.GetBytes(result, "contents").Array()
if len(contents) != 3 {
t.Fatalf("contents length = %d, want 3. Output: %s", len(contents), result)
}
part := contents[1].Get("parts.0")
if got := contents[1].Get("role").String(); got != "model" {
t.Fatalf("contents.1.role = %q, want model. Output: %s", got, result)
}
if got := part.Get("text").String(); got != "thinking only" {
t.Fatalf("reasoning text = %q, want thinking only. Output: %s", got, result)
}
if !part.Get("thought").Bool() {
t.Fatalf("reasoning part should be marked as thought. Output: %s", result)
}
if got := part.Get("thoughtSignature").String(); got != geminiFunctionThoughtSignature {
t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, result)
}
}
func TestConvertOpenAIRequestToGeminiPreservesReasoningBeforeVisibleContentAndToolCall(t *testing.T) {
inputJSON := `{
"model": "gemini-3-flash",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "visible answer", "reasoning_content": "thinking only", "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "read_file", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "call_1", "content": "{\"output\":\"ok\"}"},
{"role": "user", "content": "say ok"}
]
}`
result := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), true)
contents := gjson.GetBytes(result, "contents").Array()
if len(contents) != 4 {
t.Fatalf("contents length = %d, want 4. Output: %s", len(contents), result)
}
parts := contents[1].Get("parts").Array()
if len(parts) != 3 {
t.Fatalf("model parts length = %d, want 3. Output: %s", len(parts), result)
}
if got := parts[0].Get("text").String(); got != "thinking only" || !parts[0].Get("thought").Bool() {
t.Fatalf("first part should be the reasoning thought. Output: %s", result)
}
if got := parts[1].Get("text").String(); got != "visible answer" || parts[1].Get("thought").Bool() {
t.Fatalf("second part should be visible assistant content. Output: %s", result)
}
if got := parts[2].Get("functionCall.name").String(); got != "read_file" {
t.Fatalf("functionCall.name = %q, want read_file. Output: %s", got, result)
}
if got := parts[2].Get("thoughtSignature").String(); got != geminiFunctionThoughtSignature {
t.Fatalf("functionCall thoughtSignature = %q, want bypass sentinel. Output: %s", got, result)
}
if got := contents[2].Get("parts.0.functionResponse.name").String(); got != "read_file" {
t.Fatalf("functionResponse.name = %q, want read_file. Output: %s", got, result)
}
}
func TestConvertOpenAIRequestToGeminiSkipsEmptyAssistantMessages(t *testing.T) {
inputJSON := `{
"model": "gemini-3-flash",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "", "tool_calls": [{"type": "function", "function": {"name": "", "arguments": "{}"}}, {"type": "custom"}]},
{"role": "user", "content": "say ok"}
]
}`
result := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), true)
contents := gjson.GetBytes(result, "contents").Array()
if len(contents) != 2 {
t.Fatalf("contents length = %d, want 2. Output: %s", len(contents), result)
}
}
func TestConvertOpenAIRequestToGeminiMapsMaxTokens(t *testing.T) {
tests := []struct {
name string
body string
want int64
}{
{
name: "max_tokens",
body: `{"model":"gemini-2.0-flash","messages":[{"role":"user","content":"hi"}],"max_tokens":30}`,
want: 30,
},
{
name: "max_completion_tokens",
body: `{"model":"gemini-2.0-flash","messages":[{"role":"user","content":"hi"}],"max_completion_tokens":40}`,
want: 40,
},
{
name: "max_tokens preferred over max_completion_tokens",
body: `{"model":"gemini-2.0-flash","messages":[{"role":"user","content":"hi"}],"max_tokens":30,"max_completion_tokens":40}`,
want: 30,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := ConvertOpenAIRequestToGemini("gemini-2.0-flash", []byte(tt.body), false)
if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != tt.want {
t.Fatalf("generationConfig.maxOutputTokens = %d, want %d. Output: %s", got, tt.want, out)
}
})
}
}
func TestConvertOpenAIRequestToGeminiCleansToolSchemaRequiredFields(t *testing.T) {
inputJSON := `{
"model": "gemini-2.0-flash",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{
"type": "function",
"function": {
"name": "search_company",
"description": "Search",
"parameters": {
"type": "object",
"title": "SearchCompany",
"properties": {
"country": {"type": "string"},
"industry": {"type": "string"}
},
"required": ["country", "industry", "stale_field", "another_stale"]
}
}
}]
}`
output := ConvertOpenAIRequestToGemini("gemini-2.0-flash", []byte(inputJSON), false)
schema := gjson.GetBytes(output, "tools.0.functionDeclarations.0.parametersJsonSchema")
if !schema.Exists() {
t.Fatalf("parametersJsonSchema missing. Output: %s", output)
}
if schema.Get("title").Exists() {
t.Fatalf("schema title should be removed. Output: %s", output)
}
required := schema.Get("required").Array()
if len(required) != 2 {
t.Fatalf("required length = %d, want 2. Schema: %s", len(required), schema.Raw)
}
if got := required[0].String(); got != "country" {
t.Fatalf("required[0] = %q, want country. Schema: %s", got, schema.Raw)
}
if got := required[1].String(); got != "industry" {
t.Fatalf("required[1] = %q, want industry. Schema: %s", got, schema.Raw)
}
}
func TestConvertOpenAIRequestToGeminiResponseFormatJSONSchema(t *testing.T) {
inputJSON := `{
"model": "gemini-3.1-flash-lite",
"generationConfig": {
"temperature": 0.2,
"responseSchema": {"type": "string"}
},
"messages": [{"role": "user", "content": "Return structured JSON."}],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "response",
"strict": true,
"schema": {
"type": "object",
"properties": {"cleanedContent": {"type": "string"}},
"required": ["cleanedContent"],
"additionalProperties": false
}
}
}
}`
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
generationConfig := gjson.GetBytes(output, "generationConfig")
if got := generationConfig.Get("responseMimeType").String(); got != "application/json" {
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output)
}
schema := generationConfig.Get("responseJsonSchema")
if !schema.Exists() {
t.Fatalf("responseJsonSchema missing. Output: %s", output)
}
if generationConfig.Get("responseSchema").Exists() {
t.Fatalf("responseSchema should be removed. Output: %s", output)
}
if additionalProperties := schema.Get("additionalProperties"); !additionalProperties.Exists() || additionalProperties.Bool() {
t.Fatalf("additionalProperties = %s, want false. Output: %s", additionalProperties.Raw, output)
}
if got := generationConfig.Get("temperature").Float(); got != 0.2 {
t.Fatalf("temperature = %v, want 0.2. Output: %s", got, output)
}
}
func TestConvertOpenAIRequestToGeminiResponseFormatJSONObject(t *testing.T) {
inputJSON := `{
"model": "gemini-3.1-flash-lite",
"generationConfig": {"temperature": 0.6},
"messages": [{"role": "user", "content": "Return a JSON object."}],
"response_format": {"type": "json_object"}
}`
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
generationConfig := gjson.GetBytes(output, "generationConfig")
if got := generationConfig.Get("responseMimeType").String(); got != "application/json" {
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output)
}
if generationConfig.Get("responseJsonSchema").Exists() {
t.Fatalf("responseJsonSchema should not be set for json_object. Output: %s", output)
}
if got := generationConfig.Get("temperature").Float(); got != 0.6 {
t.Fatalf("temperature = %v, want 0.6. Output: %s", got, output)
}
}
func TestConvertOpenAIRequestToGeminiResponseFormatJSONSchemaWithoutSchema(t *testing.T) {
inputJSON := `{
"model": "gemini-3.1-flash-lite",
"messages": [{"role": "user", "content": "Return structured JSON."}],
"response_format": {"type": "json_schema", "json_schema": {"name": "response"}}
}`
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
generationConfig := gjson.GetBytes(output, "generationConfig")
if got := generationConfig.Get("responseMimeType").String(); got != "application/json" {
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output)
}
if generationConfig.Get("responseJsonSchema").Exists() {
t.Fatalf("responseJsonSchema should not be set without a schema. Output: %s", output)
}
}
func TestConvertOpenAIRequestToGeminiResponseFormatNoOp(t *testing.T) {
tests := []struct {
name string
body string
}{
{
name: "absent",
body: `{"model":"gemini-3.1-flash-lite","messages":[{"role":"user","content":"plain text"}],"temperature":0.5}`,
},
{
name: "unknown type",
body: `{"model":"gemini-3.1-flash-lite","messages":[{"role":"user","content":"plain text"}],"temperature":0.5,"response_format":{"type":"text"}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(tt.body), false)
generationConfig := gjson.GetBytes(output, "generationConfig")
if generationConfig.Get("responseMimeType").Exists() {
t.Fatalf("responseMimeType should not be set. Output: %s", output)
}
if generationConfig.Get("responseJsonSchema").Exists() {
t.Fatalf("responseJsonSchema should not be set. Output: %s", output)
}
if got := generationConfig.Get("temperature").Float(); got != 0.5 {
t.Fatalf("temperature = %v, want 0.5. Output: %s", got, output)
}
})
}
}

View file

@ -0,0 +1,444 @@
// Package openai provides response translation functionality for Gemini to OpenAI API compatibility.
// This package handles the conversion of Gemini API responses into OpenAI Chat Completions-compatible
// JSON format, transforming streaming events and non-streaming responses into the format
// expected by OpenAI API clients. It supports both streaming and non-streaming modes,
// handling text content, tool calls, reasoning content, and usage metadata appropriately.
package chat_completions
import (
"bytes"
"context"
"fmt"
"strings"
"sync/atomic"
"time"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// convertGeminiResponseToOpenAIChatParams holds parameters for response conversion.
type convertGeminiResponseToOpenAIChatParams struct {
UnixTimestamp int64
// FunctionIndex tracks tool call indices per candidate index to support multiple candidates.
FunctionIndex map[int]int
SawToolCall map[int]bool
UpstreamFinishReason map[int]string
SanitizedNameMap map[string]string
}
// functionCallIDCounter provides a process-wide unique counter for function call identifiers.
var functionCallIDCounter uint64
// ConvertGeminiResponseToOpenAI translates a single chunk of a streaming response from the
// Gemini API format to the OpenAI Chat Completions streaming format.
// It processes various Gemini event types and transforms them into OpenAI-compatible JSON responses.
// The function handles text content, tool calls, reasoning content, and usage metadata, outputting
// responses that match the OpenAI API format. It supports incremental updates for streaming responses.
//
// Parameters:
// - ctx: The context for the request, used for cancellation and timeout handling
// - modelName: The name of the model being used for the response (unused in current implementation)
// - rawJSON: The raw JSON response from the Gemini API
// - param: A pointer to a parameter object for maintaining state between calls
//
// Returns:
// - [][]byte: A slice of OpenAI-compatible JSON responses
func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
// Initialize parameters if nil.
if *param == nil {
*param = &convertGeminiResponseToOpenAIChatParams{
UnixTimestamp: 0,
FunctionIndex: make(map[int]int),
SawToolCall: make(map[int]bool),
UpstreamFinishReason: make(map[int]string),
SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON),
}
}
// Ensure the Map is initialized (handling cases where param might be reused from older context).
p := (*param).(*convertGeminiResponseToOpenAIChatParams)
if p.FunctionIndex == nil {
p.FunctionIndex = make(map[int]int)
}
if p.SawToolCall == nil {
p.SawToolCall = make(map[int]bool)
}
if p.UpstreamFinishReason == nil {
p.UpstreamFinishReason = make(map[int]string)
}
if p.SanitizedNameMap == nil {
p.SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON)
}
if bytes.HasPrefix(rawJSON, []byte("data:")) {
rawJSON = bytes.TrimSpace(rawJSON[5:])
}
if bytes.Equal(rawJSON, []byte("[DONE]")) {
return [][]byte{}
}
// Initialize the OpenAI SSE base template.
// We use a base template and clone it for each candidate to support multiple candidates.
baseTemplate := []byte(`{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`)
// Extract and set the model version.
if modelVersionResult := gjson.GetBytes(rawJSON, "modelVersion"); modelVersionResult.Exists() {
baseTemplate, _ = sjson.SetBytes(baseTemplate, "model", modelVersionResult.String())
}
// Extract and set the creation timestamp.
if createTimeResult := gjson.GetBytes(rawJSON, "createTime"); createTimeResult.Exists() {
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
if err == nil {
p.UnixTimestamp = t.Unix()
}
baseTemplate, _ = sjson.SetBytes(baseTemplate, "created", p.UnixTimestamp)
} else {
baseTemplate, _ = sjson.SetBytes(baseTemplate, "created", p.UnixTimestamp)
}
// Extract and set the response ID.
if responseIDResult := gjson.GetBytes(rawJSON, "responseId"); responseIDResult.Exists() {
baseTemplate, _ = sjson.SetBytes(baseTemplate, "id", responseIDResult.String())
}
// Extract and set usage metadata (token counts).
// Usage is applied to the base template so it appears in the chunks.
if usageResult := gjson.GetBytes(rawJSON, "usageMetadata"); usageResult.Exists() {
cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int()
baseTemplate, _ = sjson.SetBytes(baseTemplate, "usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int())
if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
baseTemplate, _ = sjson.SetBytes(baseTemplate, "usage.total_tokens", totalTokenCountResult.Int())
}
promptTokenCount := usageResult.Get("promptTokenCount").Int()
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
baseTemplate, _ = sjson.SetBytes(baseTemplate, "usage.prompt_tokens", promptTokenCount)
if thoughtsTokenCount > 0 {
baseTemplate, _ = sjson.SetBytes(baseTemplate, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
}
// Include cached token count if present (indicates prompt caching is working)
if cachedTokenCount > 0 {
var err error
baseTemplate, err = sjson.SetBytes(baseTemplate, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount)
if err != nil {
log.Warnf("gemini openai response: failed to set cached_tokens in streaming: %v", err)
}
}
}
var responseStrings [][]byte
candidates := gjson.GetBytes(rawJSON, "candidates")
// Iterate over all candidates to support candidate_count > 1.
if candidates.IsArray() {
candidates.ForEach(func(_, candidate gjson.Result) bool {
// Clone the template for the current candidate.
template := append([]byte(nil), baseTemplate...)
// Set the specific index for this candidate.
candidateIndex := int(candidate.Get("index").Int())
template, _ = sjson.SetBytes(template, "choices.0.index", candidateIndex)
if finishReasonResult := candidate.Get("finishReason"); finishReasonResult.Exists() {
p.UpstreamFinishReason[candidateIndex] = strings.ToUpper(finishReasonResult.String())
}
partsResult := candidate.Get("content.parts")
assistantRoleSet := false
setAssistantRole := func() {
if assistantRoleSet {
return
}
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
assistantRoleSet = true
}
if partsResult.IsArray() {
partResults := partsResult.Array()
for i := 0; i < len(partResults); i++ {
partResult := partResults[i]
partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall")
inlineDataResult := partResult.Get("inlineData")
if !inlineDataResult.Exists() {
inlineDataResult = partResult.Get("inline_data")
}
thoughtSignatureResult := partResult.Get("thoughtSignature")
if !thoughtSignatureResult.Exists() {
thoughtSignatureResult = partResult.Get("thought_signature")
}
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""
hasContentPayload := partTextResult.Exists() || functionCallResult.Exists() || inlineDataResult.Exists()
// Skip pure thoughtSignature parts but keep any actual payload in the same part.
if hasThoughtSignature && !hasContentPayload {
continue
}
if partTextResult.Exists() {
text := partTextResult.String()
setAssistantRole()
// Handle text content, distinguishing between regular content and reasoning/thoughts.
if partResult.Get("thought").Bool() {
template, _ = sjson.SetBytes(template, "choices.0.delta.reasoning_content", text)
} else {
template, _ = sjson.SetBytes(template, "choices.0.delta.content", text)
}
} else if functionCallResult.Exists() {
// Handle function call content.
p.SawToolCall[candidateIndex] = true
toolCallsResult := gjson.GetBytes(template, "choices.0.delta.tool_calls")
// Retrieve the function index for this specific candidate.
functionCallIndex := p.FunctionIndex[candidateIndex]
p.FunctionIndex[candidateIndex]++
if toolCallsResult.Exists() && toolCallsResult.IsArray() {
functionCallIndex = len(toolCallsResult.Array())
} else {
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
}
functionCallTemplate := []byte(`{"id":"","index":0,"type":"function","function":{"name":"","arguments":""}}`)
fcName := util.RestoreSanitizedToolName(p.SanitizedNameMap, functionCallResult.Get("name").String())
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)))
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "index", functionCallIndex)
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", fcName)
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.arguments", fcArgsResult.Raw)
}
setAssistantRole()
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallTemplate)
} else if inlineDataResult.Exists() {
data := inlineDataResult.Get("data").String()
if data == "" {
continue
}
mimeType := inlineDataResult.Get("mimeType").String()
if mimeType == "" {
mimeType = inlineDataResult.Get("mime_type").String()
}
if mimeType == "" {
mimeType = "image/png"
}
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
imagesResult := gjson.GetBytes(template, "choices.0.delta.images")
if !imagesResult.Exists() || !imagesResult.IsArray() {
template, _ = sjson.SetRawBytes(template, "choices.0.delta.images", []byte(`[]`))
}
imageIndex := len(gjson.GetBytes(template, "choices.0.delta.images").Array())
imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex)
imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
setAssistantRole()
template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload)
}
}
}
upstreamFinishReason := p.UpstreamFinishReason[candidateIndex]
sawToolCall := p.SawToolCall[candidateIndex]
usageExists := gjson.GetBytes(rawJSON, "usageMetadata").Exists()
isFinalChunk := upstreamFinishReason != "" && usageExists
if isFinalChunk {
var finishReason string
if sawToolCall {
finishReason = "tool_calls"
} else if upstreamFinishReason == "MAX_TOKENS" {
finishReason = "max_tokens"
} else {
finishReason = "stop"
}
template, _ = sjson.SetBytes(template, "choices.0.finish_reason", finishReason)
template, _ = sjson.SetBytes(template, "choices.0.native_finish_reason", strings.ToLower(upstreamFinishReason))
}
responseStrings = append(responseStrings, template)
return true // continue loop
})
} else {
// If there are no candidates (e.g., a pure usageMetadata chunk), return the usage chunk if present.
if gjson.GetBytes(rawJSON, "usageMetadata").Exists() && len(responseStrings) == 0 {
responseStrings = append(responseStrings, append([]byte(nil), baseTemplate...))
}
}
return responseStrings
}
// ConvertGeminiResponseToOpenAINonStream converts a non-streaming Gemini response to a non-streaming OpenAI response.
// This function processes the complete Gemini response and transforms it into a single OpenAI-compatible
// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all
// the information into a single response that matches the OpenAI API format.
//
// Parameters:
// - ctx: The context for the request, used for cancellation and timeout handling
// - modelName: The name of the model being used for the response (unused in current implementation)
// - rawJSON: The raw JSON response from the Gemini API
// - param: A pointer to a parameter object for the conversion (unused in current implementation)
//
// Returns:
// - []byte: An OpenAI-compatible JSON response containing all message content and metadata
func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
sanitizedNameMap := util.SanitizedToolNameMap(originalRequestRawJSON)
var unixTimestamp int64
// Initialize template with an empty choices array to support multiple candidates.
template := []byte(`{"id":"","object":"chat.completion","created":123456,"model":"model","choices":[]}`)
if modelVersionResult := gjson.GetBytes(rawJSON, "modelVersion"); modelVersionResult.Exists() {
template, _ = sjson.SetBytes(template, "model", modelVersionResult.String())
}
if createTimeResult := gjson.GetBytes(rawJSON, "createTime"); createTimeResult.Exists() {
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
if err == nil {
unixTimestamp = t.Unix()
}
template, _ = sjson.SetBytes(template, "created", unixTimestamp)
} else {
template, _ = sjson.SetBytes(template, "created", unixTimestamp)
}
if responseIDResult := gjson.GetBytes(rawJSON, "responseId"); responseIDResult.Exists() {
template, _ = sjson.SetBytes(template, "id", responseIDResult.String())
}
if usageResult := gjson.GetBytes(rawJSON, "usageMetadata"); usageResult.Exists() {
template, _ = sjson.SetBytes(template, "usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int())
if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokenCountResult.Int())
}
promptTokenCount := usageResult.Get("promptTokenCount").Int()
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int()
template, _ = sjson.SetBytes(template, "usage.prompt_tokens", promptTokenCount)
if thoughtsTokenCount > 0 {
template, _ = sjson.SetBytes(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
}
// Include cached token count if present (indicates prompt caching is working)
if cachedTokenCount > 0 {
var err error
template, err = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount)
if err != nil {
log.Warnf("gemini openai response: failed to set cached_tokens in non-streaming: %v", err)
}
}
}
// Process the main content part of the response for all candidates.
candidates := gjson.GetBytes(rawJSON, "candidates")
if candidates.IsArray() {
var choicesList [][]byte
candidates.ForEach(func(_, candidate gjson.Result) bool {
// Construct a single Choice object.
choiceTemplate := []byte(`{"index":0,"message":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}`)
// Set the index for this choice.
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "index", candidate.Get("index").Int())
// Set finish reason.
if finishReasonResult := candidate.Get("finishReason"); finishReasonResult.Exists() {
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "finish_reason", strings.ToLower(finishReasonResult.String()))
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "native_finish_reason", strings.ToLower(finishReasonResult.String()))
}
partsResult := candidate.Get("content.parts")
hasFunctionCall := false
if partsResult.IsArray() {
partsResults := partsResult.Array()
var toolCalls [][]byte
var images [][]byte
var textContent strings.Builder
var reasoningContent strings.Builder
hasTextContent := false
hasReasoningContent := false
for i := 0; i < len(partsResults); i++ {
partResult := partsResults[i]
partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall")
inlineDataResult := partResult.Get("inlineData")
if !inlineDataResult.Exists() {
inlineDataResult = partResult.Get("inline_data")
}
if partTextResult.Exists() {
// Append text content, distinguishing between regular content and reasoning.
if partResult.Get("thought").Bool() {
hasReasoningContent = true
reasoningContent.WriteString(partTextResult.String())
} else {
hasTextContent = true
textContent.WriteString(partTextResult.String())
}
} else if functionCallResult.Exists() {
// Append function call content to the tool_calls array.
hasFunctionCall = true
functionCallItemTemplate := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
fcName := util.RestoreSanitizedToolName(sanitizedNameMap, functionCallResult.Get("name").String())
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)))
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.name", fcName)
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", fcArgsResult.Raw)
}
toolCalls = append(toolCalls, functionCallItemTemplate)
} else if inlineDataResult.Exists() {
data := inlineDataResult.Get("data").String()
if data != "" {
mimeType := inlineDataResult.Get("mimeType").String()
if mimeType == "" {
mimeType = inlineDataResult.Get("mime_type").String()
}
if mimeType == "" {
mimeType = "image/png"
}
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
imagePayload, _ = sjson.SetBytes(imagePayload, "index", len(images))
imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
images = append(images, imagePayload)
}
}
}
if hasTextContent {
if !hasReasoningContent && len(partsResults) == 1 && len(toolCalls) == 0 && len(images) == 0 {
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "message.content", partsResults[0].Get("text").String())
} else {
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "message.content", textContent.String())
}
}
if hasReasoningContent {
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "message.reasoning_content", reasoningContent.String())
}
if len(toolCalls) > 0 {
choiceTemplate, _ = sjson.SetRawBytes(choiceTemplate, "message.tool_calls", translatorcommon.JoinRawArray(toolCalls))
}
if len(images) > 0 {
choiceTemplate, _ = sjson.SetRawBytes(choiceTemplate, "message.images", translatorcommon.JoinRawArray(images))
}
}
if hasFunctionCall {
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "finish_reason", "tool_calls")
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "native_finish_reason", "tool_calls")
}
// Append the constructed choice to the main choices array.
choicesList = append(choicesList, choiceTemplate)
return true
})
if len(choicesList) > 0 {
template = translatorcommon.SetRawArrayItems(template, "choices", choicesList)
}
}
return template
}

View file

@ -0,0 +1,79 @@
package chat_completions
import (
"context"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertGeminiResponseToOpenAIIncludesZeroCompletionTokensWhenMissing(t *testing.T) {
var param any
chunk := []byte(`{"usageMetadata":{"promptTokenCount":16,"thoughtsTokenCount":42,"totalTokenCount":58}}`)
result := ConvertGeminiResponseToOpenAI(context.Background(), "model", nil, nil, chunk, &param)
if len(result) != 1 {
t.Fatalf("expected 1 result, got %d", len(result))
}
completionTokens := gjson.GetBytes(result[0], "usage.completion_tokens")
if !completionTokens.Exists() || completionTokens.Int() != 0 {
t.Fatalf("completion_tokens = %s, want present with value 0. Output: %s", completionTokens.Raw, result[0])
}
}
func TestConvertGeminiResponseToOpenAINonStreamIncludesZeroCompletionTokensWhenMissing(t *testing.T) {
response := []byte(`{"usageMetadata":{"promptTokenCount":16,"thoughtsTokenCount":42,"totalTokenCount":58}}`)
result := ConvertGeminiResponseToOpenAINonStream(context.Background(), "model", nil, nil, response, nil)
completionTokens := gjson.GetBytes(result, "usage.completion_tokens")
if !completionTokens.Exists() || completionTokens.Int() != 0 {
t.Fatalf("completion_tokens = %s, want present with value 0. Output: %s", completionTokens.Raw, result)
}
}
func TestGeminiFinishReasonOnlyOnFinalChunk(t *testing.T) {
ctx := context.Background()
var param any
chunk1 := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_dir","args":{"path":"C:/"}}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"}}`)
result1 := ConvertGeminiResponseToOpenAI(ctx, "model", nil, nil, chunk1, &param)
if len(result1) != 1 {
t.Fatalf("expected 1 result from chunk1, got %d", len(result1))
}
fr1 := gjson.GetBytes(result1[0], "choices.0.finish_reason")
if fr1.Exists() && fr1.String() != "" && fr1.Type.String() != "Null" {
t.Fatalf("expected null finish_reason on tool chunk, got %v", fr1.String())
}
chunk2 := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_dir","args":{"path":"D:/"}}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"}}`)
ConvertGeminiResponseToOpenAI(ctx, "model", nil, nil, chunk2, &param)
chunk3 := []byte(`{"candidates":[{"content":{"parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}`)
result3 := ConvertGeminiResponseToOpenAI(ctx, "model", nil, nil, chunk3, &param)
if len(result3) != 1 {
t.Fatalf("expected 1 result from chunk3, got %d", len(result3))
}
fr3 := gjson.GetBytes(result3[0], "choices.0.finish_reason").String()
if fr3 != "tool_calls" {
t.Fatalf("expected finish_reason tool_calls, got %s", fr3)
}
nfr3 := gjson.GetBytes(result3[0], "choices.0.native_finish_reason").String()
if nfr3 != "stop" {
t.Fatalf("expected native_finish_reason stop, got %s", nfr3)
}
}
func TestConvertGeminiResponseToOpenAINonStream_EmptyTextProducesEmptyString(t *testing.T) {
response := []byte(`{"candidates":[{"content":{"parts":[{"text":""},{"text":"","thought":true}]},"finishReason":"STOP"}]}`)
result := ConvertGeminiResponseToOpenAINonStream(context.Background(), "model", nil, nil, response, nil)
content := gjson.GetBytes(result, "choices.0.message.content")
if !content.Exists() || content.String() != "" || content.Type == gjson.Null {
t.Fatalf("expected content to be empty string \"\", got %v (type %v)", content.Value(), content.Type)
}
reasoning := gjson.GetBytes(result, "choices.0.message.reasoning_content")
if !reasoning.Exists() || reasoning.String() != "" || reasoning.Type == gjson.Null {
t.Fatalf("expected reasoning_content to be empty string \"\", got %v (type %v)", reasoning.Value(), reasoning.Type)
}
}

View file

@ -0,0 +1,51 @@
package chat_completions
import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/tidwall/gjson"
)
const capturedGeminiToolCallThoughtSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
func TestConvertOpenAIRequestToGemini_ToolCallSignatureCompatibility(t *testing.T) {
tests := []struct {
name string
rawSignature string
wantSignature string
}{
{
name: "Gemini signature is preserved",
rawSignature: "gemini#" + capturedGeminiToolCallThoughtSignature,
wantSignature: capturedGeminiToolCallThoughtSignature,
},
{
name: "unknown signature uses bypass",
rawSignature: "not-a-provider-signature",
wantSignature: signature.GeminiSkipThoughtSignatureValidator,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := []byte(`{
"model": "gemini-3.5-flash",
"messages": [{
"role": "assistant",
"tool_calls": [{
"id": "call_123",
"type": "function",
"function": {"name": "lookup", "arguments": "{\"q\":\"Paris\"}"},
"extra_content": {"google": {"thought_signature": "` + tt.rawSignature + `"}}
}]
}]
}`)
output := ConvertOpenAIRequestToGemini("gemini-3.5-flash", input, false)
if got := gjson.GetBytes(output, "contents.0.parts.0.thoughtSignature").String(); got != tt.wantSignature {
t.Fatalf("thoughtSignature = %q, want %q. Output: %s", got, tt.wantSignature, output)
}
})
}
}

View file

@ -0,0 +1,19 @@
package chat_completions
import (
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
translator.Register(
OpenAI,
Gemini,
ConvertOpenAIRequestToGemini,
interfaces.TranslateResponse{
Stream: ConvertGeminiResponseToOpenAI,
NonStream: ConvertGeminiResponseToOpenAINonStream,
},
)
}

View file

@ -0,0 +1,55 @@
package chat_completions
import (
"context"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIRequestToGeminiNormalizesToolNameAndStrict(t *testing.T) {
input := []byte(`{"messages":[],"tools":[{"type":"function","function":{"name":true,"strict":true,"parameters":{"type":"object"}}}]}`)
output := ConvertOpenAIRequestToGemini("gemini-test", input, false)
name := gjson.GetBytes(output, "tools.0.functionDeclarations.0.name")
if name.Type != gjson.String || name.String() != "true" {
t.Fatalf("tool name = %s, want string true", name.Raw)
}
if gjson.GetBytes(output, "tools.0.functionDeclarations.0.strict").Exists() {
t.Fatal("strict should be removed")
}
}
func TestConvertGeminiResponseToOpenAINonStreamKeepsAssistantRole(t *testing.T) {
input := []byte(`{"candidates":[{"index":0,"content":{"parts":[{"text":"hello"}]},"finishReason":"STOP"}]}`)
output := ConvertGeminiResponseToOpenAINonStream(context.Background(), "", nil, nil, input, nil)
if role := gjson.GetBytes(output, "choices.0.message.role").String(); role != "assistant" {
t.Fatalf("role = %q, want assistant", role)
}
}
func TestConvertGeminiResponseToOpenAIStreamingSetsAssistantRoleOnce(t *testing.T) {
input := []byte(`{"candidates":[{"index":0,"content":{"parts":[{"text":"hello"},{"functionCall":{"name":"lookup","args":{}}},{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}}]}`)
var param any
outputs := ConvertGeminiResponseToOpenAI(context.Background(), "", nil, nil, input, &param)
if len(outputs) != 1 {
t.Fatalf("output count = %d, want 1", len(outputs))
}
if role := gjson.GetBytes(outputs[0], "choices.0.delta.role").String(); role != "assistant" {
t.Fatalf("role = %q, want assistant", role)
}
if got := gjson.GetBytes(outputs[0], "choices.0.delta.content").String(); got != "hello" {
t.Fatalf("content = %q, want hello", got)
}
if !gjson.GetBytes(outputs[0], "choices.0.delta.tool_calls.0").Exists() {
t.Fatal("tool call should be present")
}
if !gjson.GetBytes(outputs[0], "choices.0.delta.images.0").Exists() {
t.Fatal("image should be present")
}
}

View file

@ -0,0 +1,19 @@
package responses
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(
OpenaiResponse,
Gemini,
ConvertOpenAIResponsesRequestToGemini,
interfaces.TranslateResponse{
Stream: ConvertGeminiResponseToOpenAIResponses,
NonStream: ConvertGeminiResponseToOpenAIResponsesNonStream,
},
)
}

View file

@ -0,0 +1,32 @@
package responses
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIResponsesRequestToGeminiBuildsGenerationConfigWithoutIntermediateObject(t *testing.T) {
input := []byte(`{"input":"hello","temperature":0.5,"top_p":0.9,"stop_sequences":["done"],"text":{"format":{"type":"json_schema","schema":{"type":"object"}}}}`)
output := ConvertOpenAIResponsesRequestToGemini("gemini-test", input, false)
if got := gjson.GetBytes(output, "generationConfig.temperature").Float(); got != 0.5 {
t.Fatalf("temperature = %v, want 0.5", got)
}
if got := gjson.GetBytes(output, "generationConfig.topP").Float(); got != 0.9 {
t.Fatalf("topP = %v, want 0.9", got)
}
if got := gjson.GetBytes(output, "generationConfig.stopSequences.0").String(); got != "done" {
t.Fatalf("stop sequence = %q, want done", got)
}
if got := gjson.GetBytes(output, "generationConfig.responseMimeType").String(); got != "application/json" {
t.Fatalf("responseMimeType = %q, want application/json", got)
}
if !gjson.GetBytes(output, "generationConfig.responseJsonSchema").Exists() {
t.Fatal("responseJsonSchema should be present")
}
if gjson.GetBytes(output, "generationConfig.responseSchema").Exists() {
t.Fatal("responseSchema should not be present")
}
}

View file

@ -0,0 +1,199 @@
package responses
import (
"encoding/base64"
"encoding/json"
"strings"
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
geminiResponsesCarrierPrefix = "cpa-gemini-responses-carrier-v1:"
geminiResponsesCarrierNext = "next"
geminiResponsesCarrierPrevious = "previous"
geminiResponsesCarrierStandalone = "standalone"
geminiResponsesCarrierText = "text"
geminiResponsesCarrierFunction = "function"
geminiResponsesCarrierAny = "any"
geminiResponsesCarrierDirectionField = "_cpa_reasoning_direction"
geminiResponsesCarrierTargetField = "_cpa_reasoning_target"
geminiResponsesCarrierSignatureField = "_cpa_reasoning_signature"
geminiResponsesCarrierSummaryField = "_cpa_reasoning_summary"
)
func encodeGeminiResponsesCarrier(rawSignature, direction, targetKind string) string {
rawSignature = strings.TrimSpace(rawSignature)
if rawSignature == "" {
return ""
}
return geminiResponsesCarrierPrefix + direction + ":" + targetKind + ":" + base64.RawStdEncoding.EncodeToString([]byte(rawSignature))
}
func decodeGeminiResponsesCarrier(rawSignature string) (signatureValue, direction, targetKind string, marked, ok bool) {
rawSignature = strings.TrimSpace(rawSignature)
if !strings.HasPrefix(rawSignature, geminiResponsesCarrierPrefix) {
return rawSignature, "", "", false, true
}
marked = true
if len(rawSignature) > (sigcompat.MaxGeminiThoughtSignatureLen*4/3)+1024 {
return "", "", "", true, false
}
fields := strings.SplitN(strings.TrimPrefix(rawSignature, geminiResponsesCarrierPrefix), ":", 3)
if len(fields) != 3 {
return "", "", "", true, false
}
direction, targetKind = fields[0], fields[1]
switch direction {
case geminiResponsesCarrierNext, geminiResponsesCarrierPrevious, geminiResponsesCarrierStandalone:
default:
return "", "", "", true, false
}
switch targetKind {
case geminiResponsesCarrierText, geminiResponsesCarrierFunction, geminiResponsesCarrierAny:
default:
return "", "", "", true, false
}
decoded, errDecode := base64.RawStdEncoding.DecodeString(fields[2])
if errDecode != nil || len(decoded) == 0 || strings.HasPrefix(string(decoded), geminiResponsesCarrierPrefix) {
return "", "", "", true, false
}
return string(decoded), direction, targetKind, true, true
}
func compatibleGeminiResponsesCarrierSignature(rawSignature, targetKind string) (string, bool) {
blockKind := sigcompat.SignatureBlockKindGeminiModelPart
if targetKind == geminiResponsesCarrierFunction {
blockKind = sigcompat.SignatureBlockKindGeminiFunctionCall
}
normalized, compatible := sigcompat.CompatibleSignatureForProviderBlock(sigcompat.SignatureProviderGemini, rawSignature, blockKind)
if !compatible || sigcompat.IsGeminiThoughtSignatureBypass(sigcompat.SignaturePayloadWithoutProviderPrefix(normalized)) {
return "", false
}
return normalized, true
}
func geminiResponsesCarrierSemanticTarget(item gjson.Result) string {
switch item.Get("type").String() {
case "function_call", "custom_tool_call":
return geminiResponsesCarrierFunction
case "reasoning":
if strings.TrimSpace(item.Get("summary.0.text").String()) != "" {
return geminiResponsesCarrierText
}
}
if _, ok := openAIResponsesAssistantVisibleText(item); ok {
return geminiResponsesCarrierText
}
return ""
}
func geminiResponsesCarrierMatchesAdjacent(items []gjson.Result, index int, direction, targetKind string) bool {
step := 1
if direction == geminiResponsesCarrierPrevious {
step = -1
}
for adjacent := index + step; adjacent >= 0 && adjacent < len(items); adjacent += step {
if kind := geminiResponsesCarrierSemanticTarget(items[adjacent]); kind != "" {
return targetKind == geminiResponsesCarrierAny || targetKind == kind
}
if !isOpenAIResponsesDetachedCarrier(items[adjacent]) {
return false
}
}
return false
}
func hasInternalCarrierFields(item gjson.Result) bool {
return item.Get(geminiResponsesCarrierDirectionField).Exists() ||
item.Get(geminiResponsesCarrierTargetField).Exists() ||
item.Get(geminiResponsesCarrierSignatureField).Exists() ||
item.Get(geminiResponsesCarrierSummaryField).Exists()
}
func stripGeminiResponsesCarrierMetadata(rawJSON string) ([]byte, bool) {
var fields map[string]json.RawMessage
if err := json.Unmarshal([]byte(rawJSON), &fields); err != nil {
return []byte(rawJSON), false
}
delete(fields, geminiResponsesCarrierDirectionField)
delete(fields, geminiResponsesCarrierTargetField)
delete(fields, geminiResponsesCarrierSignatureField)
delete(fields, geminiResponsesCarrierSummaryField)
stripped, errMarshal := json.Marshal(fields)
if errMarshal != nil {
return []byte(rawJSON), false
}
return stripped, true
}
func normalizeGeminiResponsesCarriers(items []gjson.Result) ([]gjson.Result, bool) {
normalized := make([]gjson.Result, 0, len(items))
hasValidCarrier := false
for itemIndex, originalItem := range items {
item := originalItem
var itemJSON []byte
if hasInternalCarrierFields(originalItem) {
stripped, ok := stripGeminiResponsesCarrierMetadata(originalItem.Raw)
if ok {
itemJSON = stripped
item = gjson.ParseBytes(itemJSON)
}
}
if item.Get("type").String() != "reasoning" {
normalized = append(normalized, item)
continue
}
if len(itemJSON) == 0 {
itemJSON = []byte(item.Raw)
}
rawSignature := strings.TrimSpace(item.Get("encrypted_content").String())
signature, direction, targetKind, marked, ok := decodeGeminiResponsesCarrier(rawSignature)
if !marked {
if rawSignature != "" {
_, hasCompatibleRawCarrier := compatibleGeminiResponsesCarrierSignature(rawSignature, geminiResponsesCarrierAny)
hasValidCarrier = hasValidCarrier || hasCompatibleRawCarrier
}
normalized = append(normalized, item)
continue
}
if ok {
signature, ok = compatibleGeminiResponsesCarrierSignature(signature, targetKind)
}
if ok && direction != geminiResponsesCarrierStandalone {
ok = geminiResponsesCarrierMatchesAdjacent(items, itemIndex, direction, targetKind)
}
isDetached := isOpenAIResponsesDetachedCarrier(item)
hasSummary := strings.TrimSpace(item.Get("summary.0.text").String()) != ""
validSummaryCarrier := hasSummary && ((direction == geminiResponsesCarrierStandalone && (targetKind == geminiResponsesCarrierText || targetKind == geminiResponsesCarrierAny)) || direction == geminiResponsesCarrierNext)
if !ok || (!isDetached && !validSummaryCarrier) {
if strings.TrimSpace(item.Get("summary.0.text").String()) == "" {
continue
}
itemJSON, _ = sjson.DeleteBytes(itemJSON, "encrypted_content")
normalized = append(normalized, gjson.ParseBytes(itemJSON))
continue
}
hasValidCarrier = true
itemJSON, _ = sjson.SetBytes(itemJSON, "encrypted_content", signature)
itemJSON, _ = sjson.SetBytes(itemJSON, geminiResponsesCarrierDirectionField, direction)
itemJSON, _ = sjson.SetBytes(itemJSON, geminiResponsesCarrierTargetField, targetKind)
normalized = append(normalized, gjson.ParseBytes(itemJSON))
}
return normalized, hasValidCarrier
}
func geminiResponsesCarrierDirection(item gjson.Result) string {
return item.Get(geminiResponsesCarrierDirectionField).String()
}
func geminiResponsesCarrierTarget(item gjson.Result) string {
return item.Get(geminiResponsesCarrierTargetField).String()
}
func isOpenAIResponsesDetachedCarrier(item gjson.Result) bool {
return item.Get("type").String() == "reasoning" && strings.TrimSpace(item.Get("encrypted_content").String()) != "" && strings.TrimSpace(item.Get("summary.0.text").String()) == ""
}

View file

@ -0,0 +1,167 @@
package responses
import (
"context"
"encoding/base64"
"strconv"
"strings"
"testing"
"github.com/tidwall/gjson"
"google.golang.org/protobuf/encoding/protowire"
)
func TestGeminiResponsesCarrierRoundTrip(t *testing.T) {
for _, testCase := range []struct {
direction string
targetKind string
}{
{geminiResponsesCarrierNext, geminiResponsesCarrierText},
{geminiResponsesCarrierPrevious, geminiResponsesCarrierFunction},
{geminiResponsesCarrierStandalone, geminiResponsesCarrierAny},
} {
encoded := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, testCase.direction, testCase.targetKind)
signature, direction, targetKind, marked, ok := decodeGeminiResponsesCarrier(encoded)
if !marked || !ok || signature != testResponsesGeminiThoughtSignature || direction != testCase.direction || targetKind != testCase.targetKind {
t.Fatalf("carrier round-trip = %q/%q/%q marked=%v ok=%v", signature, direction, targetKind, marked, ok)
}
}
}
func TestNormalizeGeminiResponsesCarriersDropsMalformedEnvelope(t *testing.T) {
items := gjson.Parse(`[{"type":"reasoning","encrypted_content":"` + geminiResponsesCarrierPrefix + `previous:text:not-base64!","summary":[]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"safe"}]}]`).Array()
normalized, hasCarrier := normalizeGeminiResponsesCarriers(items)
if hasCarrier || len(normalized) != 1 || normalized[0].Get("type").String() != "message" || strings.Contains(normalized[0].Raw, geminiResponsesCarrierPrefix) {
t.Fatalf("malformed carrier was preserved: %v", normalized)
}
}
func TestConvertOpenAIResponsesRequestToGemini_DecodesCarrierForAliasModel(t *testing.T) {
carrier := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierText)
request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"reasoning","encrypted_content":"` + carrier + `","summary":[]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}]}`)
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
part := gjson.GetBytes(translated, "contents.0.parts.0")
if part.Get("text").String() != "answer" || part.Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || strings.Contains(string(translated), geminiResponsesCarrierPrefix) {
t.Fatalf("alias model did not decode carrier: %s", translated)
}
}
func TestGeminiResponsesWrappedUUIDFunctionSignatureRoundTrip(t *testing.T) {
const providerUUID = "e24830a7-5cd6-42fe-998b-ee539e72b9c3"
inner := protowire.AppendTag(nil, 1, protowire.BytesType)
inner = protowire.AppendBytes(inner, []byte(providerUUID))
outer := protowire.AppendTag(nil, 2, protowire.BytesType)
outer = protowire.AppendBytes(outer, inner)
signature := base64.StdEncoding.EncodeToString(outer)
providerResponse := `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"` + signature + `","functionCall":{"id":"native-call","name":"run","args":{"command":"true"}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"wrapped-uuid"}}`
var state any
chunks := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash", []byte(`{"model":"alias-without-provider-name"}`), nil, []byte(providerResponse), &state)
clientItems := make([]string, 0, 2)
callID := ""
for _, chunk := range chunks {
event, data := parseSSEEvent(t, chunk)
if event != "response.output_item.done" {
continue
}
item := data.Get("item")
switch item.Get("type").String() {
case "reasoning":
decoded, direction, targetKind, marked, ok := decodeGeminiResponsesCarrier(item.Get("encrypted_content").String())
if !marked || !ok || decoded != signature || direction != geminiResponsesCarrierNext || targetKind != geminiResponsesCarrierFunction {
t.Fatalf("provider signature carrier = marked:%v ok:%v direction:%q target:%q", marked, ok, direction, targetKind)
}
clientItems = append(clientItems, item.Raw)
case "function_call":
callID = item.Get("call_id").String()
clientItems = append(clientItems, item.Raw)
}
}
if len(clientItems) != 2 || callID == "" {
t.Fatalf("Responses client items = %v, call ID present=%v", clientItems, callID != "")
}
clientItems = append(clientItems, `{"type":"function_call_output","call_id":`+strconv.Quote(callID)+`,"output":"ok"}`)
request := []byte(`{"model":"alias-without-provider-name","input":[` + strings.Join(clientItems, ",") + `]}`)
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
var functionPart gjson.Result
gjson.GetBytes(translated, "contents").ForEach(func(_, content gjson.Result) bool {
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
if part.Get("functionCall").Exists() {
functionPart = part
return false
}
return true
})
return !functionPart.Exists()
})
if !functionPart.Exists() || functionPart.Get("functionCall.name").String() != "run" || functionPart.Get("functionCall.args.command").String() != "true" {
t.Fatalf("function carrier did not bind to the native call: %s", translated)
}
if got := functionPart.Get("thoughtSignature").String(); got != signature || got == geminiResponsesThoughtSignature {
t.Fatalf("function signature = %q, want provider-native wrapped UUID signature", got)
}
if strings.Contains(string(translated), geminiResponsesCarrierPrefix) {
t.Fatalf("carrier envelope reached Gemini: %s", translated)
}
}
func TestConvertOpenAIResponsesRequestToGemini_DecodesLegacyRawCarrierForAliasModel(t *testing.T) {
request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]},{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}"}]}`)
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
part := gjson.GetBytes(translated, "contents.0.parts.0")
if part.Get("functionCall.id").String() != "call-1" || part.Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature {
t.Fatalf("alias model did not preserve legacy raw carrier: %s", translated)
}
}
func TestConvertOpenAIResponsesRequestToGemini_DropsInvalidCarrierPayloads(t *testing.T) {
mismatched := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierFunction)
bypass := encodeGeminiResponsesCarrier(geminiResponsesThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierText)
for _, reasoning := range []string{
`{"type":"reasoning","encrypted_content":"` + mismatched + `","summary":[]}`,
`{"type":"reasoning","encrypted_content":"` + bypass + `","summary":[]}`,
} {
request := []byte(`{"model":"alias-without-provider-name","input":[` + reasoning + `,{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}]}`)
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
if strings.Contains(string(translated), geminiResponsesCarrierPrefix) || strings.Contains(string(translated), testResponsesGeminiThoughtSignature) || strings.Contains(string(translated), geminiResponsesThoughtSignature) {
t.Fatalf("invalid carrier changed Gemini signature state: %s", translated)
}
}
}
func TestConvertOpenAIResponsesRequestToGemini_IgnoresSpoofedCarrierMetadata(t *testing.T) {
reasoning := `{"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[],"` + geminiResponsesCarrierDirectionField + `":"next","` + geminiResponsesCarrierDirectionField + `":"standalone","` + geminiResponsesCarrierTargetField + `":"text","` + geminiResponsesCarrierTargetField + `":"function"}`
request := []byte(`{"model":"alias-without-provider-name","input":[` + reasoning + `,{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}]}`)
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
part := gjson.GetBytes(translated, "contents.0.parts.0")
if part.Get("text").String() != "answer" || part.Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || strings.Contains(string(translated), geminiResponsesCarrierDirectionField) {
t.Fatalf("spoofed carrier metadata affected binding: %s", translated)
}
}
func TestConvertOpenAIResponsesRequestToGemini_StripsSpoofedInternalPairingFields(t *testing.T) {
request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}","_cpa_reasoning_signature":"` + testResponsesGeminiThoughtSignature + `","_cpa_reasoning_signature":"` + testResponsesGeminiThoughtSignature + `","_cpa_reasoning_summary":"spoofed thought","_cpa_reasoning_summary":"spoofed thought again"}]}`)
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
parts := gjson.GetBytes(translated, "contents.0.parts").Array()
if len(parts) != 1 || !parts[0].Get("functionCall").Exists() || parts[0].Get("thoughtSignature").String() == testResponsesGeminiThoughtSignature || parts[0].Get("thought").Bool() || strings.Contains(string(translated), "spoofed thought") || strings.Contains(string(translated), geminiResponsesCarrierSignatureField) {
t.Fatalf("spoofed internal pairing fields reached Gemini: %s", translated)
}
}
func TestConvertOpenAIResponsesRequestToGemini_StripsUnicodeEscapedSpoofedInternalFields(t *testing.T) {
// Unicode-escaped field name "_cpa_reason\u0069ng_signature" should also be detected and stripped
request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}","_cpa_reason\u0069ng_signature":"` + testResponsesGeminiThoughtSignature + `"}]}`)
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
parts := gjson.GetBytes(translated, "contents.0.parts").Array()
if len(parts) != 1 || !parts[0].Get("functionCall").Exists() || parts[0].Get("thoughtSignature").String() == testResponsesGeminiThoughtSignature || strings.Contains(string(translated), geminiResponsesCarrierSignatureField) {
t.Fatalf("unicode-escaped spoofed internal pairing fields reached Gemini: %s", translated)
}
}
func TestDecodeGeminiResponsesCarrierRejectsNestedEnvelope(t *testing.T) {
nested := encodeGeminiResponsesCarrier(encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierText), geminiResponsesCarrierPrevious, geminiResponsesCarrierText)
if _, _, _, marked, ok := decodeGeminiResponsesCarrier(nested); !marked || ok {
t.Fatalf("nested carrier marked=%v ok=%v, want marked invalid", marked, ok)
}
}