Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
|
|
@ -0,0 +1,522 @@
|
|||
// Package gemini provides request translation functionality for Gemini to Claude Code API compatibility.
|
||||
// It handles parsing and transforming Gemini API requests into Claude Code API format,
|
||||
// extracting model information, system instructions, message contents, and tool declarations.
|
||||
// The package performs JSON data transformation to ensure compatibility
|
||||
// between Gemini API format and Claude Code API's expected format.
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// ConvertGeminiRequestToClaude parses and transforms a Gemini API request into Claude Code API format.
|
||||
// It extracts the model name, system instruction, message contents, and tool declarations
|
||||
// from the raw JSON request and returns them in the format expected by the Claude Code API.
|
||||
// The function performs comprehensive transformation including:
|
||||
// 1. Model name mapping and generation configuration extraction
|
||||
// 2. System instruction conversion to Claude Code format
|
||||
// 3. Message content conversion with proper role mapping
|
||||
// 4. Tool call and tool result handling with FIFO queue for ID matching
|
||||
// 5. Image and file data conversion to Claude Code base64 format
|
||||
// 6. Tool declaration and tool choice configuration mapping
|
||||
//
|
||||
// Parameters:
|
||||
// - modelName: The name of the model to use for the request
|
||||
// - rawJSON: The raw JSON request data from the Gemini API
|
||||
// - stream: A boolean indicating if the request is for a streaming response
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: The transformed request data in Claude Code API format
|
||||
func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
rawJSON := inputRawJSON
|
||||
|
||||
userID := translatorcommon.DeriveClaudeUserID(rawJSON)
|
||||
|
||||
// Base Claude message payload
|
||||
out := []byte(`{"model":"","max_tokens":32000,"messages":[],"metadata":{}}`)
|
||||
out, _ = sjson.SetBytes(out, "metadata.user_id", userID)
|
||||
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
messageAccumulator := translatorcommon.NewClaudeMessageAccumulator(int(root.Get("contents.#").Int()) + 1)
|
||||
|
||||
getGeminiToolID := func(value gjson.Result) string {
|
||||
if toolID := strings.TrimSpace(value.Get("id").String()); toolID != "" {
|
||||
return toolID
|
||||
}
|
||||
return strings.TrimSpace(value.Get("call_id").String())
|
||||
}
|
||||
|
||||
removePendingToolID := func(ids []string, toolID string) []string {
|
||||
if toolID == "" {
|
||||
return ids
|
||||
}
|
||||
for idx, pendingID := range ids {
|
||||
if pendingID == toolID {
|
||||
return append(ids[:idx], ids[idx+1:]...)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// FIFO queue to store tool call IDs for matching with tool results
|
||||
// Gemini uses sequential pairing across possibly multiple in-flight
|
||||
// functionCalls, so we keep a FIFO queue of generated tool IDs and
|
||||
// consume them in order when functionResponses arrive.
|
||||
var pendingToolIDs []string
|
||||
toolCallCounter := 0
|
||||
|
||||
// Model mapping to specify which Claude Code model to use
|
||||
out, _ = sjson.SetBytes(out, "model", modelName)
|
||||
if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String {
|
||||
out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String())
|
||||
}
|
||||
|
||||
// Generation config extraction from Gemini format
|
||||
if genConfig := root.Get("generationConfig"); genConfig.Exists() {
|
||||
// Max output tokens configuration
|
||||
if maxTokens := genConfig.Get("maxOutputTokens"); maxTokens.Exists() {
|
||||
out, _ = sjson.SetBytes(out, "max_tokens", maxTokens.Int())
|
||||
}
|
||||
// Top P setting for nucleus sampling.
|
||||
if topP := genConfig.Get("topP"); topP.Exists() {
|
||||
out, _ = sjson.SetBytes(out, "top_p", topP.Float())
|
||||
}
|
||||
// Stop sequences configuration for custom termination conditions
|
||||
if stopSeqs := genConfig.Get("stopSequences"); stopSeqs.Exists() && stopSeqs.IsArray() {
|
||||
var stopSequences []string
|
||||
stopSeqs.ForEach(func(_, value gjson.Result) bool {
|
||||
stopSequences = append(stopSequences, value.String())
|
||||
return true
|
||||
})
|
||||
if len(stopSequences) > 0 {
|
||||
out, _ = sjson.SetBytes(out, "stop_sequences", stopSequences)
|
||||
}
|
||||
}
|
||||
// Include thoughts configuration for reasoning process visibility
|
||||
// Translator only does format conversion, ApplyThinking handles model capability validation.
|
||||
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
|
||||
mi := registry.LookupModelInfo(modelName, "claude")
|
||||
supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0
|
||||
supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax))
|
||||
|
||||
// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid
|
||||
// validation errors since validate treats same-provider unsupported levels as errors.
|
||||
thinkingLevel := thinkingConfig.Get("thinkingLevel")
|
||||
if !thinkingLevel.Exists() {
|
||||
thinkingLevel = thinkingConfig.Get("thinking_level")
|
||||
}
|
||||
if thinkingLevel.Exists() {
|
||||
level := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
|
||||
if supportsAdaptive {
|
||||
switch level {
|
||||
case "":
|
||||
case "none":
|
||||
out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
|
||||
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
|
||||
out, _ = sjson.DeleteBytes(out, "output_config.effort")
|
||||
default:
|
||||
if mapped, ok := thinking.MapToClaudeEffort(level, supportsMax); ok {
|
||||
level = mapped
|
||||
}
|
||||
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
|
||||
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
|
||||
out, _ = sjson.SetBytes(out, "output_config.effort", level)
|
||||
}
|
||||
} else {
|
||||
switch level {
|
||||
case "":
|
||||
case "none":
|
||||
out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
|
||||
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
|
||||
case "auto":
|
||||
out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
|
||||
default:
|
||||
if budget, ok := thinking.ConvertLevelToBudget(level); ok {
|
||||
out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.SetBytes(out, "thinking.budget_tokens", budget)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
thinkingBudget := thinkingConfig.Get("thinkingBudget")
|
||||
if !thinkingBudget.Exists() {
|
||||
thinkingBudget = thinkingConfig.Get("thinking_budget")
|
||||
}
|
||||
if thinkingBudget.Exists() {
|
||||
budget := int(thinkingBudget.Int())
|
||||
if supportsAdaptive {
|
||||
switch budget {
|
||||
case 0:
|
||||
out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
|
||||
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
|
||||
out, _ = sjson.DeleteBytes(out, "output_config.effort")
|
||||
default:
|
||||
level, ok := thinking.ConvertBudgetToLevel(budget)
|
||||
if ok {
|
||||
if mapped, okM := thinking.MapToClaudeEffort(level, supportsMax); okM {
|
||||
level = mapped
|
||||
}
|
||||
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
|
||||
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
|
||||
out, _ = sjson.SetBytes(out, "output_config.effort", level)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch budget {
|
||||
case 0:
|
||||
out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
|
||||
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
|
||||
case -1:
|
||||
out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
|
||||
default:
|
||||
out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.SetBytes(out, "thinking.budget_tokens", budget)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// System instruction conversion to Claude Code format
|
||||
if sysInstr := root.Get("system_instruction"); sysInstr.Exists() {
|
||||
if parts := sysInstr.Get("parts"); parts.Exists() && parts.IsArray() {
|
||||
var systemText strings.Builder
|
||||
parts.ForEach(func(_, part gjson.Result) bool {
|
||||
if translatorcommon.IsGeminiThoughtPart(part) {
|
||||
return true
|
||||
}
|
||||
if text := part.Get("text"); text.Exists() {
|
||||
if systemText.Len() > 0 {
|
||||
systemText.WriteString("\n")
|
||||
}
|
||||
systemText.WriteString(text.String())
|
||||
}
|
||||
return true
|
||||
})
|
||||
if systemText.Len() > 0 {
|
||||
// Create system message in Claude Code format.
|
||||
systemMessage := []byte(`{"role":"user","content":[{"type":"text","text":""}]}`)
|
||||
systemMessage, _ = sjson.SetBytes(systemMessage, "content.0.text", systemText.String())
|
||||
messageAccumulator.Append(systemMessage)
|
||||
messageAccumulator.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Contents conversion to messages with proper role mapping
|
||||
if contents := root.Get("contents"); contents.Exists() && contents.IsArray() {
|
||||
contents.ForEach(func(_, content gjson.Result) bool {
|
||||
role := content.Get("role").String()
|
||||
// Map Gemini roles to Claude Code roles
|
||||
if role == "model" {
|
||||
role = "assistant"
|
||||
}
|
||||
|
||||
if role == "function" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
if role == "tool" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
contentItems := make([][]byte, 0, 4)
|
||||
if parts := content.Get("parts"); parts.Exists() && parts.IsArray() {
|
||||
parts.ForEach(func(_, part gjson.Result) bool {
|
||||
if translatorcommon.IsGeminiThoughtPart(part) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Text content conversion
|
||||
if text := part.Get("text"); text.Exists() {
|
||||
textContent := []byte(`{"type":"text","text":""}`)
|
||||
textContent, _ = sjson.SetBytes(textContent, "text", text.String())
|
||||
contentItems = append(contentItems, textContent)
|
||||
return true
|
||||
}
|
||||
|
||||
// Function call (from model/assistant) conversion to tool use
|
||||
if fc := part.Get("functionCall"); fc.Exists() && role == "assistant" {
|
||||
toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
|
||||
|
||||
// Reuse gateway-provided IDs when present, otherwise generate one for pairing.
|
||||
toolID := getGeminiToolID(fc)
|
||||
if toolID == "" {
|
||||
toolCallCounter++
|
||||
toolID = fmt.Sprintf("toolu_gemini_%016d", toolCallCounter)
|
||||
}
|
||||
pendingToolIDs = append(pendingToolIDs, toolID)
|
||||
toolUse, _ = sjson.SetBytes(toolUse, "id", toolID)
|
||||
|
||||
if name := fc.Get("name"); name.Exists() {
|
||||
toolUse, _ = sjson.SetBytes(toolUse, "name", name.String())
|
||||
}
|
||||
if args := fc.Get("args"); args.Exists() && args.IsObject() {
|
||||
toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(args.Raw))
|
||||
}
|
||||
contentItems = append(contentItems, toolUse)
|
||||
return true
|
||||
}
|
||||
|
||||
// Function response (from user) conversion to tool result
|
||||
if fr := part.Get("functionResponse"); fr.Exists() {
|
||||
toolResult := []byte(`{"type":"tool_result","tool_use_id":"","content":""}`)
|
||||
|
||||
// Attach the oldest queued tool_id to pair the response
|
||||
// with its call. If the queue is empty, generate a new id.
|
||||
var toolID string
|
||||
if customID := getGeminiToolID(fr); customID != "" {
|
||||
toolID = customID
|
||||
pendingToolIDs = removePendingToolID(pendingToolIDs, toolID)
|
||||
} else if len(pendingToolIDs) > 0 {
|
||||
toolID = pendingToolIDs[0]
|
||||
// Pop the first element from the queue
|
||||
pendingToolIDs = pendingToolIDs[1:]
|
||||
} else {
|
||||
// Fallback: generate new ID if no pending tool_use found
|
||||
toolCallCounter++
|
||||
toolID = fmt.Sprintf("toolu_gemini_%016d", toolCallCounter)
|
||||
}
|
||||
toolResult, _ = sjson.SetBytes(toolResult, "tool_use_id", toolID)
|
||||
|
||||
// Extract result content from the function response
|
||||
if result := fr.Get("response.result"); result.Exists() {
|
||||
toolResult, _ = sjson.SetBytes(toolResult, "content", result.String())
|
||||
} else if response := fr.Get("response"); response.Exists() {
|
||||
toolResult, _ = sjson.SetBytes(toolResult, "content", response.Raw)
|
||||
}
|
||||
contentItems = append(contentItems, toolResult)
|
||||
return true
|
||||
}
|
||||
|
||||
// Inline data conversion to Claude Code content format
|
||||
if inlineData := geminiClaudeInlineData(part); inlineData.Exists() {
|
||||
if contentPart, ok := claudeContentPartFromGeminiInlineData(inlineData); ok {
|
||||
contentItems = append(contentItems, contentPart)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// File data conversion to Claude Code content format
|
||||
if fileData := geminiClaudeFileData(part); fileData.Exists() {
|
||||
if contentPart, ok := claudeContentPartFromGeminiFileData(fileData); ok {
|
||||
contentItems = append(contentItems, contentPart)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Only add message if it has content.
|
||||
if len(contentItems) > 0 {
|
||||
msg := []byte(`{"role":"","content":[]}`)
|
||||
msg, _ = sjson.SetBytes(msg, "role", role)
|
||||
msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
|
||||
messageAccumulator.Append(msg)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
out = translatorcommon.SetRawArrayItems(out, "messages", messageAccumulator.Messages())
|
||||
|
||||
// Tools mapping: Gemini functionDeclarations -> Claude Code tools
|
||||
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
|
||||
var anthropicTools []interface{}
|
||||
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
if funcDecls := tool.Get("functionDeclarations"); funcDecls.Exists() && funcDecls.IsArray() {
|
||||
funcDecls.ForEach(func(_, funcDecl gjson.Result) bool {
|
||||
anthropicTool := []byte(`{"name":"","description":"","input_schema":{}}`)
|
||||
|
||||
if name := funcDecl.Get("name"); name.Exists() {
|
||||
anthropicTool, _ = sjson.SetBytes(anthropicTool, "name", name.String())
|
||||
}
|
||||
if desc := funcDecl.Get("description"); desc.Exists() {
|
||||
anthropicTool, _ = sjson.SetBytes(anthropicTool, "description", desc.String())
|
||||
}
|
||||
if params := funcDecl.Get("parameters"); params.Exists() {
|
||||
cleaned := normalizeClaudeToolSchema(params)
|
||||
anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", cleaned)
|
||||
} else if params = funcDecl.Get("parametersJsonSchema"); params.Exists() {
|
||||
cleaned := normalizeClaudeToolSchema(params)
|
||||
anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", cleaned)
|
||||
}
|
||||
|
||||
anthropicTool = lowercaseClaudeToolSchemaTypes(anthropicTool)
|
||||
anthropicTools = append(anthropicTools, gjson.ParseBytes(anthropicTool).Value())
|
||||
return true
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if len(anthropicTools) > 0 {
|
||||
out, _ = sjson.SetBytes(out, "tools", anthropicTools)
|
||||
}
|
||||
}
|
||||
|
||||
// Tool config mapping from Gemini format to Claude Code format
|
||||
if toolConfig := root.Get("tool_config"); toolConfig.Exists() {
|
||||
out = setClaudeToolChoiceFromGeminiToolConfig(out, toolConfig.Get("function_calling_config"))
|
||||
} else if toolConfig := root.Get("toolConfig"); toolConfig.Exists() {
|
||||
out = setClaudeToolChoiceFromGeminiToolConfig(out, toolConfig.Get("functionCallingConfig"))
|
||||
}
|
||||
|
||||
// Stream setting configuration
|
||||
out, _ = sjson.SetBytes(out, "stream", stream)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeClaudeToolSchema(parameters gjson.Result) []byte {
|
||||
cleaned := []byte(parameters.Raw)
|
||||
if parameters.Get("additionalProperties").Type != gjson.False {
|
||||
cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false)
|
||||
}
|
||||
const schema = "http://json-schema.org/draft-07/schema#"
|
||||
currentSchema := parameters.Get("$schema")
|
||||
if currentSchema.Type != gjson.String || currentSchema.String() != schema {
|
||||
cleaned, _ = sjson.SetBytes(cleaned, "$schema", schema)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func lowercaseClaudeToolSchemaTypes(tool []byte) []byte {
|
||||
var pathsToLower []string
|
||||
util.Walk(gjson.ParseBytes(tool), "", "type", &pathsToLower)
|
||||
for _, path := range pathsToLower {
|
||||
typeValue := gjson.GetBytes(tool, path)
|
||||
normalizedType := strings.ToLower(typeValue.String())
|
||||
if typeValue.Type == gjson.String && normalizedType == typeValue.String() {
|
||||
continue
|
||||
}
|
||||
tool, _ = sjson.SetBytes(tool, path, normalizedType)
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
func setClaudeToolChoiceFromGeminiToolConfig(out []byte, funcCalling gjson.Result) []byte {
|
||||
if !funcCalling.Exists() {
|
||||
return out
|
||||
}
|
||||
mode := funcCalling.Get("mode")
|
||||
if !mode.Exists() {
|
||||
return out
|
||||
}
|
||||
switch mode.String() {
|
||||
case "AUTO":
|
||||
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`))
|
||||
case "NONE":
|
||||
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"none"}`))
|
||||
case "ANY":
|
||||
allowedNames := funcCalling.Get("allowedFunctionNames")
|
||||
if !allowedNames.Exists() {
|
||||
allowedNames = funcCalling.Get("allowed_function_names")
|
||||
}
|
||||
allowedNameItems := allowedNames.Array()
|
||||
if allowedNames.IsArray() && len(allowedNameItems) == 1 {
|
||||
choice := []byte(`{"type":"tool","name":""}`)
|
||||
choice, _ = sjson.SetBytes(choice, "name", allowedNameItems[0].String())
|
||||
out, _ = sjson.SetRawBytes(out, "tool_choice", choice)
|
||||
} else {
|
||||
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func geminiClaudeInlineData(part gjson.Result) gjson.Result {
|
||||
inlineData := part.Get("inlineData")
|
||||
if inlineData.Exists() {
|
||||
return inlineData
|
||||
}
|
||||
return part.Get("inline_data")
|
||||
}
|
||||
|
||||
func geminiClaudeFileData(part gjson.Result) gjson.Result {
|
||||
fileData := part.Get("fileData")
|
||||
if fileData.Exists() {
|
||||
return fileData
|
||||
}
|
||||
return part.Get("file_data")
|
||||
}
|
||||
|
||||
func claudeContentPartFromGeminiInlineData(inlineData gjson.Result) ([]byte, bool) {
|
||||
mimeType := inlineData.Get("mimeType").String()
|
||||
if mimeType == "" {
|
||||
mimeType = inlineData.Get("mime_type").String()
|
||||
}
|
||||
data := inlineData.Get("data").String()
|
||||
if mimeType == "" || data == "" {
|
||||
return nil, false
|
||||
}
|
||||
lowerMimeType := strings.ToLower(mimeType)
|
||||
switch {
|
||||
case strings.HasPrefix(lowerMimeType, "image/"):
|
||||
imageContent := []byte(`{"type":"image","source":{"type":"base64","media_type":"","data":""}}`)
|
||||
imageContent, _ = sjson.SetBytes(imageContent, "source.media_type", mimeType)
|
||||
imageContent, _ = sjson.SetBytes(imageContent, "source.data", data)
|
||||
return imageContent, true
|
||||
case strings.HasPrefix(lowerMimeType, "application/"), strings.HasPrefix(lowerMimeType, "text/"):
|
||||
documentContent := []byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`)
|
||||
documentContent, _ = sjson.SetBytes(documentContent, "source.media_type", mimeType)
|
||||
documentContent, _ = sjson.SetBytes(documentContent, "source.data", data)
|
||||
return documentContent, true
|
||||
default:
|
||||
return claudeTextContentPart(fmt.Sprintf("Media content: inline data (Type: %s)", mimeType)), true
|
||||
}
|
||||
}
|
||||
|
||||
func claudeContentPartFromGeminiFileData(fileData gjson.Result) ([]byte, bool) {
|
||||
fileURI := fileData.Get("fileUri").String()
|
||||
if fileURI == "" {
|
||||
fileURI = fileData.Get("file_uri").String()
|
||||
}
|
||||
if fileURI == "" {
|
||||
return nil, false
|
||||
}
|
||||
mimeType := fileData.Get("mimeType").String()
|
||||
if mimeType == "" {
|
||||
mimeType = fileData.Get("mime_type").String()
|
||||
}
|
||||
lowerMimeType := strings.ToLower(mimeType)
|
||||
switch {
|
||||
case strings.HasPrefix(lowerMimeType, "image/"):
|
||||
imageContent := []byte(`{"type":"image","source":{"type":"url","url":""}}`)
|
||||
imageContent, _ = sjson.SetBytes(imageContent, "source.url", fileURI)
|
||||
return imageContent, true
|
||||
case strings.HasPrefix(lowerMimeType, "application/"), strings.HasPrefix(lowerMimeType, "text/"):
|
||||
documentContent := []byte(`{"type":"document","source":{"type":"url","url":""}}`)
|
||||
documentContent, _ = sjson.SetBytes(documentContent, "source.url", fileURI)
|
||||
if mimeType != "" {
|
||||
documentContent, _ = sjson.SetBytes(documentContent, "source.media_type", mimeType)
|
||||
}
|
||||
return documentContent, true
|
||||
default:
|
||||
fileInfo := "File: " + fileURI
|
||||
if mimeType != "" {
|
||||
fileInfo += " (Type: " + mimeType + ")"
|
||||
}
|
||||
return claudeTextContentPart(fileInfo), true
|
||||
}
|
||||
}
|
||||
|
||||
func claudeTextContentPart(text string) []byte {
|
||||
textContent := []byte(`{"type":"text","text":""}`)
|
||||
textContent, _ = sjson.SetBytes(textContent, "text", text)
|
||||
return textContent
|
||||
}
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertGeminiRequestToClaude_PreservesCustomToolIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
callField string
|
||||
responseField string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "id",
|
||||
callField: `"id":"call_gateway_id"`,
|
||||
responseField: `"id":"call_gateway_id"`,
|
||||
want: "call_gateway_id",
|
||||
},
|
||||
{
|
||||
name: "call_id",
|
||||
callField: `"call_id":"call_gateway_call_id"`,
|
||||
responseField: `"call_id":"call_gateway_call_id"`,
|
||||
want: "call_gateway_call_id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
raw := []byte(fmt.Sprintf(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "lookup", %s, "args": {"query": "status"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "lookup", %s, "response": {"result": "ok"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, tt.callField, tt.responseField))
|
||||
|
||||
out := ConvertGeminiRequestToClaude("claude-sonnet-4", raw, false)
|
||||
|
||||
gotCallID := gjson.GetBytes(out, "messages.0.content.0.id").String()
|
||||
if gotCallID != tt.want {
|
||||
t.Fatalf("expected tool_use id %q, got %q; output=%s", tt.want, gotCallID, string(out))
|
||||
}
|
||||
|
||||
gotResultID := gjson.GetBytes(out, "messages.1.content.0.tool_use_id").String()
|
||||
if gotResultID != tt.want {
|
||||
t.Fatalf("expected tool_result tool_use_id %q, got %q; output=%s", tt.want, gotResultID, string(out))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToClaude_GroupsConsecutiveRoleTurns(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"contents":[
|
||||
{"role":"model","parts":[{"text":"answer"}]},
|
||||
{"role":"model","parts":[{"functionCall":{"name":"first","id":"call_1","args":{}}}]},
|
||||
{"role":"model","parts":[{"functionCall":{"name":"second","id":"call_2","args":{}}}]},
|
||||
{"role":"user","parts":[{"functionResponse":{"name":"first","id":"call_1","response":{"result":"one"}}}]},
|
||||
{"role":"user","parts":[{"functionResponse":{"name":"second","id":"call_2","response":{"result":"two"}}}]}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertGeminiRequestToClaude("claude-test", raw, false)
|
||||
messages := gjson.GetBytes(out, "messages").Array()
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("message count = %d, want 2. Output: %s", len(messages), string(out))
|
||||
}
|
||||
assistantContent := messages[0].Get("content").Array()
|
||||
wantAssistantTypes := []string{"text", "tool_use", "tool_use"}
|
||||
if len(assistantContent) != len(wantAssistantTypes) {
|
||||
t.Fatalf("assistant content count = %d, want %d. Output: %s", len(assistantContent), len(wantAssistantTypes), string(out))
|
||||
}
|
||||
for i, wantType := range wantAssistantTypes {
|
||||
if got := assistantContent[i].Get("type").String(); got != wantType {
|
||||
t.Fatalf("assistant content[%d].type = %q, want %q", i, got, wantType)
|
||||
}
|
||||
}
|
||||
userContent := messages[1].Get("content").Array()
|
||||
if len(userContent) != 2 {
|
||||
t.Fatalf("user content count = %d, want 2. Output: %s", len(userContent), string(out))
|
||||
}
|
||||
for i, wantID := range []string{"call_1", "call_2"} {
|
||||
if got := userContent[i].Get("type").String(); got != "tool_result" {
|
||||
t.Fatalf("user content[%d].type = %q, want tool_result", i, got)
|
||||
}
|
||||
if got := userContent[i].Get("tool_use_id").String(); got != wantID {
|
||||
t.Fatalf("user content[%d].tool_use_id = %q, want %q", i, got, wantID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToClaude_KeepsSystemInstructionUserSeparate(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"system_instruction":{"parts":[{"text":"system rule"}]},
|
||||
"contents":[{"role":"user","parts":[{"text":"question"}]}]
|
||||
}`)
|
||||
out := ConvertGeminiRequestToClaude("claude-test", raw, false)
|
||||
messages := gjson.GetBytes(out, "messages").Array()
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("message count = %d, want 2. Output: %s", len(messages), string(out))
|
||||
}
|
||||
if got := messages[0].Get("content.0.text").String(); got != "system rule" {
|
||||
t.Fatalf("system user text = %q, want system rule", got)
|
||||
}
|
||||
if got := messages[1].Get("content.0.text").String(); got != "question" {
|
||||
t.Fatalf("ordinary user text = %q, want question", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToClaude_DropsTemperature(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"generationConfig": {
|
||||
"temperature": 0.2,
|
||||
"topP": 0.8
|
||||
},
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [{"text": "hi"}]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertGeminiRequestToClaude("claude-sonnet-5", raw, false)
|
||||
|
||||
if gjson.GetBytes(out, "temperature").Exists() {
|
||||
t.Fatalf("temperature should be removed")
|
||||
}
|
||||
if got := gjson.GetBytes(out, "top_p").Float(); got != 0.8 {
|
||||
t.Fatalf("top_p = %v, want 0.8", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToClaude_AcceptsCamelInlineData(t *testing.T) {
|
||||
out := ConvertGeminiRequestToClaude("claude-sonnet-4", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}]}`), false)
|
||||
if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "image" {
|
||||
t.Fatalf("content type = %q, want image. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.0.content.0.source.media_type").String(); got != "image/png" {
|
||||
t.Fatalf("media_type = %q, want image/png. Output: %s", got, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToClaude_SplitsNonImageInlineDataByMIME(t *testing.T) {
|
||||
out := ConvertGeminiRequestToClaude("claude-sonnet-4", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"audio/wav","data":"UklGRg=="}},{"inlineData":{"mimeType":"video/mp4","data":"AAAAIGZ0eXA="}},{"inlineData":{"mimeType":"application/pdf","data":"JVBERi0="}}]}]}`), false)
|
||||
|
||||
if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "text" {
|
||||
t.Fatalf("audio fallback type = %q, want text. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.0.content.1.type").String(); got != "text" {
|
||||
t.Fatalf("video fallback type = %q, want text. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.0.content.2.type").String(); got != "document" {
|
||||
t.Fatalf("document content type = %q, want document. Output: %s", got, string(out))
|
||||
}
|
||||
if gjson.GetBytes(out, "messages.0.content.#(type==\"image\")").Exists() {
|
||||
t.Fatalf("non-image inlineData must not be converted to image. Output: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToClaude_DropsHiddenThoughtParts(t *testing.T) {
|
||||
t.Run("thought-only turn", func(t *testing.T) {
|
||||
out := ConvertGeminiRequestToClaude("claude-test", []byte(`{
|
||||
"contents":[
|
||||
{"role":"model","parts":[{"thought":true,"text":"internal reasoning","thoughtSignature":"opaque-provider-state"}]},
|
||||
{"role":"user","parts":[{"text":"continue"}]}
|
||||
]
|
||||
}`), false)
|
||||
|
||||
messages := gjson.GetBytes(out, "messages").Array()
|
||||
if len(messages) != 1 || messages[0].Get("role").String() != "user" || messages[0].Get("content.0.text").String() != "continue" {
|
||||
t.Fatalf("hidden thought turn was not dropped. Output: %s", string(out))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mixed turn", func(t *testing.T) {
|
||||
out := ConvertGeminiRequestToClaude("claude-test", []byte(`{
|
||||
"contents":[{"role":"model","parts":[
|
||||
{"thought":true,"text":"internal reasoning","thoughtSignature":"opaque-provider-state"},
|
||||
{"text":"visible answer"}
|
||||
]}]
|
||||
}`), false)
|
||||
|
||||
content := gjson.GetBytes(out, "messages.0.content").Array()
|
||||
if len(content) != 1 || content[0].Get("type").String() != "text" || content[0].Get("text").String() != "visible answer" {
|
||||
t.Fatalf("hidden thought was not dropped independently of visible text. Output: %s", string(out))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToClaude_DeterministicToolIDs(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "first_tool", "args": {"q": "one"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "first_tool", "response": {"result": "ok1"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "second_tool", "args": {"q": "two"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "second_tool", "response": {"result": "ok2"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out1 := ConvertGeminiRequestToClaude("claude-sonnet-4", raw, false)
|
||||
out2 := ConvertGeminiRequestToClaude("claude-sonnet-4", raw, false)
|
||||
|
||||
if string(out1) != string(out2) {
|
||||
t.Fatalf("expected deterministic output across multiple conversions, got different outputs:\nout1=%s\nout2=%s", string(out1), string(out2))
|
||||
}
|
||||
|
||||
wantID1 := "toolu_gemini_0000000000000001"
|
||||
wantID2 := "toolu_gemini_0000000000000002"
|
||||
|
||||
gotCall1 := gjson.GetBytes(out1, "messages.0.content.0.id").String()
|
||||
gotResp1 := gjson.GetBytes(out1, "messages.1.content.0.tool_use_id").String()
|
||||
gotCall2 := gjson.GetBytes(out1, "messages.2.content.0.id").String()
|
||||
gotResp2 := gjson.GetBytes(out1, "messages.3.content.0.tool_use_id").String()
|
||||
|
||||
if gotCall1 != wantID1 || gotResp1 != wantID1 {
|
||||
t.Fatalf("expected first tool pair to have id %q, got call=%q, resp=%q", wantID1, gotCall1, gotResp1)
|
||||
}
|
||||
if gotCall2 != wantID2 || gotResp2 != wantID2 {
|
||||
t.Fatalf("expected second tool pair to have id %q, got call=%q, resp=%q", wantID2, gotCall2, gotResp2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToClaude_PreservesCallerSuppliedMetadataUserID(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
rawJSON string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "plain string",
|
||||
rawJSON: `{"model":"claude-test","metadata":{"user_id":"custom-gemini-user-123"},"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`,
|
||||
expected: "custom-gemini-user-123",
|
||||
},
|
||||
{
|
||||
name: "special characters and json string",
|
||||
rawJSON: `{"model":"claude-test","metadata":{"user_id":"foo\"bar\nbaz\\qux"},"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`,
|
||||
expected: "foo\"bar\nbaz\\qux",
|
||||
},
|
||||
{
|
||||
name: "claude code json format",
|
||||
rawJSON: `{"model":"claude-test","metadata":{"user_id":"{\"device_id\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"session_id\":\"11111111-2222-4333-8444-555555555555\"}"},"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`,
|
||||
expected: `{"device_id":"0000000000000000000000000000000000000000000000000000000000000000","session_id":"11111111-2222-4333-8444-555555555555"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := ConvertGeminiRequestToClaude("claude-test", []byte(tc.rawJSON), false)
|
||||
if !gjson.ValidBytes(out) {
|
||||
t.Fatalf("output is invalid json: %s", string(out))
|
||||
}
|
||||
got := gjson.GetBytes(out, "metadata.user_id").String()
|
||||
if got != tc.expected {
|
||||
t.Fatalf("metadata.user_id = %q, want %q", got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToClaude_DifferentSessionsProduceDifferentUserIDs(t *testing.T) {
|
||||
a := []byte(`{"model":"claude-test","prompt_cache_key":"gemini-session-a","contents":[{"role":"user","parts":[{"text":"hello"}]}]}`)
|
||||
b := []byte(`{"model":"claude-test","prompt_cache_key":"gemini-session-b","contents":[{"role":"user","parts":[{"text":"hello"}]}]}`)
|
||||
outA := ConvertGeminiRequestToClaude("claude-test", a, false)
|
||||
outB := ConvertGeminiRequestToClaude("claude-test", b, false)
|
||||
idA := gjson.GetBytes(outA, "metadata.user_id").String()
|
||||
idB := gjson.GetBytes(outB, "metadata.user_id").String()
|
||||
if idA == idB {
|
||||
t.Fatalf("different prompt_cache_key produced identical metadata.user_id: %q", idA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToClaude_DefaultRoleDifferentContentProducesDifferentUserIDs(t *testing.T) {
|
||||
a := []byte(`{"contents":[{"parts":[{"text":"first prompt"}]}]}`)
|
||||
b := []byte(`{"contents":[{"parts":[{"text":"second prompt"}]}]}`)
|
||||
outA := ConvertGeminiRequestToClaude("claude-test", a, false)
|
||||
outB := ConvertGeminiRequestToClaude("claude-test", b, false)
|
||||
idA := gjson.GetBytes(outA, "metadata.user_id").String()
|
||||
idB := gjson.GetBytes(outB, "metadata.user_id").String()
|
||||
if idA == "" || idB == "" || idA == "unknown" || idB == "unknown" {
|
||||
t.Fatalf("expected valid derived user_id without role, got idA=%q idB=%q", idA, idB)
|
||||
}
|
||||
if idA == idB {
|
||||
t.Fatalf("different prompt texts without role produced identical metadata.user_id: %q", idA)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,635 @@
|
|||
// Package gemini provides response translation functionality for Claude Code to Gemini API compatibility.
|
||||
// This package handles the conversion of Claude Code API responses into Gemini-compatible
|
||||
// JSON format, transforming streaming events and non-streaming responses into the format
|
||||
// expected by Gemini API clients. It supports both streaming and non-streaming modes,
|
||||
// handling text content, tool calls, and usage metadata appropriately.
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
var (
|
||||
dataTag = []byte("data:")
|
||||
)
|
||||
|
||||
// ConvertAnthropicResponseToGeminiParams holds parameters for response conversion
|
||||
// It also carries minimal streaming state across calls to assemble tool_use input_json_delta.
|
||||
// This structure maintains state information needed for proper conversion of streaming responses
|
||||
// from Claude Code format to Gemini format, particularly for handling tool calls that span
|
||||
// multiple streaming events.
|
||||
type ConvertAnthropicResponseToGeminiParams struct {
|
||||
Model string
|
||||
CreatedAt int64
|
||||
ResponseID string
|
||||
LastStorageOutput []byte
|
||||
IsStreaming bool
|
||||
|
||||
// Streaming state for tool_use assembly
|
||||
// Keyed by content_block index from Claude SSE events
|
||||
ToolUseNames map[int]string // function/tool name per block index
|
||||
ToolUseArgs map[int]*strings.Builder // accumulates partial_json across deltas
|
||||
ToolUseIDs map[int]string // tool use ID per block index
|
||||
}
|
||||
|
||||
// ConvertClaudeResponseToGemini converts Claude Code streaming response format to Gemini format.
|
||||
// This function processes various Claude Code event types and transforms them into Gemini-compatible JSON responses.
|
||||
// It handles text content, tool calls, reasoning content, and usage metadata, outputting responses that match
|
||||
// the Gemini API format. The function supports incremental updates for streaming responses and maintains
|
||||
// state information to properly assemble multi-part tool calls.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request, used for cancellation and timeout handling
|
||||
// - modelName: The name of the model being used for the response
|
||||
// - rawJSON: The raw JSON response from the Claude Code API
|
||||
// - param: A pointer to a parameter object for maintaining state between calls
|
||||
//
|
||||
// Returns:
|
||||
// - [][]byte: A slice of Gemini-compatible JSON responses
|
||||
func ConvertClaudeResponseToGemini(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
if *param == nil {
|
||||
*param = &ConvertAnthropicResponseToGeminiParams{
|
||||
Model: modelName,
|
||||
CreatedAt: 0,
|
||||
ResponseID: "",
|
||||
}
|
||||
}
|
||||
|
||||
if !bytes.HasPrefix(rawJSON, dataTag) {
|
||||
return [][]byte{}
|
||||
}
|
||||
rawJSON = bytes.TrimSpace(rawJSON[5:])
|
||||
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
eventType := root.Get("type").String()
|
||||
|
||||
// Base Gemini response template with default values
|
||||
template := []byte(`{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"","createTime":"","responseId":""}`)
|
||||
|
||||
// Set model version
|
||||
if (*param).(*ConvertAnthropicResponseToGeminiParams).Model != "" {
|
||||
// Map Claude model names back to Gemini model names
|
||||
template, _ = sjson.SetBytes(template, "modelVersion", (*param).(*ConvertAnthropicResponseToGeminiParams).Model)
|
||||
}
|
||||
|
||||
// Set response ID and creation time
|
||||
if (*param).(*ConvertAnthropicResponseToGeminiParams).ResponseID != "" {
|
||||
template, _ = sjson.SetBytes(template, "responseId", (*param).(*ConvertAnthropicResponseToGeminiParams).ResponseID)
|
||||
}
|
||||
|
||||
// Set creation time to current time if not provided
|
||||
if (*param).(*ConvertAnthropicResponseToGeminiParams).CreatedAt == 0 {
|
||||
(*param).(*ConvertAnthropicResponseToGeminiParams).CreatedAt = time.Now().Unix()
|
||||
}
|
||||
template, _ = sjson.SetBytes(template, "createTime", time.Unix((*param).(*ConvertAnthropicResponseToGeminiParams).CreatedAt, 0).Format(time.RFC3339Nano))
|
||||
|
||||
switch eventType {
|
||||
case "message_start":
|
||||
// Initialize response with message metadata when a new message begins
|
||||
if message := root.Get("message"); message.Exists() {
|
||||
(*param).(*ConvertAnthropicResponseToGeminiParams).ResponseID = message.Get("id").String()
|
||||
(*param).(*ConvertAnthropicResponseToGeminiParams).Model = message.Get("model").String()
|
||||
}
|
||||
return [][]byte{}
|
||||
|
||||
case "content_block_start":
|
||||
// Start of a content block - record tool_use name by index for functionCall assembly
|
||||
if cb := root.Get("content_block"); cb.Exists() {
|
||||
if cb.Get("type").String() == "tool_use" {
|
||||
idx := int(root.Get("index").Int())
|
||||
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames == nil {
|
||||
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames = map[int]string{}
|
||||
}
|
||||
if name := cb.Get("name"); name.Exists() {
|
||||
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames[idx] = name.String()
|
||||
}
|
||||
if toolID := cb.Get("id").String(); toolID != "" {
|
||||
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs == nil {
|
||||
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs = map[int]string{}
|
||||
}
|
||||
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs[idx] = toolID
|
||||
}
|
||||
} else if cb.Get("type").String() == "thinking" {
|
||||
if sig := cb.Get("signature"); sig.Exists() && sig.String() != "" {
|
||||
thinkingPart := []byte(`{"thought":true,"thoughtSignature":""}`)
|
||||
thinkingPart, _ = sjson.SetBytes(thinkingPart, "thoughtSignature", sigcompat.GeminiReplaySignatureOrBypass(sig.String(), sigcompat.SignatureBlockKindGeminiModelPart))
|
||||
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", thinkingPart)
|
||||
return [][]byte{template}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [][]byte{}
|
||||
|
||||
case "content_block_delta":
|
||||
// Handle content delta (text, thinking, or tool use arguments)
|
||||
if delta := root.Get("delta"); delta.Exists() {
|
||||
deltaType := delta.Get("type").String()
|
||||
|
||||
switch deltaType {
|
||||
case "text_delta":
|
||||
// Regular text content delta for normal response text
|
||||
if text := delta.Get("text"); text.Exists() && text.String() != "" {
|
||||
textPart := []byte(`{"text":""}`)
|
||||
textPart, _ = sjson.SetBytes(textPart, "text", text.String())
|
||||
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", textPart)
|
||||
}
|
||||
case "thinking_delta":
|
||||
// Thinking/reasoning content delta for models with reasoning capabilities
|
||||
if text := delta.Get("thinking"); text.Exists() && text.String() != "" {
|
||||
thinkingPart := []byte(`{"thought":true,"text":""}`)
|
||||
thinkingPart, _ = sjson.SetBytes(thinkingPart, "text", text.String())
|
||||
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", thinkingPart)
|
||||
}
|
||||
case "signature_delta":
|
||||
if sig := delta.Get("signature"); sig.Exists() && sig.String() != "" {
|
||||
thinkingPart := []byte(`{"thought":true,"thoughtSignature":""}`)
|
||||
thinkingPart, _ = sjson.SetBytes(thinkingPart, "thoughtSignature", sigcompat.GeminiReplaySignatureOrBypass(sig.String(), sigcompat.SignatureBlockKindGeminiModelPart))
|
||||
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", thinkingPart)
|
||||
}
|
||||
case "input_json_delta":
|
||||
// Tool use input delta - accumulate partial_json by index for later assembly at content_block_stop
|
||||
idx := int(root.Get("index").Int())
|
||||
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs == nil {
|
||||
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs = map[int]*strings.Builder{}
|
||||
}
|
||||
b, ok := (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs[idx]
|
||||
if !ok || b == nil {
|
||||
bb := &strings.Builder{}
|
||||
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs[idx] = bb
|
||||
b = bb
|
||||
}
|
||||
if pj := delta.Get("partial_json"); pj.Exists() {
|
||||
b.WriteString(pj.String())
|
||||
}
|
||||
return [][]byte{}
|
||||
}
|
||||
}
|
||||
return [][]byte{template}
|
||||
|
||||
case "content_block_stop":
|
||||
// End of content block - finalize tool calls if any
|
||||
idx := int(root.Get("index").Int())
|
||||
// Claude's content_block_stop often doesn't include content_block payload (see docs/response-claude.txt)
|
||||
// So we finalize using accumulated state captured during content_block_start and input_json_delta.
|
||||
name := ""
|
||||
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames != nil {
|
||||
name = (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames[idx]
|
||||
}
|
||||
var argsTrim string
|
||||
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs != nil {
|
||||
if b := (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs[idx]; b != nil {
|
||||
argsTrim = strings.TrimSpace(b.String())
|
||||
}
|
||||
}
|
||||
toolID := ""
|
||||
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs != nil {
|
||||
toolID = (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs[idx]
|
||||
}
|
||||
if name != "" || argsTrim != "" {
|
||||
functionCall := []byte(`{"functionCall":{"name":"","args":{}}}`)
|
||||
if name != "" {
|
||||
functionCall, _ = sjson.SetBytes(functionCall, "functionCall.name", name)
|
||||
}
|
||||
if argsTrim != "" {
|
||||
functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsTrim))
|
||||
}
|
||||
if toolID != "" {
|
||||
functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", toolID)
|
||||
}
|
||||
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", functionCall)
|
||||
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
|
||||
(*param).(*ConvertAnthropicResponseToGeminiParams).LastStorageOutput = append([]byte(nil), template...)
|
||||
// cleanup used state for this index
|
||||
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs != nil {
|
||||
delete((*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs, idx)
|
||||
}
|
||||
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames != nil {
|
||||
delete((*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames, idx)
|
||||
}
|
||||
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs != nil {
|
||||
delete((*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs, idx)
|
||||
}
|
||||
return [][]byte{template}
|
||||
}
|
||||
return [][]byte{}
|
||||
|
||||
case "message_delta":
|
||||
// Handle message-level changes (like stop reason and usage information)
|
||||
if delta := root.Get("delta"); delta.Exists() {
|
||||
if stopReason := delta.Get("stop_reason"); stopReason.Exists() {
|
||||
switch stopReason.String() {
|
||||
case "end_turn":
|
||||
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
|
||||
case "tool_use":
|
||||
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
|
||||
case "max_tokens":
|
||||
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "MAX_TOKENS")
|
||||
case "stop_sequence":
|
||||
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
|
||||
default:
|
||||
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if usage := root.Get("usage"); usage.Exists() {
|
||||
// Basic token counts for prompt and completion
|
||||
inputTokens := usage.Get("input_tokens").Int()
|
||||
outputTokens := usage.Get("output_tokens").Int()
|
||||
|
||||
// Set basic usage metadata according to Gemini API specification
|
||||
template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", inputTokens)
|
||||
template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", outputTokens)
|
||||
template, _ = sjson.SetBytes(template, "usageMetadata.totalTokenCount", inputTokens+outputTokens)
|
||||
|
||||
// Add cache-related token counts if present (Claude Code API cache fields)
|
||||
if cacheCreationTokens := usage.Get("cache_creation_input_tokens"); cacheCreationTokens.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usageMetadata.cachedContentTokenCount", cacheCreationTokens.Int())
|
||||
}
|
||||
if cacheReadTokens := usage.Get("cache_read_input_tokens"); cacheReadTokens.Exists() {
|
||||
// Add cache read tokens to cached content count
|
||||
existingCacheTokens := usage.Get("cache_creation_input_tokens").Int()
|
||||
totalCacheTokens := existingCacheTokens + cacheReadTokens.Int()
|
||||
template, _ = sjson.SetBytes(template, "usageMetadata.cachedContentTokenCount", totalCacheTokens)
|
||||
}
|
||||
|
||||
// Add thinking tokens if present (for models with reasoning capabilities)
|
||||
if thinkingTokens := usage.Get("thinking_tokens"); thinkingTokens.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usageMetadata.thoughtsTokenCount", thinkingTokens.Int())
|
||||
}
|
||||
|
||||
// Set traffic type (required by Gemini API)
|
||||
template, _ = sjson.SetBytes(template, "usageMetadata.trafficType", "PROVISIONED_THROUGHPUT")
|
||||
}
|
||||
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
|
||||
|
||||
return [][]byte{template}
|
||||
case "message_stop":
|
||||
// Final message with usage information - no additional output needed
|
||||
return [][]byte{}
|
||||
case "error":
|
||||
// Handle error responses and convert to Gemini error format
|
||||
errorMsg := root.Get("error.message").String()
|
||||
if errorMsg == "" {
|
||||
errorMsg = "Unknown error occurred"
|
||||
}
|
||||
|
||||
// Create error response in Gemini format
|
||||
errorResponse := []byte(`{"error":{"code":400,"message":"","status":"INVALID_ARGUMENT"}}`)
|
||||
errorResponse, _ = sjson.SetBytes(errorResponse, "error.message", errorMsg)
|
||||
return [][]byte{errorResponse}
|
||||
|
||||
default:
|
||||
// Unknown event type, return empty response
|
||||
return [][]byte{}
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertClaudeResponseToGeminiNonStream converts a non-streaming Claude Code response to a non-streaming Gemini response.
|
||||
// This function processes the complete Claude Code response and transforms it into a single Gemini-compatible
|
||||
// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all
|
||||
// the information into a single response that matches the Gemini API format.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request, used for cancellation and timeout handling
|
||||
// - modelName: The name of the model being used for the response
|
||||
// - rawJSON: The raw JSON response from the Claude Code API
|
||||
// - param: A pointer to a parameter object for the conversion (unused in current implementation)
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: A Gemini-compatible JSON response containing all message content and metadata
|
||||
func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
|
||||
// Base Gemini response template for non-streaming with default values
|
||||
template := []byte(`{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"STOP"}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"","createTime":"","responseId":""}`)
|
||||
|
||||
// Set model version
|
||||
template, _ = sjson.SetBytes(template, "modelVersion", modelName)
|
||||
|
||||
streamingEvents := make([][]byte, 0)
|
||||
remaining := rawJSON
|
||||
for len(remaining) > 0 {
|
||||
var line []byte
|
||||
idx := bytes.IndexByte(remaining, '\n')
|
||||
if idx >= 0 {
|
||||
line = remaining[:idx]
|
||||
remaining = remaining[idx+1:]
|
||||
} else {
|
||||
line = remaining
|
||||
remaining = nil
|
||||
}
|
||||
line = bytes.TrimRight(line, "\r")
|
||||
if bytes.HasPrefix(line, dataTag) {
|
||||
jsonData := bytes.TrimSpace(line[5:])
|
||||
streamingEvents = append(streamingEvents, jsonData)
|
||||
}
|
||||
}
|
||||
// log.Debug("streamingEvents: ", streamingEvents)
|
||||
// log.Debug("rawJSON: ", string(rawJSON))
|
||||
|
||||
// Initialize parameters for streaming conversion with proper state management
|
||||
newParam := &ConvertAnthropicResponseToGeminiParams{
|
||||
Model: modelName,
|
||||
CreatedAt: 0,
|
||||
ResponseID: "",
|
||||
LastStorageOutput: nil,
|
||||
IsStreaming: false,
|
||||
ToolUseNames: nil,
|
||||
ToolUseArgs: nil,
|
||||
ToolUseIDs: nil,
|
||||
}
|
||||
|
||||
// Process each streaming event and collect parts
|
||||
var allParts [][]byte
|
||||
var finalUsageJSON []byte
|
||||
var responseID string
|
||||
var createdAt int64
|
||||
|
||||
for _, eventData := range streamingEvents {
|
||||
if len(eventData) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
root := gjson.ParseBytes(eventData)
|
||||
eventType := root.Get("type").String()
|
||||
|
||||
switch eventType {
|
||||
case "message_start":
|
||||
// Extract response metadata including ID, model, and creation time
|
||||
if message := root.Get("message"); message.Exists() {
|
||||
responseID = message.Get("id").String()
|
||||
newParam.ResponseID = responseID
|
||||
newParam.Model = message.Get("model").String()
|
||||
|
||||
// Set creation time to current time if not provided
|
||||
createdAt = time.Now().Unix()
|
||||
newParam.CreatedAt = createdAt
|
||||
}
|
||||
|
||||
case "content_block_start":
|
||||
// Prepare for content block; record tool_use name by index for later functionCall assembly
|
||||
idx := int(root.Get("index").Int())
|
||||
if cb := root.Get("content_block"); cb.Exists() {
|
||||
if cb.Get("type").String() == "tool_use" {
|
||||
if newParam.ToolUseNames == nil {
|
||||
newParam.ToolUseNames = map[int]string{}
|
||||
}
|
||||
if name := cb.Get("name"); name.Exists() {
|
||||
newParam.ToolUseNames[idx] = name.String()
|
||||
}
|
||||
if toolID := cb.Get("id").String(); toolID != "" {
|
||||
if newParam.ToolUseIDs == nil {
|
||||
newParam.ToolUseIDs = map[int]string{}
|
||||
}
|
||||
newParam.ToolUseIDs[idx] = toolID
|
||||
}
|
||||
} else if cb.Get("type").String() == "thinking" {
|
||||
if sig := cb.Get("signature"); sig.Exists() && sig.String() != "" {
|
||||
partJSON := []byte(`{"thought":true,"thoughtSignature":""}`)
|
||||
partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", sigcompat.GeminiReplaySignatureOrBypass(sig.String(), sigcompat.SignatureBlockKindGeminiModelPart))
|
||||
allParts = append(allParts, partJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
|
||||
case "content_block_delta":
|
||||
// Handle content delta (text, thinking, or tool input)
|
||||
if delta := root.Get("delta"); delta.Exists() {
|
||||
deltaType := delta.Get("type").String()
|
||||
switch deltaType {
|
||||
case "text_delta":
|
||||
// Process regular text content
|
||||
if text := delta.Get("text"); text.Exists() && text.String() != "" {
|
||||
partJSON := []byte(`{"text":""}`)
|
||||
partJSON, _ = sjson.SetBytes(partJSON, "text", text.String())
|
||||
allParts = append(allParts, partJSON)
|
||||
}
|
||||
case "thinking_delta":
|
||||
// Process reasoning/thinking content
|
||||
if text := delta.Get("thinking"); text.Exists() && text.String() != "" {
|
||||
partJSON := []byte(`{"thought":true,"text":""}`)
|
||||
partJSON, _ = sjson.SetBytes(partJSON, "text", text.String())
|
||||
allParts = append(allParts, partJSON)
|
||||
}
|
||||
case "signature_delta":
|
||||
if sig := delta.Get("signature"); sig.Exists() && sig.String() != "" {
|
||||
partJSON := []byte(`{"thought":true,"thoughtSignature":""}`)
|
||||
partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", sigcompat.GeminiReplaySignatureOrBypass(sig.String(), sigcompat.SignatureBlockKindGeminiModelPart))
|
||||
allParts = append(allParts, partJSON)
|
||||
}
|
||||
case "input_json_delta":
|
||||
// accumulate args partial_json for this index
|
||||
idx := int(root.Get("index").Int())
|
||||
if newParam.ToolUseArgs == nil {
|
||||
newParam.ToolUseArgs = map[int]*strings.Builder{}
|
||||
}
|
||||
if _, ok := newParam.ToolUseArgs[idx]; !ok || newParam.ToolUseArgs[idx] == nil {
|
||||
newParam.ToolUseArgs[idx] = &strings.Builder{}
|
||||
}
|
||||
if pj := delta.Get("partial_json"); pj.Exists() {
|
||||
newParam.ToolUseArgs[idx].WriteString(pj.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "content_block_stop":
|
||||
// Handle tool use completion by assembling accumulated arguments
|
||||
idx := int(root.Get("index").Int())
|
||||
// Claude's content_block_stop often doesn't include content_block payload (see docs/response-claude.txt)
|
||||
// So we finalize using accumulated state captured during content_block_start and input_json_delta.
|
||||
name := ""
|
||||
if newParam.ToolUseNames != nil {
|
||||
name = newParam.ToolUseNames[idx]
|
||||
}
|
||||
var argsTrim string
|
||||
if newParam.ToolUseArgs != nil {
|
||||
if b := newParam.ToolUseArgs[idx]; b != nil {
|
||||
argsTrim = strings.TrimSpace(b.String())
|
||||
}
|
||||
}
|
||||
toolID := ""
|
||||
if newParam.ToolUseIDs != nil {
|
||||
toolID = newParam.ToolUseIDs[idx]
|
||||
}
|
||||
if name != "" || argsTrim != "" {
|
||||
functionCallJSON := []byte(`{"functionCall":{"name":"","args":{}}}`)
|
||||
if name != "" {
|
||||
functionCallJSON, _ = sjson.SetBytes(functionCallJSON, "functionCall.name", name)
|
||||
}
|
||||
if argsTrim != "" {
|
||||
functionCallJSON, _ = sjson.SetRawBytes(functionCallJSON, "functionCall.args", []byte(argsTrim))
|
||||
}
|
||||
if toolID != "" {
|
||||
functionCallJSON, _ = sjson.SetBytes(functionCallJSON, "functionCall.id", toolID)
|
||||
}
|
||||
allParts = append(allParts, functionCallJSON)
|
||||
// cleanup used state for this index
|
||||
if newParam.ToolUseArgs != nil {
|
||||
delete(newParam.ToolUseArgs, idx)
|
||||
}
|
||||
if newParam.ToolUseNames != nil {
|
||||
delete(newParam.ToolUseNames, idx)
|
||||
}
|
||||
if newParam.ToolUseIDs != nil {
|
||||
delete(newParam.ToolUseIDs, idx)
|
||||
}
|
||||
}
|
||||
|
||||
case "message_delta":
|
||||
// Extract final usage information using sjson for token counts and metadata
|
||||
if usage := root.Get("usage"); usage.Exists() {
|
||||
usageJSON := []byte(`{}`)
|
||||
|
||||
// Basic token counts for prompt and completion
|
||||
inputTokens := usage.Get("input_tokens").Int()
|
||||
outputTokens := usage.Get("output_tokens").Int()
|
||||
|
||||
// Set basic usage metadata according to Gemini API specification
|
||||
usageJSON, _ = sjson.SetBytes(usageJSON, "promptTokenCount", inputTokens)
|
||||
usageJSON, _ = sjson.SetBytes(usageJSON, "candidatesTokenCount", outputTokens)
|
||||
usageJSON, _ = sjson.SetBytes(usageJSON, "totalTokenCount", inputTokens+outputTokens)
|
||||
|
||||
// Add cache-related token counts if present (Claude Code API cache fields)
|
||||
if cacheCreationTokens := usage.Get("cache_creation_input_tokens"); cacheCreationTokens.Exists() {
|
||||
usageJSON, _ = sjson.SetBytes(usageJSON, "cachedContentTokenCount", cacheCreationTokens.Int())
|
||||
}
|
||||
if cacheReadTokens := usage.Get("cache_read_input_tokens"); cacheReadTokens.Exists() {
|
||||
// Add cache read tokens to cached content count
|
||||
existingCacheTokens := usage.Get("cache_creation_input_tokens").Int()
|
||||
totalCacheTokens := existingCacheTokens + cacheReadTokens.Int()
|
||||
usageJSON, _ = sjson.SetBytes(usageJSON, "cachedContentTokenCount", totalCacheTokens)
|
||||
}
|
||||
|
||||
// Add thinking tokens if present (for models with reasoning capabilities)
|
||||
if thinkingTokens := usage.Get("thinking_tokens"); thinkingTokens.Exists() {
|
||||
usageJSON, _ = sjson.SetBytes(usageJSON, "thoughtsTokenCount", thinkingTokens.Int())
|
||||
}
|
||||
|
||||
// Set traffic type (required by Gemini API)
|
||||
usageJSON, _ = sjson.SetBytes(usageJSON, "trafficType", "PROVISIONED_THROUGHPUT")
|
||||
|
||||
finalUsageJSON = usageJSON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set response metadata
|
||||
if responseID != "" {
|
||||
template, _ = sjson.SetBytes(template, "responseId", responseID)
|
||||
}
|
||||
if createdAt > 0 {
|
||||
template, _ = sjson.SetBytes(template, "createTime", time.Unix(createdAt, 0).Format(time.RFC3339Nano))
|
||||
}
|
||||
|
||||
// Consolidate consecutive text parts and thinking parts for cleaner output
|
||||
consolidatedParts := consolidateParts(allParts)
|
||||
|
||||
// Set the consolidated parts array
|
||||
if len(consolidatedParts) > 0 {
|
||||
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts", translatorcommon.JoinRawArray(consolidatedParts))
|
||||
}
|
||||
|
||||
// Set usage metadata
|
||||
if len(finalUsageJSON) > 0 {
|
||||
template, _ = sjson.SetRawBytes(template, "usageMetadata", finalUsageJSON)
|
||||
}
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
func GeminiTokenCount(ctx context.Context, count int64) []byte {
|
||||
return translatorcommon.GeminiTokenCountJSON(count)
|
||||
}
|
||||
|
||||
// consolidateParts merges consecutive text parts and thinking parts to create a cleaner response.
|
||||
// This function processes the parts array to combine adjacent text elements and thinking elements
|
||||
// into single consolidated parts, which results in a more readable and efficient response structure.
|
||||
// Tool calls and other non-text parts are preserved as separate elements.
|
||||
func consolidateParts(parts [][]byte) [][]byte {
|
||||
if len(parts) == 0 {
|
||||
return parts
|
||||
}
|
||||
|
||||
var consolidated [][]byte
|
||||
var currentTextPart strings.Builder
|
||||
var currentThoughtPart strings.Builder
|
||||
var currentThoughtSignature string
|
||||
var hasText, hasThought bool
|
||||
|
||||
flushText := func() {
|
||||
// Flush accumulated text content to the consolidated parts array
|
||||
if hasText && currentTextPart.Len() > 0 {
|
||||
textPartJSON := []byte(`{"text":""}`)
|
||||
textPartJSON, _ = sjson.SetBytes(textPartJSON, "text", currentTextPart.String())
|
||||
consolidated = append(consolidated, textPartJSON)
|
||||
currentTextPart.Reset()
|
||||
hasText = false
|
||||
}
|
||||
}
|
||||
|
||||
flushThought := func() {
|
||||
// Flush accumulated thinking content to the consolidated parts array
|
||||
if hasThought && (currentThoughtPart.Len() > 0 || currentThoughtSignature != "") {
|
||||
thoughtPartJSON := []byte(`{"thought":true,"text":""}`)
|
||||
thoughtPartJSON, _ = sjson.SetBytes(thoughtPartJSON, "text", currentThoughtPart.String())
|
||||
if currentThoughtSignature != "" {
|
||||
thoughtPartJSON, _ = sjson.SetBytes(thoughtPartJSON, "thoughtSignature", currentThoughtSignature)
|
||||
}
|
||||
consolidated = append(consolidated, thoughtPartJSON)
|
||||
currentThoughtPart.Reset()
|
||||
currentThoughtSignature = ""
|
||||
hasThought = false
|
||||
}
|
||||
}
|
||||
|
||||
for _, partJSON := range parts {
|
||||
part := gjson.ParseBytes(partJSON)
|
||||
if !part.Exists() || !part.IsObject() {
|
||||
// Flush any pending parts and add this non-text part
|
||||
flushText()
|
||||
flushThought()
|
||||
consolidated = append(consolidated, partJSON)
|
||||
continue
|
||||
}
|
||||
|
||||
thought := part.Get("thought")
|
||||
if thought.Exists() && thought.Type == gjson.True {
|
||||
// This is a thinking part - flush any pending text first
|
||||
flushText() // Flush any pending text first
|
||||
|
||||
if text := part.Get("text"); text.Exists() && text.Type == gjson.String {
|
||||
currentThoughtPart.WriteString(text.String())
|
||||
hasThought = true
|
||||
}
|
||||
if sig := part.Get("thoughtSignature"); sig.Exists() && sig.Type == gjson.String && sig.String() != "" {
|
||||
currentThoughtSignature = sig.String()
|
||||
hasThought = true
|
||||
}
|
||||
} else if text := part.Get("text"); text.Exists() && text.Type == gjson.String {
|
||||
// This is a regular text part - flush any pending thought first
|
||||
flushThought() // Flush any pending thought first
|
||||
|
||||
currentTextPart.WriteString(text.String())
|
||||
hasText = true
|
||||
} else {
|
||||
// This is some other type of part (like function call) - flush both text and thought
|
||||
flushText()
|
||||
flushThought()
|
||||
consolidated = append(consolidated, partJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any remaining parts
|
||||
flushThought() // Flush thought first to maintain order
|
||||
flushText()
|
||||
|
||||
return consolidated
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertClaudeResponseToGemini_StreamPreservesToolUseID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
start := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_gateway","name":"lookup"}}`)
|
||||
out := ConvertClaudeResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, start, ¶m)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected content_block_start to be buffered, got %d chunks", len(out))
|
||||
}
|
||||
|
||||
delta := []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"status\"}"}}`)
|
||||
out = ConvertClaudeResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, delta, ¶m)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected input_json_delta to be buffered, got %d chunks", len(out))
|
||||
}
|
||||
|
||||
stop := []byte(`data: {"type":"content_block_stop","index":0}`)
|
||||
out = ConvertClaudeResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, stop, ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected content_block_stop to emit 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.functionCall.id").String()
|
||||
if got != "toolu_gateway" {
|
||||
t.Fatalf("expected functionCall.id %q, got %q; chunk=%s", "toolu_gateway", got, string(out[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeResponseToGeminiNonStreamPreservesToolUseID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
raw := []byte(strings.Join([]string{
|
||||
`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_gateway","name":"lookup"}}`,
|
||||
`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"status\"}"}}`,
|
||||
`data: {"type":"content_block_stop","index":0}`,
|
||||
}, "\n"))
|
||||
|
||||
out := ConvertClaudeResponseToGeminiNonStream(ctx, "gemini-2.5-pro", nil, nil, raw, nil)
|
||||
|
||||
got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.id").String()
|
||||
if got != "toolu_gateway" {
|
||||
t.Fatalf("expected functionCall.id %q, got %q; chunk=%s", "toolu_gateway", got, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeResponseToGemini_StreamThinkingSignature(t *testing.T) {
|
||||
const validGeminiSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
signature string
|
||||
wantSignature string
|
||||
}{
|
||||
{
|
||||
name: "foreign claude signature maps to bypass sentinel",
|
||||
signature: "foreign_claude_sig_123",
|
||||
wantSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
{
|
||||
name: "preserves valid gemini signature",
|
||||
signature: "gemini#" + validGeminiSignature,
|
||||
wantSignature: validGeminiSignature,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
chunks := [][]byte{
|
||||
[]byte(`data: {"type":"message_start","message":{"id":"msg_123","model":"claude-3-7-sonnet-20250219"}}`),
|
||||
[]byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`),
|
||||
[]byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"thinking text"}}`),
|
||||
[]byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"` + tt.signature + `"}}`),
|
||||
[]byte(`data: {"type":"content_block_stop","index":0}`),
|
||||
[]byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`),
|
||||
[]byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"final answer"}}`),
|
||||
[]byte(`data: {"type":"content_block_stop","index":1}`),
|
||||
[]byte(`data: {"type":"message_stop"}`),
|
||||
}
|
||||
|
||||
var emittedParts []gjson.Result
|
||||
for _, chunk := range chunks {
|
||||
out := ConvertClaudeResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, chunk, ¶m)
|
||||
for _, c := range out {
|
||||
parts := gjson.GetBytes(c, "candidates.0.content.parts").Array()
|
||||
emittedParts = append(emittedParts, parts...)
|
||||
}
|
||||
}
|
||||
|
||||
var foundSignature string
|
||||
for _, p := range emittedParts {
|
||||
if p.Get("thought").Bool() && p.Get("thoughtSignature").Exists() {
|
||||
foundSignature = p.Get("thoughtSignature").String()
|
||||
}
|
||||
}
|
||||
|
||||
if foundSignature != tt.wantSignature {
|
||||
t.Fatalf("expected thoughtSignature %q, got %q", tt.wantSignature, foundSignature)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeResponseToGeminiNonStream_ThinkingSignature(t *testing.T) {
|
||||
const validGeminiSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
signature string
|
||||
wantSignature string
|
||||
}{
|
||||
{
|
||||
name: "foreign claude signature maps to bypass sentinel",
|
||||
signature: "foreign_claude_sig_123",
|
||||
wantSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
{
|
||||
name: "preserves valid gemini signature",
|
||||
signature: "gemini#" + validGeminiSignature,
|
||||
wantSignature: validGeminiSignature,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
raw := []byte(strings.Join([]string{
|
||||
`data: {"type":"message_start","message":{"id":"msg_123","model":"claude-3-7-sonnet-20250219"}}`,
|
||||
`data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`,
|
||||
`data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"thinking text"}}`,
|
||||
`data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"` + tt.signature + `"}}`,
|
||||
`data: {"type":"content_block_stop","index":0}`,
|
||||
`data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`,
|
||||
`data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"final answer"}}`,
|
||||
`data: {"type":"content_block_stop","index":1}`,
|
||||
`data: {"type":"message_stop"}`,
|
||||
}, "\n"))
|
||||
|
||||
out := ConvertClaudeResponseToGeminiNonStream(ctx, "gemini-2.5-pro", nil, nil, raw, nil)
|
||||
|
||||
thoughtPart := gjson.GetBytes(out, "candidates.0.content.parts.0")
|
||||
if !thoughtPart.Get("thought").Bool() || thoughtPart.Get("text").String() != "thinking text" {
|
||||
t.Fatalf("expected thought part with text 'thinking text', got %s", thoughtPart.Raw)
|
||||
}
|
||||
if got := thoughtPart.Get("thoughtSignature").String(); got != tt.wantSignature {
|
||||
t.Fatalf("expected thoughtSignature %q, got %q", tt.wantSignature, got)
|
||||
}
|
||||
|
||||
textPart := gjson.GetBytes(out, "candidates.0.content.parts.1")
|
||||
if textPart.Get("text").String() != "final answer" {
|
||||
t.Fatalf("expected text part 'final answer', got %s", textPart.Raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
20
backend/internal/translator/claude/gemini/init.go
Normal file
20
backend/internal/translator/claude/gemini/init.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
Gemini,
|
||||
Claude,
|
||||
ConvertGeminiRequestToClaude,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertClaudeResponseToGemini,
|
||||
NonStream: ConvertClaudeResponseToGeminiNonStream,
|
||||
TokenCount: GeminiTokenCount,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestNormalizeClaudeToolSchemaPreservesCanonicalSchema(t *testing.T) {
|
||||
input := []byte(`{"type":"object","properties":{"value":{"type":"string"}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}`)
|
||||
|
||||
output := normalizeClaudeToolSchema(gjson.ParseBytes(input))
|
||||
|
||||
if string(output) != string(input) {
|
||||
t.Fatalf("canonical schema changed:\n got: %s\nwant: %s", output, input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeClaudeToolSchemaCorrectsWrongTypes(t *testing.T) {
|
||||
input := []byte(`{"type":"object","additionalProperties":"false","$schema":123}`)
|
||||
|
||||
output := normalizeClaudeToolSchema(gjson.ParseBytes(input))
|
||||
|
||||
if additionalProperties := gjson.GetBytes(output, "additionalProperties"); additionalProperties.Type != gjson.False {
|
||||
t.Fatalf("additionalProperties = %s, want false", additionalProperties.Raw)
|
||||
}
|
||||
if schema := gjson.GetBytes(output, "$schema"); schema.Type != gjson.String || schema.String() != "http://json-schema.org/draft-07/schema#" {
|
||||
t.Fatalf("$schema = %s, want canonical string", schema.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowercaseClaudeToolSchemaTypesReusesLowercaseSchema(t *testing.T) {
|
||||
input := []byte(`{"name":"lookup","input_schema":{"type":"object","properties":{"value":{"type":"string"}}}}`)
|
||||
|
||||
output := lowercaseClaudeToolSchemaTypes(input)
|
||||
|
||||
if &output[0] != &input[0] {
|
||||
t.Fatal("lowercase schema types caused a payload copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowercaseClaudeToolSchemaTypesNormalizesNonStringType(t *testing.T) {
|
||||
input := []byte(`{"input_schema":{"type":123}}`)
|
||||
|
||||
output := lowercaseClaudeToolSchemaTypes(input)
|
||||
|
||||
if got := gjson.GetBytes(output, "input_schema.type"); got.Type != gjson.String || got.String() != "123" {
|
||||
t.Fatalf("input_schema.type = %s, want string 123", got.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowercaseClaudeToolSchemaTypesNormalizesUppercaseTypes(t *testing.T) {
|
||||
input := []byte(`{"input_schema":{"type":"OBJECT","properties":{"value":{"type":"STRING"}}}}`)
|
||||
|
||||
output := lowercaseClaudeToolSchemaTypes(input)
|
||||
|
||||
if got := gjson.GetBytes(output, "input_schema.type").String(); got != "object" {
|
||||
t.Fatalf("input_schema.type = %q, want object", got)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "input_schema.properties.value.type").String(); got != "string" {
|
||||
t.Fatalf("nested type = %q, want string", got)
|
||||
}
|
||||
}
|
||||
19
backend/internal/translator/claude/interactions/init.go
Normal file
19
backend/internal/translator/claude/interactions/init.go
Normal 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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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{}
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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, ¶m)...)
|
||||
}
|
||||
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"}}`), ¶m)
|
||||
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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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{}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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}}`),
|
||||
¶m,
|
||||
)
|
||||
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}}}`),
|
||||
¶m,
|
||||
)
|
||||
out := ConvertClaudeResponseToOpenAI(
|
||||
ctx,
|
||||
"claude-opus-4-6",
|
||||
nil,
|
||||
nil,
|
||||
[]byte(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":4}}`),
|
||||
¶m,
|
||||
)
|
||||
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}}`),
|
||||
¶m,
|
||||
)
|
||||
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, ¶m)
|
||||
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, ¶m)
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
19
backend/internal/translator/claude/openai/responses/init.go
Normal file
19
backend/internal/translator/claude/openai/responses/init.go
Normal 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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue