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,522 @@
// Package gemini provides request translation functionality for Gemini to Claude Code API compatibility.
// It handles parsing and transforming Gemini API requests into Claude Code API format,
// extracting model information, system instructions, message contents, and tool declarations.
// The package performs JSON data transformation to ensure compatibility
// between Gemini API format and Claude Code API's expected format.
package gemini
import (
"fmt"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertGeminiRequestToClaude parses and transforms a Gemini API request into Claude Code API format.
// It extracts the model name, system instruction, message contents, and tool declarations
// from the raw JSON request and returns them in the format expected by the Claude Code API.
// The function performs comprehensive transformation including:
// 1. Model name mapping and generation configuration extraction
// 2. System instruction conversion to Claude Code format
// 3. Message content conversion with proper role mapping
// 4. Tool call and tool result handling with FIFO queue for ID matching
// 5. Image and file data conversion to Claude Code base64 format
// 6. Tool declaration and tool choice configuration mapping
//
// Parameters:
// - modelName: The name of the model to use for the request
// - rawJSON: The raw JSON request data from the Gemini API
// - stream: A boolean indicating if the request is for a streaming response
//
// Returns:
// - []byte: The transformed request data in Claude Code API format
func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := inputRawJSON
userID := translatorcommon.DeriveClaudeUserID(rawJSON)
// Base Claude message payload
out := []byte(`{"model":"","max_tokens":32000,"messages":[],"metadata":{}}`)
out, _ = sjson.SetBytes(out, "metadata.user_id", userID)
root := gjson.ParseBytes(rawJSON)
messageAccumulator := translatorcommon.NewClaudeMessageAccumulator(int(root.Get("contents.#").Int()) + 1)
getGeminiToolID := func(value gjson.Result) string {
if toolID := strings.TrimSpace(value.Get("id").String()); toolID != "" {
return toolID
}
return strings.TrimSpace(value.Get("call_id").String())
}
removePendingToolID := func(ids []string, toolID string) []string {
if toolID == "" {
return ids
}
for idx, pendingID := range ids {
if pendingID == toolID {
return append(ids[:idx], ids[idx+1:]...)
}
}
return ids
}
// FIFO queue to store tool call IDs for matching with tool results
// Gemini uses sequential pairing across possibly multiple in-flight
// functionCalls, so we keep a FIFO queue of generated tool IDs and
// consume them in order when functionResponses arrive.
var pendingToolIDs []string
toolCallCounter := 0
// Model mapping to specify which Claude Code model to use
out, _ = sjson.SetBytes(out, "model", modelName)
if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String {
out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String())
}
// Generation config extraction from Gemini format
if genConfig := root.Get("generationConfig"); genConfig.Exists() {
// Max output tokens configuration
if maxTokens := genConfig.Get("maxOutputTokens"); maxTokens.Exists() {
out, _ = sjson.SetBytes(out, "max_tokens", maxTokens.Int())
}
// Top P setting for nucleus sampling.
if topP := genConfig.Get("topP"); topP.Exists() {
out, _ = sjson.SetBytes(out, "top_p", topP.Float())
}
// Stop sequences configuration for custom termination conditions
if stopSeqs := genConfig.Get("stopSequences"); stopSeqs.Exists() && stopSeqs.IsArray() {
var stopSequences []string
stopSeqs.ForEach(func(_, value gjson.Result) bool {
stopSequences = append(stopSequences, value.String())
return true
})
if len(stopSequences) > 0 {
out, _ = sjson.SetBytes(out, "stop_sequences", stopSequences)
}
}
// Include thoughts configuration for reasoning process visibility
// Translator only does format conversion, ApplyThinking handles model capability validation.
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
mi := registry.LookupModelInfo(modelName, "claude")
supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0
supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax))
// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid
// validation errors since validate treats same-provider unsupported levels as errors.
thinkingLevel := thinkingConfig.Get("thinkingLevel")
if !thinkingLevel.Exists() {
thinkingLevel = thinkingConfig.Get("thinking_level")
}
if thinkingLevel.Exists() {
level := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
if supportsAdaptive {
switch level {
case "":
case "none":
out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
out, _ = sjson.DeleteBytes(out, "output_config.effort")
default:
if mapped, ok := thinking.MapToClaudeEffort(level, supportsMax); ok {
level = mapped
}
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
out, _ = sjson.SetBytes(out, "output_config.effort", level)
}
} else {
switch level {
case "":
case "none":
out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
case "auto":
out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
default:
if budget, ok := thinking.ConvertLevelToBudget(level); ok {
out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
out, _ = sjson.SetBytes(out, "thinking.budget_tokens", budget)
}
}
}
} else {
thinkingBudget := thinkingConfig.Get("thinkingBudget")
if !thinkingBudget.Exists() {
thinkingBudget = thinkingConfig.Get("thinking_budget")
}
if thinkingBudget.Exists() {
budget := int(thinkingBudget.Int())
if supportsAdaptive {
switch budget {
case 0:
out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
out, _ = sjson.DeleteBytes(out, "output_config.effort")
default:
level, ok := thinking.ConvertBudgetToLevel(budget)
if ok {
if mapped, okM := thinking.MapToClaudeEffort(level, supportsMax); okM {
level = mapped
}
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
out, _ = sjson.SetBytes(out, "output_config.effort", level)
}
}
} else {
switch budget {
case 0:
out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
case -1:
out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
default:
out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
out, _ = sjson.SetBytes(out, "thinking.budget_tokens", budget)
}
}
}
}
}
}
// System instruction conversion to Claude Code format
if sysInstr := root.Get("system_instruction"); sysInstr.Exists() {
if parts := sysInstr.Get("parts"); parts.Exists() && parts.IsArray() {
var systemText strings.Builder
parts.ForEach(func(_, part gjson.Result) bool {
if translatorcommon.IsGeminiThoughtPart(part) {
return true
}
if text := part.Get("text"); text.Exists() {
if systemText.Len() > 0 {
systemText.WriteString("\n")
}
systemText.WriteString(text.String())
}
return true
})
if systemText.Len() > 0 {
// Create system message in Claude Code format.
systemMessage := []byte(`{"role":"user","content":[{"type":"text","text":""}]}`)
systemMessage, _ = sjson.SetBytes(systemMessage, "content.0.text", systemText.String())
messageAccumulator.Append(systemMessage)
messageAccumulator.Flush()
}
}
}
// Contents conversion to messages with proper role mapping
if contents := root.Get("contents"); contents.Exists() && contents.IsArray() {
contents.ForEach(func(_, content gjson.Result) bool {
role := content.Get("role").String()
// Map Gemini roles to Claude Code roles
if role == "model" {
role = "assistant"
}
if role == "function" {
role = "user"
}
if role == "tool" {
role = "user"
}
contentItems := make([][]byte, 0, 4)
if parts := content.Get("parts"); parts.Exists() && parts.IsArray() {
parts.ForEach(func(_, part gjson.Result) bool {
if translatorcommon.IsGeminiThoughtPart(part) {
return true
}
// Text content conversion
if text := part.Get("text"); text.Exists() {
textContent := []byte(`{"type":"text","text":""}`)
textContent, _ = sjson.SetBytes(textContent, "text", text.String())
contentItems = append(contentItems, textContent)
return true
}
// Function call (from model/assistant) conversion to tool use
if fc := part.Get("functionCall"); fc.Exists() && role == "assistant" {
toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
// Reuse gateway-provided IDs when present, otherwise generate one for pairing.
toolID := getGeminiToolID(fc)
if toolID == "" {
toolCallCounter++
toolID = fmt.Sprintf("toolu_gemini_%016d", toolCallCounter)
}
pendingToolIDs = append(pendingToolIDs, toolID)
toolUse, _ = sjson.SetBytes(toolUse, "id", toolID)
if name := fc.Get("name"); name.Exists() {
toolUse, _ = sjson.SetBytes(toolUse, "name", name.String())
}
if args := fc.Get("args"); args.Exists() && args.IsObject() {
toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(args.Raw))
}
contentItems = append(contentItems, toolUse)
return true
}
// Function response (from user) conversion to tool result
if fr := part.Get("functionResponse"); fr.Exists() {
toolResult := []byte(`{"type":"tool_result","tool_use_id":"","content":""}`)
// Attach the oldest queued tool_id to pair the response
// with its call. If the queue is empty, generate a new id.
var toolID string
if customID := getGeminiToolID(fr); customID != "" {
toolID = customID
pendingToolIDs = removePendingToolID(pendingToolIDs, toolID)
} else if len(pendingToolIDs) > 0 {
toolID = pendingToolIDs[0]
// Pop the first element from the queue
pendingToolIDs = pendingToolIDs[1:]
} else {
// Fallback: generate new ID if no pending tool_use found
toolCallCounter++
toolID = fmt.Sprintf("toolu_gemini_%016d", toolCallCounter)
}
toolResult, _ = sjson.SetBytes(toolResult, "tool_use_id", toolID)
// Extract result content from the function response
if result := fr.Get("response.result"); result.Exists() {
toolResult, _ = sjson.SetBytes(toolResult, "content", result.String())
} else if response := fr.Get("response"); response.Exists() {
toolResult, _ = sjson.SetBytes(toolResult, "content", response.Raw)
}
contentItems = append(contentItems, toolResult)
return true
}
// Inline data conversion to Claude Code content format
if inlineData := geminiClaudeInlineData(part); inlineData.Exists() {
if contentPart, ok := claudeContentPartFromGeminiInlineData(inlineData); ok {
contentItems = append(contentItems, contentPart)
}
return true
}
// File data conversion to Claude Code content format
if fileData := geminiClaudeFileData(part); fileData.Exists() {
if contentPart, ok := claudeContentPartFromGeminiFileData(fileData); ok {
contentItems = append(contentItems, contentPart)
}
return true
}
return true
})
}
// Only add message if it has content.
if len(contentItems) > 0 {
msg := []byte(`{"role":"","content":[]}`)
msg, _ = sjson.SetBytes(msg, "role", role)
msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
messageAccumulator.Append(msg)
}
return true
})
}
out = translatorcommon.SetRawArrayItems(out, "messages", messageAccumulator.Messages())
// Tools mapping: Gemini functionDeclarations -> Claude Code tools
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
var anthropicTools []interface{}
tools.ForEach(func(_, tool gjson.Result) bool {
if funcDecls := tool.Get("functionDeclarations"); funcDecls.Exists() && funcDecls.IsArray() {
funcDecls.ForEach(func(_, funcDecl gjson.Result) bool {
anthropicTool := []byte(`{"name":"","description":"","input_schema":{}}`)
if name := funcDecl.Get("name"); name.Exists() {
anthropicTool, _ = sjson.SetBytes(anthropicTool, "name", name.String())
}
if desc := funcDecl.Get("description"); desc.Exists() {
anthropicTool, _ = sjson.SetBytes(anthropicTool, "description", desc.String())
}
if params := funcDecl.Get("parameters"); params.Exists() {
cleaned := normalizeClaudeToolSchema(params)
anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", cleaned)
} else if params = funcDecl.Get("parametersJsonSchema"); params.Exists() {
cleaned := normalizeClaudeToolSchema(params)
anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", cleaned)
}
anthropicTool = lowercaseClaudeToolSchemaTypes(anthropicTool)
anthropicTools = append(anthropicTools, gjson.ParseBytes(anthropicTool).Value())
return true
})
}
return true
})
if len(anthropicTools) > 0 {
out, _ = sjson.SetBytes(out, "tools", anthropicTools)
}
}
// Tool config mapping from Gemini format to Claude Code format
if toolConfig := root.Get("tool_config"); toolConfig.Exists() {
out = setClaudeToolChoiceFromGeminiToolConfig(out, toolConfig.Get("function_calling_config"))
} else if toolConfig := root.Get("toolConfig"); toolConfig.Exists() {
out = setClaudeToolChoiceFromGeminiToolConfig(out, toolConfig.Get("functionCallingConfig"))
}
// Stream setting configuration
out, _ = sjson.SetBytes(out, "stream", stream)
return out
}
func normalizeClaudeToolSchema(parameters gjson.Result) []byte {
cleaned := []byte(parameters.Raw)
if parameters.Get("additionalProperties").Type != gjson.False {
cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false)
}
const schema = "http://json-schema.org/draft-07/schema#"
currentSchema := parameters.Get("$schema")
if currentSchema.Type != gjson.String || currentSchema.String() != schema {
cleaned, _ = sjson.SetBytes(cleaned, "$schema", schema)
}
return cleaned
}
func lowercaseClaudeToolSchemaTypes(tool []byte) []byte {
var pathsToLower []string
util.Walk(gjson.ParseBytes(tool), "", "type", &pathsToLower)
for _, path := range pathsToLower {
typeValue := gjson.GetBytes(tool, path)
normalizedType := strings.ToLower(typeValue.String())
if typeValue.Type == gjson.String && normalizedType == typeValue.String() {
continue
}
tool, _ = sjson.SetBytes(tool, path, normalizedType)
}
return tool
}
func setClaudeToolChoiceFromGeminiToolConfig(out []byte, funcCalling gjson.Result) []byte {
if !funcCalling.Exists() {
return out
}
mode := funcCalling.Get("mode")
if !mode.Exists() {
return out
}
switch mode.String() {
case "AUTO":
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`))
case "NONE":
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"none"}`))
case "ANY":
allowedNames := funcCalling.Get("allowedFunctionNames")
if !allowedNames.Exists() {
allowedNames = funcCalling.Get("allowed_function_names")
}
allowedNameItems := allowedNames.Array()
if allowedNames.IsArray() && len(allowedNameItems) == 1 {
choice := []byte(`{"type":"tool","name":""}`)
choice, _ = sjson.SetBytes(choice, "name", allowedNameItems[0].String())
out, _ = sjson.SetRawBytes(out, "tool_choice", choice)
} else {
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
}
}
return out
}
func geminiClaudeInlineData(part gjson.Result) gjson.Result {
inlineData := part.Get("inlineData")
if inlineData.Exists() {
return inlineData
}
return part.Get("inline_data")
}
func geminiClaudeFileData(part gjson.Result) gjson.Result {
fileData := part.Get("fileData")
if fileData.Exists() {
return fileData
}
return part.Get("file_data")
}
func claudeContentPartFromGeminiInlineData(inlineData gjson.Result) ([]byte, bool) {
mimeType := inlineData.Get("mimeType").String()
if mimeType == "" {
mimeType = inlineData.Get("mime_type").String()
}
data := inlineData.Get("data").String()
if mimeType == "" || data == "" {
return nil, false
}
lowerMimeType := strings.ToLower(mimeType)
switch {
case strings.HasPrefix(lowerMimeType, "image/"):
imageContent := []byte(`{"type":"image","source":{"type":"base64","media_type":"","data":""}}`)
imageContent, _ = sjson.SetBytes(imageContent, "source.media_type", mimeType)
imageContent, _ = sjson.SetBytes(imageContent, "source.data", data)
return imageContent, true
case strings.HasPrefix(lowerMimeType, "application/"), strings.HasPrefix(lowerMimeType, "text/"):
documentContent := []byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`)
documentContent, _ = sjson.SetBytes(documentContent, "source.media_type", mimeType)
documentContent, _ = sjson.SetBytes(documentContent, "source.data", data)
return documentContent, true
default:
return claudeTextContentPart(fmt.Sprintf("Media content: inline data (Type: %s)", mimeType)), true
}
}
func claudeContentPartFromGeminiFileData(fileData gjson.Result) ([]byte, bool) {
fileURI := fileData.Get("fileUri").String()
if fileURI == "" {
fileURI = fileData.Get("file_uri").String()
}
if fileURI == "" {
return nil, false
}
mimeType := fileData.Get("mimeType").String()
if mimeType == "" {
mimeType = fileData.Get("mime_type").String()
}
lowerMimeType := strings.ToLower(mimeType)
switch {
case strings.HasPrefix(lowerMimeType, "image/"):
imageContent := []byte(`{"type":"image","source":{"type":"url","url":""}}`)
imageContent, _ = sjson.SetBytes(imageContent, "source.url", fileURI)
return imageContent, true
case strings.HasPrefix(lowerMimeType, "application/"), strings.HasPrefix(lowerMimeType, "text/"):
documentContent := []byte(`{"type":"document","source":{"type":"url","url":""}}`)
documentContent, _ = sjson.SetBytes(documentContent, "source.url", fileURI)
if mimeType != "" {
documentContent, _ = sjson.SetBytes(documentContent, "source.media_type", mimeType)
}
return documentContent, true
default:
fileInfo := "File: " + fileURI
if mimeType != "" {
fileInfo += " (Type: " + mimeType + ")"
}
return claudeTextContentPart(fileInfo), true
}
}
func claudeTextContentPart(text string) []byte {
textContent := []byte(`{"type":"text","text":""}`)
textContent, _ = sjson.SetBytes(textContent, "text", text)
return textContent
}

View file

@ -0,0 +1,319 @@
package gemini
import (
"fmt"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertGeminiRequestToClaude_PreservesCustomToolIDs(t *testing.T) {
tests := []struct {
name string
callField string
responseField string
want string
}{
{
name: "id",
callField: `"id":"call_gateway_id"`,
responseField: `"id":"call_gateway_id"`,
want: "call_gateway_id",
},
{
name: "call_id",
callField: `"call_id":"call_gateway_call_id"`,
responseField: `"call_id":"call_gateway_call_id"`,
want: "call_gateway_call_id",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
raw := []byte(fmt.Sprintf(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "lookup", %s, "args": {"query": "status"}}}
]
},
{
"role": "user",
"parts": [
{"functionResponse": {"name": "lookup", %s, "response": {"result": "ok"}}}
]
}
]
}`, tt.callField, tt.responseField))
out := ConvertGeminiRequestToClaude("claude-sonnet-4", raw, false)
gotCallID := gjson.GetBytes(out, "messages.0.content.0.id").String()
if gotCallID != tt.want {
t.Fatalf("expected tool_use id %q, got %q; output=%s", tt.want, gotCallID, string(out))
}
gotResultID := gjson.GetBytes(out, "messages.1.content.0.tool_use_id").String()
if gotResultID != tt.want {
t.Fatalf("expected tool_result tool_use_id %q, got %q; output=%s", tt.want, gotResultID, string(out))
}
})
}
}
func TestConvertGeminiRequestToClaude_GroupsConsecutiveRoleTurns(t *testing.T) {
raw := []byte(`{
"contents":[
{"role":"model","parts":[{"text":"answer"}]},
{"role":"model","parts":[{"functionCall":{"name":"first","id":"call_1","args":{}}}]},
{"role":"model","parts":[{"functionCall":{"name":"second","id":"call_2","args":{}}}]},
{"role":"user","parts":[{"functionResponse":{"name":"first","id":"call_1","response":{"result":"one"}}}]},
{"role":"user","parts":[{"functionResponse":{"name":"second","id":"call_2","response":{"result":"two"}}}]}
]
}`)
out := ConvertGeminiRequestToClaude("claude-test", raw, false)
messages := gjson.GetBytes(out, "messages").Array()
if len(messages) != 2 {
t.Fatalf("message count = %d, want 2. Output: %s", len(messages), string(out))
}
assistantContent := messages[0].Get("content").Array()
wantAssistantTypes := []string{"text", "tool_use", "tool_use"}
if len(assistantContent) != len(wantAssistantTypes) {
t.Fatalf("assistant content count = %d, want %d. Output: %s", len(assistantContent), len(wantAssistantTypes), string(out))
}
for i, wantType := range wantAssistantTypes {
if got := assistantContent[i].Get("type").String(); got != wantType {
t.Fatalf("assistant content[%d].type = %q, want %q", i, got, wantType)
}
}
userContent := messages[1].Get("content").Array()
if len(userContent) != 2 {
t.Fatalf("user content count = %d, want 2. Output: %s", len(userContent), string(out))
}
for i, wantID := range []string{"call_1", "call_2"} {
if got := userContent[i].Get("type").String(); got != "tool_result" {
t.Fatalf("user content[%d].type = %q, want tool_result", i, got)
}
if got := userContent[i].Get("tool_use_id").String(); got != wantID {
t.Fatalf("user content[%d].tool_use_id = %q, want %q", i, got, wantID)
}
}
}
func TestConvertGeminiRequestToClaude_KeepsSystemInstructionUserSeparate(t *testing.T) {
raw := []byte(`{
"system_instruction":{"parts":[{"text":"system rule"}]},
"contents":[{"role":"user","parts":[{"text":"question"}]}]
}`)
out := ConvertGeminiRequestToClaude("claude-test", raw, false)
messages := gjson.GetBytes(out, "messages").Array()
if len(messages) != 2 {
t.Fatalf("message count = %d, want 2. Output: %s", len(messages), string(out))
}
if got := messages[0].Get("content.0.text").String(); got != "system rule" {
t.Fatalf("system user text = %q, want system rule", got)
}
if got := messages[1].Get("content.0.text").String(); got != "question" {
t.Fatalf("ordinary user text = %q, want question", got)
}
}
func TestConvertGeminiRequestToClaude_DropsTemperature(t *testing.T) {
raw := []byte(`{
"generationConfig": {
"temperature": 0.2,
"topP": 0.8
},
"contents": [
{
"role": "user",
"parts": [{"text": "hi"}]
}
]
}`)
out := ConvertGeminiRequestToClaude("claude-sonnet-5", raw, false)
if gjson.GetBytes(out, "temperature").Exists() {
t.Fatalf("temperature should be removed")
}
if got := gjson.GetBytes(out, "top_p").Float(); got != 0.8 {
t.Fatalf("top_p = %v, want 0.8", got)
}
}
func TestConvertGeminiRequestToClaude_AcceptsCamelInlineData(t *testing.T) {
out := ConvertGeminiRequestToClaude("claude-sonnet-4", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}]}`), false)
if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "image" {
t.Fatalf("content type = %q, want image. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.0.content.0.source.media_type").String(); got != "image/png" {
t.Fatalf("media_type = %q, want image/png. Output: %s", got, string(out))
}
}
func TestConvertGeminiRequestToClaude_SplitsNonImageInlineDataByMIME(t *testing.T) {
out := ConvertGeminiRequestToClaude("claude-sonnet-4", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"audio/wav","data":"UklGRg=="}},{"inlineData":{"mimeType":"video/mp4","data":"AAAAIGZ0eXA="}},{"inlineData":{"mimeType":"application/pdf","data":"JVBERi0="}}]}]}`), false)
if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "text" {
t.Fatalf("audio fallback type = %q, want text. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.0.content.1.type").String(); got != "text" {
t.Fatalf("video fallback type = %q, want text. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.0.content.2.type").String(); got != "document" {
t.Fatalf("document content type = %q, want document. Output: %s", got, string(out))
}
if gjson.GetBytes(out, "messages.0.content.#(type==\"image\")").Exists() {
t.Fatalf("non-image inlineData must not be converted to image. Output: %s", string(out))
}
}
func TestConvertGeminiRequestToClaude_DropsHiddenThoughtParts(t *testing.T) {
t.Run("thought-only turn", func(t *testing.T) {
out := ConvertGeminiRequestToClaude("claude-test", []byte(`{
"contents":[
{"role":"model","parts":[{"thought":true,"text":"internal reasoning","thoughtSignature":"opaque-provider-state"}]},
{"role":"user","parts":[{"text":"continue"}]}
]
}`), false)
messages := gjson.GetBytes(out, "messages").Array()
if len(messages) != 1 || messages[0].Get("role").String() != "user" || messages[0].Get("content.0.text").String() != "continue" {
t.Fatalf("hidden thought turn was not dropped. Output: %s", string(out))
}
})
t.Run("mixed turn", func(t *testing.T) {
out := ConvertGeminiRequestToClaude("claude-test", []byte(`{
"contents":[{"role":"model","parts":[
{"thought":true,"text":"internal reasoning","thoughtSignature":"opaque-provider-state"},
{"text":"visible answer"}
]}]
}`), false)
content := gjson.GetBytes(out, "messages.0.content").Array()
if len(content) != 1 || content[0].Get("type").String() != "text" || content[0].Get("text").String() != "visible answer" {
t.Fatalf("hidden thought was not dropped independently of visible text. Output: %s", string(out))
}
})
}
func TestConvertGeminiRequestToClaude_DeterministicToolIDs(t *testing.T) {
raw := []byte(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "first_tool", "args": {"q": "one"}}}
]
},
{
"role": "user",
"parts": [
{"functionResponse": {"name": "first_tool", "response": {"result": "ok1"}}}
]
},
{
"role": "model",
"parts": [
{"functionCall": {"name": "second_tool", "args": {"q": "two"}}}
]
},
{
"role": "user",
"parts": [
{"functionResponse": {"name": "second_tool", "response": {"result": "ok2"}}}
]
}
]
}`)
out1 := ConvertGeminiRequestToClaude("claude-sonnet-4", raw, false)
out2 := ConvertGeminiRequestToClaude("claude-sonnet-4", raw, false)
if string(out1) != string(out2) {
t.Fatalf("expected deterministic output across multiple conversions, got different outputs:\nout1=%s\nout2=%s", string(out1), string(out2))
}
wantID1 := "toolu_gemini_0000000000000001"
wantID2 := "toolu_gemini_0000000000000002"
gotCall1 := gjson.GetBytes(out1, "messages.0.content.0.id").String()
gotResp1 := gjson.GetBytes(out1, "messages.1.content.0.tool_use_id").String()
gotCall2 := gjson.GetBytes(out1, "messages.2.content.0.id").String()
gotResp2 := gjson.GetBytes(out1, "messages.3.content.0.tool_use_id").String()
if gotCall1 != wantID1 || gotResp1 != wantID1 {
t.Fatalf("expected first tool pair to have id %q, got call=%q, resp=%q", wantID1, gotCall1, gotResp1)
}
if gotCall2 != wantID2 || gotResp2 != wantID2 {
t.Fatalf("expected second tool pair to have id %q, got call=%q, resp=%q", wantID2, gotCall2, gotResp2)
}
}
func TestConvertGeminiRequestToClaude_PreservesCallerSuppliedMetadataUserID(t *testing.T) {
testCases := []struct {
name string
rawJSON string
expected string
}{
{
name: "plain string",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"custom-gemini-user-123"},"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`,
expected: "custom-gemini-user-123",
},
{
name: "special characters and json string",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"foo\"bar\nbaz\\qux"},"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`,
expected: "foo\"bar\nbaz\\qux",
},
{
name: "claude code json format",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"{\"device_id\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"session_id\":\"11111111-2222-4333-8444-555555555555\"}"},"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`,
expected: `{"device_id":"0000000000000000000000000000000000000000000000000000000000000000","session_id":"11111111-2222-4333-8444-555555555555"}`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
out := ConvertGeminiRequestToClaude("claude-test", []byte(tc.rawJSON), false)
if !gjson.ValidBytes(out) {
t.Fatalf("output is invalid json: %s", string(out))
}
got := gjson.GetBytes(out, "metadata.user_id").String()
if got != tc.expected {
t.Fatalf("metadata.user_id = %q, want %q", got, tc.expected)
}
})
}
}
func TestConvertGeminiRequestToClaude_DifferentSessionsProduceDifferentUserIDs(t *testing.T) {
a := []byte(`{"model":"claude-test","prompt_cache_key":"gemini-session-a","contents":[{"role":"user","parts":[{"text":"hello"}]}]}`)
b := []byte(`{"model":"claude-test","prompt_cache_key":"gemini-session-b","contents":[{"role":"user","parts":[{"text":"hello"}]}]}`)
outA := ConvertGeminiRequestToClaude("claude-test", a, false)
outB := ConvertGeminiRequestToClaude("claude-test", b, false)
idA := gjson.GetBytes(outA, "metadata.user_id").String()
idB := gjson.GetBytes(outB, "metadata.user_id").String()
if idA == idB {
t.Fatalf("different prompt_cache_key produced identical metadata.user_id: %q", idA)
}
}
func TestConvertGeminiRequestToClaude_DefaultRoleDifferentContentProducesDifferentUserIDs(t *testing.T) {
a := []byte(`{"contents":[{"parts":[{"text":"first prompt"}]}]}`)
b := []byte(`{"contents":[{"parts":[{"text":"second prompt"}]}]}`)
outA := ConvertGeminiRequestToClaude("claude-test", a, false)
outB := ConvertGeminiRequestToClaude("claude-test", b, false)
idA := gjson.GetBytes(outA, "metadata.user_id").String()
idB := gjson.GetBytes(outB, "metadata.user_id").String()
if idA == "" || idB == "" || idA == "unknown" || idB == "unknown" {
t.Fatalf("expected valid derived user_id without role, got idA=%q idB=%q", idA, idB)
}
if idA == idB {
t.Fatalf("different prompt texts without role produced identical metadata.user_id: %q", idA)
}
}

