Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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, ¶m)
|
||||
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, ¶m)
|
||||
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, ¶m)
|
||||
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, ¶m)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
20
backend/internal/translator/claude/gemini/init.go
Normal file
20
backend/internal/translator/claude/gemini/init.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
Gemini,
|
||||
Claude,
|
||||
ConvertGeminiRequestToClaude,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertClaudeResponseToGemini,
|
||||
NonStream: ConvertClaudeResponseToGeminiNonStream,
|
||||
TokenCount: GeminiTokenCount,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue