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 index e87bf08f..98faa844 100644 --- 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 @@ -92,11 +92,13 @@ func TestConvertOpenAIResponsesRequestToClaude_NonThinkingBlocksSeparateReasonin responsesReasoningItem(secondRaw, "second reasoning"), `{"type":"reasoning","encrypted_content":"`+ClaudeResponsesRedactedThinkingPrefix+redactedData+`","summary":[]}`, responsesReasoningItem(firstRaw, "third reasoning"), + responsesFunctionCallItem("call_separator", "separator_tool"), + responsesFunctionCallOutputItem("call_separator", "done"), ) out := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false) content := gjson.GetBytes(out, "messages.0.content").Array() - wantTypes := []string{"thinking", "text", "thinking", "redacted_thinking", "thinking"} + wantTypes := []string{"thinking", "text", "thinking", "redacted_thinking", "thinking", "tool_use"} if len(content) != len(wantTypes) { t.Fatalf("assistant content count = %d, want %d. Output: %s", len(content), len(wantTypes), out) } @@ -117,6 +119,9 @@ func TestConvertOpenAIResponsesRequestToClaude_NonThinkingBlocksSeparateReasonin if got := content[4].Get("signature").String(); got != firstSignature { t.Fatalf("third thinking signature = %q, want %q", got, firstSignature) } + if got := content[5].Get("id").String(); got != "call_separator" { + t.Fatalf("tool_use id = %q, want call_separator", got) + } } func responsesReasoningItem(signature, text string) string { 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 138b6b9e..0fefe15f 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -476,6 +476,7 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte flushPendingMessage() hadMessages := len(messageBlocks) > 0 if !preserveEmptyThinkingBlocks { + messageBlocks = stripTrailingClaudeThinkingBlocks(messageBlocks) messageBlocks = dropUnsupportedClaudeAssistantPrefill(modelName, messageBlocks) } // Preserve a minimal conversational turn for system-only inputs or when messages became empty @@ -593,6 +594,58 @@ func dropUnsupportedClaudeAssistantPrefill(modelName string, messages [][]byte) return messages[:len(messages)-1] } +// stripTrailingClaudeThinkingBlocks removes trailing thinking and +// redacted_thinking blocks from the final assistant message. Anthropic rejects +// requests whose final assistant content block is thinking. If no content +// blocks remain after stripping, the assistant message itself is dropped. +func stripTrailingClaudeThinkingBlocks(messages [][]byte) [][]byte { + if len(messages) == 0 { + return messages + } + lastIdx := len(messages) - 1 + last := gjson.ParseBytes(messages[lastIdx]) + if !strings.EqualFold(strings.TrimSpace(last.Get("role").String()), "assistant") { + return messages + } + content := last.Get("content") + if !content.IsArray() { + return messages + } + parts := content.Array() + end := len(parts) + for end > 0 { + partType := strings.TrimSpace(parts[end-1].Get("type").String()) + if partType == "thinking" || partType == "redacted_thinking" { + end-- + } else { + break + } + } + if end == len(parts) { + return messages + } + if end == 0 { + return messages[:lastIdx] + } + remainingParts := make([][]byte, end) + for i := 0; i < end; i++ { + remainingParts[i] = []byte(parts[i].Raw) + } + var updatedMsg []byte + if end == 1 { + part := parts[0] + if part.Get("type").String() == "text" && !part.Get("cache_control").Exists() && !part.Get("citations").Exists() { + updatedMsg, _ = sjson.SetBytes(messages[lastIdx], "content", part.Get("text").String()) + } else { + updatedMsg, _ = sjson.SetRawBytes(messages[lastIdx], "content", common.JoinRawArray(remainingParts)) + } + } else { + updatedMsg, _ = sjson.SetRawBytes(messages[lastIdx], "content", common.JoinRawArray(remainingParts)) + } + messages[lastIdx] = updatedMsg + return messages +} + // claudeModelRejectsAssistantPrefill reports whether a Claude model family // disallows trailing assistant prefill in its conversation history. func claudeModelRejectsAssistantPrefill(modelName string) bool { 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 7c923d3d..3cb58404 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 @@ -1572,3 +1572,102 @@ func TestConvertOpenAIResponsesRequestToClaudeWithCompat_FablePreservesAssistant t.Fatalf("expected second message role = assistant, got %q", got) } } + +func TestConvertOpenAIResponsesRequestToClaude_StripsTrailingThinkingBlocksFromAssistant(t *testing.T) { + rawSignature, _ := testClaudeResponsesThinkingSignature(t) + + t.Run("user_then_reasoning_drops_trailing_assistant_message", func(t *testing.T) { + raw := []byte(fmt.Sprintf(`{ + "model": "claude-haiku-4-5-20251001", + "input": [ + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}, + {"type": "reasoning", "encrypted_content": %q, "summary": [{"type": "summary_text", "text": "thought"}]} + ] + }`, rawSignature)) + out := ConvertOpenAIResponsesRequestToClaude("claude-haiku-4-5-20251001", raw, false) + messages := gjson.GetBytes(out, "messages").Array() + if len(messages) != 1 { + t.Fatalf("expected 1 message (user only) after stripping trailing thinking, got %d: %s", len(messages), string(out)) + } + if got := messages[0].Get("role").String(); got != "user" { + t.Fatalf("expected role = user, got %q", got) + } + if got := messages[0].Get("content").String(); got != "hello" { + t.Fatalf("expected user content = hello, got %q", got) + } + }) + + t.Run("user_assistant_text_then_reasoning_strips_trailing_thinking_keeps_assistant_text", func(t *testing.T) { + raw := []byte(fmt.Sprintf(`{ + "model": "claude-haiku-4-5-20251001", + "input": [ + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "prefill text"}]}, + {"type": "reasoning", "encrypted_content": %q, "summary": [{"type": "summary_text", "text": "thought"}]} + ] + }`, rawSignature)) + out := ConvertOpenAIResponsesRequestToClaude("claude-haiku-4-5-20251001", raw, false) + messages := gjson.GetBytes(out, "messages").Array() + if len(messages) != 2 { + t.Fatalf("expected 2 messages (user + assistant text), got %d: %s", len(messages), string(out)) + } + if got := messages[1].Get("role").String(); got != "assistant" { + t.Fatalf("expected second message role = assistant, got %q", got) + } + if got := messages[1].Get("content").String(); got != "prefill text" { + t.Fatalf("expected assistant content = prefill text, got %q (raw: %s)", got, messages[1].Get("content").Raw) + } + }) + + t.Run("trailing_redacted_thinking_is_stripped", func(t *testing.T) { + raw := []byte(fmt.Sprintf(`{ + "model": "claude-haiku-4-5-20251001", + "input": [ + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "prefill text"}]}, + {"type": "reasoning", "encrypted_content": %q, "summary": []}, + {"type": "reasoning", "encrypted_content": %q, "summary": []} + ] + }`, rawSignature, ClaudeResponsesRedactedThinkingPrefix+"redacted-data")) + out := ConvertOpenAIResponsesRequestToClaude("claude-haiku-4-5-20251001", raw, false) + messages := gjson.GetBytes(out, "messages").Array() + if len(messages) != 2 { + t.Fatalf("expected 2 messages (user + assistant text), got %d: %s", len(messages), string(out)) + } + if got := messages[1].Get("content").String(); got != "prefill text" { + t.Fatalf("expected assistant content = prefill text, got %q (raw: %s)", got, messages[1].Get("content").Raw) + } + }) + + t.Run("only_reasoning_item_yields_fallback_user_message", func(t *testing.T) { + raw := []byte(fmt.Sprintf(`{ + "model": "claude-haiku-4-5-20251001", + "input": [ + {"type": "reasoning", "encrypted_content": %q, "summary": [{"type": "summary_text", "text": "thought"}]} + ] + }`, rawSignature)) + out := ConvertOpenAIResponsesRequestToClaude("claude-haiku-4-5-20251001", raw, false) + messages := gjson.GetBytes(out, "messages").Array() + if len(messages) != 1 { + t.Fatalf("expected 1 message (fallback user), got %d: %s", len(messages), string(out)) + } + if got := messages[0].Get("role").String(); got != "user" { + t.Fatalf("expected fallback role = user, got %q", got) + } + }) + + t.Run("compat_mode_preserves_trailing_thinking", func(t *testing.T) { + raw := []byte(fmt.Sprintf(`{ + "model": "claude-haiku-4-5-20251001", + "input": [ + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}, + {"type": "reasoning", "encrypted_content": %q, "summary": [{"type": "summary_text", "text": "thought"}]} + ] + }`, rawSignature)) + out := ConvertOpenAIResponsesRequestToClaudeWithCompat("claude-haiku-4-5-20251001", raw, false) + messages := gjson.GetBytes(out, "messages").Array() + if len(messages) != 2 { + t.Fatalf("expected 2 messages in compat mode, got %d: %s", len(messages), string(out)) + } + }) +}