Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,19 @@
package interactions
import (
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
translator.Register(
Interactions,
Claude,
ConvertInteractionsRequestToClaude,
interfaces.TranslateResponse{
Stream: ConvertClaudeResponseToInteractions,
NonStream: ConvertClaudeResponseToInteractionsNonStream,
},
)
}

View file

@ -0,0 +1,461 @@
package interactions
import (
"fmt"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func ConvertInteractionsRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {
root := gjson.ParseBytes(inputRawJSON)
out := []byte(`{"model":"","max_tokens":32000,"messages":[]}`)
out, _ = sjson.SetBytes(out, "model", modelName)
if stream || root.Get("stream").Bool() {
out, _ = sjson.SetBytes(out, "stream", true)
}
out = copyInteractionsSystemToClaude(out, root)
out = copyInteractionsGenerationConfigToClaude(out, root)
messageAccumulator := translatorcommon.NewClaudeMessageAccumulator(int(root.Get("input.#").Int()))
appendInteractionsInputToClaudeMessages(messageAccumulator, root.Get("input"))
out = translatorcommon.SetRawArrayItems(out, "messages", messageAccumulator.Messages())
out = copyInteractionsToolsToClaude(out, root)
return out
}
func copyInteractionsSystemToClaude(out []byte, root gjson.Result) []byte {
sys := root.Get("system_instruction")
if !sys.Exists() {
sys = root.Get("systemInstruction")
}
text := interactionsClaudeText(sys)
if text == "" {
return out
}
out, _ = sjson.SetBytes(out, "system", text)
return out
}
func copyInteractionsGenerationConfigToClaude(out []byte, root gjson.Result) []byte {
cfg := root.Get("generation_config")
if !cfg.Exists() {
cfg = root.Get("generationConfig")
}
if cfg.Exists() {
out = copyJSONField(out, cfg, "max_output_tokens", "max_tokens")
out = copyJSONField(out, cfg, "maxOutputTokens", "max_tokens")
out = copyJSONField(out, cfg, "top_p", "top_p")
out = copyJSONField(out, cfg, "topP", "top_p")
out = copyJSONField(out, cfg, "temperature", "temperature")
out = copyJSONField(out, cfg, "stop_sequences", "stop_sequences")
out = copyJSONField(out, cfg, "stopSequences", "stop_sequences")
out = copyInteractionsThinkingConfigToClaude(out, cfg)
out = copyInteractionsToolChoiceToClaude(out, cfg.Get("tool_choice"))
out = copyInteractionsToolChoiceToClaude(out, cfg.Get("toolChoice"))
}
out = copyInteractionsReasoningToClaude(out, root.Get("reasoning"))
out = copyInteractionsToolChoiceToClaude(out, root.Get("tool_choice"))
out = copyInteractionsToolChoiceToClaude(out, root.Get("toolChoice"))
return out
}
func copyJSONField(out []byte, root gjson.Result, from, to string) []byte {
value := root.Get(from)
if !value.Exists() {
return out
}
out, _ = sjson.SetRawBytes(out, to, []byte(value.Raw))
return out
}
func copyInteractionsThinkingConfigToClaude(out []byte, cfg gjson.Result) []byte {
level := firstClaudeInteractionsExisting(cfg, "thinking_level", "thinkingLevel", "reasoning.effort")
if !level.Exists() {
return out
}
return setClaudeThinkingFromLevel(out, level.String())
}
func copyInteractionsReasoningToClaude(out []byte, reasoning gjson.Result) []byte {
if !reasoning.Exists() {
return out
}
if effort := reasoning.Get("effort"); effort.Exists() {
return setClaudeThinkingFromLevel(out, effort.String())
}
if level := reasoning.Get("thinking_level"); level.Exists() {
return setClaudeThinkingFromLevel(out, level.String())
}
return out
}
func setClaudeThinkingFromLevel(out []byte, level string) []byte {
normalized := strings.ToLower(strings.TrimSpace(level))
if normalized == "" {
return out
}
switch normalized {
case "none", "disabled", "off", "false":
out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
return out
case "auto", "adaptive":
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
return out
}
if budget, ok := thinking.ConvertLevelToBudget(normalized); ok {
switch {
case budget == 0:
out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
case budget < 0:
out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
default:
out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
out, _ = sjson.SetBytes(out, "thinking.budget_tokens", budget)
}
return out
}
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
out, _ = sjson.SetBytes(out, "output_config.effort", normalized)
return out
}
func appendInteractionsInputToClaudeMessages(accumulator *translatorcommon.ClaudeMessageAccumulator, input gjson.Result) {
if !input.Exists() {
return
}
if input.Type == gjson.String {
step := []byte(`{"type":"user_input","content":[{"type":"text","text":""}]}`)
step, _ = sjson.SetBytes(step, "content.0.text", input.String())
appendInteractionsStepToClaude(accumulator, gjson.ParseBytes(step), "user")
return
}
if input.IsObject() {
appendInteractionsInputItemToClaude(accumulator, input)
return
}
input.ForEach(func(_, step gjson.Result) bool {
appendInteractionsInputItemToClaude(accumulator, step)
return true
})
}
func appendInteractionsInputItemToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step gjson.Result) {
if step.Get("steps").IsArray() {
defaultRole := "user"
if role := step.Get("role").String(); role == "model" || role == "assistant" {
defaultRole = "assistant"
}
step.Get("steps").ForEach(func(_, nestedStep gjson.Result) bool {
appendInteractionsStepToClaude(accumulator, nestedStep, defaultRole)
return true
})
return
}
if step.Get("parts").Exists() {
wrapped := []byte(`{"type":"user_input","content":[]}`)
if role := step.Get("role").String(); role == "model" || role == "assistant" {
wrapped, _ = sjson.SetBytes(wrapped, "type", "model_output")
}
wrapped, _ = sjson.SetRawBytes(wrapped, "content", []byte(step.Get("parts").Raw))
appendInteractionsStepToClaude(accumulator, gjson.ParseBytes(wrapped), "user")
return
}
stepType := step.Get("type").String()
switch stepType {
case "function_call":
appendInteractionsFunctionCallToClaude(accumulator, step)
case "function_result":
appendInteractionsFunctionResultToClaude(accumulator, step)
case "model_output", "thought":
appendInteractionsStepToClaude(accumulator, step, "assistant")
default:
appendInteractionsStepToClaude(accumulator, step, "user")
}
}
func appendInteractionsStepToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step gjson.Result, defaultRole string) {
role := defaultRole
if stepRole := step.Get("role").String(); stepRole == "user" || stepRole == "assistant" {
role = stepRole
}
contentItems := make([][]byte, 0, 4)
stepContent := step.Get("content")
if stepContent.Type == gjson.String {
part := []byte(`{"type":"text","text":""}`)
part, _ = sjson.SetBytes(part, "text", stepContent.String())
contentItems = append(contentItems, part)
} else if stepContent.IsArray() {
stepContent.ForEach(func(_, part gjson.Result) bool {
if converted := interactionsContentToClaude(part, role); len(converted) > 0 {
contentItems = append(contentItems, converted)
}
return true
})
} else if text := step.Get("text"); text.Exists() {
part := []byte(`{"type":"text","text":""}`)
part, _ = sjson.SetBytes(part, "text", text.String())
contentItems = append(contentItems, part)
}
if len(contentItems) == 0 {
return
}
msg := []byte(`{"role":"","content":[]}`)
msg, _ = sjson.SetBytes(msg, "role", role)
msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
accumulator.Append(msg)
}
func interactionsContentToClaude(part gjson.Result, role string) []byte {
partType := part.Get("type").String()
if partType == "" && part.Get("text").Exists() {
partType = "text"
}
switch partType {
case "text":
textPart := []byte(`{"type":"text","text":""}`)
textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String())
return textPart
case "thinking", "reasoning":
if role != "assistant" {
return nil
}
thinkingPart := []byte(`{"type":"thinking","thinking":""}`)
thinkingPart, _ = sjson.SetBytes(thinkingPart, "thinking", interactionsClaudeText(part))
return thinkingPart
case "image":
imagePart, _ := interactionsClaudeMediaPart(part, "image")
return imagePart
case "document", "file":
documentPart, _ := interactionsClaudeMediaPart(part, "document")
return documentPart
default:
if text := interactionsClaudeText(part); text != "" {
textPart := []byte(`{"type":"text","text":""}`)
textPart, _ = sjson.SetBytes(textPart, "text", text)
return textPart
}
if part.Get("data").String() != "" || part.Get("file_data").String() != "" {
textPart := []byte(`{"type":"text","text":""}`)
textPart, _ = sjson.SetBytes(textPart, "text", fmt.Sprintf("[%s content omitted]", partType))
return textPart
}
}
return nil
}
func appendInteractionsFunctionCallToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step gjson.Result) {
toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
toolUse, _ = sjson.SetBytes(toolUse, "id", interactionsClaudeToolID(step))
toolUse, _ = sjson.SetBytes(toolUse, "name", step.Get("name").String())
args := step.Get("arguments")
if !args.Exists() {
args = step.Get("args")
}
if args.Exists() && args.IsObject() {
toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(args.Raw))
}
msg := []byte(`{"role":"assistant","content":[]}`)
msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray([][]byte{toolUse}))
accumulator.Append(msg)
}
func appendInteractionsFunctionResultToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step gjson.Result) {
toolResult := []byte(`{"type":"tool_result","tool_use_id":"","content":""}`)
toolResult, _ = sjson.SetBytes(toolResult, "tool_use_id", interactionsClaudeToolID(step))
result := step.Get("result")
if !result.Exists() {
result = step.Get("output")
}
switch {
case result.IsArray():
contentItems := make([][]byte, 0, 4)
result.ForEach(func(_, part gjson.Result) bool {
if converted := interactionsContentToClaude(part, "user"); len(converted) > 0 {
contentItems = append(contentItems, converted)
}
return true
})
toolResult, _ = sjson.SetRawBytes(toolResult, "content", translatorcommon.JoinRawArray(contentItems))
case result.Exists() && result.Raw != "":
toolResult, _ = sjson.SetBytes(toolResult, "content", result.Raw)
default:
toolResult, _ = sjson.SetBytes(toolResult, "content", "")
}
msg := []byte(`{"role":"user","content":[]}`)
msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray([][]byte{toolResult}))
accumulator.Append(msg)
}
func copyInteractionsToolsToClaude(out []byte, root gjson.Result) []byte {
tools := root.Get("tools")
if !tools.Exists() || !tools.IsArray() {
return out
}
var toolItems [][]byte
tools.ForEach(func(_, tool gjson.Result) bool {
if tool.Get("function_declarations").IsArray() {
tool.Get("function_declarations").ForEach(func(_, decl gjson.Result) bool {
if converted := interactionsClaudeTool(decl); len(converted) > 0 {
toolItems = append(toolItems, converted)
}
return true
})
return true
}
if tool.Get("functionDeclarations").IsArray() {
tool.Get("functionDeclarations").ForEach(func(_, decl gjson.Result) bool {
if converted := interactionsClaudeTool(decl); len(converted) > 0 {
toolItems = append(toolItems, converted)
}
return true
})
return true
}
if converted := interactionsClaudeTool(tool); len(converted) > 0 {
toolItems = append(toolItems, converted)
}
return true
})
if len(toolItems) > 0 {
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
}
return out
}
func interactionsClaudeTool(tool gjson.Result) []byte {
name := tool.Get("name").String()
if name == "" {
name = tool.Get("function.name").String()
}
if name == "" {
return nil
}
converted := []byte(`{"name":"","input_schema":{}}`)
converted, _ = sjson.SetBytes(converted, "name", name)
if desc := tool.Get("description"); desc.Exists() {
converted, _ = sjson.SetBytes(converted, "description", desc.String())
} else if desc := tool.Get("function.description"); desc.Exists() {
converted, _ = sjson.SetBytes(converted, "description", desc.String())
}
params := firstClaudeInteractionsExisting(tool, "parameters", "parametersJsonSchema", "parameters_json_schema", "input_schema")
if params.Exists() && params.IsObject() {
converted, _ = sjson.SetRawBytes(converted, "input_schema", []byte(params.Raw))
}
return converted
}
func copyInteractionsToolChoiceToClaude(out []byte, toolChoice gjson.Result) []byte {
if !toolChoice.Exists() {
return out
}
switch toolChoice.Type {
case gjson.String:
switch strings.ToLower(strings.TrimSpace(toolChoice.String())) {
case "auto":
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`))
case "required", "any":
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
}
case gjson.JSON:
toolType := strings.ToLower(strings.TrimSpace(toolChoice.Get("type").String()))
switch toolType {
case "auto":
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`))
case "required", "any":
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
case "function", "tool":
name := toolChoice.Get("name").String()
if name == "" {
name = toolChoice.Get("function.name").String()
}
if name != "" {
choice := []byte(`{"type":"tool","name":""}`)
choice, _ = sjson.SetBytes(choice, "name", name)
out, _ = sjson.SetRawBytes(out, "tool_choice", choice)
}
}
}
return out
}
func interactionsClaudeToolID(step gjson.Result) string {
for _, path := range []string{"call_id", "id", "tool_use_id"} {
if value := step.Get(path).String(); value != "" {
return util.SanitizeClaudeToolID(value)
}
}
if name := step.Get("name").String(); name != "" {
return util.SanitizeClaudeToolID("toolu_" + name)
}
return "toolu_interactions"
}
func interactionsClaudeText(value gjson.Result) string {
if !value.Exists() {
return ""
}
if value.Type == gjson.String {
return value.String()
}
if text := value.Get("text"); text.Exists() {
return text.String()
}
if thinking := value.Get("thinking"); thinking.Exists() {
return thinking.String()
}
if content := value.Get("content"); content.Exists() {
return interactionsClaudeText(content)
}
if parts := value.Get("parts"); parts.Exists() && parts.IsArray() {
var builder strings.Builder
parts.ForEach(func(_, part gjson.Result) bool {
text := interactionsClaudeText(part)
if text == "" {
return true
}
if builder.Len() > 0 {
builder.WriteByte('\n')
}
builder.WriteString(text)
return true
})
return builder.String()
}
return ""
}
func interactionsClaudeMediaPart(part gjson.Result, claudeType string) ([]byte, bool) {
mimeType := firstClaudeInteractionsExisting(part, "mime_type", "mimeType", "media_type", "mediaType").String()
data := firstClaudeInteractionsExisting(part, "data", "file_data", "fileData").String()
if source := part.Get("source"); source.Exists() {
if mimeType == "" {
mimeType = source.Get("media_type").String()
}
if data == "" {
data = source.Get("data").String()
}
}
if mimeType == "" || data == "" {
return nil, false
}
out := []byte(`{"type":"","source":{"type":"base64","media_type":"","data":""}}`)
out, _ = sjson.SetBytes(out, "type", claudeType)
out, _ = sjson.SetBytes(out, "source.media_type", mimeType)
out, _ = sjson.SetBytes(out, "source.data", data)
return out, true
}
func firstClaudeInteractionsExisting(root gjson.Result, paths ...string) gjson.Result {
for _, path := range paths {
if value := root.Get(path); value.Exists() {
return value
}
}
return gjson.Result{}
}