View file

@ -0,0 +1,635 @@
// Package gemini provides response translation functionality for Claude Code to Gemini API compatibility.
// This package handles the conversion of Claude Code API responses into Gemini-compatible
// JSON format, transforming streaming events and non-streaming responses into the format
// expected by Gemini API clients. It supports both streaming and non-streaming modes,
// handling text content, tool calls, and usage metadata appropriately.
package gemini
import (
"bytes"
"context"
"strings"
"time"
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
var (
dataTag = []byte("data:")
)
// ConvertAnthropicResponseToGeminiParams holds parameters for response conversion
// It also carries minimal streaming state across calls to assemble tool_use input_json_delta.
// This structure maintains state information needed for proper conversion of streaming responses
// from Claude Code format to Gemini format, particularly for handling tool calls that span
// multiple streaming events.
type ConvertAnthropicResponseToGeminiParams struct {
Model string
CreatedAt int64
ResponseID string
LastStorageOutput []byte
IsStreaming bool
// Streaming state for tool_use assembly
// Keyed by content_block index from Claude SSE events
ToolUseNames map[int]string // function/tool name per block index
ToolUseArgs map[int]*strings.Builder // accumulates partial_json across deltas
ToolUseIDs map[int]string // tool use ID per block index
}
// ConvertClaudeResponseToGemini converts Claude Code streaming response format to Gemini format.
// This function processes various Claude Code event types and transforms them into Gemini-compatible JSON responses.
// It handles text content, tool calls, reasoning content, and usage metadata, outputting responses that match
// the Gemini API format. The function supports incremental updates for streaming responses and maintains
// state information to properly assemble multi-part tool calls.
//
// Parameters:
// - ctx: The context for the request, used for cancellation and timeout handling
// - modelName: The name of the model being used for the response
// - rawJSON: The raw JSON response from the Claude Code API
// - param: A pointer to a parameter object for maintaining state between calls
//
// Returns:
// - [][]byte: A slice of Gemini-compatible JSON responses
func ConvertClaudeResponseToGemini(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
if *param == nil {
*param = &ConvertAnthropicResponseToGeminiParams{
Model: modelName,
CreatedAt: 0,
ResponseID: "",
}
}
if !bytes.HasPrefix(rawJSON, dataTag) {
return [][]byte{}
}
rawJSON = bytes.TrimSpace(rawJSON[5:])
root := gjson.ParseBytes(rawJSON)
eventType := root.Get("type").String()
// Base Gemini response template with default values
template := []byte(`{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"","createTime":"","responseId":""}`)
// Set model version
if (*param).(*ConvertAnthropicResponseToGeminiParams).Model != "" {
// Map Claude model names back to Gemini model names
template, _ = sjson.SetBytes(template, "modelVersion", (*param).(*ConvertAnthropicResponseToGeminiParams).Model)
}
// Set response ID and creation time
if (*param).(*ConvertAnthropicResponseToGeminiParams).ResponseID != "" {
template, _ = sjson.SetBytes(template, "responseId", (*param).(*ConvertAnthropicResponseToGeminiParams).ResponseID)
}
// Set creation time to current time if not provided
if (*param).(*ConvertAnthropicResponseToGeminiParams).CreatedAt == 0 {
(*param).(*ConvertAnthropicResponseToGeminiParams).CreatedAt = time.Now().Unix()
}
template, _ = sjson.SetBytes(template, "createTime", time.Unix((*param).(*ConvertAnthropicResponseToGeminiParams).CreatedAt, 0).Format(time.RFC3339Nano))
switch eventType {
case "message_start":
// Initialize response with message metadata when a new message begins
if message := root.Get("message"); message.Exists() {
(*param).(*ConvertAnthropicResponseToGeminiParams).ResponseID = message.Get("id").String()
(*param).(*ConvertAnthropicResponseToGeminiParams).Model = message.Get("model").String()
}
return [][]byte{}
case "content_block_start":
// Start of a content block - record tool_use name by index for functionCall assembly
if cb := root.Get("content_block"); cb.Exists() {
if cb.Get("type").String() == "tool_use" {
idx := int(root.Get("index").Int())
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames == nil {
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames = map[int]string{}
}
if name := cb.Get("name"); name.Exists() {
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames[idx] = name.String()
}
if toolID := cb.Get("id").String(); toolID != "" {
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs == nil {
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs = map[int]string{}
}
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs[idx] = toolID
}
} else if cb.Get("type").String() == "thinking" {
if sig := cb.Get("signature"); sig.Exists() && sig.String() != "" {
thinkingPart := []byte(`{"thought":true,"thoughtSignature":""}`)
thinkingPart, _ = sjson.SetBytes(thinkingPart, "thoughtSignature", sigcompat.GeminiReplaySignatureOrBypass(sig.String(), sigcompat.SignatureBlockKindGeminiModelPart))
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", thinkingPart)
return [][]byte{template}
}
}
}
return [][]byte{}
case "content_block_delta":
// Handle content delta (text, thinking, or tool use arguments)
if delta := root.Get("delta"); delta.Exists() {
deltaType := delta.Get("type").String()
switch deltaType {
case "text_delta":
// Regular text content delta for normal response text
if text := delta.Get("text"); text.Exists() && text.String() != "" {
textPart := []byte(`{"text":""}`)
textPart, _ = sjson.SetBytes(textPart, "text", text.String())
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", textPart)
}
case "thinking_delta":
// Thinking/reasoning content delta for models with reasoning capabilities
if text := delta.Get("thinking"); text.Exists() && text.String() != "" {
thinkingPart := []byte(`{"thought":true,"text":""}`)
thinkingPart, _ = sjson.SetBytes(thinkingPart, "text", text.String())
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", thinkingPart)
}
case "signature_delta":
if sig := delta.Get("signature"); sig.Exists() && sig.String() != "" {
thinkingPart := []byte(`{"thought":true,"thoughtSignature":""}`)
thinkingPart, _ = sjson.SetBytes(thinkingPart, "thoughtSignature", sigcompat.GeminiReplaySignatureOrBypass(sig.String(), sigcompat.SignatureBlockKindGeminiModelPart))
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", thinkingPart)
}
case "input_json_delta":
// Tool use input delta - accumulate partial_json by index for later assembly at content_block_stop
idx := int(root.Get("index").Int())
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs == nil {
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs = map[int]*strings.Builder{}
}
b, ok := (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs[idx]
if !ok || b == nil {
bb := &strings.Builder{}
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs[idx] = bb
b = bb
}
if pj := delta.Get("partial_json"); pj.Exists() {
b.WriteString(pj.String())
}
return [][]byte{}
}
}
return [][]byte{template}
case "content_block_stop":
// End of content block - finalize tool calls if any
idx := int(root.Get("index").Int())
// Claude's content_block_stop often doesn't include content_block payload (see docs/response-claude.txt)
// So we finalize using accumulated state captured during content_block_start and input_json_delta.
name := ""
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames != nil {
name = (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames[idx]
}
var argsTrim string
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs != nil {
if b := (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs[idx]; b != nil {
argsTrim = strings.TrimSpace(b.String())
}
}
toolID := ""
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs != nil {
toolID = (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs[idx]
}
if name != "" || argsTrim != "" {
functionCall := []byte(`{"functionCall":{"name":"","args":{}}}`)
if name != "" {
functionCall, _ = sjson.SetBytes(functionCall, "functionCall.name", name)
}
if argsTrim != "" {
functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsTrim))
}
if toolID != "" {
functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", toolID)
}
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", functionCall)
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
(*param).(*ConvertAnthropicResponseToGeminiParams).LastStorageOutput = append([]byte(nil), template...)
// cleanup used state for this index
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs != nil {
delete((*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs, idx)
}
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames != nil {
delete((*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames, idx)
}
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs != nil {
delete((*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs, idx)
}
return [][]byte{template}
}
return [][]byte{}
case "message_delta":
// Handle message-level changes (like stop reason and usage information)
if delta := root.Get("delta"); delta.Exists() {
if stopReason := delta.Get("stop_reason"); stopReason.Exists() {
switch stopReason.String() {
case "end_turn":
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
case "tool_use":
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
case "max_tokens":
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "MAX_TOKENS")
case "stop_sequence":
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
default:
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
}
}
}
if usage := root.Get("usage"); usage.Exists() {
// Basic token counts for prompt and completion
inputTokens := usage.Get("input_tokens").Int()
outputTokens := usage.Get("output_tokens").Int()
// Set basic usage metadata according to Gemini API specification
template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", inputTokens)
template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", outputTokens)
template, _ = sjson.SetBytes(template, "usageMetadata.totalTokenCount", inputTokens+outputTokens)
// Add cache-related token counts if present (Claude Code API cache fields)
if cacheCreationTokens := usage.Get("cache_creation_input_tokens"); cacheCreationTokens.Exists() {
template, _ = sjson.SetBytes(template, "usageMetadata.cachedContentTokenCount", cacheCreationTokens.Int())
}
if cacheReadTokens := usage.Get("cache_read_input_tokens"); cacheReadTokens.Exists() {
// Add cache read tokens to cached content count
existingCacheTokens := usage.Get("cache_creation_input_tokens").Int()
totalCacheTokens := existingCacheTokens + cacheReadTokens.Int()
template, _ = sjson.SetBytes(template, "usageMetadata.cachedContentTokenCount", totalCacheTokens)
}
// Add thinking tokens if present (for models with reasoning capabilities)
if thinkingTokens := usage.Get("thinking_tokens"); thinkingTokens.Exists() {
template, _ = sjson.SetBytes(template, "usageMetadata.thoughtsTokenCount", thinkingTokens.Int())
}
// Set traffic type (required by Gemini API)
template, _ = sjson.SetBytes(template, "usageMetadata.trafficType", "PROVISIONED_THROUGHPUT")
}
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
return [][]byte{template}
case "message_stop":
// Final message with usage information - no additional output needed
return [][]byte{}
case "error":
// Handle error responses and convert to Gemini error format
errorMsg := root.Get("error.message").String()
if errorMsg == "" {
errorMsg = "Unknown error occurred"
}
// Create error response in Gemini format
errorResponse := []byte(`{"error":{"code":400,"message":"","status":"INVALID_ARGUMENT"}}`)
errorResponse, _ = sjson.SetBytes(errorResponse, "error.message", errorMsg)
return [][]byte{errorResponse}
default:
// Unknown event type, return empty response
return [][]byte{}
}
}
// ConvertClaudeResponseToGeminiNonStream converts a non-streaming Claude Code response to a non-streaming Gemini response.
// This function processes the complete Claude Code response and transforms it into a single Gemini-compatible
// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all
// the information into a single response that matches the Gemini API format.
//
// Parameters:
// - ctx: The context for the request, used for cancellation and timeout handling
// - modelName: The name of the model being used for the response
// - rawJSON: The raw JSON response from the Claude Code API
// - param: A pointer to a parameter object for the conversion (unused in current implementation)
//
// Returns:
// - []byte: A Gemini-compatible JSON response containing all message content and metadata
func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
// Base Gemini response template for non-streaming with default values
template := []byte(`{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"STOP"}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"","createTime":"","responseId":""}`)
// Set model version
template, _ = sjson.SetBytes(template, "modelVersion", modelName)
streamingEvents := make([][]byte, 0)
remaining := rawJSON
for len(remaining) > 0 {
var line []byte
idx := bytes.IndexByte(remaining, '\n')
if idx >= 0 {
line = remaining[:idx]
remaining = remaining[idx+1:]
} else {
line = remaining
remaining = nil
}
line = bytes.TrimRight(line, "\r")
if bytes.HasPrefix(line, dataTag) {
jsonData := bytes.TrimSpace(line[5:])
streamingEvents = append(streamingEvents, jsonData)
}
}
// log.Debug("streamingEvents: ", streamingEvents)
// log.Debug("rawJSON: ", string(rawJSON))
// Initialize parameters for streaming conversion with proper state management
newParam := &ConvertAnthropicResponseToGeminiParams{
Model: modelName,
CreatedAt: 0,
ResponseID: "",
LastStorageOutput: nil,
IsStreaming: false,
ToolUseNames: nil,
ToolUseArgs: nil,
ToolUseIDs: nil,
}
// Process each streaming event and collect parts
var allParts [][]byte
var finalUsageJSON []byte
var responseID string
var createdAt int64
for _, eventData := range streamingEvents {
if len(eventData) == 0 {
continue
}
root := gjson.ParseBytes(eventData)
eventType := root.Get("type").String()
switch eventType {
case "message_start":
// Extract response metadata including ID, model, and creation time
if message := root.Get("message"); message.Exists() {
responseID = message.Get("id").String()
newParam.ResponseID = responseID
newParam.Model = message.Get("model").String()
// Set creation time to current time if not provided
createdAt = time.Now().Unix()
newParam.CreatedAt = createdAt
}
case "content_block_start":
// Prepare for content block; record tool_use name by index for later functionCall assembly
idx := int(root.Get("index").Int())
if cb := root.Get("content_block"); cb.Exists() {
if cb.Get("type").String() == "tool_use" {
if newParam.ToolUseNames == nil {
newParam.ToolUseNames = map[int]string{}
}
if name := cb.Get("name"); name.Exists() {
newParam.ToolUseNames[idx] = name.String()
}
if toolID := cb.Get("id").String(); toolID != "" {
if newParam.ToolUseIDs == nil {
newParam.ToolUseIDs = map[int]string{}
}
newParam.ToolUseIDs[idx] = toolID
}
} else if cb.Get("type").String() == "thinking" {
if sig := cb.Get("signature"); sig.Exists() && sig.String() != "" {
partJSON := []byte(`{"thought":true,"thoughtSignature":""}`)
partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", sigcompat.GeminiReplaySignatureOrBypass(sig.String(), sigcompat.SignatureBlockKindGeminiModelPart))
allParts = append(allParts, partJSON)
}
}
}
continue
case "content_block_delta":
// Handle content delta (text, thinking, or tool input)
if delta := root.Get("delta"); delta.Exists() {
deltaType := delta.Get("type").String()
switch deltaType {
case "text_delta":
// Process regular text content
if text := delta.Get("text"); text.Exists() && text.String() != "" {
partJSON := []byte(`{"text":""}`)
partJSON, _ = sjson.SetBytes(partJSON, "text", text.String())
allParts = append(allParts, partJSON)
}
case "thinking_delta":
// Process reasoning/thinking content
if text := delta.Get("thinking"); text.Exists() && text.String() != "" {
partJSON := []byte(`{"thought":true,"text":""}`)
partJSON, _ = sjson.SetBytes(partJSON, "text", text.String())
allParts = append(allParts, partJSON)
}
case "signature_delta":
if sig := delta.Get("signature"); sig.Exists() && sig.String() != "" {
partJSON := []byte(`{"thought":true,"thoughtSignature":""}`)
partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", sigcompat.GeminiReplaySignatureOrBypass(sig.String(), sigcompat.SignatureBlockKindGeminiModelPart))
allParts = append(allParts, partJSON)
}
case "input_json_delta":
// accumulate args partial_json for this index
idx := int(root.Get("index").Int())
if newParam.ToolUseArgs == nil {
newParam.ToolUseArgs = map[int]*strings.Builder{}
}
if _, ok := newParam.ToolUseArgs[idx]; !ok || newParam.ToolUseArgs[idx] == nil {
newParam.ToolUseArgs[idx] = &strings.Builder{}
}
if pj := delta.Get("partial_json"); pj.Exists() {
newParam.ToolUseArgs[idx].WriteString(pj.String())
}
}
}
case "content_block_stop":
// Handle tool use completion by assembling accumulated arguments
idx := int(root.Get("index").Int())
// Claude's content_block_stop often doesn't include content_block payload (see docs/response-claude.txt)
// So we finalize using accumulated state captured during content_block_start and input_json_delta.
name := ""
if newParam.ToolUseNames != nil {
name = newParam.ToolUseNames[idx]
}
var argsTrim string
if newParam.ToolUseArgs != nil {
if b := newParam.ToolUseArgs[idx]; b != nil {
argsTrim = strings.TrimSpace(b.String())
}
}
toolID := ""
if newParam.ToolUseIDs != nil {
toolID = newParam.ToolUseIDs[idx]
}
if name != "" || argsTrim != "" {
functionCallJSON := []byte(`{"functionCall":{"name":"","args":{}}}`)
if name != "" {
functionCallJSON, _ = sjson.SetBytes(functionCallJSON, "functionCall.name", name)
}
if argsTrim != "" {
functionCallJSON, _ = sjson.SetRawBytes(functionCallJSON, "functionCall.args", []byte(argsTrim))
}
if toolID != "" {
functionCallJSON, _ = sjson.SetBytes(functionCallJSON, "functionCall.id", toolID)
}
allParts = append(allParts, functionCallJSON)
// cleanup used state for this index
if newParam.ToolUseArgs != nil {
delete(newParam.ToolUseArgs, idx)
}
if newParam.ToolUseNames != nil {
delete(newParam.ToolUseNames, idx)
}
if newParam.ToolUseIDs != nil {
delete(newParam.ToolUseIDs, idx)
}
}
case "message_delta":
// Extract final usage information using sjson for token counts and metadata
if usage := root.Get("usage"); usage.Exists() {
usageJSON := []byte(`{}`)
// Basic token counts for prompt and completion
inputTokens := usage.Get("input_tokens").Int()
outputTokens := usage.Get("output_tokens").Int()
// Set basic usage metadata according to Gemini API specification
usageJSON, _ = sjson.SetBytes(usageJSON, "promptTokenCount", inputTokens)
usageJSON, _ = sjson.SetBytes(usageJSON, "candidatesTokenCount", outputTokens)
usageJSON, _ = sjson.SetBytes(usageJSON, "totalTokenCount", inputTokens+outputTokens)
// Add cache-related token counts if present (Claude Code API cache fields)
if cacheCreationTokens := usage.Get("cache_creation_input_tokens"); cacheCreationTokens.Exists() {
usageJSON, _ = sjson.SetBytes(usageJSON, "cachedContentTokenCount", cacheCreationTokens.Int())
}
if cacheReadTokens := usage.Get("cache_read_input_tokens"); cacheReadTokens.Exists() {
// Add cache read tokens to cached content count
existingCacheTokens := usage.Get("cache_creation_input_tokens").Int()
totalCacheTokens := existingCacheTokens + cacheReadTokens.Int()
usageJSON, _ = sjson.SetBytes(usageJSON, "cachedContentTokenCount", totalCacheTokens)
}
// Add thinking tokens if present (for models with reasoning capabilities)
if thinkingTokens := usage.Get("thinking_tokens"); thinkingTokens.Exists() {
usageJSON, _ = sjson.SetBytes(usageJSON, "thoughtsTokenCount", thinkingTokens.Int())
}
// Set traffic type (required by Gemini API)
usageJSON, _ = sjson.SetBytes(usageJSON, "trafficType", "PROVISIONED_THROUGHPUT")
finalUsageJSON = usageJSON
}
}
}
// Set response metadata
if responseID != "" {
template, _ = sjson.SetBytes(template, "responseId", responseID)
}
if createdAt > 0 {
template, _ = sjson.SetBytes(template, "createTime", time.Unix(createdAt, 0).Format(time.RFC3339Nano))
}
// Consolidate consecutive text parts and thinking parts for cleaner output
consolidatedParts := consolidateParts(allParts)
// Set the consolidated parts array
if len(consolidatedParts) > 0 {
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts", translatorcommon.JoinRawArray(consolidatedParts))
}
// Set usage metadata
if len(finalUsageJSON) > 0 {
template, _ = sjson.SetRawBytes(template, "usageMetadata", finalUsageJSON)
}
return template
}
func GeminiTokenCount(ctx context.Context, count int64) []byte {
return translatorcommon.GeminiTokenCountJSON(count)
}
// consolidateParts merges consecutive text parts and thinking parts to create a cleaner response.
// This function processes the parts array to combine adjacent text elements and thinking elements
// into single consolidated parts, which results in a more readable and efficient response structure.
// Tool calls and other non-text parts are preserved as separate elements.
func consolidateParts(parts [][]byte) [][]byte {
if len(parts) == 0 {
return parts
}
var consolidated [][]byte
var currentTextPart strings.Builder
var currentThoughtPart strings.Builder
var currentThoughtSignature string
var hasText, hasThought bool
flushText := func() {
// Flush accumulated text content to the consolidated parts array
if hasText && currentTextPart.Len() > 0 {
textPartJSON := []byte(`{"text":""}`)
textPartJSON, _ = sjson.SetBytes(textPartJSON, "text", currentTextPart.String())
consolidated = append(consolidated, textPartJSON)
currentTextPart.Reset()
hasText = false
}
}
flushThought := func() {
// Flush accumulated thinking content to the consolidated parts array
if hasThought && (currentThoughtPart.Len() > 0 || currentThoughtSignature != "") {
thoughtPartJSON := []byte(`{"thought":true,"text":""}`)
thoughtPartJSON, _ = sjson.SetBytes(thoughtPartJSON, "text", currentThoughtPart.String())
if currentThoughtSignature != "" {
thoughtPartJSON, _ = sjson.SetBytes(thoughtPartJSON, "thoughtSignature", currentThoughtSignature)
}
consolidated = append(consolidated, thoughtPartJSON)
currentThoughtPart.Reset()
currentThoughtSignature = ""
hasThought = false
}
}
for _, partJSON := range parts {
part := gjson.ParseBytes(partJSON)
if !part.Exists() || !part.IsObject() {
// Flush any pending parts and add this non-text part
flushText()
flushThought()
consolidated = append(consolidated, partJSON)
continue
}
thought := part.Get("thought")
if thought.Exists() && thought.Type == gjson.True {
// This is a thinking part - flush any pending text first
flushText() // Flush any pending text first
if text := part.Get("text"); text.Exists() && text.Type == gjson.String {
currentThoughtPart.WriteString(text.String())
hasThought = true
}
if sig := part.Get("thoughtSignature"); sig.Exists() && sig.Type == gjson.String && sig.String() != "" {
currentThoughtSignature = sig.String()
hasThought = true
}
} else if text := part.Get("text"); text.Exists() && text.Type == gjson.String {
// This is a regular text part - flush any pending thought first
flushThought() // Flush any pending thought first
currentTextPart.WriteString(text.String())
hasText = true
} else {
// This is some other type of part (like function call) - flush both text and thought
flushText()
flushThought()
consolidated = append(consolidated, partJSON)
}
}
// Flush any remaining parts
flushThought() // Flush thought first to maintain order
flushText()
return consolidated
}

View file

@ -0,0 +1,166 @@
package gemini
import (
"context"
"strings"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertClaudeResponseToGemini_StreamPreservesToolUseID(t *testing.T) {
ctx := context.Background()
var param any
start := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_gateway","name":"lookup"}}`)
out := ConvertClaudeResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, start, &param)
if len(out) != 0 {
t.Fatalf("expected content_block_start to be buffered, got %d chunks", len(out))
}
delta := []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"status\"}"}}`)
out = ConvertClaudeResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, delta, &param)
if len(out) != 0 {
t.Fatalf("expected input_json_delta to be buffered, got %d chunks", len(out))
}
stop := []byte(`data: {"type":"content_block_stop","index":0}`)
out = ConvertClaudeResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, stop, &param)
if len(out) != 1 {
t.Fatalf("expected content_block_stop to emit 1 chunk, got %d", len(out))
}
got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.functionCall.id").String()
if got != "toolu_gateway" {
t.Fatalf("expected functionCall.id %q, got %q; chunk=%s", "toolu_gateway", got, string(out[0]))
}
}
func TestConvertClaudeResponseToGeminiNonStreamPreservesToolUseID(t *testing.T) {
ctx := context.Background()
raw := []byte(strings.Join([]string{
`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_gateway","name":"lookup"}}`,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"status\"}"}}`,
`data: {"type":"content_block_stop","index":0}`,
}, "\n"))
out := ConvertClaudeResponseToGeminiNonStream(ctx, "gemini-2.5-pro", nil, nil, raw, nil)
got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.id").String()
if got != "toolu_gateway" {
t.Fatalf("expected functionCall.id %q, got %q; chunk=%s", "toolu_gateway", got, string(out))
}
}
func TestConvertClaudeResponseToGemini_StreamThinkingSignature(t *testing.T) {
const validGeminiSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
tests := []struct {
name string
signature string
wantSignature string
}{
{
name: "foreign claude signature maps to bypass sentinel",
signature: "foreign_claude_sig_123",
wantSignature: "skip_thought_signature_validator",
},
{
name: "preserves valid gemini signature",
signature: "gemini#" + validGeminiSignature,
wantSignature: validGeminiSignature,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
var param any
chunks := [][]byte{
[]byte(`data: {"type":"message_start","message":{"id":"msg_123","model":"claude-3-7-sonnet-20250219"}}`),
[]byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`),
[]byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"thinking text"}}`),
[]byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"` + tt.signature + `"}}`),
[]byte(`data: {"type":"content_block_stop","index":0}`),
[]byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`),
[]byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"final answer"}}`),
[]byte(`data: {"type":"content_block_stop","index":1}`),
[]byte(`data: {"type":"message_stop"}`),
}
var emittedParts []gjson.Result
for _, chunk := range chunks {
out := ConvertClaudeResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, chunk, &param)
for _, c := range out {
parts := gjson.GetBytes(c, "candidates.0.content.parts").Array()
emittedParts = append(emittedParts, parts...)
}
}
var foundSignature string
for _, p := range emittedParts {
if p.Get("thought").Bool() && p.Get("thoughtSignature").Exists() {
foundSignature = p.Get("thoughtSignature").String()
}
}
if foundSignature != tt.wantSignature {
t.Fatalf("expected thoughtSignature %q, got %q", tt.wantSignature, foundSignature)
}
})
}
}
func TestConvertClaudeResponseToGeminiNonStream_ThinkingSignature(t *testing.T) {
const validGeminiSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
tests := []struct {
name string
signature string
wantSignature string
}{
{
name: "foreign claude signature maps to bypass sentinel",
signature: "foreign_claude_sig_123",
wantSignature: "skip_thought_signature_validator",
},
{
name: "preserves valid gemini signature",
signature: "gemini#" + validGeminiSignature,
wantSignature: validGeminiSignature,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
raw := []byte(strings.Join([]string{
`data: {"type":"message_start","message":{"id":"msg_123","model":"claude-3-7-sonnet-20250219"}}`,
`data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"thinking text"}}`,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"` + tt.signature + `"}}`,
`data: {"type":"content_block_stop","index":0}`,
`data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`,
`data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"final answer"}}`,
`data: {"type":"content_block_stop","index":1}`,
`data: {"type":"message_stop"}`,
}, "\n"))
out := ConvertClaudeResponseToGeminiNonStream(ctx, "gemini-2.5-pro", nil, nil, raw, nil)
thoughtPart := gjson.GetBytes(out, "candidates.0.content.parts.0")
if !thoughtPart.Get("thought").Bool() || thoughtPart.Get("text").String() != "thinking text" {
t.Fatalf("expected thought part with text 'thinking text', got %s", thoughtPart.Raw)
}
if got := thoughtPart.Get("thoughtSignature").String(); got != tt.wantSignature {
t.Fatalf("expected thoughtSignature %q, got %q", tt.wantSignature, got)
}
textPart := gjson.GetBytes(out, "candidates.0.content.parts.1")
if textPart.Get("text").String() != "final answer" {
t.Fatalf("expected text part 'final answer', got %s", textPart.Raw)
}
})
}
}

View file

@ -0,0 +1,20 @@
package gemini
import (
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
translator.Register(
Gemini,
Claude,
ConvertGeminiRequestToClaude,
interfaces.TranslateResponse{
Stream: ConvertClaudeResponseToGemini,
NonStream: ConvertClaudeResponseToGeminiNonStream,
TokenCount: GeminiTokenCount,
},
)
}

View file

@ -0,0 +1,63 @@
package gemini
import (
"testing"
"github.com/tidwall/gjson"
)
func TestNormalizeClaudeToolSchemaPreservesCanonicalSchema(t *testing.T) {
input := []byte(`{"type":"object","properties":{"value":{"type":"string"}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}`)
output := normalizeClaudeToolSchema(gjson.ParseBytes(input))
if string(output) != string(input) {
t.Fatalf("canonical schema changed:\n got: %s\nwant: %s", output, input)
}
}
func TestNormalizeClaudeToolSchemaCorrectsWrongTypes(t *testing.T) {
input := []byte(`{"type":"object","additionalProperties":"false","$schema":123}`)
output := normalizeClaudeToolSchema(gjson.ParseBytes(input))
if additionalProperties := gjson.GetBytes(output, "additionalProperties"); additionalProperties.Type != gjson.False {
t.Fatalf("additionalProperties = %s, want false", additionalProperties.Raw)
}
if schema := gjson.GetBytes(output, "$schema"); schema.Type != gjson.String || schema.String() != "http://json-schema.org/draft-07/schema#" {
t.Fatalf("$schema = %s, want canonical string", schema.Raw)
}
}
func TestLowercaseClaudeToolSchemaTypesReusesLowercaseSchema(t *testing.T) {
input := []byte(`{"name":"lookup","input_schema":{"type":"object","properties":{"value":{"type":"string"}}}}`)
output := lowercaseClaudeToolSchemaTypes(input)
if &output[0] != &input[0] {
t.Fatal("lowercase schema types caused a payload copy")
}
}
func TestLowercaseClaudeToolSchemaTypesNormalizesNonStringType(t *testing.T) {
input := []byte(`{"input_schema":{"type":123}}`)
output := lowercaseClaudeToolSchemaTypes(input)
if got := gjson.GetBytes(output, "input_schema.type"); got.Type != gjson.String || got.String() != "123" {
t.Fatalf("input_schema.type = %s, want string 123", got.Raw)
}
}
func TestLowercaseClaudeToolSchemaTypesNormalizesUppercaseTypes(t *testing.T) {
input := []byte(`{"input_schema":{"type":"OBJECT","properties":{"value":{"type":"STRING"}}}}`)
output := lowercaseClaudeToolSchemaTypes(input)
if got := gjson.GetBytes(output, "input_schema.type").String(); got != "object" {
t.Fatalf("input_schema.type = %q, want object", got)
}
if got := gjson.GetBytes(output, "input_schema.properties.value.type").String(); got != "string" {
t.Fatalf("nested type = %q, want string", got)
}
}