diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_reasoning_order_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_reasoning_order_test.go new file mode 100644 index 00000000..e87bf08f --- /dev/null +++ b/internal/translator/claude/openai/responses/claude_openai-responses_reasoning_order_test.go @@ -0,0 +1,132 @@ +package responses + +import ( + "fmt" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertOpenAIResponsesRequestToClaude_KeepsLatestConsecutiveReasoning(t *testing.T) { + firstRaw, _ := testClaudeResponsesThinkingSignatureForModel(t, "claude-opus-5-first") + secondRaw, _ := testClaudeResponsesThinkingSignatureForModel(t, "claude-opus-5-second") + thirdRaw, thirdSignature := testClaudeResponsesThinkingSignatureForModel(t, "claude-opus-5-third") + + raw := responsesRequestFromItems( + `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"prefix"}]}`, + responsesReasoningItem(firstRaw, "first reasoning"), + responsesReasoningItem(secondRaw, "second reasoning"), + responsesReasoningItem(thirdRaw, "third reasoning"), + responsesFunctionCallItem("call_latest", "latest_tool"), + responsesFunctionCallOutputItem("call_latest", "done"), + ) + + out := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false) + content := gjson.GetBytes(out, "messages.0.content").Array() + wantTypes := []string{"text", "thinking", "tool_use"} + if len(content) != len(wantTypes) { + t.Fatalf("assistant content count = %d, want %d. Output: %s", len(content), len(wantTypes), out) + } + for index, wantType := range wantTypes { + if got := content[index].Get("type").String(); got != wantType { + t.Fatalf("assistant content[%d].type = %q, want %q. Output: %s", index, got, wantType, out) + } + } + if got := content[0].Get("text").String(); got != "prefix" { + t.Fatalf("prefix text = %q, want prefix. Output: %s", got, out) + } + if got := content[1].Get("thinking").String(); got != "third reasoning" { + t.Fatalf("thinking text = %q, want latest consecutive reasoning. Output: %s", got, out) + } + if got := content[1].Get("signature").String(); got != thirdSignature { + t.Fatalf("thinking signature = %q, want latest signature %q. Output: %s", got, thirdSignature, out) + } +} + +func TestConvertOpenAIResponsesRequestToClaude_ToolCallsSeparateReasoningBlocks(t *testing.T) { + firstRaw, firstSignature := testClaudeResponsesThinkingSignatureForModel(t, "claude-opus-5-first") + secondRaw, secondSignature := testClaudeResponsesThinkingSignatureForModel(t, "claude-opus-5-second") + + raw := responsesRequestFromItems( + responsesReasoningItem(firstRaw, "first reasoning"), + responsesFunctionCallItem("call_first", "first_tool"), + responsesReasoningItem(secondRaw, "second reasoning"), + responsesFunctionCallItem("call_second", "second_tool"), + responsesFunctionCallOutputItem("call_first", "first result"), + responsesFunctionCallOutputItem("call_second", "second result"), + ) + + out := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false) + content := gjson.GetBytes(out, "messages.0.content").Array() + wantTypes := []string{"thinking", "tool_use", "thinking", "tool_use"} + if len(content) != len(wantTypes) { + t.Fatalf("assistant content count = %d, want %d. Output: %s", len(content), len(wantTypes), out) + } + for index, wantType := range wantTypes { + if got := content[index].Get("type").String(); got != wantType { + t.Fatalf("assistant content[%d].type = %q, want %q. Output: %s", index, got, wantType, out) + } + } + if got := content[0].Get("signature").String(); got != firstSignature { + t.Fatalf("first thinking signature = %q, want %q", got, firstSignature) + } + if got := content[2].Get("signature").String(); got != secondSignature { + t.Fatalf("second thinking signature = %q, want %q", got, secondSignature) + } + if got := content[1].Get("id").String(); got != "call_first" { + t.Fatalf("first tool_use id = %q, want call_first", got) + } + if got := content[3].Get("id").String(); got != "call_second" { + t.Fatalf("second tool_use id = %q, want call_second", got) + } +} + +func TestConvertOpenAIResponsesRequestToClaude_NonThinkingBlocksSeparateReasoning(t *testing.T) { + firstRaw, firstSignature := testClaudeResponsesThinkingSignatureForModel(t, "claude-opus-5-first") + secondRaw, secondSignature := testClaudeResponsesThinkingSignatureForModel(t, "claude-opus-5-second") + const redactedData = "opaque-redacted-data" + + raw := responsesRequestFromItems( + responsesReasoningItem(firstRaw, "first reasoning"), + `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"visible separator"}]}`, + responsesReasoningItem(secondRaw, "second reasoning"), + `{"type":"reasoning","encrypted_content":"`+ClaudeResponsesRedactedThinkingPrefix+redactedData+`","summary":[]}`, + responsesReasoningItem(firstRaw, "third reasoning"), + ) + + out := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false) + content := gjson.GetBytes(out, "messages.0.content").Array() + wantTypes := []string{"thinking", "text", "thinking", "redacted_thinking", "thinking"} + if len(content) != len(wantTypes) { + t.Fatalf("assistant content count = %d, want %d. Output: %s", len(content), len(wantTypes), out) + } + for index, wantType := range wantTypes { + if got := content[index].Get("type").String(); got != wantType { + t.Fatalf("assistant content[%d].type = %q, want %q. Output: %s", index, got, wantType, out) + } + } + if got := content[0].Get("signature").String(); got != firstSignature { + t.Fatalf("first thinking signature = %q, want %q", got, firstSignature) + } + if got := content[2].Get("signature").String(); got != secondSignature { + t.Fatalf("second thinking signature = %q, want %q", got, secondSignature) + } + if got := content[3].Get("data").String(); got != redactedData { + t.Fatalf("redacted data = %q, want %q", got, redactedData) + } + if got := content[4].Get("signature").String(); got != firstSignature { + t.Fatalf("third thinking signature = %q, want %q", got, firstSignature) + } +} + +func responsesReasoningItem(signature, text string) string { + return fmt.Sprintf(`{"type":"reasoning","encrypted_content":%q,"summary":[{"type":"summary_text","text":%q}]}`, signature, text) +} + +func responsesFunctionCallItem(callID, name string) string { + return fmt.Sprintf(`{"type":"function_call","call_id":%q,"name":%q,"arguments":"{}"}`, callID, name) +} + +func responsesFunctionCallOutputItem(callID, output string) string { + return fmt.Sprintf(`{"type":"function_call_output","call_id":%q,"output":%q}`, callID, output) +} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index db79e3a2..1d00479f 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -226,6 +226,32 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte pendingRole = "assistant" pendingToolUseParts = append(pendingToolUseParts, toolUse) } + appendReasoning := func(reasoningPart []byte) { + if len(reasoningPart) == 0 { + return + } + if pendingRole != "" && pendingRole != "assistant" { + flushPendingMessage() + } + pendingRole = "assistant" + + // Client tool calls normally stay at the end of an assistant message, but + // a later reasoning item makes them a real separator between thinking blocks. + if len(pendingToolUseParts) > 0 { + pendingParts = append(pendingParts, pendingToolUseParts...) + pendingToolUseParts = nil + } + + currentType := gjson.GetBytes(reasoningPart, "type").String() + if currentType == "thinking" && len(pendingParts) > 0 { + lastIdx := len(pendingParts) - 1 + if gjson.GetBytes(pendingParts[lastIdx], "type").String() == "thinking" { + pendingParts[lastIdx] = reasoningPart + return + } + } + pendingParts = append(pendingParts, reasoningPart) + } lastToolResult := map[string]gjson.Result{} if input := root.Get("input"); input.Exists() && input.IsArray() { @@ -379,9 +405,7 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte } case "reasoning": - if thinkingPart := convertResponsesReasoningToClaudeThinking(item, preserveEmptyThinkingBlocks); len(thinkingPart) > 0 { - appendParts("assistant", thinkingPart) - } + appendReasoning(convertResponsesReasoningToClaudeThinking(item, preserveEmptyThinkingBlocks)) case "function_call", "custom_tool_call": // Map to assistant tool_use. Freeform custom input is wrapped in an diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go index 46191f05..a0234b8a 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go @@ -941,6 +941,11 @@ func TestSplitResponsesQualifiedFunctionCallFromAdditionalTools(t *testing.T) { } func testClaudeResponsesThinkingSignature(t *testing.T) (string, string) { + t.Helper() + return testClaudeResponsesThinkingSignatureForModel(t, "claude-sonnet-4-6") +} + +func testClaudeResponsesThinkingSignatureForModel(t *testing.T, model string) (string, string) { t.Helper() channelBlock := []byte{} channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType) @@ -948,7 +953,7 @@ func testClaudeResponsesThinkingSignature(t *testing.T) (string, string) { channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType) channelBlock = protowire.AppendVarint(channelBlock, 2) channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType) - channelBlock = protowire.AppendString(channelBlock, "claude-sonnet-4-6") + channelBlock = protowire.AppendString(channelBlock, model) container := []byte{} container = protowire.AppendTag(container, 1, protowire.BytesType)