View file

@ -0,0 +1,595 @@
package interactions
import (
"bytes"
"context"
"fmt"
"strings"
"time"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
var claudeInteractionsDataTag = []byte("data:")
type claudeToInteractionsStreamState struct {
ID string
Model string
Created bool
StatusUpdated bool
Completed bool
Done bool
UsageRaw []byte
StepIndex int
ActiveStepIndex int
ActiveStepType string
ActiveStepOpen bool
CurrentStepByIndex map[int]string
ToolNames map[int]string
ToolIDs map[int]string
ToolArgs map[int]*strings.Builder
}
func ConvertClaudeResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
if param == nil {
var local any
param = &local
}
if *param == nil {
*param = &claudeToInteractionsStreamState{Model: modelName}
}
st := (*param).(*claudeToInteractionsStreamState)
st.Model = firstNonEmptyString(st.Model, modelName)
st.ensureMaps()
return convertClaudeEventToInteractions(modelName, rawJSON, st)
}
func ConvertClaudeResponseToInteractionsNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
root := gjson.ParseBytes(rawJSON)
if root.Exists() && root.Get("content").Exists() {
return convertClaudeMessageToInteractions(modelName, root)
}
return convertClaudeSSEToInteractionsNonStream(modelName, rawJSON)
}
func convertClaudeMessageToInteractions(modelName string, root gjson.Result) []byte {
out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`)
out, _ = sjson.SetBytes(out, "id", firstNonEmptyString(root.Get("id").String(), fmt.Sprintf("interaction_%d", time.Now().UnixNano())))
out, _ = sjson.SetBytes(out, "model", firstNonEmptyString(root.Get("model").String(), modelName))
steps := make([][]byte, 0, 4)
root.Get("content").ForEach(func(_, part gjson.Result) bool {
if step := claudeContentBlockToInteractionsStep(part); len(step) > 0 {
steps = append(steps, step)
}
return true
})
if len(steps) > 0 {
out, _ = sjson.SetRawBytes(out, "steps", translatorcommon.JoinRawArray(steps))
}
out = setInteractionsUsageFromClaude(out, "usage", root.Get("usage"))
return out
}
func convertClaudeSSEToInteractionsNonStream(modelName string, rawJSON []byte) []byte {
out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`)
out, _ = sjson.SetBytes(out, "id", fmt.Sprintf("interaction_%d", time.Now().UnixNano()))
out, _ = sjson.SetBytes(out, "model", modelName)
st := &claudeToInteractionsStreamState{Model: modelName}
st.ensureMaps()
steps := make([][]byte, 0, 8)
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.TrimSpace(line)
if !bytes.HasPrefix(line, claudeInteractionsDataTag) {
continue
}
payload := bytes.TrimSpace(line[len(claudeInteractionsDataTag):])
if bytes.Equal(payload, []byte("[DONE]")) {
continue
}
root := gjson.ParseBytes(payload)
switch root.Get("type").String() {
case "message_start":
msg := root.Get("message")
if id := msg.Get("id").String(); id != "" {
out, _ = sjson.SetBytes(out, "id", id)
}
if model := msg.Get("model").String(); model != "" {
out, _ = sjson.SetBytes(out, "model", model)
}
mergeClaudeUsage(st, msg.Get("usage"))
case "content_block_start":
claudeNonStreamContentBlockStart(root, st)
case "content_block_delta":
claudeNonStreamContentBlockDelta(root, st)
case "content_block_stop":
if step := claudeNonStreamContentBlockStop(root, st); len(step) > 0 {
steps = append(steps, step)
}
case "message_delta":
mergeClaudeUsage(st, root.Get("usage"))
}
}
if len(steps) > 0 {
out, _ = sjson.SetRawBytes(out, "steps", translatorcommon.JoinRawArray(steps))
}
out = setInteractionsUsageFromClaude(out, "usage", claudeMergedUsage(st))
return out
}
func convertClaudeEventToInteractions(modelName string, rawJSON []byte, st *claudeToInteractionsStreamState) [][]byte {
payload := claudeInteractionsSSEPayload(rawJSON)
if len(payload) == 0 {
return nil
}
if bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) {
return appendClaudeInteractionsDone(nil, st)
}
root := gjson.ParseBytes(payload)
switch root.Get("type").String() {
case "message_start":
msg := root.Get("message")
st.ID = firstNonEmptyString(msg.Get("id").String(), st.ID, fmt.Sprintf("interaction_%d", time.Now().UnixNano()))
st.Model = firstNonEmptyString(msg.Get("model").String(), st.Model, modelName)
mergeClaudeUsage(st, msg.Get("usage"))
return appendClaudeInteractionsCreated(nil, st, st.Model)
case "content_block_start":
return claudeContentBlockStartToInteractions(modelName, root, st)
case "content_block_delta":
return claudeContentBlockDeltaToInteractions(modelName, root, st)
case "content_block_stop":
return claudeContentBlockStopToInteractions(root, st)
case "message_delta":
mergeClaudeUsage(st, root.Get("usage"))
out := appendClaudeInteractionsStepStop(nil, st)
out = appendClaudeInteractionsCompleted(out, st, modelName, root)
return out
case "message_stop":
if st.Completed {
return nil
}
return appendClaudeInteractionsCompleted(nil, st, modelName, root)
case "error":
out := appendClaudeInteractionsCreated(nil, st, modelName)
return appendClaudeInteractionsCompleted(out, st, modelName, root)
}
return nil
}
func claudeContentBlockStartToInteractions(modelName string, root gjson.Result, st *claudeToInteractionsStreamState) [][]byte {
out := appendClaudeInteractionsCreated(nil, st, modelName)
out = appendClaudeInteractionsStepStop(out, st)
index := int(root.Get("index").Int())
block := root.Get("content_block")
stepType := claudeBlockInteractionsStepType(block.Get("type").String())
st.CurrentStepByIndex[index] = stepType
if stepType == "function_call" {
if name := block.Get("name").String(); name != "" {
st.ToolNames[index] = name
}
if id := block.Get("id").String(); id != "" {
st.ToolIDs[index] = id
}
if input := block.Get("input"); input.Exists() && input.IsObject() && input.Raw != "{}" {
builder := &strings.Builder{}
builder.WriteString(input.Raw)
st.ToolArgs[index] = builder
}
}
step := claudeBlockToInteractionsStep(block, stepType)
return appendClaudeInteractionsStepStart(out, st, stepType, step)
}
func claudeContentBlockDeltaToInteractions(modelName string, root gjson.Result, st *claudeToInteractionsStreamState) [][]byte {
index := int(root.Get("index").Int())
stepType := st.CurrentStepByIndex[index]
if stepType == "" {
stepType = claudeDeltaInteractionsStepType(root.Get("delta.type").String())
out := appendClaudeInteractionsCreated(nil, st, modelName)
out = appendClaudeInteractionsStepStop(out, st)
out = appendClaudeInteractionsStepStart(out, st, stepType, []byte(`{"type":"`+stepType+`"}`))
st.CurrentStepByIndex[index] = stepType
return appendClaudeDeltaToInteractions(out, st, root.Get("delta"), index)
}
if !st.ActiveStepOpen || st.ActiveStepIndex != index {
out := appendClaudeInteractionsCreated(nil, st, modelName)
out = appendClaudeInteractionsStepStop(out, st)
step := claudeStepForKnownIndex(stepType, index, st)
out = appendClaudeInteractionsStepStart(out, st, stepType, step)
return appendClaudeDeltaToInteractions(out, st, root.Get("delta"), index)
}
return appendClaudeDeltaToInteractions(nil, st, root.Get("delta"), index)
}
func claudeContentBlockStopToInteractions(root gjson.Result, st *claudeToInteractionsStreamState) [][]byte {
index := int(root.Get("index").Int())
out := appendClaudeInteractionsStepStop(nil, st)
delete(st.CurrentStepByIndex, index)
delete(st.ToolNames, index)
delete(st.ToolIDs, index)
delete(st.ToolArgs, index)
return out
}
func appendClaudeDeltaToInteractions(out [][]byte, st *claudeToInteractionsStreamState, delta gjson.Result, index int) [][]byte {
switch delta.Get("type").String() {
case "text_delta":
return appendClaudeInteractionsTextDelta(out, st, delta.Get("text").String(), false)
case "thinking_delta":
return appendClaudeInteractionsTextDelta(out, st, delta.Get("thinking").String(), true)
case "input_json_delta":
if st.ToolArgs[index] == nil {
st.ToolArgs[index] = &strings.Builder{}
}
partial := delta.Get("partial_json").String()
st.ToolArgs[index].WriteString(partial)
return appendClaudeInteractionsArgumentsDelta(out, st, partial)
}
return out
}
func claudeContentBlockToInteractionsStep(part gjson.Result) []byte {
switch part.Get("type").String() {
case "text":
step := []byte(`{"type":"model_output","content":[]}`)
content := []byte(`{"type":"text","text":""}`)
content, _ = sjson.SetBytes(content, "text", part.Get("text").String())
return translatorcommon.SetRawArrayItems(step, "content", [][]byte{content})
case "thinking":
step := []byte(`{"type":"thought","content":[]}`)
content := []byte(`{"type":"text","text":""}`)
content, _ = sjson.SetBytes(content, "text", part.Get("thinking").String())
return translatorcommon.SetRawArrayItems(step, "content", [][]byte{content})
case "tool_use":
return claudeToolUseToInteractionsStep(part, strings.TrimSpace(part.Get("input").Raw))
}
return nil
}
func claudeToolUseToInteractionsStep(part gjson.Result, argsRaw string) []byte {
step := []byte(`{"type":"function_call","name":"","arguments":{}}`)
step, _ = sjson.SetBytes(step, "name", part.Get("name").String())
if id := part.Get("id").String(); id != "" {
step, _ = sjson.SetBytes(step, "id", id)
step, _ = sjson.SetBytes(step, "call_id", id)
}
if argsRaw != "" && gjson.Valid(argsRaw) {
step, _ = sjson.SetRawBytes(step, "arguments", []byte(argsRaw))
}
return step
}
func claudeBlockToInteractionsStep(block gjson.Result, stepType string) []byte {
step := []byte(`{"type":""}`)
step, _ = sjson.SetBytes(step, "type", stepType)
if stepType == "function_call" {
step, _ = sjson.SetBytes(step, "name", block.Get("name").String())
if id := block.Get("id").String(); id != "" {
step, _ = sjson.SetBytes(step, "id", id)
step, _ = sjson.SetBytes(step, "call_id", id)
}
step, _ = sjson.SetRawBytes(step, "arguments", []byte(`{}`))
}
return step
}
func claudeStepForKnownIndex(stepType string, index int, st *claudeToInteractionsStreamState) []byte {
step := []byte(`{"type":""}`)
step, _ = sjson.SetBytes(step, "type", stepType)
if stepType == "function_call" {
step, _ = sjson.SetBytes(step, "name", st.ToolNames[index])
if id := st.ToolIDs[index]; id != "" {
step, _ = sjson.SetBytes(step, "id", id)
step, _ = sjson.SetBytes(step, "call_id", id)
}
step, _ = sjson.SetRawBytes(step, "arguments", []byte(`{}`))
}
return step
}
func claudeNonStreamContentBlockStart(root gjson.Result, st *claudeToInteractionsStreamState) {
index := int(root.Get("index").Int())
block := root.Get("content_block")
st.CurrentStepByIndex[index] = claudeBlockInteractionsStepType(block.Get("type").String())
if block.Get("type").String() != "tool_use" {
return
}
st.ToolNames[index] = block.Get("name").String()
st.ToolIDs[index] = block.Get("id").String()
if input := block.Get("input"); input.Exists() && input.IsObject() && input.Raw != "{}" {
builder := &strings.Builder{}
builder.WriteString(input.Raw)
st.ToolArgs[index] = builder
}
}
func claudeNonStreamContentBlockDelta(root gjson.Result, st *claudeToInteractionsStreamState) {
index := int(root.Get("index").Int())
delta := root.Get("delta")
switch delta.Get("type").String() {
case "text_delta", "thinking_delta":
if st.ToolArgs[index] == nil {
st.ToolArgs[index] = &strings.Builder{}
}
if delta.Get("type").String() == "text_delta" {
st.ToolArgs[index].WriteString(delta.Get("text").String())
} else {
st.ToolArgs[index].WriteString(delta.Get("thinking").String())
}
case "input_json_delta":
if st.ToolArgs[index] == nil {
st.ToolArgs[index] = &strings.Builder{}
}
st.ToolArgs[index].WriteString(delta.Get("partial_json").String())
}
}
func claudeNonStreamContentBlockStop(root gjson.Result, st *claudeToInteractionsStreamState) []byte {
index := int(root.Get("index").Int())
stepType := st.CurrentStepByIndex[index]
builder := st.ToolArgs[index]
text := ""
if builder != nil {
text = builder.String()
}
var step []byte
switch stepType {
case "thought":
step = []byte(`{"type":"thought","content":[]}`)
content := []byte(`{"type":"text","text":""}`)
content, _ = sjson.SetBytes(content, "text", text)
step = translatorcommon.SetRawArrayItems(step, "content", [][]byte{content})
case "function_call":
part := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
part, _ = sjson.SetBytes(part, "id", st.ToolIDs[index])
part, _ = sjson.SetBytes(part, "name", st.ToolNames[index])
step = claudeToolUseToInteractionsStep(gjson.ParseBytes(part), strings.TrimSpace(text))
default:
step = []byte(`{"type":"model_output","content":[]}`)
content := []byte(`{"type":"text","text":""}`)
content, _ = sjson.SetBytes(content, "text", text)
step = translatorcommon.SetRawArrayItems(step, "content", [][]byte{content})
}
delete(st.CurrentStepByIndex, index)
delete(st.ToolNames, index)
delete(st.ToolIDs, index)
delete(st.ToolArgs, index)
return step
}
func mergeClaudeUsage(st *claudeToInteractionsStreamState, usage gjson.Result) {
if !usage.Exists() {
return
}
if len(st.UsageRaw) == 0 {
st.UsageRaw = []byte(`{}`)
}
for _, key := range []string{
"input_tokens",
"output_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
"thinking_tokens",
} {
value := usage.Get(key)
if !value.Exists() {
continue
}
st.UsageRaw, _ = sjson.SetRawBytes(st.UsageRaw, key, []byte(value.Raw))
}
}
func claudeMergedUsage(st *claudeToInteractionsStreamState) gjson.Result {
if len(st.UsageRaw) == 0 {
return gjson.Result{}
}
return gjson.ParseBytes(st.UsageRaw)
}
func setInteractionsUsageFromClaude(out []byte, path string, usage gjson.Result) []byte {
if !usage.Exists() {
return out
}
inputTokens := usage.Get("input_tokens").Int()
outputTokens := usage.Get("output_tokens").Int()
cacheRead := usage.Get("cache_read_input_tokens").Int()
cacheCreation := usage.Get("cache_creation_input_tokens").Int()
thinkingTokens := usage.Get("thinking_tokens").Int()
if usage.Get("input_tokens").Exists() {
out, _ = sjson.SetBytes(out, path+".input_tokens", inputTokens)
out, _ = sjson.SetBytes(out, path+".total_input_tokens", inputTokens)
}
if usage.Get("output_tokens").Exists() {
out, _ = sjson.SetBytes(out, path+".output_tokens", outputTokens)
out, _ = sjson.SetBytes(out, path+".total_output_tokens", outputTokens)
}
total := inputTokens + outputTokens
if usage.Get("input_tokens").Exists() || usage.Get("output_tokens").Exists() {
out, _ = sjson.SetBytes(out, path+".total_tokens", total)
}
if cacheRead != 0 || cacheCreation != 0 {
out, _ = sjson.SetBytes(out, path+".cached_tokens", cacheRead+cacheCreation)
out, _ = sjson.SetBytes(out, path+".total_cached_tokens", cacheRead+cacheCreation)
}
if thinkingTokens != 0 {
out, _ = sjson.SetBytes(out, path+".reasoning_tokens", thinkingTokens)
out, _ = sjson.SetBytes(out, path+".total_thought_tokens", thinkingTokens)
}
return out
}
func appendClaudeInteractionsCreated(out [][]byte, st *claudeToInteractionsStreamState, modelName string) [][]byte {
if st.Created {
return out
}
st.ID = firstNonEmptyString(st.ID, fmt.Sprintf("interaction_%d", time.Now().UnixNano()))
created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`)
created, _ = sjson.SetBytes(created, "interaction.id", st.ID)
created, _ = sjson.SetBytes(created, "interaction.model", firstNonEmptyString(st.Model, modelName))
out = append(out, translatorcommon.SSEEventData("interaction.created", created))
st.Created = true
return appendClaudeInteractionsStatusUpdate(out, st)
}
func appendClaudeInteractionsStatusUpdate(out [][]byte, st *claudeToInteractionsStreamState) [][]byte {
if st.StatusUpdated {
return out
}
statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`)
statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID)
out = append(out, translatorcommon.SSEEventData("interaction.status_update", statusUpdate))
st.StatusUpdated = true
return out
}
func appendClaudeInteractionsStepStart(out [][]byte, st *claudeToInteractionsStreamState, stepType string, step []byte) [][]byte {
st.ActiveStepIndex = st.StepIndex
st.ActiveStepType = stepType
st.ActiveStepOpen = true
payload := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`)
payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
if len(step) > 0 && gjson.ValidBytes(step) {
payload, _ = sjson.SetRawBytes(payload, "step", step)
} else {
payload, _ = sjson.SetBytes(payload, "step.type", stepType)
}
return append(out, translatorcommon.SSEEventData("step.start", payload))
}
func appendClaudeInteractionsTextDelta(out [][]byte, st *claudeToInteractionsStreamState, text string, thought bool) [][]byte {
payload := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
if thought {
payload, _ = sjson.SetBytes(payload, "delta.type", "thought_summary")
payload, _ = sjson.SetBytes(payload, "delta.content.type", "text")
payload, _ = sjson.SetBytes(payload, "delta.content.text", text)
payload, _ = sjson.DeleteBytes(payload, "delta.text")
} else {
payload, _ = sjson.SetBytes(payload, "delta.text", text)
}
return append(out, translatorcommon.SSEEventData("step.delta", payload))
}
func appendClaudeInteractionsArgumentsDelta(out [][]byte, st *claudeToInteractionsStreamState, arguments string) [][]byte {
payload := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
payload, _ = sjson.SetBytes(payload, "delta.arguments", arguments)
return append(out, translatorcommon.SSEEventData("step.delta", payload))
}
func appendClaudeInteractionsStepStop(out [][]byte, st *claudeToInteractionsStreamState) [][]byte {
if !st.ActiveStepOpen {
return out
}
payload := []byte(`{"index":0,"event_type":"step.stop"}`)
payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
out = append(out, translatorcommon.SSEEventData("step.stop", payload))
st.ActiveStepOpen = false
st.ActiveStepType = ""
st.StepIndex++
return out
}
func appendClaudeInteractionsCompleted(out [][]byte, st *claudeToInteractionsStreamState, modelName string, root gjson.Result) [][]byte {
if st.Completed {
return out
}
out = appendClaudeInteractionsCreated(out, st, modelName)
now := time.Now().UTC().Format(time.RFC3339)
completed := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`)
completed, _ = sjson.SetBytes(completed, "interaction.id", st.ID)
completed, _ = sjson.SetBytes(completed, "interaction.created", now)
completed, _ = sjson.SetBytes(completed, "interaction.updated", now)
completed, _ = sjson.SetBytes(completed, "interaction.model", firstNonEmptyString(st.Model, modelName))
usage := claudeMergedUsage(st)
if !usage.Exists() {
usage = root.Get("usage")
}
completed = setInteractionsUsageFromClaude(completed, "interaction.usage", usage)
out = append(out, translatorcommon.SSEEventData("interaction.completed", completed))
st.Completed = true
return out
}
func appendClaudeInteractionsDone(out [][]byte, st *claudeToInteractionsStreamState) [][]byte {
if st.Done {
return out
}
out = append(out, translatorcommon.SSEEventData("done", []byte("[DONE]")))
st.Done = true
return out
}
func claudeInteractionsSSEPayload(rawJSON []byte) []byte {
rawJSON = bytes.TrimSpace(rawJSON)
if bytes.Equal(rawJSON, []byte("[DONE]")) {
return rawJSON
}
if !bytes.HasPrefix(rawJSON, claudeInteractionsDataTag) {
return nil
}
return bytes.TrimSpace(rawJSON[len(claudeInteractionsDataTag):])
}
func claudeBlockInteractionsStepType(blockType string) string {
switch blockType {
case "thinking":
return "thought"
case "tool_use":
return "function_call"
default:
return "model_output"
}
}
func claudeDeltaInteractionsStepType(deltaType string) string {
switch deltaType {
case "thinking_delta":
return "thought"
case "input_json_delta":
return "function_call"
default:
return "model_output"
}
}
func (st *claudeToInteractionsStreamState) ensureMaps() {
if st.CurrentStepByIndex == nil {
st.CurrentStepByIndex = make(map[int]string)
}
if st.ToolNames == nil {
st.ToolNames = make(map[int]string)
}
if st.ToolIDs == nil {
st.ToolIDs = make(map[int]string)
}
if st.ToolArgs == nil {
st.ToolArgs = make(map[int]*strings.Builder)
}
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}

