Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
|
|
@ -0,0 +1,66 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const capturedGeminiThinkingSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
|
||||
|
||||
func TestConvertClaudeRequestToGeminiWithCompat_SignatureCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
signature string
|
||||
wantSignature string
|
||||
}{
|
||||
{
|
||||
name: "preserves valid gemini signature",
|
||||
signature: "gemini#" + capturedGeminiThinkingSignature,
|
||||
wantSignature: capturedGeminiThinkingSignature,
|
||||
},
|
||||
{
|
||||
name: "foreign claude signature maps to bypass sentinel",
|
||||
signature: "claude#opaque-signature-12345",
|
||||
wantSignature: signature.GeminiSkipThoughtSignatureValidator,
|
||||
},
|
||||
{
|
||||
name: "empty signature maps to bypass sentinel",
|
||||
signature: "",
|
||||
wantSignature: signature.GeminiSkipThoughtSignatureValidator,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"` + tt.signature + `"}]}]}`)
|
||||
withCompat := ConvertClaudeRequestToGeminiWithCompat("deepseek-v4", payload, false)
|
||||
part := gjson.GetBytes(withCompat, "contents.0.parts.0")
|
||||
if !part.Get("thought").Bool() || part.Get("text").String() != "reason" {
|
||||
t.Fatalf("compat translation missing thought part: %s", withCompat)
|
||||
}
|
||||
if got := part.Get("thoughtSignature").String(); got != tt.wantSignature {
|
||||
t.Fatalf("thoughtSignature = %q, want %q; output: %s", got, tt.wantSignature, withCompat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToGeminiWithCompatPreservesEmptyThinking(t *testing.T) {
|
||||
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""}]}]}`)
|
||||
|
||||
withoutCompat := ConvertClaudeRequestToGemini("deepseek-v4", payload, false)
|
||||
if gjson.GetBytes(withoutCompat, "contents.0.parts.#").Int() != 0 {
|
||||
t.Fatalf("default translation preserved thinking: %s", withoutCompat)
|
||||
}
|
||||
|
||||
withCompat := ConvertClaudeRequestToGeminiWithCompat("deepseek-v4", payload, false)
|
||||
part := gjson.GetBytes(withCompat, "contents.0.parts.0")
|
||||
if !part.Get("thought").Bool() || part.Get("text").String() != "reason" {
|
||||
t.Fatalf("compat translation missing thought part: %s", withCompat)
|
||||
}
|
||||
if !part.Get("thoughtSignature").Exists() || part.Get("thoughtSignature").String() != signature.GeminiSkipThoughtSignatureValidator {
|
||||
t.Fatalf("compat translation did not preserve bypass signature: %s", withCompat)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
// Package claude provides request translation functionality for Claude API.
|
||||
// It handles parsing and transforming Claude API requests into the internal client format,
|
||||
// extracting model information, system instructions, message contents, and tool declarations.
|
||||
// The package also performs JSON data cleaning and transformation to ensure compatibility
|
||||
// between Claude API format and the internal client's expected format.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const geminiClaudeThoughtSignature = "skip_thought_signature_validator"
|
||||
|
||||
// ConvertClaudeRequestToGemini parses a Claude API request and returns a complete
|
||||
// Gemini request body (as JSON bytes) ready to be sent via SendRawMessageStream.
|
||||
// All JSON transformations are performed using gjson/sjson.
|
||||
//
|
||||
// Parameters:
|
||||
// - modelName: The name of the model.
|
||||
// - rawJSON: The raw JSON request from the Claude API.
|
||||
// - stream: A boolean indicating if the request is for a streaming response.
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: The transformed request in Gemini format.
|
||||
func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
return convertClaudeRequestToGemini(modelName, inputRawJSON, stream, false)
|
||||
}
|
||||
|
||||
// ConvertClaudeRequestToGeminiWithCompat preserves assistant thinking blocks
|
||||
// with empty signatures for configured compatibility endpoints.
|
||||
func ConvertClaudeRequestToGeminiWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
return convertClaudeRequestToGemini(modelName, inputRawJSON, stream, true)
|
||||
}
|
||||
|
||||
func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, preserveEmptyThinkingBlocks bool) []byte {
|
||||
rawJSON := inputRawJSON
|
||||
// Build output Gemini request JSON
|
||||
out := []byte(`{"contents":[]}`)
|
||||
out, _ = sjson.SetBytes(out, "model", modelName)
|
||||
|
||||
// system instruction
|
||||
if systemResult := gjson.GetBytes(rawJSON, "system"); systemResult.IsArray() {
|
||||
systemParts := make([][]byte, 0, 2)
|
||||
systemResult.ForEach(func(_, systemPromptResult gjson.Result) bool {
|
||||
if systemPromptResult.Get("type").String() == "text" {
|
||||
textResult := systemPromptResult.Get("text")
|
||||
if textResult.Type == gjson.String {
|
||||
if util.IsClaudeCodeAttributionSystemText(textResult.String()) {
|
||||
return true
|
||||
}
|
||||
part := []byte(`{"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", textResult.String())
|
||||
systemParts = append(systemParts, part)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if len(systemParts) > 0 {
|
||||
systemInstruction := []byte(`{"role":"user","parts":[]}`)
|
||||
systemInstruction, _ = sjson.SetRawBytes(systemInstruction, "parts", translatorcommon.JoinRawArray(systemParts))
|
||||
out, _ = sjson.SetRawBytes(out, "systemInstruction", systemInstruction)
|
||||
}
|
||||
} else if systemResult.Type == gjson.String && !util.IsClaudeCodeAttributionSystemText(systemResult.String()) {
|
||||
part := []byte(`{"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", systemResult.String())
|
||||
systemInstruction := []byte(`{"parts":[]}`)
|
||||
systemInstruction = translatorcommon.SetRawArrayItems(systemInstruction, "parts", [][]byte{part})
|
||||
out, _ = sjson.SetRawBytes(out, "systemInstruction", systemInstruction)
|
||||
}
|
||||
|
||||
// contents
|
||||
if messagesResult := gjson.GetBytes(rawJSON, "messages"); messagesResult.IsArray() {
|
||||
contentItems := translatorcommon.NewRawArrayItems(messagesResult.Get("#").Int())
|
||||
messagesResult.ForEach(func(_, messageResult gjson.Result) bool {
|
||||
roleResult := messageResult.Get("role")
|
||||
if roleResult.Type != gjson.String {
|
||||
return true
|
||||
}
|
||||
role := roleResult.String()
|
||||
if role == "assistant" {
|
||||
role = "model"
|
||||
} else if role == "system" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
partItems := make([][]byte, 0, 4)
|
||||
contentsResult := messageResult.Get("content")
|
||||
if roleResult.String() == "system" {
|
||||
if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(contentsResult); ok {
|
||||
part := []byte(`{"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", reminderText)
|
||||
partItems = append(partItems, part)
|
||||
contentItems = append(contentItems, geminiContentWithParts(role, partItems))
|
||||
}
|
||||
return true
|
||||
}
|
||||
if contentsResult.IsArray() {
|
||||
contentsResult.ForEach(func(_, contentResult gjson.Result) bool {
|
||||
switch contentResult.Get("type").String() {
|
||||
case "text":
|
||||
text := contentResult.Get("text").String()
|
||||
if text == "" {
|
||||
return true
|
||||
}
|
||||
part := []byte(`{"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", text)
|
||||
partItems = append(partItems, part)
|
||||
|
||||
case "thinking":
|
||||
if !preserveEmptyThinkingBlocks {
|
||||
return true
|
||||
}
|
||||
part := []byte(`{"text":"","thought":true,"thoughtSignature":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", contentResult.Get("thinking").String())
|
||||
signature := sigcompat.GeminiReplaySignatureOrBypass(contentResult.Get("signature").String(), sigcompat.SignatureBlockKindGeminiModelPart)
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", signature)
|
||||
partItems = append(partItems, part)
|
||||
|
||||
case "tool_use":
|
||||
functionName := contentResult.Get("name").String()
|
||||
if toolUseID := contentResult.Get("id").String(); toolUseID != "" {
|
||||
if derived := toolNameFromClaudeToolUseID(toolUseID); derived != "" {
|
||||
functionName = derived
|
||||
}
|
||||
}
|
||||
functionName = util.SanitizeFunctionName(functionName)
|
||||
functionArgs := contentResult.Get("input").String()
|
||||
argsResult := gjson.Parse(functionArgs)
|
||||
if argsResult.IsObject() && gjson.Valid(functionArgs) {
|
||||
part := []byte(`{"thoughtSignature":"","functionCall":{"name":"","args":{}}}`)
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", geminiClaudeThoughtSignature)
|
||||
part, _ = sjson.SetBytes(part, "functionCall.name", functionName)
|
||||
part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(functionArgs))
|
||||
partItems = append(partItems, part)
|
||||
}
|
||||
|
||||
case "tool_result":
|
||||
toolCallID := contentResult.Get("tool_use_id").String()
|
||||
if toolCallID == "" {
|
||||
return true
|
||||
}
|
||||
funcName := toolNameFromClaudeToolUseID(toolCallID)
|
||||
if funcName == "" {
|
||||
funcName = toolCallID
|
||||
}
|
||||
funcName = util.SanitizeFunctionName(funcName)
|
||||
toolResult := util.ConvertClaudeToolResultContent(contentResult.Get("content"))
|
||||
part := []byte(`{"functionResponse":{"name":"","response":{"result":""}}}`)
|
||||
part, _ = sjson.SetBytes(part, "functionResponse.name", funcName)
|
||||
if toolResult.ResultIsRaw {
|
||||
part, _ = sjson.SetRawBytes(part, "functionResponse.response.result", []byte(toolResult.Result))
|
||||
} else {
|
||||
part, _ = sjson.SetBytes(part, "functionResponse.response.result", toolResult.Result)
|
||||
}
|
||||
partItems = append(partItems, part)
|
||||
for _, img := range toolResult.Images {
|
||||
imagePart := []byte(`{"inline_data":{"mime_type":"","data":""}}`)
|
||||
imagePart, _ = sjson.SetBytes(imagePart, "inline_data.mime_type", img.MimeType)
|
||||
imagePart, _ = sjson.SetBytes(imagePart, "inline_data.data", img.Data)
|
||||
partItems = append(partItems, imagePart)
|
||||
}
|
||||
|
||||
case "image":
|
||||
source := contentResult.Get("source")
|
||||
if source.Get("type").String() != "base64" {
|
||||
return true
|
||||
}
|
||||
mimeType := source.Get("media_type").String()
|
||||
data := source.Get("data").String()
|
||||
if mimeType == "" || data == "" {
|
||||
return true
|
||||
}
|
||||
part := []byte(`{"inline_data":{"mime_type":"","data":""}}`)
|
||||
part, _ = sjson.SetBytes(part, "inline_data.mime_type", mimeType)
|
||||
part, _ = sjson.SetBytes(part, "inline_data.data", data)
|
||||
partItems = append(partItems, part)
|
||||
}
|
||||
return true
|
||||
})
|
||||
contentItems = append(contentItems, geminiContentWithParts(role, partItems))
|
||||
} else if contentsResult.Type == gjson.String {
|
||||
part := []byte(`{"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", contentsResult.String())
|
||||
partItems = append(partItems, part)
|
||||
contentItems = append(contentItems, geminiContentWithParts(role, partItems))
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Strip a trailing model turn with unanswered function calls.
|
||||
if len(contentItems) > 0 {
|
||||
last := gjson.ParseBytes(contentItems[len(contentItems)-1])
|
||||
if last.Get("role").String() == "model" {
|
||||
hasFunctionCall := false
|
||||
last.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
||||
if part.Get("functionCall").Exists() {
|
||||
hasFunctionCall = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if hasFunctionCall {
|
||||
contentItems = contentItems[:len(contentItems)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
out = translatorcommon.SetRawArrayItems(out, "contents", contentItems)
|
||||
}
|
||||
|
||||
// tools
|
||||
if toolsResult := gjson.GetBytes(rawJSON, "tools"); toolsResult.IsArray() {
|
||||
var toolItems [][]byte
|
||||
toolsResult.ForEach(func(_, toolResult gjson.Result) bool {
|
||||
inputSchemaResult := toolResult.Get("input_schema")
|
||||
if inputSchemaResult.Exists() && inputSchemaResult.IsObject() {
|
||||
inputSchema := util.CleanJSONSchemaForGemini(inputSchemaResult.Raw)
|
||||
tool := []byte(toolResult.Raw)
|
||||
var err error
|
||||
tool, err = sjson.DeleteBytes(tool, "input_schema")
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
tool, err = sjson.SetRawBytes(tool, "parametersJsonSchema", []byte(inputSchema))
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
for _, path := range []string{"strict", "input_examples", "type", "cache_control", "defer_loading", "eager_input_streaming"} {
|
||||
if toolResult.Get(path).Exists() {
|
||||
tool, _ = sjson.DeleteBytes(tool, path)
|
||||
}
|
||||
}
|
||||
nameResult := toolResult.Get("name")
|
||||
originalName := nameResult.String()
|
||||
sanitizedName := util.SanitizeFunctionName(originalName)
|
||||
if nameResult.Type != gjson.String || sanitizedName != originalName {
|
||||
tool, _ = sjson.SetBytes(tool, "name", sanitizedName)
|
||||
}
|
||||
if gjson.ValidBytes(tool) && gjson.ParseBytes(tool).IsObject() {
|
||||
toolItems = append(toolItems, tool)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if len(toolItems) > 0 {
|
||||
tools := []byte(`[{"functionDeclarations":[]}]`)
|
||||
tools, _ = sjson.SetRawBytes(tools, "0.functionDeclarations", translatorcommon.JoinRawArray(toolItems))
|
||||
out, _ = sjson.SetRawBytes(out, "tools", tools)
|
||||
}
|
||||
}
|
||||
|
||||
// tool_choice
|
||||
toolChoiceResult := gjson.GetBytes(rawJSON, "tool_choice")
|
||||
if toolChoiceResult.Exists() {
|
||||
toolChoiceType := ""
|
||||
toolChoiceName := ""
|
||||
if toolChoiceResult.IsObject() {
|
||||
toolChoiceType = toolChoiceResult.Get("type").String()
|
||||
toolChoiceName = toolChoiceResult.Get("name").String()
|
||||
} else if toolChoiceResult.Type == gjson.String {
|
||||
toolChoiceType = toolChoiceResult.String()
|
||||
}
|
||||
|
||||
switch toolChoiceType {
|
||||
case "auto":
|
||||
out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "AUTO")
|
||||
case "none":
|
||||
out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "NONE")
|
||||
case "any":
|
||||
out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "ANY")
|
||||
case "tool":
|
||||
out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "ANY")
|
||||
if toolChoiceName != "" {
|
||||
out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames", []string{util.SanitizeFunctionName(toolChoiceName)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map Anthropic thinking -> Gemini thinking config when enabled
|
||||
// Translator only does format conversion, ApplyThinking handles model capability validation.
|
||||
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
|
||||
switch t.Get("type").String() {
|
||||
case "enabled":
|
||||
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
|
||||
budget := int(b.Int())
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||
}
|
||||
case "adaptive", "auto":
|
||||
// For adaptive thinking:
|
||||
// - If output_config.effort is explicitly present, pass through as thinkingLevel.
|
||||
// - Otherwise, treat it as "enabled with target-model maximum" and emit thinkingBudget=max.
|
||||
// ApplyThinking handles clamping to target model's supported levels.
|
||||
effort := ""
|
||||
if v := gjson.GetBytes(rawJSON, "output_config.effort"); v.Exists() && v.Type == gjson.String {
|
||||
effort = strings.ToLower(strings.TrimSpace(v.String()))
|
||||
}
|
||||
if effort != "" {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingLevel", effort)
|
||||
} else {
|
||||
maxBudget := 0
|
||||
if mi := registry.LookupModelInfo(modelName, "gemini"); mi != nil && mi.Thinking != nil {
|
||||
maxBudget = mi.Thinking.Max
|
||||
}
|
||||
if maxBudget > 0 {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", maxBudget)
|
||||
} else {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingLevel", "high")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.temperature", v.Num)
|
||||
}
|
||||
if v := gjson.GetBytes(rawJSON, "top_p"); v.Exists() && v.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.topP", v.Num)
|
||||
}
|
||||
if v := gjson.GetBytes(rawJSON, "top_k"); v.Exists() && v.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.topK", v.Num)
|
||||
}
|
||||
|
||||
result := out
|
||||
result = common.AttachDefaultSafetySettings(result, "safetySettings")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func geminiContentWithParts(role string, parts [][]byte) []byte {
|
||||
content := []byte(`{"role":"","parts":[]}`)
|
||||
content, _ = sjson.SetBytes(content, "role", role)
|
||||
content, _ = sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(parts))
|
||||
return content
|
||||
}
|
||||
|
||||
func toolNameFromClaudeToolUseID(toolUseID string) string {
|
||||
parts := strings.Split(toolUseID, "-")
|
||||
if len(parts) <= 1 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(parts[0:len(parts)-1], "-")
|
||||
}
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertClaudeRequestToGemini_ToolChoice_SpecificTool(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "hi"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "json",
|
||||
"description": "A JSON tool",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tool_choice": {"type": "tool", "name": "json"}
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
if got := gjson.GetBytes(output, "toolConfig.functionCallingConfig.mode").String(); got != "ANY" {
|
||||
t.Fatalf("Expected toolConfig.functionCallingConfig.mode 'ANY', got '%s'", got)
|
||||
}
|
||||
allowed := gjson.GetBytes(output, "toolConfig.functionCallingConfig.allowedFunctionNames").Array()
|
||||
if len(allowed) != 1 || allowed[0].String() != "json" {
|
||||
t.Fatalf("Expected allowedFunctionNames ['json'], got %s", gjson.GetBytes(output, "toolConfig.functionCallingConfig.allowedFunctionNames").Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToGemini_StringSystemInstruction(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"system": "Be concise",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
if got := gjson.GetBytes(output, "systemInstruction.parts.0.text").String(); got != "Be concise" {
|
||||
t.Fatalf("Expected systemInstruction text %q, got %q", "Be concise", got)
|
||||
}
|
||||
if gjson.GetBytes(output, "systemInstruction.role").Exists() {
|
||||
t.Fatalf("Expected systemInstruction.role to not exist, got %q", gjson.GetBytes(output, "systemInstruction.role").String())
|
||||
}
|
||||
if gjson.GetBytes(output, "system_instruction").Exists() {
|
||||
t.Fatalf("Legacy system_instruction field should not be emitted: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToGemini_ImageContent(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "describe this image"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "aGVsbG8="
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
parts := gjson.GetBytes(output, "contents.0.parts").Array()
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("Expected 2 parts, got %d", len(parts))
|
||||
}
|
||||
if got := parts[0].Get("text").String(); got != "describe this image" {
|
||||
t.Fatalf("Expected first part text 'describe this image', got '%s'", got)
|
||||
}
|
||||
if got := parts[1].Get("inline_data.mime_type").String(); got != "image/png" {
|
||||
t.Fatalf("Expected image mime type 'image/png', got '%s'", got)
|
||||
}
|
||||
if got := parts[1].Get("inline_data.data").String(); got != "aGVsbG8=" {
|
||||
t.Fatalf("Expected image data 'aGVsbG8=', got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToGemini_StripsClaudeCodeAttribution(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "claude-sonnet-4-5",
|
||||
"system": [
|
||||
{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.63.abc; cc_entrypoint=cli; cch=12345;"},
|
||||
{"type": "text", "text": "You are a Claude agent, built on Anthropic's Claude Agent SDK."},
|
||||
{"type": "text", "text": "User system prompt"}
|
||||
],
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
parts := gjson.GetBytes(output, "systemInstruction.parts").Array()
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("Expected 2 system parts after attribution strip, got %d: %s", len(parts), gjson.GetBytes(output, "systemInstruction.parts").Raw)
|
||||
}
|
||||
if got := parts[0].Get("text").String(); got != "You are a Claude agent, built on Anthropic's Claude Agent SDK." {
|
||||
t.Fatalf("Unexpected first system part: %q", got)
|
||||
}
|
||||
if got := parts[1].Get("text").String(); got != "User system prompt" {
|
||||
t.Fatalf("Unexpected second system part: %q", got)
|
||||
}
|
||||
if gjson.GetBytes(output, `systemInstruction.parts.#(text%"x-anthropic-billing-header:*")`).Exists() {
|
||||
t.Fatalf("Claude Code attribution block was forwarded: %s", gjson.GetBytes(output, "systemInstruction.parts").Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToGemini_ConvertsMessageSystemRoleToUserContent(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"system": [{"type": "text", "text": "Top-level rules"}],
|
||||
"messages": [
|
||||
{"role": "user", "content": [{"type": "text", "text": "Hello"}]},
|
||||
{"role": "system", "content": "String mid-conversation rule"},
|
||||
{"role": "system", "content": [{"type": "text", "text": "Array mid-conversation rule"}]}
|
||||
]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
if systemContent := gjson.GetBytes(output, `contents.#(role=="system")`); systemContent.Exists() {
|
||||
t.Fatalf("system role should not be emitted in contents: %s", systemContent.Raw)
|
||||
}
|
||||
|
||||
contents := gjson.GetBytes(output, "contents").Array()
|
||||
if len(contents) != 3 {
|
||||
t.Fatalf("Expected the user and message-level system turns in contents, got %d: %s", len(contents), gjson.GetBytes(output, "contents").Raw)
|
||||
}
|
||||
if got := contents[0].Get("role").String(); got != "user" {
|
||||
t.Fatalf("Expected first content role user, got %q", got)
|
||||
}
|
||||
if got := contents[1].Get("role").String(); got != "user" {
|
||||
t.Fatalf("Expected message-level string system content to be downgraded to user role, got %q", got)
|
||||
}
|
||||
if got := contents[1].Get("parts.0.text").String(); got != "<system-reminder>\nString mid-conversation rule\n</system-reminder>" {
|
||||
t.Fatalf("Unexpected string message-level system content text: %q", got)
|
||||
}
|
||||
if got := contents[2].Get("role").String(); got != "user" {
|
||||
t.Fatalf("Expected message-level array system content to be downgraded to user role, got %q", got)
|
||||
}
|
||||
if got := contents[2].Get("parts.0.text").String(); got != "<system-reminder>\nArray mid-conversation rule\n</system-reminder>" {
|
||||
t.Fatalf("Unexpected array message-level system content text: %q", got)
|
||||
}
|
||||
|
||||
parts := gjson.GetBytes(output, "systemInstruction.parts").Array()
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("Expected only top-level system parts, got %d: %s", len(parts), gjson.GetBytes(output, "systemInstruction.parts").Raw)
|
||||
}
|
||||
if got := parts[0].Get("text").String(); got != "Top-level rules" {
|
||||
t.Fatalf("Unexpected first system part: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToGemini_SkipsEmptyTextParts(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "claude-3-5-sonnet",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": ""},
|
||||
{"type": "text", "text": "hello"},
|
||||
{"type": "text", "text": ""}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
parts := gjson.GetBytes(output, "contents.0.parts").Array()
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("Expected 1 part after skipping empty text, got %d: %s", len(parts), output)
|
||||
}
|
||||
if got := parts[0].Get("text").String(); got != "hello" {
|
||||
t.Fatalf("Expected part text 'hello', got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToGemini_StructuredToolResult(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "json-call-1", "name": "json", "input": {"ok": true}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "json-call-1",
|
||||
"content": [
|
||||
{"type": "text", "text": "alpha"},
|
||||
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "aGVsbG8="}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
fr := gjson.GetBytes(output, "contents.1.parts.0.functionResponse")
|
||||
if !fr.Exists() {
|
||||
t.Fatalf("expected functionResponse part, contents=%s", gjson.GetBytes(output, "contents").Raw)
|
||||
}
|
||||
// The text block must remain structured JSON, not a double-encoded string blob.
|
||||
if got := fr.Get("response.result.text").String(); got != "alpha" {
|
||||
t.Fatalf("expected structured result text 'alpha', got result=%s", fr.Get("response.result").Raw)
|
||||
}
|
||||
// The image block must be emitted as a separate inline_data part, not embedded in result.
|
||||
img := gjson.GetBytes(output, "contents.1.parts.1.inline_data")
|
||||
if got := img.Get("mime_type").String(); got != "image/png" {
|
||||
t.Fatalf("expected image mime type 'image/png', got '%s'", got)
|
||||
}
|
||||
if got := img.Get("data").String(); got != "aGVsbG8=" {
|
||||
t.Fatalf("expected image data 'aGVsbG8=', got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToGemini_StringToolResult(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "json-call-1", "name": "json", "input": {"ok": true}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "json-call-1", "content": "alpha"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
fr := gjson.GetBytes(output, "contents.1.parts.0.functionResponse")
|
||||
if !fr.Exists() {
|
||||
t.Fatalf("expected functionResponse part, contents=%s", gjson.GetBytes(output, "contents").Raw)
|
||||
}
|
||||
// String content must not be double-encoded: result should be exactly "alpha".
|
||||
if got := fr.Get("response.result").String(); got != "alpha" {
|
||||
t.Fatalf("expected result 'alpha', got '%s' (raw=%s)", got, fr.Get("response.result").Raw)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,421 @@
|
|||
// Package claude provides response translation functionality for Claude API.
|
||||
// This package handles the conversion of backend client responses into Claude-compatible
|
||||
// Server-Sent Events (SSE) format, implementing a sophisticated state machine that manages
|
||||
// different response types including text content, thinking processes, and function calls.
|
||||
// The translation ensures proper sequencing of SSE events and maintains state across
|
||||
// multiple response chunks to provide a seamless streaming experience.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// Params holds parameters for response conversion.
|
||||
type Params struct {
|
||||
IsGlAPIKey bool
|
||||
HasFirstResponse bool
|
||||
ResponseType int
|
||||
ResponseIndex int
|
||||
HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output
|
||||
ToolNameMap map[string]string
|
||||
SanitizedNameMap map[string]string
|
||||
SawToolCall bool
|
||||
HasFinalEvents bool
|
||||
}
|
||||
|
||||
// toolUseIDCounter provides a process-wide unique counter for tool use identifiers.
|
||||
var toolUseIDCounter uint64
|
||||
|
||||
// ConvertGeminiResponseToClaude performs sophisticated streaming response format conversion.
|
||||
// This function implements a complex state machine that translates backend client responses
|
||||
// into Claude-compatible Server-Sent Events (SSE) format. It manages different response types
|
||||
// and handles state transitions between content blocks, thinking processes, and function calls.
|
||||
//
|
||||
// Response type states: 0=none, 1=content, 2=thinking, 3=function
|
||||
// The function maintains state across multiple calls to ensure proper SSE event sequencing.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request.
|
||||
// - modelName: The name of the model.
|
||||
// - rawJSON: The raw JSON response from the Gemini API.
|
||||
// - param: A pointer to a parameter object for the conversion.
|
||||
//
|
||||
// Returns:
|
||||
// - [][]byte: A slice of bytes, each containing a Claude-compatible SSE payload.
|
||||
func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
if *param == nil {
|
||||
*param = &Params{
|
||||
IsGlAPIKey: false,
|
||||
HasFirstResponse: false,
|
||||
ResponseType: 0,
|
||||
ResponseIndex: 0,
|
||||
ToolNameMap: util.ToolNameMapFromClaudeRequest(originalRequestRawJSON),
|
||||
SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON),
|
||||
SawToolCall: false,
|
||||
}
|
||||
}
|
||||
|
||||
if bytes.Equal(rawJSON, []byte("[DONE]")) {
|
||||
// Only send message_stop if we have actually output content
|
||||
if (*param).(*Params).HasContent {
|
||||
return [][]byte{translatorcommon.AppendSSEEventString(nil, "message_stop", `{"type":"message_stop"}`, 3)}
|
||||
}
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
output := make([]byte, 0, 1024)
|
||||
appendEvent := func(event, payload string) {
|
||||
output = translatorcommon.AppendSSEEventString(output, event, payload, 3)
|
||||
}
|
||||
appendSignatureDelta := func(signature string) {
|
||||
if signature == "" || (*param).(*Params).ResponseType != 2 {
|
||||
return
|
||||
}
|
||||
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, (*param).(*Params).ResponseIndex)), "delta.signature", signature)
|
||||
appendEvent("content_block_delta", string(data))
|
||||
(*param).(*Params).HasContent = true
|
||||
}
|
||||
|
||||
// Initialize the streaming session with a message_start event
|
||||
// This is only sent for the very first response chunk
|
||||
if !(*param).(*Params).HasFirstResponse {
|
||||
// Create the initial message structure with default values
|
||||
// This follows the Claude API specification for streaming message initialization
|
||||
messageStartTemplate := []byte(`{"type":"message_start","message":{"id":"msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY","type":"message","role":"assistant","content":[],"model":"claude-3-5-sonnet-20241022","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`)
|
||||
|
||||
// Override default values with actual response metadata if available
|
||||
if modelVersionResult := gjson.GetBytes(rawJSON, "modelVersion"); modelVersionResult.Exists() {
|
||||
messageStartTemplate, _ = sjson.SetBytes(messageStartTemplate, "message.model", modelVersionResult.String())
|
||||
}
|
||||
if responseIDResult := gjson.GetBytes(rawJSON, "responseId"); responseIDResult.Exists() {
|
||||
messageStartTemplate, _ = sjson.SetBytes(messageStartTemplate, "message.id", responseIDResult.String())
|
||||
}
|
||||
appendEvent("message_start", string(messageStartTemplate))
|
||||
|
||||
(*param).(*Params).HasFirstResponse = true
|
||||
}
|
||||
|
||||
// Process the response parts array from the backend client
|
||||
// Each part can contain text content, thinking content, or function calls
|
||||
partsResult := gjson.GetBytes(rawJSON, "candidates.0.content.parts")
|
||||
if partsResult.IsArray() {
|
||||
partResults := partsResult.Array()
|
||||
for i := 0; i < len(partResults); i++ {
|
||||
partResult := partResults[i]
|
||||
|
||||
// Extract the different types of content from each part
|
||||
partTextResult := partResult.Get("text")
|
||||
functionCallResult := partResult.Get("functionCall")
|
||||
thoughtSignatureResult := partResult.Get("thoughtSignature")
|
||||
if !thoughtSignatureResult.Exists() {
|
||||
thoughtSignatureResult = partResult.Get("thought_signature")
|
||||
}
|
||||
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""
|
||||
|
||||
if hasThoughtSignature && !partTextResult.Exists() && !functionCallResult.Exists() {
|
||||
appendSignatureDelta(thoughtSignatureResult.String())
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle text content (both regular content and thinking)
|
||||
if partTextResult.Exists() {
|
||||
// Process thinking content (internal reasoning)
|
||||
if partResult.Get("thought").Bool() || hasThoughtSignature {
|
||||
if hasThoughtSignature && partTextResult.String() == "" {
|
||||
appendSignatureDelta(thoughtSignatureResult.String())
|
||||
continue
|
||||
}
|
||||
// Continue existing thinking block
|
||||
if (*param).(*Params).ResponseType == 2 {
|
||||
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partTextResult.String())
|
||||
appendEvent("content_block_delta", string(data))
|
||||
(*param).(*Params).HasContent = true
|
||||
} else {
|
||||
// Transition from another state to thinking
|
||||
// First, close any existing content block
|
||||
if (*param).(*Params).ResponseType != 0 {
|
||||
if (*param).(*Params).ResponseType == 2 {
|
||||
// output = output + "event: content_block_delta\n"
|
||||
// output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, (*param).(*Params).ResponseIndex)
|
||||
// output = output + "\n\n\n"
|
||||
}
|
||||
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
|
||||
(*param).(*Params).ResponseIndex++
|
||||
}
|
||||
|
||||
// Start a new thinking content block
|
||||
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, (*param).(*Params).ResponseIndex))
|
||||
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partTextResult.String())
|
||||
appendEvent("content_block_delta", string(data))
|
||||
(*param).(*Params).ResponseType = 2 // Set state to thinking
|
||||
(*param).(*Params).HasContent = true
|
||||
}
|
||||
appendSignatureDelta(thoughtSignatureResult.String())
|
||||
} else {
|
||||
// Process regular text content (user-visible output)
|
||||
// Continue existing text block
|
||||
if (*param).(*Params).ResponseType == 1 {
|
||||
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partTextResult.String())
|
||||
appendEvent("content_block_delta", string(data))
|
||||
(*param).(*Params).HasContent = true
|
||||
} else {
|
||||
// Transition from another state to text content
|
||||
// First, close any existing content block
|
||||
if (*param).(*Params).ResponseType != 0 {
|
||||
if (*param).(*Params).ResponseType == 2 {
|
||||
// output = output + "event: content_block_delta\n"
|
||||
// output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, (*param).(*Params).ResponseIndex)
|
||||
// output = output + "\n\n\n"
|
||||
}
|
||||
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
|
||||
(*param).(*Params).ResponseIndex++
|
||||
}
|
||||
|
||||
// Start a new text content block
|
||||
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, (*param).(*Params).ResponseIndex))
|
||||
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partTextResult.String())
|
||||
appendEvent("content_block_delta", string(data))
|
||||
(*param).(*Params).ResponseType = 1 // Set state to content
|
||||
(*param).(*Params).HasContent = true
|
||||
}
|
||||
}
|
||||
} else if functionCallResult.Exists() {
|
||||
// Handle function/tool calls from the AI model
|
||||
// This processes tool usage requests and formats them for Claude API compatibility
|
||||
(*param).(*Params).SawToolCall = true
|
||||
upstreamToolName := functionCallResult.Get("name").String()
|
||||
upstreamToolName = util.RestoreSanitizedToolName((*param).(*Params).SanitizedNameMap, upstreamToolName)
|
||||
clientToolName := util.MapToolName((*param).(*Params).ToolNameMap, upstreamToolName)
|
||||
|
||||
// FIX: Handle streaming split/delta where name might be empty in subsequent chunks.
|
||||
// If we are already in tool use mode and name is empty, treat as continuation (delta).
|
||||
if (*param).(*Params).ResponseType == 3 && upstreamToolName == "" {
|
||||
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
|
||||
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", fcArgsResult.Raw)
|
||||
appendEvent("content_block_delta", string(data))
|
||||
}
|
||||
// Continue to next part without closing/opening logic
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle state transitions when switching to function calls
|
||||
// Close any existing function call block first
|
||||
if (*param).(*Params).ResponseType == 3 {
|
||||
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
|
||||
(*param).(*Params).ResponseIndex++
|
||||
(*param).(*Params).ResponseType = 0
|
||||
}
|
||||
|
||||
// Special handling for thinking state transition
|
||||
if (*param).(*Params).ResponseType == 2 {
|
||||
// output = output + "event: content_block_delta\n"
|
||||
// output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, (*param).(*Params).ResponseIndex)
|
||||
// output = output + "\n\n\n"
|
||||
}
|
||||
|
||||
// Close any other existing content block
|
||||
if (*param).(*Params).ResponseType != 0 {
|
||||
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
|
||||
(*param).(*Params).ResponseIndex++
|
||||
}
|
||||
|
||||
// Start a new tool use content block
|
||||
// This creates the structure for a function call in Claude format
|
||||
// Create the tool use block with unique ID and function details
|
||||
data := []byte(fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`, (*param).(*Params).ResponseIndex))
|
||||
data, _ = sjson.SetBytes(data, "content_block.id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, atomic.AddUint64(&toolUseIDCounter, 1))))
|
||||
data, _ = sjson.SetBytes(data, "content_block.name", clientToolName)
|
||||
appendEvent("content_block_start", string(data))
|
||||
|
||||
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
|
||||
data, _ = sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", fcArgsResult.Raw)
|
||||
appendEvent("content_block_delta", string(data))
|
||||
}
|
||||
(*param).(*Params).ResponseType = 3
|
||||
(*param).(*Params).HasContent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usageResult := gjson.GetBytes(rawJSON, "usageMetadata")
|
||||
if usageResult.Exists() && bytes.Contains(rawJSON, []byte(`"finishReason"`)) && !(*param).(*Params).HasFinalEvents {
|
||||
// Only send final events if we have actually output content
|
||||
if (*param).(*Params).HasContent {
|
||||
if (*param).(*Params).ResponseType != 0 {
|
||||
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
|
||||
(*param).(*Params).ResponseType = 0
|
||||
}
|
||||
|
||||
template := []byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
|
||||
if (*param).(*Params).SawToolCall {
|
||||
template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
|
||||
} else if finish := gjson.GetBytes(rawJSON, "candidates.0.finishReason"); finish.Exists() && finish.String() == "MAX_TOKENS" {
|
||||
template = []byte(`{"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
|
||||
}
|
||||
|
||||
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
|
||||
candidatesTokenCount := usageResult.Get("candidatesTokenCount").Int()
|
||||
template, _ = sjson.SetBytes(template, "usage.output_tokens", candidatesTokenCount+thoughtsTokenCount)
|
||||
template, _ = sjson.SetBytes(template, "usage.input_tokens", usageResult.Get("promptTokenCount").Int())
|
||||
|
||||
appendEvent("message_delta", string(template))
|
||||
(*param).(*Params).HasFinalEvents = true
|
||||
}
|
||||
}
|
||||
|
||||
return [][]byte{output}
|
||||
}
|
||||
|
||||
// ConvertGeminiResponseToClaudeNonStream converts a non-streaming Gemini response to a non-streaming Claude response.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request.
|
||||
// - modelName: The name of the model.
|
||||
// - rawJSON: The raw JSON response from the Gemini API.
|
||||
// - param: A pointer to a parameter object for the conversion.
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: A Claude-compatible JSON response.
|
||||
func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
|
||||
_ = requestRawJSON
|
||||
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
toolNameMap := util.ToolNameMapFromClaudeRequest(originalRequestRawJSON)
|
||||
sanitizedNameMap := util.SanitizedToolNameMap(originalRequestRawJSON)
|
||||
|
||||
out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
|
||||
out, _ = sjson.SetBytes(out, "id", root.Get("responseId").String())
|
||||
out, _ = sjson.SetBytes(out, "model", root.Get("modelVersion").String())
|
||||
|
||||
inputTokens := root.Get("usageMetadata.promptTokenCount").Int()
|
||||
outputTokens := root.Get("usageMetadata.candidatesTokenCount").Int() + root.Get("usageMetadata.thoughtsTokenCount").Int()
|
||||
out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens)
|
||||
out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens)
|
||||
|
||||
parts := root.Get("candidates.0.content.parts")
|
||||
textBuilder := strings.Builder{}
|
||||
thinkingBuilder := strings.Builder{}
|
||||
var thinkingSignature string
|
||||
toolIDCounter := 0
|
||||
hasToolCall := false
|
||||
var blocks [][]byte
|
||||
|
||||
flushText := func() {
|
||||
if textBuilder.Len() == 0 {
|
||||
return
|
||||
}
|
||||
block := []byte(`{"type":"text","text":""}`)
|
||||
block, _ = sjson.SetBytes(block, "text", textBuilder.String())
|
||||
blocks = append(blocks, block)
|
||||
textBuilder.Reset()
|
||||
}
|
||||
|
||||
flushThinking := func() {
|
||||
if thinkingBuilder.Len() == 0 && thinkingSignature == "" {
|
||||
return
|
||||
}
|
||||
block := []byte(`{"type":"thinking","thinking":""}`)
|
||||
block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
|
||||
if thinkingSignature != "" {
|
||||
block, _ = sjson.SetBytes(block, "signature", thinkingSignature)
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
thinkingBuilder.Reset()
|
||||
thinkingSignature = ""
|
||||
}
|
||||
|
||||
if parts.IsArray() {
|
||||
for _, part := range parts.Array() {
|
||||
thoughtSignatureResult := part.Get("thoughtSignature")
|
||||
if !thoughtSignatureResult.Exists() {
|
||||
thoughtSignatureResult = part.Get("thought_signature")
|
||||
}
|
||||
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""
|
||||
if hasThoughtSignature {
|
||||
thinkingSignature = thoughtSignatureResult.String()
|
||||
}
|
||||
|
||||
text := part.Get("text")
|
||||
functionCall := part.Get("functionCall")
|
||||
|
||||
if hasThoughtSignature && (!text.Exists() || text.String() == "") && !functionCall.Exists() {
|
||||
continue
|
||||
}
|
||||
|
||||
if text.Exists() && text.String() != "" {
|
||||
if part.Get("thought").Bool() || hasThoughtSignature {
|
||||
flushText()
|
||||
thinkingBuilder.WriteString(text.String())
|
||||
continue
|
||||
}
|
||||
flushThinking()
|
||||
textBuilder.WriteString(text.String())
|
||||
continue
|
||||
}
|
||||
|
||||
if functionCall.Exists() {
|
||||
flushThinking()
|
||||
flushText()
|
||||
hasToolCall = true
|
||||
|
||||
upstreamToolName := functionCall.Get("name").String()
|
||||
upstreamToolName = util.RestoreSanitizedToolName(sanitizedNameMap, upstreamToolName)
|
||||
clientToolName := util.MapToolName(toolNameMap, upstreamToolName)
|
||||
toolIDCounter++
|
||||
toolBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
|
||||
toolBlock, _ = sjson.SetBytes(toolBlock, "id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, toolIDCounter)))
|
||||
toolBlock, _ = sjson.SetBytes(toolBlock, "name", clientToolName)
|
||||
inputRaw := "{}"
|
||||
if args := functionCall.Get("args"); args.Exists() && gjson.Valid(args.Raw) && args.IsObject() {
|
||||
inputRaw = args.Raw
|
||||
}
|
||||
toolBlock, _ = sjson.SetRawBytes(toolBlock, "input", []byte(inputRaw))
|
||||
blocks = append(blocks, toolBlock)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushThinking()
|
||||
flushText()
|
||||
|
||||
if len(blocks) > 0 {
|
||||
out, _ = sjson.SetRawBytes(out, "content", translatorcommon.JoinRawArray(blocks))
|
||||
}
|
||||
|
||||
stopReason := "end_turn"
|
||||
if hasToolCall {
|
||||
stopReason = "tool_use"
|
||||
} else {
|
||||
if finish := root.Get("candidates.0.finishReason"); finish.Exists() {
|
||||
switch finish.String() {
|
||||
case "MAX_TOKENS":
|
||||
stopReason = "max_tokens"
|
||||
case "STOP", "FINISH_REASON_UNSPECIFIED", "UNKNOWN":
|
||||
stopReason = "end_turn"
|
||||
default:
|
||||
stopReason = "end_turn"
|
||||
}
|
||||
}
|
||||
}
|
||||
out, _ = sjson.SetBytes(out, "stop_reason", stopReason)
|
||||
|
||||
if inputTokens == int64(0) && outputTokens == int64(0) && !root.Get("usageMetadata").Exists() {
|
||||
out, _ = sjson.DeleteBytes(out, "usage")
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func ClaudeTokenCount(ctx context.Context, count int64) []byte {
|
||||
return translatorcommon.ClaudeInputTokensJSON(count)
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertGeminiResponseToClaude_SignatureOnlyPartDoesNotOpenEmptyTextBlock(t *testing.T) {
|
||||
requestJSON := []byte(`{"model":"gemini-test","messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
||||
thinkingChunk := []byte(`{
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [{"text": "thinking text", "thought": true}]
|
||||
}
|
||||
}],
|
||||
"modelVersion": "gemini-test",
|
||||
"responseId": "resp-test"
|
||||
}`)
|
||||
signatureChunk := []byte(`{
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [{"text": "", "thoughtSignature": "sig-test"}]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"thoughtsTokenCount": 2,
|
||||
"totalTokenCount": 12
|
||||
},
|
||||
"modelVersion": "gemini-test",
|
||||
"responseId": "resp-test"
|
||||
}`)
|
||||
|
||||
var param any
|
||||
ctx := context.Background()
|
||||
output := bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-test", requestJSON, requestJSON, thinkingChunk, ¶m), nil)
|
||||
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-test", requestJSON, requestJSON, signatureChunk, ¶m), nil)...)
|
||||
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-test", requestJSON, requestJSON, []byte("[DONE]"), ¶m), nil)...)
|
||||
outputText := string(output)
|
||||
|
||||
if strings.Contains(outputText, `"content_block":{"type":"text"`) {
|
||||
t.Fatalf("signature-only part must not open an empty text block: %s", outputText)
|
||||
}
|
||||
if strings.Contains(outputText, `"type":"content_block_stop","index":1`) {
|
||||
t.Fatalf("signature-only part must not produce a stop for unopened index 1: %s", outputText)
|
||||
}
|
||||
if !strings.Contains(outputText, `"type":"signature_delta"`) || !strings.Contains(outputText, `"signature":"sig-test"`) {
|
||||
t.Fatalf("signature-only part must be emitted as a thinking signature delta: %s", outputText)
|
||||
}
|
||||
if got := strings.Count(outputText, `"type":"content_block_stop","index":0`); got != 1 {
|
||||
t.Fatalf("expected exactly one stop for thinking index 0, got %d: %s", got, outputText)
|
||||
}
|
||||
if !strings.Contains(outputText, `"type":"message_delta"`) || !strings.Contains(outputText, `"output_tokens":2`) {
|
||||
t.Fatalf("finish chunk without candidatesTokenCount must still emit final message_delta: %s", outputText)
|
||||
}
|
||||
if !strings.Contains(outputText, `"type":"message_stop"`) {
|
||||
t.Fatalf("DONE chunk must still emit message_stop after final events: %s", outputText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToClaudeNonStream_PreservesThoughtSignature(t *testing.T) {
|
||||
requestJSON := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}]}`)
|
||||
geminiResponse := []byte(`{
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": "thinking step 1\n", "thought": true},
|
||||
{"text": "thinking step 2", "thought": true, "thoughtSignature": "sig-xyz-123"},
|
||||
{"text": "visible answer"}
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"candidatesTokenCount": 5
|
||||
},
|
||||
"modelVersion": "gemini-2.5-pro",
|
||||
"responseId": "resp-non-stream"
|
||||
}`)
|
||||
|
||||
ctx := context.Background()
|
||||
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
|
||||
outputJSON := gjson.ParseBytes(output)
|
||||
|
||||
blocks := outputJSON.Get("content").Array()
|
||||
if len(blocks) != 2 {
|
||||
t.Fatalf("expected 2 content blocks (thinking + text), got %d: %s", len(blocks), string(output))
|
||||
}
|
||||
|
||||
thinkingBlock := blocks[0]
|
||||
if thinkingBlock.Get("type").String() != "thinking" {
|
||||
t.Fatalf("expected first block to be thinking, got %s", thinkingBlock.Get("type").String())
|
||||
}
|
||||
if thinkingBlock.Get("thinking").String() != "thinking step 1\nthinking step 2" {
|
||||
t.Fatalf("unexpected thinking content: %s", thinkingBlock.Get("thinking").String())
|
||||
}
|
||||
if thinkingBlock.Get("signature").String() != "sig-xyz-123" {
|
||||
t.Fatalf("expected signature 'sig-xyz-123', got %q. Output: %s", thinkingBlock.Get("signature").String(), string(output))
|
||||
}
|
||||
|
||||
textBlock := blocks[1]
|
||||
if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "visible answer" {
|
||||
t.Fatalf("unexpected text block: %s", textBlock.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToClaudeNonStream_PartWithThoughtSignatureWithoutThoughtBool(t *testing.T) {
|
||||
requestJSON := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}]}`)
|
||||
geminiResponse := []byte(`{
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": "inferred reasoning", "thought_signature": "sig-snake-case"},
|
||||
{"text": "final answer"}
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"candidatesTokenCount": 5
|
||||
},
|
||||
"modelVersion": "gemini-2.5-pro",
|
||||
"responseId": "resp-non-stream-2"
|
||||
}`)
|
||||
|
||||
ctx := context.Background()
|
||||
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
|
||||
outputJSON := gjson.ParseBytes(output)
|
||||
|
||||
blocks := outputJSON.Get("content").Array()
|
||||
if len(blocks) != 2 {
|
||||
t.Fatalf("expected 2 content blocks (thinking + text), got %d: %s", len(blocks), string(output))
|
||||
}
|
||||
|
||||
thinkingBlock := blocks[0]
|
||||
if thinkingBlock.Get("type").String() != "thinking" {
|
||||
t.Fatalf("expected first block to be thinking, got %s", thinkingBlock.Get("type").String())
|
||||
}
|
||||
if thinkingBlock.Get("thinking").String() != "inferred reasoning" {
|
||||
t.Fatalf("unexpected thinking content: %s", thinkingBlock.Get("thinking").String())
|
||||
}
|
||||
if thinkingBlock.Get("signature").String() != "sig-snake-case" {
|
||||
t.Fatalf("expected signature 'sig-snake-case', got %q. Output: %s", thinkingBlock.Get("signature").String(), string(output))
|
||||
}
|
||||
|
||||
textBlock := blocks[1]
|
||||
if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "final answer" {
|
||||
t.Fatalf("unexpected text block: %s", textBlock.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToClaudeNonStream_TrailingSignatureOnlyPart(t *testing.T) {
|
||||
requestJSON := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}]}`)
|
||||
geminiResponse := []byte(`{
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": "thinking step 1\n", "thought": true},
|
||||
{"text": "", "thoughtSignature": "sig-trailing"},
|
||||
{"text": "visible answer"}
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"candidatesTokenCount": 5
|
||||
},
|
||||
"modelVersion": "gemini-2.5-pro",
|
||||
"responseId": "resp-non-stream-trailing"
|
||||
}`)
|
||||
|
||||
ctx := context.Background()
|
||||
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
|
||||
outputJSON := gjson.ParseBytes(output)
|
||||
|
||||
blocks := outputJSON.Get("content").Array()
|
||||
if len(blocks) != 2 {
|
||||
t.Fatalf("expected 2 content blocks (thinking + text), got %d: %s", len(blocks), string(output))
|
||||
}
|
||||
|
||||
thinkingBlock := blocks[0]
|
||||
if thinkingBlock.Get("type").String() != "thinking" {
|
||||
t.Fatalf("expected first block to be thinking, got %s", thinkingBlock.Get("type").String())
|
||||
}
|
||||
if thinkingBlock.Get("thinking").String() != "thinking step 1\n" {
|
||||
t.Fatalf("unexpected thinking content: %s", thinkingBlock.Get("thinking").String())
|
||||
}
|
||||
if thinkingBlock.Get("signature").String() != "sig-trailing" {
|
||||
t.Fatalf("expected signature 'sig-trailing', got %q. Output: %s", thinkingBlock.Get("signature").String(), string(output))
|
||||
}
|
||||
|
||||
textBlock := blocks[1]
|
||||
if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "visible answer" {
|
||||
t.Fatalf("unexpected text block: %s", textBlock.Raw)
|
||||
}
|
||||
}
|
||||
20
backend/internal/translator/gemini/claude/init.go
Normal file
20
backend/internal/translator/gemini/claude/init.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
Claude,
|
||||
Gemini,
|
||||
ConvertClaudeRequestToGemini,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertGeminiResponseToClaude,
|
||||
NonStream: ConvertGeminiResponseToClaudeNonStream,
|
||||
TokenCount: ClaudeTokenCount,
|
||||
},
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue