Merge pull request #823 from router-for-me/translator
feat(translator): enhance Claude-to-OpenAI conversion with thinking block and tool result handling
This commit is contained in:
@@ -740,7 +740,7 @@ func GetIFlowModels() []*ModelInfo {
|
|||||||
{ID: "qwen3-235b-a22b-thinking-2507", DisplayName: "Qwen3-235B-A22B-Thinking", Description: "Qwen3 235B A22B Thinking (2507)", Created: 1753401600},
|
{ID: "qwen3-235b-a22b-thinking-2507", DisplayName: "Qwen3-235B-A22B-Thinking", Description: "Qwen3 235B A22B Thinking (2507)", Created: 1753401600},
|
||||||
{ID: "qwen3-235b-a22b-instruct", DisplayName: "Qwen3-235B-A22B-Instruct", Description: "Qwen3 235B A22B Instruct", Created: 1753401600},
|
{ID: "qwen3-235b-a22b-instruct", DisplayName: "Qwen3-235B-A22B-Instruct", Description: "Qwen3 235B A22B Instruct", Created: 1753401600},
|
||||||
{ID: "qwen3-235b", DisplayName: "Qwen3-235B-A22B", Description: "Qwen3 235B A22B", Created: 1753401600},
|
{ID: "qwen3-235b", DisplayName: "Qwen3-235B-A22B", Description: "Qwen3 235B A22B", Created: 1753401600},
|
||||||
{ID: "minimax-m2", DisplayName: "MiniMax-M2", Description: "MiniMax M2", Created: 1758672000},
|
{ID: "minimax-m2", DisplayName: "MiniMax-M2", Description: "MiniMax M2", Created: 1758672000, Thinking: iFlowThinkingSupport},
|
||||||
{ID: "minimax-m2.1", DisplayName: "MiniMax-M2.1", Description: "MiniMax M2.1", Created: 1766448000, Thinking: iFlowThinkingSupport},
|
{ID: "minimax-m2.1", DisplayName: "MiniMax-M2.1", Description: "MiniMax M2.1", Created: 1766448000, Thinking: iFlowThinkingSupport},
|
||||||
}
|
}
|
||||||
models := make([]*ModelInfo, 0, len(entries))
|
models := make([]*ModelInfo, 0, len(entries))
|
||||||
|
|||||||
@@ -441,21 +441,18 @@ func ensureToolsArray(body []byte) []byte {
|
|||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
// preserveReasoningContentInMessages ensures reasoning_content from assistant messages in the
|
// preserveReasoningContentInMessages checks if reasoning_content from assistant messages
|
||||||
// conversation history is preserved when sending to iFlow models that support thinking.
|
// is preserved in conversation history for iFlow models that support thinking.
|
||||||
// This is critical for multi-turn conversations where the model needs to see its previous
|
// This is helpful for multi-turn conversations where the model may benefit from seeing
|
||||||
// reasoning to maintain coherent thought chains across tool calls and conversation turns.
|
// its previous reasoning to maintain coherent thought chains.
|
||||||
//
|
//
|
||||||
// For GLM-4.7 and MiniMax-M2.1, the full assistant response (including reasoning) must be
|
// For GLM-4.6/4.7 and MiniMax M2/M2.1, it is recommended to include the full assistant
|
||||||
// appended back into message history before the next call.
|
// response (including reasoning_content) in message history for better context continuity.
|
||||||
func preserveReasoningContentInMessages(body []byte) []byte {
|
func preserveReasoningContentInMessages(body []byte) []byte {
|
||||||
model := strings.ToLower(gjson.GetBytes(body, "model").String())
|
model := strings.ToLower(gjson.GetBytes(body, "model").String())
|
||||||
|
|
||||||
// Only apply to models that support thinking with history preservation
|
// Only apply to models that support thinking with history preservation
|
||||||
needsPreservation := strings.HasPrefix(model, "glm-4.7") ||
|
needsPreservation := strings.HasPrefix(model, "glm-4") || strings.HasPrefix(model, "minimax-m2")
|
||||||
strings.HasPrefix(model, "glm-4-7") ||
|
|
||||||
strings.HasPrefix(model, "minimax-m2.1") ||
|
|
||||||
strings.HasPrefix(model, "minimax-m2-1")
|
|
||||||
|
|
||||||
if !needsPreservation {
|
if !needsPreservation {
|
||||||
return body
|
return body
|
||||||
@@ -493,45 +490,35 @@ func preserveReasoningContentInMessages(body []byte) []byte {
|
|||||||
// This should be called after NormalizeThinkingConfig has processed the payload.
|
// This should be called after NormalizeThinkingConfig has processed the payload.
|
||||||
//
|
//
|
||||||
// Model-specific handling:
|
// Model-specific handling:
|
||||||
// - GLM-4.7: Uses extra_body={"thinking": {"type": "enabled"}, "clear_thinking": false}
|
// - GLM-4.6/4.7: Uses chat_template_kwargs.enable_thinking (boolean) and chat_template_kwargs.clear_thinking=false
|
||||||
// - MiniMax-M2.1: Uses reasoning_split=true for OpenAI-style reasoning separation
|
// - MiniMax M2/M2.1: Uses reasoning_split=true for OpenAI-style reasoning separation
|
||||||
// - Other iFlow models: Uses chat_template_kwargs.enable_thinking (boolean)
|
|
||||||
func applyIFlowThinkingConfig(body []byte) []byte {
|
func applyIFlowThinkingConfig(body []byte) []byte {
|
||||||
effort := gjson.GetBytes(body, "reasoning_effort")
|
effort := gjson.GetBytes(body, "reasoning_effort")
|
||||||
model := strings.ToLower(gjson.GetBytes(body, "model").String())
|
if !effort.Exists() {
|
||||||
|
return body
|
||||||
// Check if thinking should be enabled
|
|
||||||
val := ""
|
|
||||||
if effort.Exists() {
|
|
||||||
val = strings.ToLower(strings.TrimSpace(effort.String()))
|
|
||||||
}
|
}
|
||||||
enableThinking := effort.Exists() && val != "none" && val != ""
|
|
||||||
|
model := strings.ToLower(gjson.GetBytes(body, "model").String())
|
||||||
|
val := strings.ToLower(strings.TrimSpace(effort.String()))
|
||||||
|
enableThinking := val != "none" && val != ""
|
||||||
|
|
||||||
// Remove reasoning_effort as we'll convert to model-specific format
|
// Remove reasoning_effort as we'll convert to model-specific format
|
||||||
if effort.Exists() {
|
|
||||||
body, _ = sjson.DeleteBytes(body, "reasoning_effort")
|
body, _ = sjson.DeleteBytes(body, "reasoning_effort")
|
||||||
}
|
body, _ = sjson.DeleteBytes(body, "thinking")
|
||||||
|
|
||||||
// GLM-4.7: Use extra_body with thinking config and clear_thinking: false
|
// GLM-4.6/4.7: Use chat_template_kwargs
|
||||||
if strings.HasPrefix(model, "glm-4.7") || strings.HasPrefix(model, "glm-4-7") {
|
if strings.HasPrefix(model, "glm-4") {
|
||||||
if enableThinking {
|
|
||||||
body, _ = sjson.SetBytes(body, "extra_body.thinking.type", "enabled")
|
|
||||||
body, _ = sjson.SetBytes(body, "extra_body.clear_thinking", false)
|
|
||||||
}
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// MiniMax-M2.1: Use reasoning_split=true for interleaved thinking
|
|
||||||
if strings.HasPrefix(model, "minimax-m2.1") || strings.HasPrefix(model, "minimax-m2-1") {
|
|
||||||
if enableThinking {
|
|
||||||
body, _ = sjson.SetBytes(body, "reasoning_split", true)
|
|
||||||
}
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other iFlow models (including GLM-4.6): Use chat_template_kwargs.enable_thinking
|
|
||||||
if effort.Exists() {
|
|
||||||
body, _ = sjson.SetBytes(body, "chat_template_kwargs.enable_thinking", enableThinking)
|
body, _ = sjson.SetBytes(body, "chat_template_kwargs.enable_thinking", enableThinking)
|
||||||
|
if enableThinking {
|
||||||
|
body, _ = sjson.SetBytes(body, "chat_template_kwargs.clear_thinking", false)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiniMax M2/M2.1: Use reasoning_split
|
||||||
|
if strings.HasPrefix(model, "minimax-m2") {
|
||||||
|
body, _ = sjson.SetBytes(body, "reasoning_split", enableThinking)
|
||||||
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
return body
|
return body
|
||||||
|
|||||||
@@ -118,19 +118,36 @@ 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).
|
||||||
|
if role == "assistant" {
|
||||||
toolCallJSON := `{"id":"","type":"function","function":{"name":"","arguments":""}}`
|
toolCallJSON := `{"id":"","type":"function","function":{"name":"","arguments":""}}`
|
||||||
toolCallJSON, _ = sjson.Set(toolCallJSON, "id", part.Get("id").String())
|
toolCallJSON, _ = sjson.Set(toolCallJSON, "id", part.Get("id").String())
|
||||||
toolCallJSON, _ = sjson.Set(toolCallJSON, "function.name", part.Get("name").String())
|
toolCallJSON, _ = sjson.Set(toolCallJSON, "function.name", part.Get("name").String())
|
||||||
@@ -143,20 +160,71 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
reasoningContent = strings.Join(reasoningParts, "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
hasContent := len(contentItems) > 0
|
||||||
|
hasReasoning := reasoningContent != ""
|
||||||
|
hasToolCalls := len(toolCalls) > 0
|
||||||
|
hasToolResults := len(toolResults) > 0
|
||||||
|
|
||||||
|
// OpenAI requires: tool messages MUST immediately follow the assistant message with tool_calls.
|
||||||
|
// Therefore, we emit tool_result messages FIRST (they respond to the previous assistant's tool_calls),
|
||||||
|
// then emit the current message's content.
|
||||||
|
for _, toolResultJSON := range toolResults {
|
||||||
|
messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolResultJSON).Value())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For non-assistant roles: emit content message if we have content
|
||||||
|
// If the message only contains tool_results (no text/image), we still processed them above
|
||||||
|
if hasContent {
|
||||||
|
msgJSON := `{"role":""}`
|
||||||
msgJSON, _ = sjson.Set(msgJSON, "role", role)
|
msgJSON, _ = sjson.Set(msgJSON, "role", role)
|
||||||
|
|
||||||
contentArrayJSON := "[]"
|
contentArrayJSON := "[]"
|
||||||
@@ -165,31 +233,12 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
|
|||||||
}
|
}
|
||||||
msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON)
|
msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON)
|
||||||
|
|
||||||
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 {
|
|
||||||
messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(msgJSON).Value())
|
messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(msgJSON).Value())
|
||||||
|
} else if hasToolResults && !hasContent {
|
||||||
|
// tool_results already emitted above, no additional user message needed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit tool calls in a separate assistant message
|
|
||||||
if role == "assistant" && len(toolCalls) > 0 {
|
|
||||||
toolCallMsgJSON := `{"role":"assistant","tool_calls":[]}`
|
|
||||||
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 {
|
||||||
// Simple string content
|
// Simple string content
|
||||||
msgJSON := `{"role":"","content":""}`
|
msgJSON := `{"role":"","content":""}`
|
||||||
@@ -307,3 +356,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -480,15 +480,15 @@ func collectOpenAIReasoningTexts(node gjson.Result) []string {
|
|||||||
|
|
||||||
switch node.Type {
|
switch node.Type {
|
||||||
case gjson.String:
|
case gjson.String:
|
||||||
if text := strings.TrimSpace(node.String()); text != "" {
|
if text := node.String(); text != "" {
|
||||||
texts = append(texts, text)
|
texts = append(texts, text)
|
||||||
}
|
}
|
||||||
case gjson.JSON:
|
case gjson.JSON:
|
||||||
if text := node.Get("text"); text.Exists() {
|
if text := node.Get("text"); text.Exists() {
|
||||||
if trimmed := strings.TrimSpace(text.String()); trimmed != "" {
|
if textStr := text.String(); textStr != "" {
|
||||||
texts = append(texts, trimmed)
|
texts = append(texts, textStr)
|
||||||
}
|
}
|
||||||
} else if raw := strings.TrimSpace(node.Raw); raw != "" && !strings.HasPrefix(raw, "{") && !strings.HasPrefix(raw, "[") {
|
} else if raw := node.Raw; raw != "" && !strings.HasPrefix(raw, "{") && !strings.HasPrefix(raw, "[") {
|
||||||
texts = append(texts, raw)
|
texts = append(texts, raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user