mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-02 20:40:52 +08:00
501 lines
16 KiB
Go
501 lines
16 KiB
Go
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()
|
|
|
|
// OpenAI requires: tool messages MUST immediately follow assistant(tool_calls).
|
|
// Correct order: system + assistant(tool_calls) + tool(result) + user(before+after)
|
|
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)
|
|
}
|
|
|
|
// tool message MUST immediately follow assistant(tool_calls) per OpenAI spec
|
|
if messages[2].Get("role").String() != "tool" {
|
|
t.Fatalf("Expected messages[2] to be tool (must follow tool_calls), got %s", messages[2].Get("role").String())
|
|
}
|
|
if got := messages[2].Get("tool_call_id").String(); got != "call_1" {
|
|
t.Fatalf("Expected tool_call_id %q, got %q", "call_1", got)
|
|
}
|
|
if got := messages[2].Get("content").String(); got != "tool ok" {
|
|
t.Fatalf("Expected tool content %q, got %q", "tool ok", got)
|
|
}
|
|
|
|
// User message comes after tool message
|
|
if messages[3].Get("role").String() != "user" {
|
|
t.Fatalf("Expected messages[3] to be user, got %s", messages[3].Get("role").String())
|
|
}
|
|
// User message should contain both "before" and "after" text
|
|
if got := messages[3].Get("content.0.text").String(); got != "before" {
|
|
t.Fatalf("Expected user text[0] %q, got %q", "before", got)
|
|
}
|
|
if got := messages[3].Get("content.1.text").String(); got != "after" {
|
|
t.Fatalf("Expected user text[1] %q, got %q", "after", 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)
|
|
}
|
|
}
|