View file

@ -0,0 +1,238 @@
package interactions
import (
"bytes"
"context"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertInteractionsRequestToClaudeWithToolMessagesDirect(t *testing.T) {
out := ConvertInteractionsRequestToClaude("claude-test", []byte(`{"model":"claude-test","system_instruction":"be brief","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"function_call","name":"lookup","call_id":"toolu_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"toolu_1","result":{"ok":true}}]}`), false)
if got := gjson.GetBytes(out, "system").String(); got != "be brief" {
t.Fatalf("system = %q, want be brief. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.0.content.0.text").String(); got != "hi" {
t.Fatalf("messages.0.content.0.text = %q, want hi. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.1.content.0.type").String(); got != "tool_use" {
t.Fatalf("messages.1.content.0.type = %q, want tool_use. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.2.content.0.type").String(); got != "tool_result" {
t.Fatalf("messages.2.content.0.type = %q, want tool_result. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.2.content.0.tool_use_id").String(); got != "toolu_1" {
t.Fatalf("tool_use_id = %q, want toolu_1. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToClaudeGroupsConsecutiveRoleTurns(t *testing.T) {
raw := []byte(`{
"input":[
{"type":"thought","content":[{"type":"thinking","thinking":"reason"}]},
{"type":"model_output","content":[{"type":"text","text":"answer"}]},
{"type":"function_call","name":"first","call_id":"call_1","arguments":{}},
{"type":"function_call","name":"second","call_id":"call_2","arguments":{}},
{"type":"function_result","call_id":"call_1","result":{"value":"one"}},
{"type":"function_result","call_id":"call_2","result":{"value":"two"}}
]
}`)
out := ConvertInteractionsRequestToClaude("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{"thinking", "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("tool_use_id").String(); got != wantID {
t.Fatalf("user content[%d].tool_use_id = %q, want %q", i, got, wantID)
}
}
}
func TestConvertInteractionsRequestToClaudeDoesNotMergeAcrossRoleChanges(t *testing.T) {
raw := []byte(`{
"input":[
{"type":"model_output","content":"first assistant"},
{"type":"user_input","content":"user reply"},
{"type":"model_output","content":"second assistant"}
]
}`)
out := ConvertInteractionsRequestToClaude("claude-test", raw, false)
messages := gjson.GetBytes(out, "messages").Array()
if len(messages) != 3 {
t.Fatalf("message count = %d, want 3. Output: %s", len(messages), string(out))
}
for i, wantRole := range []string{"assistant", "user", "assistant"} {
if got := messages[i].Get("role").String(); got != wantRole {
t.Fatalf("messages[%d].role = %q, want %q", i, got, wantRole)
}
}
}
func TestConvertInteractionsRequestToClaudeStringInputDirect(t *testing.T) {
out := ConvertInteractionsRequestToClaude("claude-test", []byte(`{"model":"claude-test","input":"hello"}`), false)
if got := gjson.GetBytes(out, "messages.0.role").String(); got != "user" {
t.Fatalf("messages.0.role = %q, want user. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.0.content.0.text").String(); got != "hello" {
t.Fatalf("messages.0.content.0.text = %q, want hello. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToClaudeMapsGenerationConfigToolsAndStreamDirect(t *testing.T) {
out := ConvertInteractionsRequestToClaude("claude-test", []byte(`{"model":"claude-test","stream":true,"input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]}],"tools":[{"type":"function","name":"lookup","description":"Lookup data","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}],"generation_config":{"max_output_tokens":99,"top_p":0.7,"stop_sequences":["END"],"tool_choice":{"type":"function","name":"lookup"},"thinking_level":"high"}}`), false)
if !gjson.GetBytes(out, "stream").Bool() {
t.Fatalf("stream should be true when request body asks for stream. Output: %s", string(out))
}
if got := gjson.GetBytes(out, "max_tokens").Int(); got != 99 {
t.Fatalf("max_tokens = %d, want 99. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tools.0.input_schema.properties.q.type").String(); got != "string" {
t.Fatalf("tool schema type = %q, want string. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tool_choice.name").String(); got != "lookup" {
t.Fatalf("tool_choice.name = %q, want lookup. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "thinking.type").String(); got == "" {
t.Fatalf("thinking config was not mapped. Output: %s", string(out))
}
}
func TestConvertInteractionsRequestToClaudeAcceptsImageContent(t *testing.T) {
out := ConvertInteractionsRequestToClaude("claude-test", []byte(`{"model":"claude-test","input":[{"type":"user_input","content":[{"type":"image","mime_type":"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))
}
if got := gjson.GetBytes(out, "messages.0.content.0.source.data").String(); got != "aGVsbG8=" {
t.Fatalf("data = %q, want aGVsbG8=. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToClaudePreservesNonImageMediaContent(t *testing.T) {
out := ConvertInteractionsRequestToClaude("claude-test", []byte(`{"model":"claude-test","input":[{"type":"thought","content":[{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="},{"type":"video","mime_type":"video/mp4","data":"AAAAIGZ0eXA="},{"type":"document","mime_type":"application/pdf","data":"JVBERi0="}]}]}`), false)
if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" {
t.Fatalf("messages.0.role = %q, want assistant. Output: %s", got, string(out))
}
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 media must not be converted to image. Output: %s", string(out))
}
}
func TestConvertClaudeResponseToInteractionsNonStream(t *testing.T) {
raw := []byte(`{"id":"msg_1","model":"claude-test","content":[{"type":"thinking","thinking":"reasoning"},{"type":"text","text":"ok"},{"type":"tool_use","id":"toolu_1","name":"lookup","input":{"q":"x"}}],"usage":{"input_tokens":3,"output_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":4,"thinking_tokens":5}}`)
out := ConvertClaudeResponseToInteractionsNonStream(context.Background(), "claude-test", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "steps.0.type").String(); got != "thought" {
t.Fatalf("steps.0.type = %q, want thought. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "steps.1.content.0.text").String(); got != "ok" {
t.Fatalf("steps.1.content.0.text = %q, want ok. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "steps.2.call_id").String(); got != "toolu_1" {
t.Fatalf("steps.2.call_id = %q, want toolu_1. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 5 {
t.Fatalf("usage.total_tokens = %d, want 5. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "usage.total_cached_tokens").Int(); got != 5 {
t.Fatalf("usage.total_cached_tokens = %d, want 5. Output: %s", got, string(out))
}
}
func TestConvertClaudeSSEToInteractionsNonStream(t *testing.T) {
raw := []byte(`data: {"type":"message_start","message":{"id":"msg_1","model":"claude-test","usage":{"input_tokens":3,"output_tokens":0}}}
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}
data: {"type":"content_block_stop","index":0}
data: {"type":"message_delta","usage":{"output_tokens":2}}`)
out := ConvertClaudeResponseToInteractionsNonStream(context.Background(), "claude-test", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "steps.0.content.0.text").String(); got != "ok" {
t.Fatalf("steps.0.content.0.text = %q, want ok. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 5 {
t.Fatalf("usage.total_tokens = %d, want 5. Output: %s", got, string(out))
}
}
func TestConvertClaudeResponseToInteractionsStreamMergesUsageAndStatus(t *testing.T) {
var param any
var events [][]byte
for _, raw := range [][]byte{
[]byte(`data: {"type":"message_start","message":{"id":"msg_1","model":"claude-test","usage":{"input_tokens":3,"output_tokens":0}}}`),
[]byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`),
[]byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`),
[]byte(`data: {"type":"content_block_stop","index":0}`),
[]byte(`data: {"type":"message_delta","usage":{"output_tokens":2}}`),
} {
events = append(events, ConvertClaudeResponseToInteractions(context.Background(), "claude-test", nil, nil, raw, &param)...)
}
if payload := findClaudeInteractionsEventPayload(events, "interaction.status_update"); len(payload) == 0 {
t.Fatalf("interaction.status_update event not found: %q", events)
}
payload := findClaudeInteractionsEventPayload(events, "interaction.completed")
if got := gjson.GetBytes(payload, "interaction.usage.total_input_tokens").Int(); got != 3 {
t.Fatalf("total_input_tokens = %d, want 3. Payload: %s", got, string(payload))
}
if got := gjson.GetBytes(payload, "interaction.usage.total_output_tokens").Int(); got != 2 {
t.Fatalf("total_output_tokens = %d, want 2. Payload: %s", got, string(payload))
}
if got := gjson.GetBytes(payload, "interaction.usage.total_tokens").Int(); got != 5 {
t.Fatalf("total_tokens = %d, want 5. Payload: %s", got, string(payload))
}
}
func TestConvertClaudeResponseToInteractionsStream(t *testing.T) {
var param any
events := ConvertClaudeResponseToInteractions(context.Background(), "claude-test", nil, nil, []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`), &param)
payload := findClaudeInteractionsEventPayload(events, "step.delta")
if len(payload) == 0 {
t.Fatalf("step.delta event not found: %q", events)
}
if got := gjson.GetBytes(payload, "delta.text").String(); got != "ok" {
t.Fatalf("delta.text = %q, want ok. Payload: %s", got, string(payload))
}
}
func findClaudeInteractionsEventPayload(events [][]byte, eventType string) []byte {
prefix := []byte("data:")
for _, event := range events {
for _, line := range bytes.Split(event, []byte("\n")) {
line = bytes.TrimSpace(line)
if !bytes.HasPrefix(line, prefix) {
continue
}
payload := bytes.TrimSpace(line[len(prefix):])
if gjson.GetBytes(payload, "event_type").String() == eventType || gjson.GetBytes(payload, "type").String() == eventType {
return payload
}
}
}
return nil
}

View file

@ -0,0 +1,22 @@
package chat_completions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIRequestToClaudeWithCompatPreservesReasoningContent(t *testing.T) {
payload := []byte(`{"messages":[{"role":"assistant","content":"answer","reasoning_content":"reason"}]}`)
withoutCompat := ConvertOpenAIRequestToClaude("deepseek-v4", payload, false)
if gjson.GetBytes(withoutCompat, "messages.0.content.#(type=thinking)").Exists() {
t.Fatalf("default translation preserved reasoning_content: %s", withoutCompat)
}
withCompat := ConvertOpenAIRequestToClaudeWithCompat("deepseek-v4", payload, false)
part := gjson.GetBytes(withCompat, "messages.0.content.#(type=thinking)")
if part.Get("thinking").String() != "reason" || part.Get("signature").String() != "" {
t.Fatalf("compat translation missing unsigned thinking block: %s", withCompat)
}
}

View file

@ -0,0 +1,484 @@
// Package openai provides request translation functionality for OpenAI to Claude Code API compatibility.
// It handles parsing and transforming OpenAI Chat Completions 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 OpenAI API format and Claude Code API's expected format.
package chat_completions
import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"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"
)
// ConvertOpenAIRequestToClaude parses and transforms an OpenAI Chat Completions 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 parameter extraction (max_tokens, top_p, etc.)
// 2. Message content conversion from OpenAI to Claude Code format
// 3. Tool call and tool result handling with proper ID mapping
// 4. Image data conversion from OpenAI data URLs to Claude Code base64 format
// 5. Stop sequence and streaming configuration handling
//
// Parameters:
// - modelName: The name of the model to use for the request
// - rawJSON: The raw JSON request data from the OpenAI API
// - stream: A boolean indicating if the request is for a streaming response
//
// Returns:
// - []byte: The transformed request data in Claude Code API format
func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {
return convertOpenAIRequestToClaude(modelName, inputRawJSON, stream, false)
}
// ConvertOpenAIRequestToClaudeWithCompat preserves assistant reasoning content
// as an unsigned thinking block for configured compatibility endpoints.
func ConvertOpenAIRequestToClaudeWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte {
return convertOpenAIRequestToClaude(modelName, inputRawJSON, stream, true)
}
func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream, preserveEmptyThinkingBlocks bool) []byte {
rawJSON := inputRawJSON
userID := common.DeriveClaudeUserID(rawJSON)
// Base Claude Code API template with default max_tokens value
out := []byte(`{"model":"","max_tokens":32000,"messages":[],"metadata":{}}`)
out, _ = sjson.SetBytes(out, "metadata.user_id", userID)
root := gjson.ParseBytes(rawJSON)
// Convert OpenAI reasoning_effort to Claude thinking config.
if v := root.Get("reasoning_effort"); v.Exists() {
effort := strings.ToLower(strings.TrimSpace(v.String()))
if effort != "" {
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))
// Claude 4.6 supports adaptive thinking with output_config.effort.
// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid
// validation errors since validate treats same-provider unsupported levels as errors.
if supportsAdaptive {
switch effort {
case "none":
out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
out, _ = sjson.DeleteBytes(out, "output_config.effort")
case "auto":
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
out, _ = sjson.DeleteBytes(out, "output_config.effort")
default:
if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok {
effort = mapped
}
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
out, _ = sjson.SetBytes(out, "output_config.effort", effort)
}
} else {
// Legacy/manual thinking (budget_tokens).
budget, ok := thinking.ConvertLevelToBudget(effort)
if ok {
switch budget {
case 0:
out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
case -1:
out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
default:
if budget > 0 {
out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
out, _ = sjson.SetBytes(out, "thinking.budget_tokens", budget)
}
}
}
}
}
}
// Model mapping to specify which Claude Code model to use
out, _ = sjson.SetBytes(out, "model", modelName)
// Max tokens configuration with fallback to default value.
// OpenAI Chat Completions deprecated max_tokens in favor of
// max_completion_tokens, so accept either spelling.
if maxTokens := firstExisting(root.Get("max_tokens"), root.Get("max_completion_tokens")); maxTokens.Exists() {
out, _ = sjson.SetBytes(out, "max_tokens", maxTokens.Int())
}
// Top P setting for nucleus sampling.
if topP := root.Get("top_p"); topP.Exists() {
out, _ = sjson.SetBytes(out, "top_p", topP.Float())
}
// Stop sequences configuration for custom termination conditions
if stop := root.Get("stop"); stop.Exists() {
if stop.IsArray() {
var stopSequences []string
stop.ForEach(func(_, value gjson.Result) bool {
stopSequences = append(stopSequences, value.String())
return true
})
if len(stopSequences) > 0 {
out, _ = sjson.SetBytes(out, "stop_sequences", stopSequences)
}
} else {
out, _ = sjson.SetBytes(out, "stop_sequences", []string{stop.String()})
}
}
// Stream configuration to enable or disable streaming responses
out, _ = sjson.SetBytes(out, "stream", stream)
// Process messages and transform them to Claude Code format
if messages := root.Get("messages"); messages.Exists() && messages.IsArray() {
lastToolMessage := map[string]gjson.Result{}
messages.ForEach(func(_, message gjson.Result) bool {
if message.Get("role").String() == "tool" {
rawID := message.Get("tool_call_id").String()
if rawID != "" {
lastToolMessage[rawID] = message
}
}
return true
})
emittedToolResults := map[string]struct{}{}
systemBlocks := make([][]byte, 0)
messageAccumulator := common.NewClaudeMessageAccumulator(int(root.Get("messages.#").Int()))
messages.ForEach(func(_, message gjson.Result) bool {
role := message.Get("role").String()
contentResult := message.Get("content")
switch role {
// Developer messages rank with system messages in OpenAI's instruction
// hierarchy, so both become top-level Claude system blocks. Dropping the
// developer role, as this translator used to, silently removed operator
// instructions from the upstream request.
case "system", "developer":
systemStart := len(systemBlocks)
if contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != "" {
textPart := []byte(`{"type":"text","text":""}`)
textPart, _ = sjson.SetBytes(textPart, "text", contentResult.String())
textPart = common.AttachCacheControl(textPart, message)
systemBlocks = append(systemBlocks, textPart)
} else if contentResult.Exists() && contentResult.IsArray() {
contentResult.ForEach(func(_, part gjson.Result) bool {
if part.Get("type").String() == "text" {
textPart := []byte(`{"type":"text","text":""}`)
textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String())
textPart = common.AttachCacheControl(textPart, part)
systemBlocks = append(systemBlocks, textPart)
}
return true
})
// Message-level cache_control applies to the last system block from this message.
if message.Get("cache_control").Exists() {
if len(systemBlocks) > systemStart {
lastIdx := len(systemBlocks) - 1
if !gjson.GetBytes(systemBlocks[lastIdx], "cache_control").Exists() {
systemBlocks[lastIdx] = common.AttachCacheControl(systemBlocks[lastIdx], message)
}
}
}
}
case "user", "assistant":
contentBlocks := make([][]byte, 0, 4)
if preserveEmptyThinkingBlocks && role == "assistant" {
if reasoningContent := message.Get("reasoning_content"); reasoningContent.Type == gjson.String && strings.TrimSpace(reasoningContent.String()) != "" {
part := []byte(`{"type":"thinking","thinking":"","signature":""}`)
part, _ = sjson.SetBytes(part, "thinking", reasoningContent.String())
contentBlocks = append(contentBlocks, part)
}
}
// Handle content based on its type
if contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != "" {
part := []byte(`{"type":"text","text":""}`)
part, _ = sjson.SetBytes(part, "text", contentResult.String())
contentBlocks = append(contentBlocks, part)
} else if contentResult.Exists() && contentResult.IsArray() {
contentResult.ForEach(func(_, part gjson.Result) bool {
claudePart := convertOpenAIContentPartToClaudePart(part)
if claudePart != "" {
contentBlocks = append(contentBlocks, []byte(claudePart))
}
return true
})
}
// Handle tool calls (for assistant messages)
if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() && role == "assistant" {
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
if toolCall.Get("type").String() == "function" {
toolCallID := toolCall.Get("id").String()
if toolCallID == "" {
toolCallID = common.GenerateClaudeToolCallID()
}
toolCallID = util.SanitizeClaudeToolID(toolCallID)
function := toolCall.Get("function")
toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
toolUse, _ = sjson.SetBytes(toolUse, "id", toolCallID)
toolUse, _ = sjson.SetBytes(toolUse, "name", function.Get("name").String())
// Parse arguments for the tool call
if args := function.Get("arguments"); args.Exists() {
argsStr := args.String()
if argsStr != "" && gjson.Valid(argsStr) {
argsJSON := gjson.Parse(argsStr)
if argsJSON.IsObject() {
toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(argsJSON.Raw))
} else {
toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte("{}"))
}
} else {
toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte("{}"))
}
} else {
toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte("{}"))
}
contentBlocks = append(contentBlocks, toolUse)
}
return true
})
}
msg := []byte(`{"role":"","content":[]}`)
msg, _ = sjson.SetBytes(msg, "role", role)
msg, _ = sjson.SetRawBytes(msg, "content", common.JoinRawArray(contentBlocks))
msg = common.AttachMessageCacheControl(msg, message)
messageAccumulator.Append(msg)
case "tool":
// Handle tool result messages conversion
rawID := message.Get("tool_call_id").String()
toolCallID := util.SanitizeClaudeToolID(rawID)
if rawID != "" {
if _, exists := emittedToolResults[rawID]; exists {
return true
}
emittedToolResults[rawID] = struct{}{}
}
targetMsg := message
if rawID != "" {
if lastMsg, exists := lastToolMessage[rawID]; exists {
targetMsg = lastMsg
}
}
toolContentResult := targetMsg.Get("content")
msg := []byte(`{"role":"user","content":[{"type":"tool_result","tool_use_id":"","content":""}]}`)
msg, _ = sjson.SetBytes(msg, "content.0.tool_use_id", toolCallID)
toolResultContent, toolResultContentRaw := convertOpenAIToolResultContent(toolContentResult)
if toolResultContentRaw {
msg, _ = sjson.SetRawBytes(msg, "content.0.content", []byte(toolResultContent))
} else {
msg, _ = sjson.SetBytes(msg, "content.0.content", toolResultContent)
}
msg = common.AttachMessageCacheControl(msg, targetMsg)
messageAccumulator.Append(msg)
}
return true
})
messageBlocks := messageAccumulator.Messages()
// Preserve a minimal conversational turn for system-only inputs.
// Claude payloads with top-level system instructions but no messages are risky for downstream validation.
if len(messageBlocks) == 0 && len(systemBlocks) > 0 {
messageBlocks = append(messageBlocks, []byte(`{"role":"user","content":[{"type":"text","text":""}]}`))
}
if len(systemBlocks) > 0 {
out, _ = sjson.SetRawBytes(out, "system", common.JoinRawArray(systemBlocks))
}
if len(messageBlocks) > 0 {
out = common.SetRawArrayItems(out, "messages", messageBlocks)
}
}
// Tools mapping: OpenAI tools -> Claude Code tools
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 {
var anthropicTools [][]byte
tools.ForEach(func(_, tool gjson.Result) bool {
if tool.Get("type").String() == "function" {
function := tool.Get("function")
anthropicTool := []byte(`{"name":"","description":""}`)
anthropicTool, _ = sjson.SetBytes(anthropicTool, "name", function.Get("name").String())
anthropicTool, _ = sjson.SetBytes(anthropicTool, "description", function.Get("description").String())
// Convert parameters schema for the tool
if parameters := function.Get("parameters"); parameters.Exists() {
anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", util.NormalizeClaudeToolInputSchema([]byte(parameters.Raw)))
} else if parameters := function.Get("parametersJsonSchema"); parameters.Exists() {
anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", util.NormalizeClaudeToolInputSchema([]byte(parameters.Raw)))
}
anthropicTool = common.AttachCacheControl(anthropicTool, tool)
if !gjson.GetBytes(anthropicTool, "cache_control").Exists() {
anthropicTool = common.AttachCacheControl(anthropicTool, function)
}
anthropicTools = append(anthropicTools, anthropicTool)
}
return true
})
if len(anthropicTools) > 0 {
out, _ = sjson.SetRawBytes(out, "tools", common.JoinRawArray(anthropicTools))
} else {
out, _ = sjson.DeleteBytes(out, "tools")
}
}
// Tool choice mapping from OpenAI format to Claude Code format
if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
switch toolChoice.Type {
case gjson.String:
choice := toolChoice.String()
switch choice {
case "none":
// Don't set tool_choice, Claude Code will not use tools
case "auto":
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`))
case "required":
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
}
case gjson.JSON:
// Specific tool choice mapping
if toolChoice.Get("type").String() == "function" {
functionName := toolChoice.Get("function.name").String()
toolChoiceJSON := []byte(`{"type":"tool","name":""}`)
toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", functionName)
out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON)
}
default:
}
}
return out
}
func convertOpenAIContentPartToClaudePart(part gjson.Result) string {
var claudePart []byte
switch part.Get("type").String() {
case "text":
textPart := []byte(`{"type":"text","text":""}`)
textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String())
claudePart = textPart
case "image_url":
claudePart = []byte(convertOpenAIImageURLToClaudePart(part.Get("image_url.url").String()))
case "file":
fileData := part.Get("file.file_data").String()
if strings.HasPrefix(fileData, "data:") {
semicolonIdx := strings.Index(fileData, ";")
commaIdx := strings.Index(fileData, ",")
if semicolonIdx != -1 && commaIdx != -1 && commaIdx > semicolonIdx {
mediaType := strings.TrimPrefix(fileData[:semicolonIdx], "data:")
data := fileData[commaIdx+1:]
docPart := []byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`)
docPart, _ = sjson.SetBytes(docPart, "source.media_type", mediaType)
docPart, _ = sjson.SetBytes(docPart, "source.data", data)
claudePart = docPart
}
}
}
if len(claudePart) == 0 {
return ""
}
return string(common.AttachCacheControl(claudePart, part))
}
func convertOpenAIImageURLToClaudePart(imageURL string) string {
if imageURL == "" {
return ""
}
if strings.HasPrefix(imageURL, "data:") {
parts := strings.SplitN(imageURL, ",", 2)
if len(parts) != 2 {
return ""
}
mediaTypePart := strings.SplitN(parts[0], ";", 2)[0]
mediaType := strings.TrimPrefix(mediaTypePart, "data:")
if mediaType == "" {
mediaType = "application/octet-stream"
}
imagePart := []byte(`{"type":"image","source":{"type":"base64","media_type":"","data":""}}`)
imagePart, _ = sjson.SetBytes(imagePart, "source.media_type", mediaType)
imagePart, _ = sjson.SetBytes(imagePart, "source.data", parts[1])
return string(imagePart)
}
imagePart := []byte(`{"type":"image","source":{"type":"url","url":""}}`)
imagePart, _ = sjson.SetBytes(imagePart, "source.url", imageURL)
return string(imagePart)
}
func convertOpenAIToolResultContent(content gjson.Result) (string, bool) {
if !content.Exists() {
return "", false
}
if content.Type == gjson.String {
return content.String(), false
}
if content.IsArray() {
claudeParts := make([][]byte, 0, 4)
content.ForEach(func(_, part gjson.Result) bool {
if part.Type == gjson.String {
textPart := []byte(`{"type":"text","text":""}`)
textPart, _ = sjson.SetBytes(textPart, "text", part.String())
claudeParts = append(claudeParts, textPart)
return true
}
claudePart := convertOpenAIContentPartToClaudePart(part)
if claudePart != "" {
claudeParts = append(claudeParts, []byte(claudePart))
}
return true
})
if len(claudeParts) > 0 || len(content.Array()) == 0 {
return string(common.JoinRawArray(claudeParts)), true
}
return content.Raw, false
}
if content.IsObject() {
claudePart := convertOpenAIContentPartToClaudePart(content)
if claudePart != "" {
return string(common.JoinRawArray([][]byte{[]byte(claudePart)})), true
}
return content.Raw, false
}
return content.Raw, false
}
// firstExisting returns the first result that exists, or an empty result.
func firstExisting(values ...gjson.Result) gjson.Result {
for _, value := range values {
if value.Exists() {
return value
}
}
return gjson.Result{}
}

View file

@ -0,0 +1,846 @@
package chat_completions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIRequestToClaudeWithCompat_GroupsAssistantThinkingTextAndTools(t *testing.T) {
inputJSON := []byte(`{
"messages":[
{"role":"assistant","reasoning_content":"reason","content":"answer"},
{
"role":"assistant",
"content":"",
"tool_calls":[
{"id":"call_1","type":"function","function":{"name":"first","arguments":"{}"}},
{"id":"call_2","type":"function","function":{"name":"second","arguments":"{}"}}
]
}
]
}`)
out := ConvertOpenAIRequestToClaudeWithCompat("claude-test", inputJSON, false)
messages := gjson.GetBytes(out, "messages").Array()
if len(messages) != 1 {
t.Fatalf("message count = %d, want 1. Output: %s", len(messages), string(out))
}
content := messages[0].Get("content").Array()
wantTypes := []string{"thinking", "text", "tool_use", "tool_use"}
if len(content) != len(wantTypes) {
t.Fatalf("content count = %d, want %d. Output: %s", len(content), len(wantTypes), string(out))
}
for i, wantType := range wantTypes {
if got := content[i].Get("type").String(); got != wantType {
t.Fatalf("content[%d].type = %q, want %q", i, got, wantType)
}
}
}
func TestConvertOpenAIRequestToClaude_MergesToolResultWithAdjacentUserContent(t *testing.T) {
inputJSON := []byte(`{
"messages":[
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"work","arguments":"{}"}}]},
{"role":"tool","tool_call_id":"call_1","content":"ok"},
{"role":"user","content":"continue"}
]
}`)
out := ConvertOpenAIRequestToClaude("claude-test", inputJSON, false)
messages := gjson.GetBytes(out, "messages").Array()
if len(messages) != 2 {
t.Fatalf("message count = %d, want 2. Output: %s", len(messages), string(out))
}
userContent := messages[1].Get("content").Array()
if len(userContent) != 2 {
t.Fatalf("user content count = %d, want 2. Output: %s", len(userContent), string(out))
}
if got := userContent[0].Get("type").String(); got != "tool_result" {
t.Fatalf("user content[0].type = %q, want tool_result", got)
}
if got := userContent[1].Get("text").String(); got != "continue" {
t.Fatalf("user content[1].text = %q, want continue", got)
}
}
func TestConvertOpenAIRequestToClaude_SystemDoesNotBreakUserTurnAndCacheBoundary(t *testing.T) {
inputJSON := []byte(`{
"messages":[
{"role":"user","content":"first","cache_control":{"type":"ephemeral"}},
{"role":"system","content":"system rule"},
{"role":"user","content":"second"}
]
}`)
out := ConvertOpenAIRequestToClaude("claude-test", inputJSON, false)
messages := gjson.GetBytes(out, "messages").Array()
if len(messages) != 1 {
t.Fatalf("message count = %d, want 1. Output: %s", len(messages), string(out))
}
content := messages[0].Get("content").Array()
if len(content) != 2 {
t.Fatalf("content count = %d, want 2. Output: %s", len(content), string(out))
}
if got := content[0].Get("text").String(); got != "first" {
t.Fatalf("content[0].text = %q, want first", got)
}
if got := content[0].Get("cache_control.type").String(); got != "ephemeral" {
t.Fatalf("content[0].cache_control.type = %q, want ephemeral", got)
}
if got := content[1].Get("text").String(); got != "second" {
t.Fatalf("content[1].text = %q, want second", got)
}
if got := gjson.GetBytes(out, "system.0.text").String(); got != "system rule" {
t.Fatalf("system text = %q, want system rule", got)
}
}
func TestConvertOpenAIRequestToClaude_SanitizesToolCallIDsForClaude(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [
{
"role": "assistant",
"tool_calls": [
{
"id": "call.with space:1",
"type": "function",
"function": {
"name": "Read",
"arguments": "{\"path\":\"README.md\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call.with space:1",
"content": "ok"
}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
toolUseID := resultJSON.Get("messages.0.content.0.id").String()
toolResultID := resultJSON.Get("messages.1.content.0.tool_use_id").String()
if toolUseID != "call_with_space_1" {
t.Fatalf("tool_use id = %q, want %q", toolUseID, "call_with_space_1")
}
if toolResultID != toolUseID {
t.Fatalf("tool_result tool_use_id = %q, want same sanitized id %q", toolResultID, toolUseID)
}
}
func TestConvertOpenAIRequestToClaude_GroupsConsecutiveParallelToolResults(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [
{"role": "user", "content": "Use both tools."},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "tool_a", "arguments": "{}"}},
{"id": "call_2", "type": "function", "function": {"name": "tool_b", "arguments": "{}"}}
]
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": "one",
"cache_control": {"type": "ephemeral"}
},
{"role": "tool", "tool_call_id": "call_2", "content": "two"},
{"role": "assistant", "content": "Done."}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
messages := resultJSON.Get("messages").Array()
if len(messages) != 4 {
t.Fatalf("Expected 4 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
}
if got := messages[2].Get("role").String(); got != "user" {
t.Fatalf("Expected grouped tool result role %q, got %q", "user", got)
}
toolResults := messages[2].Get("content").Array()
if len(toolResults) != 2 {
t.Fatalf("Expected 2 grouped tool results, got %d. Content: %s", len(toolResults), messages[2].Get("content").Raw)
}
wants := []struct {
id string
content string
}{
{id: "call_1", content: "one"},
{id: "call_2", content: "two"},
}
for i, want := range wants {
if got := toolResults[i].Get("type").String(); got != "tool_result" {
t.Fatalf("tool result %d type = %q, want tool_result", i, got)
}
if got := toolResults[i].Get("tool_use_id").String(); got != want.id {
t.Fatalf("tool result %d tool_use_id = %q, want %q", i, got, want.id)
}
if got := toolResults[i].Get("content").String(); got != want.content {
t.Fatalf("tool result %d content = %q, want %q", i, got, want.content)
}
}
if got := toolResults[0].Get("cache_control.type").String(); got != "ephemeral" {
t.Fatalf("first tool result cache_control.type = %q, want ephemeral", got)
}
if got := messages[3].Get("content.0.text").String(); got != "Done." {
t.Fatalf("following assistant message text = %q, want Done.", got)
}
}
func TestConvertOpenAIRequestToClaude_DropsTemperature(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"temperature": 0.2,
"top_p": 0.8,
"messages": [
{"role": "user", "content": "hi"}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if resultJSON.Get("temperature").Exists() {
t.Fatalf("temperature should be removed")
}
if got := resultJSON.Get("top_p").Float(); got != 0.8 {
t.Fatalf("top_p = %v, want 0.8", got)
}
}
func TestConvertOpenAIRequestToClaude_ToolResultTextAndBase64Image(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "do_work",
"arguments": "{\"a\":1}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": [
{"type": "text", "text": "tool ok"},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="
}
}
]
}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
messages := resultJSON.Get("messages").Array()
if len(messages) != 2 {
t.Fatalf("Expected 2 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
}
toolResult := messages[1].Get("content.0")
if got := toolResult.Get("type").String(); got != "tool_result" {
t.Fatalf("Expected content[0].type %q, got %q", "tool_result", got)
}
if got := toolResult.Get("tool_use_id").String(); got != "call_1" {
t.Fatalf("Expected tool_use_id %q, got %q", "call_1", got)
}
toolContent := toolResult.Get("content")
if !toolContent.IsArray() {
t.Fatalf("Expected tool_result content array, got %s", toolContent.Raw)
}
if got := toolContent.Get("0.type").String(); got != "text" {
t.Fatalf("Expected first tool_result part type %q, got %q", "text", got)
}
if got := toolContent.Get("0.text").String(); got != "tool ok" {
t.Fatalf("Expected first tool_result part text %q, got %q", "tool ok", got)
}
if got := toolContent.Get("1.type").String(); got != "image" {
t.Fatalf("Expected second tool_result part type %q, got %q", "image", got)
}
if got := toolContent.Get("1.source.type").String(); got != "base64" {
t.Fatalf("Expected image source type %q, got %q", "base64", got)
}
if got := toolContent.Get("1.source.media_type").String(); got != "image/png" {
t.Fatalf("Expected image media type %q, got %q", "image/png", got)
}
if got := toolContent.Get("1.source.data").String(); got != "iVBORw0KGgoAAAANSUhEUg==" {
t.Fatalf("Unexpected base64 image data: %q", got)
}
}
func TestConvertOpenAIRequestToClaude_ToolResultURLImageOnly(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "do_work",
"arguments": "{\"a\":1}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": [
{
"type": "image_url",
"image_url": {
"url": "https://example.com/tool.png"
}
}
]
}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
messages := resultJSON.Get("messages").Array()
if len(messages) != 2 {
t.Fatalf("Expected 2 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
}
toolContent := messages[1].Get("content.0.content")
if !toolContent.IsArray() {
t.Fatalf("Expected tool_result content array, got %s", toolContent.Raw)
}
if got := toolContent.Get("0.type").String(); got != "image" {
t.Fatalf("Expected tool_result part type %q, got %q", "image", got)
}
if got := toolContent.Get("0.source.type").String(); got != "url" {
t.Fatalf("Expected image source type %q, got %q", "url", got)
}
if got := toolContent.Get("0.source.url").String(); got != "https://example.com/tool.png" {
t.Fatalf("Unexpected image URL: %q", got)
}
}
func TestConvertOpenAIRequestToClaude_SystemRoleBecomesTopLevelSystem(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello"}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
system := resultJSON.Get("system")
if !system.IsArray() {
t.Fatalf("Expected top-level system array, got %s", system.Raw)
}
if len(system.Array()) != 1 {
t.Fatalf("Expected 1 system block, got %d. System: %s", len(system.Array()), system.Raw)
}
if got := system.Get("0.type").String(); got != "text" {
t.Fatalf("Expected system block type %q, got %q", "text", got)
}
if got := system.Get("0.text").String(); got != "You are a helpful assistant." {
t.Fatalf("Expected system text %q, got %q", "You are a helpful assistant.", got)
}
messages := resultJSON.Get("messages").Array()
if len(messages) != 1 {
t.Fatalf("Expected 1 non-system message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
}
if got := messages[0].Get("role").String(); got != "user" {
t.Fatalf("Expected remaining message role %q, got %q", "user", got)
}
if got := messages[0].Get("content.0.text").String(); got != "Hello" {
t.Fatalf("Expected user text %q, got %q", "Hello", got)
}
}
func TestConvertOpenAIRequestToClaude_MultipleSystemMessagesMergedIntoTopLevelSystem(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [
{"role": "system", "content": "Rule 1"},
{"role": "system", "content": [{"type": "text", "text": "Rule 2"}]},
{"role": "user", "content": "Hello"}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
system := resultJSON.Get("system").Array()
if len(system) != 2 {
t.Fatalf("Expected 2 system blocks, got %d. System: %s", len(system), resultJSON.Get("system").Raw)
}
if got := system[0].Get("text").String(); got != "Rule 1" {
t.Fatalf("Expected first system text %q, got %q", "Rule 1", got)
}
if got := system[1].Get("text").String(); got != "Rule 2" {
t.Fatalf("Expected second system text %q, got %q", "Rule 2", got)
}
messages := resultJSON.Get("messages").Array()
if len(messages) != 1 {
t.Fatalf("Expected 1 non-system message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
}
if got := messages[0].Get("role").String(); got != "user" {
t.Fatalf("Expected remaining message role %q, got %q", "user", got)
}
if got := messages[0].Get("content.0.text").String(); got != "Hello" {
t.Fatalf("Expected user text %q, got %q", "Hello", got)
}
}
func TestConvertOpenAIRequestToClaude_SystemOnlyInputKeepsFallbackUserMessage(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [
{"role": "system", "content": "You are a helpful assistant."}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
system := resultJSON.Get("system").Array()
if len(system) != 1 {
t.Fatalf("Expected 1 system block, got %d. System: %s", len(system), resultJSON.Get("system").Raw)
}
if got := system[0].Get("text").String(); got != "You are a helpful assistant." {
t.Fatalf("Expected system text %q, got %q", "You are a helpful assistant.", got)
}
messages := resultJSON.Get("messages").Array()
if len(messages) != 1 {
t.Fatalf("Expected 1 fallback message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
}
if got := messages[0].Get("role").String(); got != "user" {
t.Fatalf("Expected fallback message role %q, got %q", "user", got)
}
if got := messages[0].Get("content.0.type").String(); got != "text" {
t.Fatalf("Expected fallback content type %q, got %q", "text", got)
}
if got := messages[0].Get("content.0.text").String(); got != "" {
t.Fatalf("Expected fallback text %q, got %q", "", got)
}
}
func TestConvertOpenAIRequestToClaude_PreservesContentPartCacheControl(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "cached prefix", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "fresh question"}
]
}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("content.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
}
if resultJSON.Get("messages.0.content.1.cache_control").Exists() {
t.Fatalf("content.1 should not have cache_control. Output: %s", result)
}
if got := resultJSON.Get("messages.0.content.0.text").String(); got != "cached prefix" {
t.Fatalf("content.0.text = %q, want %q", got, "cached prefix")
}
}
func TestConvertOpenAIRequestToClaude_PreservesMessageLevelCacheControl(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [
{
"role": "user",
"content": "cache me",
"cache_control": {"type": "ephemeral", "ttl": "1h"}
}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("content.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
}
if got := resultJSON.Get("messages.0.content.0.cache_control.ttl").String(); got != "1h" {
t.Fatalf("content.0.cache_control.ttl = %q, want 1h. Output: %s", got, result)
}
}
func TestConvertOpenAIRequestToClaude_PreservesToolCacheControl(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [{"role": "user", "content": "hi"}],
"tools": [
{
"type": "function",
"function": {
"name": "lookup",
"description": "Lookup something",
"parameters": {"type": "object", "properties": {}}
},
"cache_control": {"type": "ephemeral"}
}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("tools.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("tools.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
}
if got := resultJSON.Get("tools.0.name").String(); got != "lookup" {
t.Fatalf("tools.0.name = %q, want lookup", got)
}
}
func TestConvertOpenAIRequestToClaude_NormalizesRootToolSchemaUnions(t *testing.T) {
inputJSON := `{
"model":"claude-sonnet-4-5",
"messages":[{"role":"user","content":"hi"}],
"tools":[
{
"type":"function",
"function":{
"name":"without_type",
"parameters":{
"anyOf":[
{"type":"object","properties":{"a":{"type":"string"}}},
{"type":"object","properties":{"b":{"type":"string"}}}
]
}
}
},
{
"type":"function",
"function":{
"name":"constraint_union",
"parametersJsonSchema":{
"type":"object",
"properties":{"a":{"type":"string"},"b":{"type":"string"}},
"anyOf":[{"required":["a"]},{"required":["b"]}]
}
}
}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
root := gjson.ParseBytes(result)
for _, toolName := range []string{"without_type", "constraint_union"} {
schema := root.Get(`tools.#(name=="` + toolName + `").input_schema`)
if got := schema.Get("type").String(); got != "object" {
t.Fatalf("%s input_schema.type = %q, want object. Output: %s", toolName, got, result)
}
if schema.Get("anyOf").Exists() {
t.Fatalf("%s input_schema should not contain root anyOf. Output: %s", toolName, result)
}
if !schema.Get("properties.a").Exists() || !schema.Get("properties.b").Exists() {
t.Fatalf("%s input_schema should contain properties a and b. Output: %s", toolName, result)
}
if schema.Get("required").Exists() {
t.Fatalf("%s input_schema should not merge alternative required fields. Output: %s", toolName, result)
}
}
}
func TestConvertOpenAIRequestToClaude_PartCacheControlWinsOverMessageLevel(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [
{
"role": "user",
"cache_control": {"type": "ephemeral", "ttl": "1h"},
"content": [
{"type": "text", "text": "part cached", "cache_control": {"type": "ephemeral"}}
]
}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("content.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
}
if resultJSON.Get("messages.0.content.0.cache_control.ttl").Exists() {
t.Fatalf("part-level cache_control should win; unexpected ttl: %s", result)
}
}
func TestConvertOpenAIRequestToClaude_DeveloperRoleBecomesTopLevelSystem(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [
{"role": "system", "content": "S1"},
{"role": "developer", "content": [{"type": "text", "text": "D1"}, {"type": "text", "text": "D2"}]},
{"role": "user", "content": "Hello"}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
system := resultJSON.Get("system").Array()
if len(system) != 3 {
t.Fatalf("system blocks = %d, want 3. system: %s", len(system), resultJSON.Get("system").Raw)
}
for idx, want := range []string{"S1", "D1", "D2"} {
if got := system[idx].Get("type").String(); got != "text" {
t.Fatalf("system[%d].type = %q, want text", idx, got)
}
if got := system[idx].Get("text").String(); got != want {
t.Fatalf("system[%d].text = %q, want %q", idx, got, want)
}
}
messages := resultJSON.Get("messages").Array()
if len(messages) != 1 {
t.Fatalf("messages = %d, want 1. messages: %s", len(messages), resultJSON.Get("messages").Raw)
}
if got := messages[0].Get("role").String(); got != "user" {
t.Fatalf("messages[0].role = %q, want user", got)
}
}
func TestConvertOpenAIRequestToClaude_DeveloperMessageCacheControlAppliesToLastBlock(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [
{"role": "developer", "content": [{"type": "text", "text": "D1"}, {"type": "text", "text": "D2"}], "cache_control": {"type": "ephemeral"}},
{"role": "user", "content": "Hello"}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
system := gjson.ParseBytes(result).Get("system").Array()
if len(system) != 2 {
t.Fatalf("system blocks = %d, want 2", len(system))
}
if system[0].Get("cache_control").Exists() {
t.Fatalf("system[0] must not carry cache_control: %s", system[0].Raw)
}
if got := system[1].Get("cache_control.type").String(); got != "ephemeral" {
t.Fatalf("system[1].cache_control.type = %q, want ephemeral", got)
}
}
func TestConvertOpenAIRequestToClaude_DeduplicatesToolResults(t *testing.T) {
inputJSON := []byte(`{
"messages":[
{"role":"user","content":"Run tools"},
{"role":"assistant","tool_calls":[
{"id":"call_dup","type":"function","function":{"name":"lookup","arguments":"{}"}}
]},
{"role":"tool","tool_call_id":"call_dup","content":"first output"},
{"role":"assistant","content":"Next step","tool_calls":[
{"id":"call_other","type":"function","function":{"name":"search","arguments":"{}"}}
]},
{"role":"tool","tool_call_id":"call_dup","content":"final output"},
{"role":"tool","tool_call_id":"call_other","content":"search output"},
{"role":"tool","tool_call_id":"","content":"empty id output"}
]
}`)
out := ConvertOpenAIRequestToClaude("claude-test", inputJSON, false)
root := gjson.ParseBytes(out)
messages := root.Get("messages").Array()
if len(messages) < 5 {
t.Fatalf("expected at least 5 messages, got %d. Output: %s", len(messages), string(out))
}
// Message 1: assistant tool_use call_dup
if got := messages[1].Get("content.0.id").String(); got != "call_dup" {
t.Fatalf("messages[1].content.0.id = %q, want call_dup", got)
}
// Message 2: user tool_result for call_dup with final payload, before assistant message 3
if got := messages[2].Get("content.0.type").String(); got != "tool_result" {
t.Fatalf("messages[2].content.0.type = %q, want tool_result", got)
}
if got := messages[2].Get("content.0.tool_use_id").String(); got != "call_dup" {
t.Fatalf("messages[2].content.0.tool_use_id = %q, want call_dup", got)
}
if got := messages[2].Get("content.0.content").String(); got != "final output" {
t.Fatalf("messages[2].content.0.content = %q, want 'final output'", got)
}
// Message 3: assistant Next step + tool_use call_other
if got := messages[3].Get("content.0.text").String(); got != "Next step" {
t.Fatalf("messages[3].content.0.text = %q, want 'Next step'", got)
}
if got := messages[3].Get("content.1.id").String(); got != "call_other" {
t.Fatalf("messages[3].content.1.id = %q, want call_other", got)
}
// Message 4: user tool_results for call_other (search output) and empty id output; call_dup should NOT be repeated here
msg4Blocks := messages[4].Get("content").Array()
if len(msg4Blocks) != 2 {
t.Fatalf("expected 2 tool_result blocks in message 4, got %d. Output: %s", len(msg4Blocks), string(out))
}
if got := msg4Blocks[0].Get("tool_use_id").String(); got != "call_other" {
t.Fatalf("msg4Blocks[0].tool_use_id = %q, want call_other", got)
}
if got := msg4Blocks[0].Get("content").String(); got != "search output" {
t.Fatalf("msg4Blocks[0].content = %q, want 'search output'", got)
}
if got := msg4Blocks[1].Get("content").String(); got != "empty id output" {
t.Fatalf("msg4Blocks[1].content = %q, want 'empty id output'", got)
}
}
func TestConvertOpenAIRequestToClaude_MaxTokensAndMaxCompletionTokens(t *testing.T) {
tests := []struct {
name string
rawJSON string
wantLimit int64
}{
{
name: "only max_completion_tokens",
rawJSON: `{"messages":[{"role":"user","content":"hi"}],"max_completion_tokens":128000}`,
wantLimit: 128000,
},
{
name: "only max_tokens",
rawJSON: `{"messages":[{"role":"user","content":"hi"}],"max_tokens":4096}`,
wantLimit: 4096,
},
{
name: "both present prefers max_tokens",
rawJSON: `{"messages":[{"role":"user","content":"hi"}],"max_tokens":4096,"max_completion_tokens":128000}`,
wantLimit: 4096,
},
{
name: "neither present uses default template limit",
rawJSON: `{"messages":[{"role":"user","content":"hi"}]}`,
wantLimit: 32000,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
out := ConvertOpenAIRequestToClaude("claude-3-7-sonnet-20250219", []byte(tc.rawJSON), false)
got := gjson.GetBytes(out, "max_tokens").Int()
if got != tc.wantLimit {
t.Fatalf("max_tokens = %d, want %d. Output: %s", got, tc.wantLimit, string(out))
}
})
}
}
func TestConvertOpenAIRequestToClaude_PreservesCallerSuppliedMetadataUserID(t *testing.T) {
testCases := []struct {
name string
rawJSON string
expected string
}{
{
name: "plain string",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"custom-user-123"},"messages":[{"role":"user","content":"hello"}]}`,
expected: "custom-user-123",
},
{
name: "special characters and json string",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"foo\"bar\nbaz\\qux"},"messages":[{"role":"user","content":"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\"}"},"messages":[{"role":"user","content":"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 := ConvertOpenAIRequestToClaude("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 TestConvertOpenAIRequestToClaude_PreservesOpenAIUserField(t *testing.T) {
raw := []byte(`{"model":"claude-test","user":"openai-user-456","messages":[{"role":"user","content":"hello"}]}`)
out := ConvertOpenAIRequestToClaude("claude-test", raw, false)
if !gjson.ValidBytes(out) {
t.Fatalf("output is invalid json: %s", string(out))
}
got := gjson.GetBytes(out, "metadata.user_id").String()
if got != "openai-user-456" {
t.Fatalf("metadata.user_id = %q, want %q", got, "openai-user-456")
}
}
func TestConvertOpenAIRequestToClaude_DifferentSessionsProduceDifferentUserIDs(t *testing.T) {
a := []byte(`{"model":"claude-test","prompt_cache_key":"session-a","messages":[{"role":"user","content":"hello"}]}`)
b := []byte(`{"model":"claude-test","prompt_cache_key":"session-b","messages":[{"role":"user","content":"hello"}]}`)
outA := ConvertOpenAIRequestToClaude("claude-test", a, false)
outB := ConvertOpenAIRequestToClaude("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 TestConvertOpenAIRequestToClaude_DeterministicWithoutSessionKey(t *testing.T) {
first := []byte(`{"model":"claude-test","messages":[{"role":"user","content":"stable first message"}]}`)
second := []byte(`{"model":"claude-test","messages":[{"role":"user","content":"stable first message"},{"role":"assistant","content":"hi"},{"role":"user","content":"second message"}]}`)
outFirst := ConvertOpenAIRequestToClaude("claude-test", first, false)
outSecond := ConvertOpenAIRequestToClaude("claude-test", second, false)
idFirst := gjson.GetBytes(outFirst, "metadata.user_id").String()
idSecond := gjson.GetBytes(outSecond, "metadata.user_id").String()
if idFirst == "" || idFirst == "unknown" {
t.Fatalf("expected non-empty derived user_id, got %q", idFirst)
}
if idFirst != idSecond {
t.Fatalf("turn growth changed derived user_id: %q vs %q", idFirst, idSecond)
}
}

View file

@ -0,0 +1,475 @@
// Package openai provides response translation functionality for Claude Code to OpenAI API compatibility.
// This package handles the conversion of Claude Code API responses into OpenAI Chat Completions-compatible
// JSON format, transforming streaming events and non-streaming responses into the format
// expected by OpenAI API clients. It supports both streaming and non-streaming modes,
// handling text content, tool calls, reasoning content, and usage metadata appropriately.
package chat_completions
import (
"bytes"
"context"
"fmt"
"strings"
"time"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
var (
dataTag = []byte("data:")
)
// ConvertAnthropicResponseToOpenAIParams holds parameters for response conversion
type ConvertAnthropicResponseToOpenAIParams struct {
CreatedAt int64
ResponseID string
FinishReason string
Usage claudeUsageTokens
// Tool calls accumulator for streaming
ToolCallsAccumulator map[int]*ToolCallAccumulator
}
type claudeUsageTokens struct {
InputTokens int64
OutputTokens int64
CacheCreationInputTokens int64
CacheReadInputTokens int64
HasUsage bool
}
// ToolCallAccumulator holds the state for accumulating tool call data
type ToolCallAccumulator struct {
ID string
Name string
Arguments strings.Builder
}
func (u *claudeUsageTokens) Merge(usage gjson.Result) {
if !usage.Exists() {
return
}
u.HasUsage = true
if inputTokens := usage.Get("input_tokens"); inputTokens.Exists() {
u.InputTokens = inputTokens.Int()
}
if outputTokens := usage.Get("output_tokens"); outputTokens.Exists() {
u.OutputTokens = outputTokens.Int()
}
if cacheCreationInputTokens := usage.Get("cache_creation_input_tokens"); cacheCreationInputTokens.Exists() {
u.CacheCreationInputTokens = cacheCreationInputTokens.Int()
}
if cacheReadInputTokens := usage.Get("cache_read_input_tokens"); cacheReadInputTokens.Exists() {
u.CacheReadInputTokens = cacheReadInputTokens.Int()
}
}
func (u claudeUsageTokens) OpenAIUsage() (promptTokens, completionTokens, totalTokens, cachedTokens, cachedCreationTokens int64) {
cachedTokens = u.CacheReadInputTokens
cachedCreationTokens = u.CacheCreationInputTokens
promptTokens = u.InputTokens + cachedCreationTokens + cachedTokens
completionTokens = u.OutputTokens
totalTokens = promptTokens + completionTokens
return promptTokens, completionTokens, totalTokens, cachedTokens, cachedCreationTokens
}
// ConvertClaudeResponseToOpenAI converts Claude Code streaming response format to OpenAI Chat Completions format.
// This function processes various Claude Code event types and transforms them into OpenAI-compatible JSON responses.
// It handles text content, tool calls, reasoning content, and usage metadata, outputting responses that match
// the OpenAI API format. The function supports incremental updates for streaming responses.
//
// Parameters:
// - ctx: The context for the request, used for cancellation and timeout handling
// - modelName: The name of the model being used for the response
// - 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 OpenAI-compatible JSON responses
func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
if *param == nil {
*param = &ConvertAnthropicResponseToOpenAIParams{
CreatedAt: 0,
ResponseID: "",
FinishReason: "",
}
}
if !bytes.HasPrefix(rawJSON, dataTag) {
return [][]byte{}
}
rawJSON = bytes.TrimSpace(rawJSON[5:])
root := gjson.ParseBytes(rawJSON)
eventType := root.Get("type").String()
// Base OpenAI streaming response template
template := []byte(`{"id":"","object":"chat.completion.chunk","created":0,"model":"","choices":[{"index":0,"delta":{},"finish_reason":null}]}`)
// Set model
if modelName != "" {
template, _ = sjson.SetBytes(template, "model", modelName)
}
// Set response ID and creation time
if (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID != "" {
template, _ = sjson.SetBytes(template, "id", (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID)
}
if (*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt > 0 {
template, _ = sjson.SetBytes(template, "created", (*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt)
}
switch eventType {
case "message_start":
// Initialize response with message metadata when a new message begins
if message := root.Get("message"); message.Exists() {
(*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID = message.Get("id").String()
(*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt = time.Now().Unix()
template, _ = sjson.SetBytes(template, "id", (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID)
template, _ = sjson.SetBytes(template, "model", modelName)
template, _ = sjson.SetBytes(template, "created", (*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt)
// Set initial role to assistant for the response
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
// Initialize tool calls accumulator for tracking tool call progress
if (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator == nil {
(*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
}
(*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.Merge(message.Get("usage"))
}
return [][]byte{template}
case "content_block_start":
// Start of a content block (text, tool use, or reasoning)
if contentBlock := root.Get("content_block"); contentBlock.Exists() {
blockType := contentBlock.Get("type").String()
if blockType == "tool_use" {
// Start of tool call - initialize accumulator to track arguments
toolCallID := contentBlock.Get("id").String()
toolName := contentBlock.Get("name").String()
index := int(root.Get("index").Int())
if (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator == nil {
(*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
}
(*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator[index] = &ToolCallAccumulator{
ID: toolCallID,
Name: toolName,
}
// Don't output anything yet - wait for complete tool call
return [][]byte{}
}
}
return [][]byte{}
case "content_block_delta":
// Handle content delta (text, tool use arguments, or reasoning content)
hasContent := false
if delta := root.Get("delta"); delta.Exists() {
deltaType := delta.Get("type").String()
switch deltaType {
case "text_delta":
// Text content delta - send incremental text updates
if text := delta.Get("text"); text.Exists() {
template, _ = sjson.SetBytes(template, "choices.0.delta.content", text.String())
hasContent = true
}
case "thinking_delta":
// Accumulate reasoning/thinking content
if thinking := delta.Get("thinking"); thinking.Exists() {
template, _ = sjson.SetBytes(template, "choices.0.delta.reasoning_content", thinking.String())
hasContent = true
}
case "input_json_delta":
// Tool use input delta - accumulate arguments for tool calls
if partialJSON := delta.Get("partial_json"); partialJSON.Exists() {
index := int(root.Get("index").Int())
if (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator != nil {
if accumulator, exists := (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator[index]; exists {
accumulator.Arguments.WriteString(partialJSON.String())
}
}
}
// Don't output anything yet - wait for complete tool call
return [][]byte{}
}
}
if hasContent {
return [][]byte{template}
} else {
return [][]byte{}
}
case "content_block_stop":
// End of content block - output complete tool call if it's a tool_use block
index := int(root.Get("index").Int())
if (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator != nil {
if accumulator, exists := (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator[index]; exists {
// Build complete tool call with accumulated arguments
arguments := accumulator.Arguments.String()
if arguments == "" {
arguments = "{}"
}
template, _ = sjson.SetBytes(template, "choices.0.delta.tool_calls.0.index", index)
template, _ = sjson.SetBytes(template, "choices.0.delta.tool_calls.0.id", accumulator.ID)
template, _ = sjson.SetBytes(template, "choices.0.delta.tool_calls.0.type", "function")
template, _ = sjson.SetBytes(template, "choices.0.delta.tool_calls.0.function.name", accumulator.Name)
template, _ = sjson.SetBytes(template, "choices.0.delta.tool_calls.0.function.arguments", arguments)
// Clean up the accumulator for this index
delete((*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator, index)
return [][]byte{template}
}
}
return [][]byte{}
case "message_delta":
// Handle message-level changes including stop reason and usage
if delta := root.Get("delta"); delta.Exists() {
if stopReason := delta.Get("stop_reason"); stopReason.Exists() {
(*param).(*ConvertAnthropicResponseToOpenAIParams).FinishReason = mapAnthropicStopReasonToOpenAI(stopReason.String())
template, _ = sjson.SetBytes(template, "choices.0.finish_reason", (*param).(*ConvertAnthropicResponseToOpenAIParams).FinishReason)
}
}
// Handle usage information for token counts
if usage := root.Get("usage"); usage.Exists() {
(*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.Merge(usage)
promptTokens, completionTokens, totalTokens, cachedTokens, cachedCreationTokens := (*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.OpenAIUsage()
template, _ = sjson.SetBytes(template, "usage.prompt_tokens", promptTokens)
template, _ = sjson.SetBytes(template, "usage.completion_tokens", completionTokens)
template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokens)
template, _ = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_tokens", cachedTokens)
template, _ = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_creation_tokens", cachedCreationTokens)
}
return [][]byte{template}
case "message_stop":
// Final message event - no additional output needed
return [][]byte{}
case "ping":
// Ping events for keeping connection alive - no output needed
return [][]byte{}
case "error":
// Error event - format and return error response
if errorData := root.Get("error"); errorData.Exists() {
errorJSON := []byte(`{"error":{"message":"","type":""}}`)
errorJSON, _ = sjson.SetBytes(errorJSON, "error.message", errorData.Get("message").String())
errorJSON, _ = sjson.SetBytes(errorJSON, "error.type", errorData.Get("type").String())
return [][]byte{errorJSON}
}
return [][]byte{}
default:
// Unknown event type - ignore
return [][]byte{}
}
}
// mapAnthropicStopReasonToOpenAI maps Anthropic stop reasons to OpenAI stop reasons
func mapAnthropicStopReasonToOpenAI(anthropicReason string) string {
switch anthropicReason {
case "end_turn":
return "stop"
case "tool_use":
return "tool_calls"
case "max_tokens":
return "length"
case "stop_sequence":
return "stop"
case "refusal", "sensitive":
return "content_filter"
default:
return "stop"
}
}
// ConvertClaudeResponseToOpenAINonStream converts a non-streaming Claude Code response to a non-streaming OpenAI response.
// This function processes the complete Claude Code response and transforms it into a single OpenAI-compatible
// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all
// the information into a single response that matches the OpenAI API format.
//
// Parameters:
// - ctx: The context for the request, used for cancellation and timeout handling
// - modelName: The name of the model being used for the response (unused in current implementation)
// - rawJSON: The raw JSON response from the Claude Code API
// - param: A pointer to a parameter object for the conversion (unused in current implementation)
//
// Returns:
// - []byte: An OpenAI-compatible JSON response containing all message content and metadata
func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
chunks := make([][]byte, 0)
lines := bytes.Split(rawJSON, []byte("\n"))
for _, line := range lines {
if !bytes.HasPrefix(line, dataTag) {
continue
}
chunks = append(chunks, bytes.TrimSpace(line[5:]))
}
// Base OpenAI non-streaming response template
out := []byte(`{"id":"","object":"chat.completion","created":0,"model":"","choices":[{"index":0,"message":{"role":"assistant","content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}`)
var messageID string
var model string
var createdAt int64
var stopReason string
var contentParts []string
var reasoningParts []string
usageTokens := claudeUsageTokens{}
toolCallsAccumulator := make(map[int]*ToolCallAccumulator)
for _, chunk := range chunks {
root := gjson.ParseBytes(chunk)
eventType := root.Get("type").String()
switch eventType {
case "message_start":
// Extract initial message metadata including ID, model, and input token count
if message := root.Get("message"); message.Exists() {
messageID = message.Get("id").String()
model = message.Get("model").String()
createdAt = time.Now().Unix()
usageTokens.Merge(message.Get("usage"))
}
case "content_block_start":
// Handle different content block types at the beginning
if contentBlock := root.Get("content_block"); contentBlock.Exists() {
blockType := contentBlock.Get("type").String()
if blockType == "thinking" {
// Start of thinking/reasoning content - skip for now as it's handled in delta
continue
} else if blockType == "tool_use" {
// Initialize tool call accumulator for this index
index := int(root.Get("index").Int())
toolCallsAccumulator[index] = &ToolCallAccumulator{
ID: contentBlock.Get("id").String(),
Name: contentBlock.Get("name").String(),
}
}
}
case "content_block_delta":
// Process incremental content updates
if delta := root.Get("delta"); delta.Exists() {
deltaType := delta.Get("type").String()
switch deltaType {
case "text_delta":
// Accumulate text content
if text := delta.Get("text"); text.Exists() {
contentParts = append(contentParts, text.String())
}
case "thinking_delta":
// Accumulate reasoning/thinking content
if thinking := delta.Get("thinking"); thinking.Exists() {
reasoningParts = append(reasoningParts, thinking.String())
}
case "input_json_delta":
// Accumulate tool call arguments
if partialJSON := delta.Get("partial_json"); partialJSON.Exists() {
index := int(root.Get("index").Int())
if accumulator, exists := toolCallsAccumulator[index]; exists {
accumulator.Arguments.WriteString(partialJSON.String())
}
}
}
}
case "content_block_stop":
// Finalize tool call arguments for this index when content block ends
index := int(root.Get("index").Int())
if accumulator, exists := toolCallsAccumulator[index]; exists {
if accumulator.Arguments.Len() == 0 {
accumulator.Arguments.WriteString("{}")
}
}
case "message_delta":
// Extract stop reason and output token count when message ends
if delta := root.Get("delta"); delta.Exists() {
if sr := delta.Get("stop_reason"); sr.Exists() {
stopReason = sr.String()
}
}
if usage := root.Get("usage"); usage.Exists() {
usageTokens.Merge(usage)
}
}
}
if usageTokens.HasUsage {
promptTokens, completionTokens, totalTokens, cachedTokens, cachedCreationTokens := usageTokens.OpenAIUsage()
out, _ = sjson.SetBytes(out, "usage.prompt_tokens", promptTokens)
out, _ = sjson.SetBytes(out, "usage.completion_tokens", completionTokens)
out, _ = sjson.SetBytes(out, "usage.total_tokens", totalTokens)
out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cached_tokens", cachedTokens)
out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cached_creation_tokens", cachedCreationTokens)
}
// Set basic response fields including message ID, creation time, and model
out, _ = sjson.SetBytes(out, "id", messageID)
out, _ = sjson.SetBytes(out, "created", createdAt)
out, _ = sjson.SetBytes(out, "model", model)
// Set message content by combining all text parts
messageContent := strings.Join(contentParts, "")
out, _ = sjson.SetBytes(out, "choices.0.message.content", messageContent)
// Add reasoning content if available (following OpenAI reasoning format)
if len(reasoningParts) > 0 {
reasoningContent := strings.Join(reasoningParts, "")
// Add reasoning as a separate field in the message
out, _ = sjson.SetBytes(out, "choices.0.message.reasoning_content", reasoningContent)
}
// Set tool calls if any were accumulated during processing
if len(toolCallsAccumulator) > 0 {
toolCallsCount := 0
maxIndex := -1
for index := range toolCallsAccumulator {
if index > maxIndex {
maxIndex = index
}
}
for i := 0; i <= maxIndex; i++ {
accumulator, exists := toolCallsAccumulator[i]
if !exists {
continue
}
arguments := accumulator.Arguments.String()
idPath := fmt.Sprintf("choices.0.message.tool_calls.%d.id", toolCallsCount)
typePath := fmt.Sprintf("choices.0.message.tool_calls.%d.type", toolCallsCount)
namePath := fmt.Sprintf("choices.0.message.tool_calls.%d.function.name", toolCallsCount)
argumentsPath := fmt.Sprintf("choices.0.message.tool_calls.%d.function.arguments", toolCallsCount)
out, _ = sjson.SetBytes(out, idPath, accumulator.ID)
out, _ = sjson.SetBytes(out, typePath, "function")
out, _ = sjson.SetBytes(out, namePath, accumulator.Name)
out, _ = sjson.SetBytes(out, argumentsPath, arguments)
toolCallsCount++
}
if toolCallsCount > 0 {
out, _ = sjson.SetBytes(out, "choices.0.finish_reason", "tool_calls")
} else if finishReason := mapAnthropicStopReasonToOpenAI(stopReason); finishReason != "stop" {
out, _ = sjson.SetBytes(out, "choices.0.finish_reason", finishReason)
}
} else if finishReason := mapAnthropicStopReasonToOpenAI(stopReason); finishReason != "stop" {
out, _ = sjson.SetBytes(out, "choices.0.finish_reason", finishReason)
}
return out
}

View file

@ -0,0 +1,382 @@
package chat_completions
import (
"context"
"testing"
"github.com/tidwall/gjson"
)
func assertCachedCreationTokens(t *testing.T, payload []byte, want int64) {
t.Helper()
got := gjson.GetBytes(payload, "usage.prompt_tokens_details.cached_creation_tokens")
if !got.Exists() {
t.Fatalf("expected cached_creation_tokens to exist, payload=%s", string(payload))
}
if got.Int() != want {
t.Fatalf("expected cached_creation_tokens %d, got %d", want, got.Int())
}
}
func TestConvertClaudeResponseToOpenAI_StreamUsageIncludesCachedTokens(t *testing.T) {
ctx := context.Background()
var param any
out := ConvertClaudeResponseToOpenAI(
ctx,
"claude-opus-4-6",
nil,
nil,
[]byte(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":13,"output_tokens":4,"cache_read_input_tokens":22000,"cache_creation_input_tokens":31}}`),
&param,
)
if len(out) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(out))
}
if gotPromptTokens := gjson.GetBytes(out[0], "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
t.Fatalf("expected prompt_tokens %d, got %d", 22044, gotPromptTokens)
}
if gotCompletionTokens := gjson.GetBytes(out[0], "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
t.Fatalf("expected completion_tokens %d, got %d", 4, gotCompletionTokens)
}
if gotTotalTokens := gjson.GetBytes(out[0], "usage.total_tokens").Int(); gotTotalTokens != 22048 {
t.Fatalf("expected total_tokens %d, got %d", 22048, gotTotalTokens)
}
if gotCachedTokens := gjson.GetBytes(out[0], "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
}
assertCachedCreationTokens(t, out[0], 31)
}
func TestConvertClaudeResponseToOpenAI_StreamUsageMergesMessageStartUsage(t *testing.T) {
ctx := context.Background()
var param any
ConvertClaudeResponseToOpenAI(
ctx,
"claude-opus-4-6",
nil,
nil,
[]byte(`data: {"type":"message_start","message":{"id":"msg_123","model":"claude-opus-4-6","usage":{"input_tokens":13,"output_tokens":1,"cache_read_input_tokens":22000,"cache_creation_input_tokens":31}}}`),
&param,
)
out := ConvertClaudeResponseToOpenAI(
ctx,
"claude-opus-4-6",
nil,
nil,
[]byte(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":4}}`),
&param,
)
if len(out) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(out))
}
if gotPromptTokens := gjson.GetBytes(out[0], "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
t.Fatalf("expected prompt_tokens %d, got %d", 22044, gotPromptTokens)
}
if gotCompletionTokens := gjson.GetBytes(out[0], "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
t.Fatalf("expected completion_tokens %d, got %d", 4, gotCompletionTokens)
}
if gotTotalTokens := gjson.GetBytes(out[0], "usage.total_tokens").Int(); gotTotalTokens != 22048 {
t.Fatalf("expected total_tokens %d, got %d", 22048, gotTotalTokens)
}
if gotCachedTokens := gjson.GetBytes(out[0], "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
}
assertCachedCreationTokens(t, out[0], 31)
}
func TestConvertClaudeResponseToOpenAINonStream_UsageIncludesCachedTokens(t *testing.T) {
rawJSON := []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-opus-4-6\"}}\n" +
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":13,\"output_tokens\":4,\"cache_read_input_tokens\":22000,\"cache_creation_input_tokens\":31}}\n")
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil)
if gotPromptTokens := gjson.GetBytes(out, "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
t.Fatalf("expected prompt_tokens %d, got %d", 22044, gotPromptTokens)
}
if gotCompletionTokens := gjson.GetBytes(out, "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
t.Fatalf("expected completion_tokens %d, got %d", 4, gotCompletionTokens)
}
if gotTotalTokens := gjson.GetBytes(out, "usage.total_tokens").Int(); gotTotalTokens != 22048 {
t.Fatalf("expected total_tokens %d, got %d", 22048, gotTotalTokens)
}
if gotCachedTokens := gjson.GetBytes(out, "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
}
assertCachedCreationTokens(t, out, 31)
}
func TestConvertClaudeResponseToOpenAINonStream_UsageMergesMessageStartUsage(t *testing.T) {
rawJSON := []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-opus-4-6\",\"usage\":{\"input_tokens\":13,\"output_tokens\":1,\"cache_read_input_tokens\":22000,\"cache_creation_input_tokens\":31}}}\n" +
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":4}}\n")
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil)
if gotPromptTokens := gjson.GetBytes(out, "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
t.Fatalf("expected prompt_tokens %d, got %d", 22044, gotPromptTokens)
}
if gotCompletionTokens := gjson.GetBytes(out, "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
t.Fatalf("expected completion_tokens %d, got %d", 4, gotCompletionTokens)
}
if gotTotalTokens := gjson.GetBytes(out, "usage.total_tokens").Int(); gotTotalTokens != 22048 {
t.Fatalf("expected total_tokens %d, got %d", 22048, gotTotalTokens)
}
if gotCachedTokens := gjson.GetBytes(out, "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
}
assertCachedCreationTokens(t, out, 31)
}
func TestConvertClaudeResponseToOpenAI_RefusalStopReason(t *testing.T) {
testCases := []struct {
name string
anthropicStopReason string
wantFinishReason string
}{
{
name: "refusal maps to content_filter",
anthropicStopReason: "refusal",
wantFinishReason: "content_filter",
},
{
name: "sensitive maps to content_filter",
anthropicStopReason: "sensitive",
wantFinishReason: "content_filter",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
var param any
out := ConvertClaudeResponseToOpenAI(
ctx,
"claude-opus-4-6",
nil,
nil,
[]byte(`data: {"type":"message_delta","delta":{"stop_reason":"`+tc.anthropicStopReason+`"},"usage":{"output_tokens":10}}`),
&param,
)
if len(out) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(out))
}
gotFinishReason := gjson.GetBytes(out[0], "choices.0.finish_reason").String()
if gotFinishReason != tc.wantFinishReason {
t.Fatalf("expected finish_reason %q, got %q, payload=%s", tc.wantFinishReason, gotFinishReason, string(out[0]))
}
})
}
}
func TestConvertClaudeResponseToOpenAINonStream_RefusalStopReason(t *testing.T) {
testCases := []struct {
name string
anthropicStopReason string
wantFinishReason string
}{
{
name: "refusal maps to content_filter",
anthropicStopReason: "refusal",
wantFinishReason: "content_filter",
},
{
name: "sensitive maps to content_filter",
anthropicStopReason: "sensitive",
wantFinishReason: "content_filter",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
rawJSON := []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-opus-4-6\"}}\n" +
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"" + tc.anthropicStopReason + "\"},\"usage\":{\"input_tokens\":10,\"output_tokens\":20}}\n")
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil)
gotFinishReason := gjson.GetBytes(out, "choices.0.finish_reason").String()
if gotFinishReason != tc.wantFinishReason {
t.Fatalf("expected finish_reason %q, got %q, payload=%s", tc.wantFinishReason, gotFinishReason, string(out))
}
})
}
}
func TestConvertClaudeResponseToOpenAINonStream_ReasoningContent(t *testing.T) {
rawJSON := []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-opus-4-6\"}}\n" +
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n" +
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Let me analyze the problem.\"}}\n" +
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Step 2 is clear.\"}}\n" +
"data: {\"type\":\"content_block_stop\",\"index\":0}\n" +
"data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n" +
"data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"Here is the solution.\"}}\n" +
"data: {\"type\":\"content_block_stop\",\"index\":1}\n" +
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":10,\"output_tokens\":20}}\n")
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil)
gotRC := gjson.GetBytes(out, "choices.0.message.reasoning_content")
if !gotRC.Exists() {
t.Fatalf("expected choices.0.message.reasoning_content to exist, payload=%s", string(out))
}
wantRC := "Let me analyze the problem. Step 2 is clear."
if gotRC.String() != wantRC {
t.Fatalf("reasoning_content = %q, want %q", gotRC.String(), wantRC)
}
if gotOldReasoning := gjson.GetBytes(out, "choices.0.message.reasoning"); gotOldReasoning.Exists() {
t.Fatalf("choices.0.message.reasoning should not exist, got %q", gotOldReasoning.String())
}
gotContent := gjson.GetBytes(out, "choices.0.message.content").String()
wantContent := "Here is the solution."
if gotContent != wantContent {
t.Fatalf("content = %q, want %q", gotContent, wantContent)
}
}
func TestConvertClaudeResponseToOpenAINonStream_OmitsReasoningContentWhenAbsent(t *testing.T) {
rawJSON := []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-opus-4-6\"}}\n" +
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n" +
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Just plain text.\"}}\n" +
"data: {\"type\":\"content_block_stop\",\"index\":0}\n" +
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":10,\"output_tokens\":20}}\n")
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil)
if gotRC := gjson.GetBytes(out, "choices.0.message.reasoning_content"); gotRC.Exists() {
t.Fatalf("choices.0.message.reasoning_content should be omitted when absent, got %q", gotRC.String())
}
if gotReasoning := gjson.GetBytes(out, "choices.0.message.reasoning"); gotReasoning.Exists() {
t.Fatalf("choices.0.message.reasoning should not exist, got %q", gotReasoning.String())
}
if gotContent := gjson.GetBytes(out, "choices.0.message.content").String(); gotContent != "Just plain text." {
t.Fatalf("content = %q, want %q", gotContent, "Just plain text.")
}
}
func TestConvertClaudeResponseToOpenAI_StreamAndNonStreamParity(t *testing.T) {
events := [][]byte{
[]byte(`data: {"type":"message_start","message":{"id":"msg_123","model":"claude-opus-4-6","usage":{"input_tokens":15,"output_tokens":1}}}`),
[]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":"First thought. "}}`),
[]byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Second thought."}}`),
[]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 "}}`),
[]byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"answer."}}`),
[]byte(`data: {"type":"content_block_stop","index":1}`),
[]byte(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":25}}`),
[]byte(`data: {"type":"message_stop"}`),
}
// 1. Process via streaming
ctx := context.Background()
var param any
var streamReasoning string
var streamContent string
var streamFinishReason string
for _, ev := range events {
chunks := ConvertClaudeResponseToOpenAI(ctx, "claude-opus-4-6", nil, nil, ev, &param)
for _, chunk := range chunks {
if rc := gjson.GetBytes(chunk, "choices.0.delta.reasoning_content"); rc.Exists() {
streamReasoning += rc.String()
}
if c := gjson.GetBytes(chunk, "choices.0.delta.content"); c.Exists() {
streamContent += c.String()
}
if fr := gjson.GetBytes(chunk, "choices.0.finish_reason"); fr.Exists() && fr.String() != "" {
streamFinishReason = fr.String()
}
}
}
// 2. Process via non-stream
var rawBuffer []byte
for _, ev := range events {
rawBuffer = append(rawBuffer, ev...)
rawBuffer = append(rawBuffer, '\n')
}
nonStreamOut := ConvertClaudeResponseToOpenAINonStream(ctx, "", nil, nil, rawBuffer, nil)
nonStreamRC := gjson.GetBytes(nonStreamOut, "choices.0.message.reasoning_content").String()
nonStreamContent := gjson.GetBytes(nonStreamOut, "choices.0.message.content").String()
nonStreamFinishReason := gjson.GetBytes(nonStreamOut, "choices.0.finish_reason").String()
if streamReasoning != "First thought. Second thought." {
t.Fatalf("streamReasoning = %q, want %q", streamReasoning, "First thought. Second thought.")
}
if nonStreamRC != streamReasoning {
t.Fatalf("parity mismatch for reasoning_content: nonStream=%q, stream=%q", nonStreamRC, streamReasoning)
}
if streamContent != "Final answer." {
t.Fatalf("streamContent = %q, want %q", streamContent, "Final answer.")
}
if nonStreamContent != streamContent {
t.Fatalf("parity mismatch for content: nonStream=%q, stream=%q", nonStreamContent, streamContent)
}
if streamFinishReason != "stop" {
t.Fatalf("streamFinishReason = %q, want %q", streamFinishReason, "stop")
}
if nonStreamFinishReason != streamFinishReason {
t.Fatalf("parity mismatch for finish_reason: nonStream=%q, stream=%q", nonStreamFinishReason, streamFinishReason)
}
}
func TestConvertClaudeResponseToOpenAI_RedactedThinkingIgnored(t *testing.T) {
events := [][]byte{
[]byte(`data: {"type":"message_start","message":{"id":"msg_123","model":"claude-opus-4-6"}}`),
[]byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"redacted_thinking","data":"encrypted_blob"}}`),
[]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":"Visible reply."}}`),
[]byte(`data: {"type":"content_block_stop","index":1}`),
[]byte(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"output_tokens":20}}`),
}
// Non-stream check
var rawJSON []byte
for _, ev := range events {
rawJSON = append(rawJSON, ev...)
rawJSON = append(rawJSON, '\n')
}
outNonStream := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil)
if gotRC := gjson.GetBytes(outNonStream, "choices.0.message.reasoning_content"); gotRC.Exists() {
t.Fatalf("redacted_thinking must never map to reasoning_content in non-stream, got %q", gotRC.String())
}
if gotReasoning := gjson.GetBytes(outNonStream, "choices.0.message.reasoning"); gotReasoning.Exists() {
t.Fatalf("redacted_thinking must not produce reasoning field in non-stream, got %q", gotReasoning.String())
}
if gotContent := gjson.GetBytes(outNonStream, "choices.0.message.content").String(); gotContent != "Visible reply." {
t.Fatalf("content = %q, want %q", gotContent, "Visible reply.")
}
// Stream check
ctx := context.Background()
var param any
var streamContent string
for _, line := range events {
chunks := ConvertClaudeResponseToOpenAI(ctx, "claude-opus-4-6", nil, nil, line, &param)
for _, chunk := range chunks {
if gotRC := gjson.GetBytes(chunk, "choices.0.delta.reasoning_content"); gotRC.Exists() {
t.Fatalf("redacted_thinking must never map to reasoning_content in stream, got %q", gotRC.String())
}
if gotReasoning := gjson.GetBytes(chunk, "choices.0.delta.reasoning"); gotReasoning.Exists() {
t.Fatalf("redacted_thinking must not produce delta.reasoning field in stream, got %q", gotReasoning.String())
}
if c := gjson.GetBytes(chunk, "choices.0.delta.content"); c.Exists() {
streamContent += c.String()
}
}
}
if streamContent != "Visible reply." {
t.Fatalf("stream content = %q, want %q", streamContent, "Visible reply.")
}
}

View file

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

View file

@ -0,0 +1,32 @@
package chat_completions
import (
"context"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertClaudeResponseToOpenAINonStreamFinishReasons(t *testing.T) {
tests := []struct {
name string
stopReason string
want string
}{
{name: "missing", want: "stop"},
{name: "end_turn", stopReason: "end_turn", want: "stop"},
{name: "stop_sequence", stopReason: "stop_sequence", want: "stop"},
{name: "max_tokens", stopReason: "max_tokens", want: "length"},
{name: "refusal", stopReason: "refusal", want: "content_filter"},
{name: "sensitive", stopReason: "sensitive", want: "content_filter"},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
raw := []byte(`data: {"type":"message_delta","delta":{"stop_reason":"` + testCase.stopReason + `"}}`)
output := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, raw, nil)
if got := gjson.GetBytes(output, "choices.0.finish_reason").String(); got != testCase.want {
t.Fatalf("finish_reason = %q, want %q", got, testCase.want)
}
})
}
}

View file

@ -0,0 +1,29 @@
package responses
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIResponsesRequestToClaudeWithCompatPreservesEmptyReasoning(t *testing.T) {
payload := []byte(`{"input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"reason"}],"encrypted_content":""}]}`)
withoutCompat := ConvertOpenAIResponsesRequestToClaude("deepseek-v4", payload, false)
if gjson.GetBytes(withoutCompat, "messages.#").Int() != 0 {
t.Fatalf("default translation preserved empty reasoning: %s", withoutCompat)
}
withCompat := ConvertOpenAIResponsesRequestToClaudeWithCompat("deepseek-v4", payload, false)
part := gjson.GetBytes(withCompat, "messages.0.content.0")
if part.Get("type").String() != "thinking" || part.Get("signature").String() != "" {
t.Fatalf("compat translation missing unsigned thinking block: %s", withCompat)
}
opaquePayload := []byte(`{"input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"reason"}],"encrypted_content":"opaque-deepseek-id"}]}`)
opaqueCompat := ConvertOpenAIResponsesRequestToClaudeWithCompat("deepseek-v4", opaquePayload, false)
opaquePart := gjson.GetBytes(opaqueCompat, "messages.0.content.0")
if opaquePart.Get("type").String() != "thinking" || opaquePart.Get("thinking").String() != "reason" || opaquePart.Get("signature").String() != "opaque-deepseek-id" {
t.Fatalf("compat translation dropped invalid-signature thinking block: %s", opaqueCompat)
}
}

View file

@ -0,0 +1,19 @@
package responses
import (
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
translator.Register(
OpenaiResponse,
Claude,
ConvertOpenAIResponsesRequestToClaude,
interfaces.TranslateResponse{
Stream: ConvertClaudeResponseToOpenAIResponses,
NonStream: ConvertClaudeResponseToOpenAIResponsesNonStream,
},
)
}

View file

@ -0,0 +1,21 @@
package responses
import (
"context"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertClaudeResponseToOpenAIResponsesNonStreamKeepsZeroUsageDefaults(t *testing.T) {
input := []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}`)
output := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "", nil, nil, input, nil)
for _, path := range []string{"usage.input_tokens", "usage.input_tokens_details.cached_tokens", "usage.output_tokens", "usage.total_tokens"} {
value := gjson.GetBytes(output, path)
if !value.Exists() || value.Int() != 0 {
t.Fatalf("%s = %s, want zero", path, value.Raw)
}
}
}