diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index d1f14d58..cc7fd01e 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -181,6 +181,14 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream 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 @@ -214,6 +222,7 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream } } 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) @@ -225,14 +234,11 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream 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 } } - // Emit tool_result messages after the main message (ensures proper OpenAI ordering) - for _, toolResultJSON := range toolResults { - messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolResultJSON).Value()) - } - } else if contentResult.Exists() && contentResult.Type == gjson.String { // Simple string content msgJSON := `{"role":"","content":""}` diff --git a/internal/translator/openai/claude/openai_claude_request_test.go b/internal/translator/openai/claude/openai_claude_request_test.go index ec11fc64..3a577957 100644 --- a/internal/translator/openai/claude/openai_claude_request_test.go +++ b/internal/translator/openai/claude/openai_claude_request_test.go @@ -317,8 +317,8 @@ func TestConvertClaudeRequestToOpenAI_ToolResultOrderAndContent(t *testing.T) { 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) + // 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) } @@ -331,26 +331,28 @@ func TestConvertClaudeRequestToOpenAI_ToolResultOrderAndContent(t *testing.T) { 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()) + // 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()) } - // 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" { + 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[3].Get("content").String(); got != "tool ok" { + 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) {