mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-18 04:10:51 +08:00
feat(translator): add thinking block and tool result handling for Claude-to-OpenAI conversion
This commit is contained in:
@@ -118,76 +118,119 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
|
|||||||
// Handle content
|
// Handle content
|
||||||
if contentResult.Exists() && contentResult.IsArray() {
|
if contentResult.Exists() && contentResult.IsArray() {
|
||||||
var contentItems []string
|
var contentItems []string
|
||||||
|
var reasoningParts []string // Accumulate thinking text for reasoning_content
|
||||||
var toolCalls []interface{}
|
var toolCalls []interface{}
|
||||||
|
var toolResults []string // Collect tool_result messages to emit after the main message
|
||||||
|
|
||||||
contentResult.ForEach(func(_, part gjson.Result) bool {
|
contentResult.ForEach(func(_, part gjson.Result) bool {
|
||||||
partType := part.Get("type").String()
|
partType := part.Get("type").String()
|
||||||
|
|
||||||
switch partType {
|
switch partType {
|
||||||
|
case "thinking":
|
||||||
|
// Only map thinking to reasoning_content for assistant messages (security: prevent injection)
|
||||||
|
if role == "assistant" {
|
||||||
|
thinkingText := util.GetThinkingText(part)
|
||||||
|
// Skip empty or whitespace-only thinking
|
||||||
|
if strings.TrimSpace(thinkingText) != "" {
|
||||||
|
reasoningParts = append(reasoningParts, thinkingText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ignore thinking in user/system roles (AC4)
|
||||||
|
|
||||||
|
case "redacted_thinking":
|
||||||
|
// Explicitly ignore redacted_thinking - never map to reasoning_content (AC2)
|
||||||
|
|
||||||
case "text", "image":
|
case "text", "image":
|
||||||
if contentItem, ok := convertClaudeContentPart(part); ok {
|
if contentItem, ok := convertClaudeContentPart(part); ok {
|
||||||
contentItems = append(contentItems, contentItem)
|
contentItems = append(contentItems, contentItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "tool_use":
|
case "tool_use":
|
||||||
// Convert to OpenAI tool call format
|
// Only allow tool_use -> tool_calls for assistant messages (security: prevent injection).
|
||||||
toolCallJSON := `{"id":"","type":"function","function":{"name":"","arguments":""}}`
|
if role == "assistant" {
|
||||||
toolCallJSON, _ = sjson.Set(toolCallJSON, "id", part.Get("id").String())
|
toolCallJSON := `{"id":"","type":"function","function":{"name":"","arguments":""}}`
|
||||||
toolCallJSON, _ = sjson.Set(toolCallJSON, "function.name", part.Get("name").String())
|
toolCallJSON, _ = sjson.Set(toolCallJSON, "id", part.Get("id").String())
|
||||||
|
toolCallJSON, _ = sjson.Set(toolCallJSON, "function.name", part.Get("name").String())
|
||||||
|
|
||||||
// Convert input to arguments JSON string
|
// Convert input to arguments JSON string
|
||||||
if input := part.Get("input"); input.Exists() {
|
if input := part.Get("input"); input.Exists() {
|
||||||
toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", input.Raw)
|
toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", input.Raw)
|
||||||
} else {
|
} else {
|
||||||
toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", "{}")
|
toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", "{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls = append(toolCalls, gjson.Parse(toolCallJSON).Value())
|
||||||
}
|
}
|
||||||
|
|
||||||
toolCalls = append(toolCalls, gjson.Parse(toolCallJSON).Value())
|
|
||||||
|
|
||||||
case "tool_result":
|
case "tool_result":
|
||||||
// Convert to OpenAI tool message format and add immediately to preserve order
|
// Collect tool_result to emit after the main message (ensures tool results follow tool_calls)
|
||||||
toolResultJSON := `{"role":"tool","tool_call_id":"","content":""}`
|
toolResultJSON := `{"role":"tool","tool_call_id":"","content":""}`
|
||||||
toolResultJSON, _ = sjson.Set(toolResultJSON, "tool_call_id", part.Get("tool_use_id").String())
|
toolResultJSON, _ = sjson.Set(toolResultJSON, "tool_call_id", part.Get("tool_use_id").String())
|
||||||
toolResultJSON, _ = sjson.Set(toolResultJSON, "content", part.Get("content").String())
|
toolResultJSON, _ = sjson.Set(toolResultJSON, "content", convertClaudeToolResultContentToString(part.Get("content")))
|
||||||
messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolResultJSON).Value())
|
toolResults = append(toolResults, toolResultJSON)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Emit text/image content as one message
|
// Build reasoning content string
|
||||||
if len(contentItems) > 0 {
|
reasoningContent := ""
|
||||||
msgJSON := `{"role":"","content":""}`
|
if len(reasoningParts) > 0 {
|
||||||
msgJSON, _ = sjson.Set(msgJSON, "role", role)
|
reasoningContent = strings.Join(reasoningParts, "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
contentArrayJSON := "[]"
|
hasContent := len(contentItems) > 0
|
||||||
for _, contentItem := range contentItems {
|
hasReasoning := reasoningContent != ""
|
||||||
contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem)
|
hasToolCalls := len(toolCalls) > 0
|
||||||
|
|
||||||
|
// For assistant messages: emit a single unified message with content, tool_calls, and reasoning_content
|
||||||
|
// This avoids splitting into multiple assistant messages which breaks OpenAI tool-call adjacency
|
||||||
|
if role == "assistant" {
|
||||||
|
if hasContent || hasReasoning || hasToolCalls {
|
||||||
|
msgJSON := `{"role":"assistant"}`
|
||||||
|
|
||||||
|
// Add content (as array if we have items, empty string if reasoning-only)
|
||||||
|
if hasContent {
|
||||||
|
contentArrayJSON := "[]"
|
||||||
|
for _, contentItem := range contentItems {
|
||||||
|
contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem)
|
||||||
|
}
|
||||||
|
msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON)
|
||||||
|
} else {
|
||||||
|
// Ensure content field exists for OpenAI compatibility
|
||||||
|
msgJSON, _ = sjson.Set(msgJSON, "content", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add reasoning_content if present
|
||||||
|
if hasReasoning {
|
||||||
|
msgJSON, _ = sjson.Set(msgJSON, "reasoning_content", reasoningContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tool_calls if present (in same message as content)
|
||||||
|
if hasToolCalls {
|
||||||
|
msgJSON, _ = sjson.Set(msgJSON, "tool_calls", toolCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(msgJSON).Value())
|
||||||
}
|
}
|
||||||
msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON)
|
} else {
|
||||||
|
// For non-assistant roles: emit content message if we have content
|
||||||
contentValue := gjson.Get(msgJSON, "content")
|
|
||||||
hasContent := false
|
|
||||||
switch {
|
|
||||||
case !contentValue.Exists():
|
|
||||||
hasContent = false
|
|
||||||
case contentValue.Type == gjson.String:
|
|
||||||
hasContent = contentValue.String() != ""
|
|
||||||
case contentValue.IsArray():
|
|
||||||
hasContent = len(contentValue.Array()) > 0
|
|
||||||
default:
|
|
||||||
hasContent = contentValue.Raw != "" && contentValue.Raw != "null"
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasContent {
|
if hasContent {
|
||||||
|
msgJSON := `{"role":""}`
|
||||||
|
msgJSON, _ = sjson.Set(msgJSON, "role", role)
|
||||||
|
|
||||||
|
contentArrayJSON := "[]"
|
||||||
|
for _, contentItem := range contentItems {
|
||||||
|
contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem)
|
||||||
|
}
|
||||||
|
msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON)
|
||||||
|
|
||||||
messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(msgJSON).Value())
|
messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(msgJSON).Value())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit tool calls in a separate assistant message
|
// Emit tool_result messages after the main message (ensures proper OpenAI ordering)
|
||||||
if role == "assistant" && len(toolCalls) > 0 {
|
for _, toolResultJSON := range toolResults {
|
||||||
toolCallMsgJSON := `{"role":"assistant","tool_calls":[]}`
|
messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolResultJSON).Value())
|
||||||
toolCallMsgJSON, _ = sjson.Set(toolCallMsgJSON, "tool_calls", toolCalls)
|
|
||||||
messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolCallMsgJSON).Value())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if contentResult.Exists() && contentResult.Type == gjson.String {
|
} else if contentResult.Exists() && contentResult.Type == gjson.String {
|
||||||
@@ -307,3 +350,43 @@ func convertClaudeContentPart(part gjson.Result) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func convertClaudeToolResultContentToString(content gjson.Result) string {
|
||||||
|
if !content.Exists() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if content.Type == gjson.String {
|
||||||
|
return content.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if content.IsArray() {
|
||||||
|
var parts []string
|
||||||
|
content.ForEach(func(_, item gjson.Result) bool {
|
||||||
|
switch {
|
||||||
|
case item.Type == gjson.String:
|
||||||
|
parts = append(parts, item.String())
|
||||||
|
case item.IsObject() && item.Get("text").Exists() && item.Get("text").Type == gjson.String:
|
||||||
|
parts = append(parts, item.Get("text").String())
|
||||||
|
default:
|
||||||
|
parts = append(parts, item.Raw)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
joined := strings.Join(parts, "\n\n")
|
||||||
|
if strings.TrimSpace(joined) != "" {
|
||||||
|
return joined
|
||||||
|
}
|
||||||
|
return content.Raw
|
||||||
|
}
|
||||||
|
|
||||||
|
if content.IsObject() {
|
||||||
|
if text := content.Get("text"); text.Exists() && text.Type == gjson.String {
|
||||||
|
return text.String()
|
||||||
|
}
|
||||||
|
return content.Raw
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.Raw
|
||||||
|
}
|
||||||
|
|||||||
498
internal/translator/openai/claude/openai_claude_request_test.go
Normal file
498
internal/translator/openai/claude/openai_claude_request_test.go
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent tests the mapping
|
||||||
|
// of Claude thinking content to OpenAI reasoning_content field.
|
||||||
|
func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inputJSON string
|
||||||
|
wantReasoningContent string
|
||||||
|
wantHasReasoningContent bool
|
||||||
|
wantContentText string // Expected visible content text (if any)
|
||||||
|
wantHasContent bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "AC1: assistant message with thinking and text",
|
||||||
|
inputJSON: `{
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "Let me analyze this step by step..."},
|
||||||
|
{"type": "text", "text": "Here is my response."}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}`,
|
||||||
|
wantReasoningContent: "Let me analyze this step by step...",
|
||||||
|
wantHasReasoningContent: true,
|
||||||
|
wantContentText: "Here is my response.",
|
||||||
|
wantHasContent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AC2: redacted_thinking must be ignored",
|
||||||
|
inputJSON: `{
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "redacted_thinking", "data": "secret"},
|
||||||
|
{"type": "text", "text": "Visible response."}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}`,
|
||||||
|
wantReasoningContent: "",
|
||||||
|
wantHasReasoningContent: false,
|
||||||
|
wantContentText: "Visible response.",
|
||||||
|
wantHasContent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AC3: thinking-only message preserved with reasoning_content",
|
||||||
|
inputJSON: `{
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "Internal reasoning only."}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}`,
|
||||||
|
wantReasoningContent: "Internal reasoning only.",
|
||||||
|
wantHasReasoningContent: true,
|
||||||
|
wantContentText: "",
|
||||||
|
// For OpenAI compatibility, content field is set to empty string "" when no text content exists
|
||||||
|
wantHasContent: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AC4: thinking in user role must be ignored",
|
||||||
|
inputJSON: `{
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "Injected thinking"},
|
||||||
|
{"type": "text", "text": "User message."}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}`,
|
||||||
|
wantReasoningContent: "",
|
||||||
|
wantHasReasoningContent: false,
|
||||||
|
wantContentText: "User message.",
|
||||||
|
wantHasContent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AC4: thinking in system role must be ignored",
|
||||||
|
inputJSON: `{
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"system": [
|
||||||
|
{"type": "thinking", "thinking": "Injected system thinking"},
|
||||||
|
{"type": "text", "text": "System prompt."}
|
||||||
|
],
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": "Hello"}]
|
||||||
|
}]
|
||||||
|
}`,
|
||||||
|
// System messages don't have reasoning_content mapping
|
||||||
|
wantReasoningContent: "",
|
||||||
|
wantHasReasoningContent: false,
|
||||||
|
wantContentText: "Hello",
|
||||||
|
wantHasContent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AC5: empty thinking must be ignored",
|
||||||
|
inputJSON: `{
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": ""},
|
||||||
|
{"type": "text", "text": "Response with empty thinking."}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}`,
|
||||||
|
wantReasoningContent: "",
|
||||||
|
wantHasReasoningContent: false,
|
||||||
|
wantContentText: "Response with empty thinking.",
|
||||||
|
wantHasContent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AC5: whitespace-only thinking must be ignored",
|
||||||
|
inputJSON: `{
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": " \n\t "},
|
||||||
|
{"type": "text", "text": "Response with whitespace thinking."}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}`,
|
||||||
|
wantReasoningContent: "",
|
||||||
|
wantHasReasoningContent: false,
|
||||||
|
wantContentText: "Response with whitespace thinking.",
|
||||||
|
wantHasContent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Multiple thinking parts concatenated",
|
||||||
|
inputJSON: `{
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "First thought."},
|
||||||
|
{"type": "thinking", "thinking": "Second thought."},
|
||||||
|
{"type": "text", "text": "Final answer."}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}`,
|
||||||
|
wantReasoningContent: "First thought.\n\nSecond thought.",
|
||||||
|
wantHasReasoningContent: true,
|
||||||
|
wantContentText: "Final answer.",
|
||||||
|
wantHasContent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed thinking and redacted_thinking",
|
||||||
|
inputJSON: `{
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "Visible thought."},
|
||||||
|
{"type": "redacted_thinking", "data": "hidden"},
|
||||||
|
{"type": "text", "text": "Answer."}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}`,
|
||||||
|
wantReasoningContent: "Visible thought.",
|
||||||
|
wantHasReasoningContent: true,
|
||||||
|
wantContentText: "Answer.",
|
||||||
|
wantHasContent: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := ConvertClaudeRequestToOpenAI("test-model", []byte(tt.inputJSON), false)
|
||||||
|
resultJSON := gjson.ParseBytes(result)
|
||||||
|
|
||||||
|
// Find the relevant message (skip system message at index 0)
|
||||||
|
messages := resultJSON.Get("messages").Array()
|
||||||
|
if len(messages) < 2 {
|
||||||
|
if tt.wantHasReasoningContent || tt.wantHasContent {
|
||||||
|
t.Fatalf("Expected at least 2 messages (system + user/assistant), got %d", len(messages))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the last non-system message
|
||||||
|
var targetMsg gjson.Result
|
||||||
|
for i := len(messages) - 1; i >= 0; i-- {
|
||||||
|
if messages[i].Get("role").String() != "system" {
|
||||||
|
targetMsg = messages[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check reasoning_content
|
||||||
|
gotReasoningContent := targetMsg.Get("reasoning_content").String()
|
||||||
|
gotHasReasoningContent := targetMsg.Get("reasoning_content").Exists()
|
||||||
|
|
||||||
|
if gotHasReasoningContent != tt.wantHasReasoningContent {
|
||||||
|
t.Errorf("reasoning_content existence = %v, want %v", gotHasReasoningContent, tt.wantHasReasoningContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotReasoningContent != tt.wantReasoningContent {
|
||||||
|
t.Errorf("reasoning_content = %q, want %q", gotReasoningContent, tt.wantReasoningContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check content
|
||||||
|
content := targetMsg.Get("content")
|
||||||
|
// content has meaningful content if it's a non-empty array, or a non-empty string
|
||||||
|
var gotHasContent bool
|
||||||
|
switch {
|
||||||
|
case content.IsArray():
|
||||||
|
gotHasContent = len(content.Array()) > 0
|
||||||
|
case content.Type == gjson.String:
|
||||||
|
gotHasContent = content.String() != ""
|
||||||
|
default:
|
||||||
|
gotHasContent = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotHasContent != tt.wantHasContent {
|
||||||
|
t.Errorf("content existence = %v, want %v", gotHasContent, tt.wantHasContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantHasContent && tt.wantContentText != "" {
|
||||||
|
// Find text content
|
||||||
|
var foundText string
|
||||||
|
content.ForEach(func(_, v gjson.Result) bool {
|
||||||
|
if v.Get("type").String() == "text" {
|
||||||
|
foundText = v.Get("text").String()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if foundText != tt.wantContentText {
|
||||||
|
t.Errorf("content text = %q, want %q", foundText, tt.wantContentText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConvertClaudeRequestToOpenAI_ThinkingOnlyMessagePreserved tests AC3:
|
||||||
|
// that a message with only thinking content is preserved (not dropped).
|
||||||
|
func TestConvertClaudeRequestToOpenAI_ThinkingOnlyMessagePreserved(t *testing.T) {
|
||||||
|
inputJSON := `{
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": "What is 2+2?"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "thinking", "thinking": "Let me calculate: 2+2=4"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": "Thanks"}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
|
||||||
|
resultJSON := gjson.ParseBytes(result)
|
||||||
|
|
||||||
|
messages := resultJSON.Get("messages").Array()
|
||||||
|
|
||||||
|
// Should have: system (auto-added) + user + assistant (thinking-only) + user = 4 messages
|
||||||
|
if len(messages) != 4 {
|
||||||
|
t.Fatalf("Expected 4 messages, got %d. Messages: %v", len(messages), resultJSON.Get("messages").Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the assistant message (index 2) has reasoning_content
|
||||||
|
assistantMsg := messages[2]
|
||||||
|
if assistantMsg.Get("role").String() != "assistant" {
|
||||||
|
t.Errorf("Expected message[2] to be assistant, got %s", assistantMsg.Get("role").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !assistantMsg.Get("reasoning_content").Exists() {
|
||||||
|
t.Error("Expected assistant message to have reasoning_content")
|
||||||
|
}
|
||||||
|
|
||||||
|
if assistantMsg.Get("reasoning_content").String() != "Let me calculate: 2+2=4" {
|
||||||
|
t.Errorf("Unexpected reasoning_content: %s", assistantMsg.Get("reasoning_content").String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToOpenAI_ToolResultOrderAndContent(t *testing.T) {
|
||||||
|
inputJSON := `{
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "before"},
|
||||||
|
{"type": "tool_result", "tool_use_id": "call_1", "content": [{"type":"text","text":"tool ok"}]},
|
||||||
|
{"type": "text", "text": "after"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
|
||||||
|
resultJSON := gjson.ParseBytes(result)
|
||||||
|
messages := resultJSON.Get("messages").Array()
|
||||||
|
|
||||||
|
// New behavior: user text is combined, tool_result emitted after user message
|
||||||
|
// Expect: system + assistant(tool_calls) + user(before+after) + tool(result)
|
||||||
|
if len(messages) != 4 {
|
||||||
|
t.Fatalf("Expected 4 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages[0].Get("role").String() != "system" {
|
||||||
|
t.Fatalf("Expected messages[0] to be system, got %s", messages[0].Get("role").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages[1].Get("role").String() != "assistant" || !messages[1].Get("tool_calls").Exists() {
|
||||||
|
t.Fatalf("Expected messages[1] to be assistant tool_calls, got %s: %s", messages[1].Get("role").String(), messages[1].Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages[2].Get("role").String() != "user" {
|
||||||
|
t.Fatalf("Expected messages[2] to be user, got %s", messages[2].Get("role").String())
|
||||||
|
}
|
||||||
|
// User message should contain both "before" and "after" text
|
||||||
|
if got := messages[2].Get("content.0.text").String(); got != "before" {
|
||||||
|
t.Fatalf("Expected user text[0] %q, got %q", "before", got)
|
||||||
|
}
|
||||||
|
if got := messages[2].Get("content.1.text").String(); got != "after" {
|
||||||
|
t.Fatalf("Expected user text[1] %q, got %q", "after", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages[3].Get("role").String() != "tool" {
|
||||||
|
t.Fatalf("Expected messages[3] to be tool, got %s", messages[3].Get("role").String())
|
||||||
|
}
|
||||||
|
if got := messages[3].Get("tool_call_id").String(); got != "call_1" {
|
||||||
|
t.Fatalf("Expected tool_call_id %q, got %q", "call_1", got)
|
||||||
|
}
|
||||||
|
if got := messages[3].Get("content").String(); got != "tool ok" {
|
||||||
|
t.Fatalf("Expected tool content %q, got %q", "tool ok", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToOpenAI_ToolResultObjectContent(t *testing.T) {
|
||||||
|
inputJSON := `{
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "tool_result", "tool_use_id": "call_1", "content": {"foo": "bar"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
|
||||||
|
resultJSON := gjson.ParseBytes(result)
|
||||||
|
messages := resultJSON.Get("messages").Array()
|
||||||
|
|
||||||
|
// system + assistant(tool_calls) + tool(result)
|
||||||
|
if len(messages) != 3 {
|
||||||
|
t.Fatalf("Expected 3 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages[2].Get("role").String() != "tool" {
|
||||||
|
t.Fatalf("Expected messages[2] to be tool, got %s", messages[2].Get("role").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
toolContent := messages[2].Get("content").String()
|
||||||
|
parsed := gjson.Parse(toolContent)
|
||||||
|
if parsed.Get("foo").String() != "bar" {
|
||||||
|
t.Fatalf("Expected tool content JSON foo=bar, got %q", toolContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToOpenAI_AssistantTextToolUseTextOrder(t *testing.T) {
|
||||||
|
inputJSON := `{
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "pre"},
|
||||||
|
{"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}},
|
||||||
|
{"type": "text", "text": "post"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
|
||||||
|
resultJSON := gjson.ParseBytes(result)
|
||||||
|
messages := resultJSON.Get("messages").Array()
|
||||||
|
|
||||||
|
// New behavior: content + tool_calls unified in single assistant message
|
||||||
|
// Expect: system + assistant(content[pre,post] + tool_calls)
|
||||||
|
if len(messages) != 2 {
|
||||||
|
t.Fatalf("Expected 2 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages[0].Get("role").String() != "system" {
|
||||||
|
t.Fatalf("Expected messages[0] to be system, got %s", messages[0].Get("role").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
assistantMsg := messages[1]
|
||||||
|
if assistantMsg.Get("role").String() != "assistant" {
|
||||||
|
t.Fatalf("Expected messages[1] to be assistant, got %s", assistantMsg.Get("role").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have both content and tool_calls in same message
|
||||||
|
if !assistantMsg.Get("tool_calls").Exists() {
|
||||||
|
t.Fatalf("Expected assistant message to have tool_calls")
|
||||||
|
}
|
||||||
|
if got := assistantMsg.Get("tool_calls.0.id").String(); got != "call_1" {
|
||||||
|
t.Fatalf("Expected tool_call id %q, got %q", "call_1", got)
|
||||||
|
}
|
||||||
|
if got := assistantMsg.Get("tool_calls.0.function.name").String(); got != "do_work" {
|
||||||
|
t.Fatalf("Expected tool_call name %q, got %q", "do_work", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content should have both pre and post text
|
||||||
|
if got := assistantMsg.Get("content.0.text").String(); got != "pre" {
|
||||||
|
t.Fatalf("Expected content[0] text %q, got %q", "pre", got)
|
||||||
|
}
|
||||||
|
if got := assistantMsg.Get("content.1.text").String(); got != "post" {
|
||||||
|
t.Fatalf("Expected content[1] text %q, got %q", "post", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToOpenAI_AssistantThinkingToolUseThinkingSplit(t *testing.T) {
|
||||||
|
inputJSON := `{
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "t1"},
|
||||||
|
{"type": "text", "text": "pre"},
|
||||||
|
{"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}},
|
||||||
|
{"type": "thinking", "thinking": "t2"},
|
||||||
|
{"type": "text", "text": "post"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
|
||||||
|
resultJSON := gjson.ParseBytes(result)
|
||||||
|
messages := resultJSON.Get("messages").Array()
|
||||||
|
|
||||||
|
// New behavior: all content, thinking, and tool_calls unified in single assistant message
|
||||||
|
// Expect: system + assistant(content[pre,post] + tool_calls + reasoning_content[t1+t2])
|
||||||
|
if len(messages) != 2 {
|
||||||
|
t.Fatalf("Expected 2 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
assistantMsg := messages[1]
|
||||||
|
if assistantMsg.Get("role").String() != "assistant" {
|
||||||
|
t.Fatalf("Expected messages[1] to be assistant, got %s", assistantMsg.Get("role").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have content with both pre and post
|
||||||
|
if got := assistantMsg.Get("content.0.text").String(); got != "pre" {
|
||||||
|
t.Fatalf("Expected content[0] text %q, got %q", "pre", got)
|
||||||
|
}
|
||||||
|
if got := assistantMsg.Get("content.1.text").String(); got != "post" {
|
||||||
|
t.Fatalf("Expected content[1] text %q, got %q", "post", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have tool_calls
|
||||||
|
if !assistantMsg.Get("tool_calls").Exists() {
|
||||||
|
t.Fatalf("Expected assistant message to have tool_calls")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have combined reasoning_content from both thinking blocks
|
||||||
|
if got := assistantMsg.Get("reasoning_content").String(); got != "t1\n\nt2" {
|
||||||
|
t.Fatalf("Expected reasoning_content %q, got %q", "t1\n\nt2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user