diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index bd6b713a6..bea2ecc31 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -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-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: "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}, } models := make([]*ModelInfo, 0, len(entries)) diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go index 49fd4eb7a..8492fb357 100644 --- a/internal/runtime/executor/iflow_executor.go +++ b/internal/runtime/executor/iflow_executor.go @@ -441,21 +441,18 @@ func ensureToolsArray(body []byte) []byte { return updated } -// preserveReasoningContentInMessages ensures reasoning_content from assistant messages in the -// conversation history is preserved when sending to iFlow models that support thinking. -// This is critical for multi-turn conversations where the model needs to see its previous -// reasoning to maintain coherent thought chains across tool calls and conversation turns. +// preserveReasoningContentInMessages checks if reasoning_content from assistant messages +// is preserved in conversation history for iFlow models that support thinking. +// This is helpful for multi-turn conversations where the model may benefit from seeing +// its previous reasoning to maintain coherent thought chains. // -// For GLM-4.7 and MiniMax-M2.1, the full assistant response (including reasoning) must be -// appended back into message history before the next call. +// For GLM-4.6/4.7 and MiniMax M2/M2.1, it is recommended to include the full assistant +// response (including reasoning_content) in message history for better context continuity. func preserveReasoningContentInMessages(body []byte) []byte { model := strings.ToLower(gjson.GetBytes(body, "model").String()) // Only apply to models that support thinking with history preservation - needsPreservation := strings.HasPrefix(model, "glm-4.7") || - strings.HasPrefix(model, "glm-4-7") || - strings.HasPrefix(model, "minimax-m2.1") || - strings.HasPrefix(model, "minimax-m2-1") + needsPreservation := strings.HasPrefix(model, "glm-4") || strings.HasPrefix(model, "minimax-m2") if !needsPreservation { return body @@ -493,45 +490,35 @@ func preserveReasoningContentInMessages(body []byte) []byte { // This should be called after NormalizeThinkingConfig has processed the payload. // // Model-specific handling: -// - GLM-4.7: Uses extra_body={"thinking": {"type": "enabled"}, "clear_thinking": false} -// - MiniMax-M2.1: Uses reasoning_split=true for OpenAI-style reasoning separation -// - Other iFlow models: Uses chat_template_kwargs.enable_thinking (boolean) +// - GLM-4.6/4.7: Uses chat_template_kwargs.enable_thinking (boolean) and chat_template_kwargs.clear_thinking=false +// - MiniMax M2/M2.1: Uses reasoning_split=true for OpenAI-style reasoning separation func applyIFlowThinkingConfig(body []byte) []byte { effort := gjson.GetBytes(body, "reasoning_effort") - model := strings.ToLower(gjson.GetBytes(body, "model").String()) - - // Check if thinking should be enabled - val := "" - if effort.Exists() { - val = strings.ToLower(strings.TrimSpace(effort.String())) + if !effort.Exists() { + return body } - 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 - 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 - if strings.HasPrefix(model, "glm-4.7") || strings.HasPrefix(model, "glm-4-7") { - 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() { + // GLM-4.6/4.7: Use chat_template_kwargs + if strings.HasPrefix(model, "glm-4") { 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 diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index b6fd1e092..cc7fd01ec 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -118,76 +118,125 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream // Handle content if contentResult.Exists() && contentResult.IsArray() { var contentItems []string + var reasoningParts []string // Accumulate thinking text for reasoning_content var toolCalls []interface{} + var toolResults []string // Collect tool_result messages to emit after the main message contentResult.ForEach(func(_, part gjson.Result) bool { partType := part.Get("type").String() 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": if contentItem, ok := convertClaudeContentPart(part); ok { contentItems = append(contentItems, contentItem) } case "tool_use": - // Convert to OpenAI tool call format - toolCallJSON := `{"id":"","type":"function","function":{"name":"","arguments":""}}` - toolCallJSON, _ = sjson.Set(toolCallJSON, "id", part.Get("id").String()) - toolCallJSON, _ = sjson.Set(toolCallJSON, "function.name", part.Get("name").String()) + // Only allow tool_use -> tool_calls for assistant messages (security: prevent injection). + if role == "assistant" { + toolCallJSON := `{"id":"","type":"function","function":{"name":"","arguments":""}}` + 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 - if input := part.Get("input"); input.Exists() { - toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", input.Raw) - } else { - toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", "{}") + // Convert input to arguments JSON string + if input := part.Get("input"); input.Exists() { + toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", input.Raw) + } else { + toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", "{}") + } + + toolCalls = append(toolCalls, gjson.Parse(toolCallJSON).Value()) } - toolCalls = append(toolCalls, gjson.Parse(toolCallJSON).Value()) - 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, _ = sjson.Set(toolResultJSON, "tool_call_id", part.Get("tool_use_id").String()) - toolResultJSON, _ = sjson.Set(toolResultJSON, "content", part.Get("content").String()) - messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolResultJSON).Value()) + toolResultJSON, _ = sjson.Set(toolResultJSON, "content", convertClaudeToolResultContentToString(part.Get("content"))) + toolResults = append(toolResults, toolResultJSON) } return true }) - // Emit text/image content as one message - if len(contentItems) > 0 { - msgJSON := `{"role":"","content":""}` - msgJSON, _ = sjson.Set(msgJSON, "role", role) - - contentArrayJSON := "[]" - for _, contentItem := range contentItems { - contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem) - } - 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()) - } + // Build reasoning content string + reasoningContent := "" + if len(reasoningParts) > 0 { + reasoningContent = strings.Join(reasoningParts, "\n\n") } - // 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()) + 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) + + 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()) + } else if hasToolResults && !hasContent { + // tool_results already emitted above, no additional user message needed + } } } else if contentResult.Exists() && contentResult.Type == gjson.String { @@ -307,3 +356,43 @@ func convertClaudeContentPart(part gjson.Result) (string, bool) { 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 +} diff --git a/internal/translator/openai/claude/openai_claude_request_test.go b/internal/translator/openai/claude/openai_claude_request_test.go new file mode 100644 index 000000000..3a5779579 --- /dev/null +++ b/internal/translator/openai/claude/openai_claude_request_test.go @@ -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) + } +} diff --git a/internal/translator/openai/claude/openai_claude_response.go b/internal/translator/openai/claude/openai_claude_response.go index 3c30299f2..27ab082bb 100644 --- a/internal/translator/openai/claude/openai_claude_response.go +++ b/internal/translator/openai/claude/openai_claude_response.go @@ -480,15 +480,15 @@ func collectOpenAIReasoningTexts(node gjson.Result) []string { switch node.Type { case gjson.String: - if text := strings.TrimSpace(node.String()); text != "" { + if text := node.String(); text != "" { texts = append(texts, text) } case gjson.JSON: if text := node.Get("text"); text.Exists() { - if trimmed := strings.TrimSpace(text.String()); trimmed != "" { - texts = append(texts, trimmed) + if textStr := text.String(); textStr != "" { + 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) } } diff --git a/internal/util/util.go b/internal/util/util.go index 4e8463060..6ecaa8e22 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -25,7 +25,7 @@ func SanitizeFunctionName(name string) string { if name == "" { return "" } - + // Replace invalid characters with underscore sanitized := functionNameSanitizer.ReplaceAllString(name, "_") @@ -36,7 +36,7 @@ func SanitizeFunctionName(name string) string { if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { // If it starts with an allowed character but not allowed at the beginning (digit, dot, colon, dash), // we must prepend an underscore. - + // To stay within the 64-character limit while prepending, we must truncate first. if len(sanitized) >= 64 { sanitized = sanitized[:63]