Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue