From 6f25b9a1495f8d2862a1c62e1bb5e36a6eb270cd Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 29 Aug 2026 04:58:59 +0800 Subject: [PATCH] fix(claude): drop unsupported assistant prefill for opus-5 and sonnet-4-6 - Strip trailing assistant prefill messages for Opus 5 and Sonnet 4.6 model families in addition to Fable models during OpenAI responses translation. Closes: #5319 --- .../claude_openai-responses_request.go | 24 ++++-- .../claude_openai-responses_request_test.go | 74 ++++++++++++++----- 2 files changed, 74 insertions(+), 24 deletions(-) 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 1d00479f..138b6b9e 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -476,7 +476,7 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte flushPendingMessage() hadMessages := len(messageBlocks) > 0 if !preserveEmptyThinkingBlocks { - messageBlocks = dropUnsupportedFableAssistantPrefill(modelName, messageBlocks) + messageBlocks = dropUnsupportedClaudeAssistantPrefill(modelName, messageBlocks) } // Preserve a minimal conversational turn for system-only inputs or when messages became empty // so downstream validation still sees a Claude-shaped request. @@ -579,11 +579,11 @@ func isResponsesSystemLevelRole(role string) bool { } } -// dropUnsupportedFableAssistantPrefill removes a trailing assistant message for -// Claude Fable models, which reject assistant message prefill. -func dropUnsupportedFableAssistantPrefill(modelName string, messages [][]byte) [][]byte { - normalized := strings.ToLower(strings.TrimSpace(modelName)) - if !strings.Contains(normalized, "fable") || len(messages) == 0 { +// dropUnsupportedClaudeAssistantPrefill removes a trailing assistant message for +// Claude model families that reject assistant message prefill (e.g., Fable, +// Opus 5, Sonnet 4.6). +func dropUnsupportedClaudeAssistantPrefill(modelName string, messages [][]byte) [][]byte { + if !claudeModelRejectsAssistantPrefill(modelName) || len(messages) == 0 { return messages } last := gjson.ParseBytes(messages[len(messages)-1]) @@ -593,6 +593,18 @@ func dropUnsupportedFableAssistantPrefill(modelName string, messages [][]byte) [ return messages[:len(messages)-1] } +// claudeModelRejectsAssistantPrefill reports whether a Claude model family +// disallows trailing assistant prefill in its conversation history. +func claudeModelRejectsAssistantPrefill(modelName string) bool { + normalized := strings.ToLower(strings.TrimSpace(modelName)) + for _, family := range []string{"fable", "opus-5", "sonnet-4-6"} { + if strings.Contains(normalized, family) { + return true + } + } + return false +} + // responsesSystemUnsupportedBlock represents a system-level content part that // Claude cannot carry. Anthropic accepts text only in the top-level system field // ("system..type: Input should be 'text'") and text, tool_addition and 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 a0234b8a..7c923d3d 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 @@ -1026,7 +1026,7 @@ func TestConvertOpenAIResponsesRequestToClaude_SystemLevelInputsBecomeSeparateSy ] }` - result := ConvertOpenAIResponsesRequestToClaude("claude-opus-5", []byte(inputJSON), false) + result := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false) root := gjson.ParseBytes(result) system := root.Get("system").Array() @@ -1496,24 +1496,62 @@ func TestConvertOpenAIResponsesRequestToClaude_FableOnlyAssistantMessageYieldsFa } } -func TestConvertOpenAIResponsesRequestToClaude_NonFablePreservesAssistantPrefill(t *testing.T) { - raw := []byte(`{ - "model": "claude-sonnet-4-5", - "input": [ - {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}, - {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "prefill text"}]} - ] - }`) - out := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-5", raw, false) - messages := gjson.GetBytes(out, "messages").Array() - if len(messages) != 2 { - t.Fatalf("expected 2 messages preserving assistant prefill, 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) +func TestConvertOpenAIResponsesRequestToClaude_UnsupportedPrefillModelsStripTrailingAssistant(t *testing.T) { + unsupportedModels := []string{ + "claude-fable-5", + "claude-opus-5", + "claude-sonnet-4-6", + } + for _, model := range unsupportedModels { + t.Run(model, func(t *testing.T) { + raw := []byte(fmt.Sprintf(`{ + "model": %q, + "input": [ + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "progress update"}]} + ] + }`, model)) + out := ConvertOpenAIResponsesRequestToClaude(model, raw, false) + messages := gjson.GetBytes(out, "messages").Array() + if len(messages) != 1 { + t.Fatalf("expected 1 message after stripping trailing assistant prefill for model %s, got %d: %s", model, len(messages), string(out)) + } + if got := messages[0].Get("role").String(); got != "user" { + t.Fatalf("expected final message role = %q, got %q", "user", got) + } + if got := messages[0].Get("content").String(); got != "hello" { + t.Fatalf("expected content = %q, got %q", "hello", got) + } + }) } - if got := messages[1].Get("content").String(); got != "prefill text" { - t.Fatalf("expected content = %q, got %q", "prefill text", got) +} + +func TestConvertOpenAIResponsesRequestToClaude_SupportedPrefillModelsPreserveAssistantPrefill(t *testing.T) { + supportedModels := []string{ + "claude-sonnet-4-5", + "claude-haiku-4-5", + } + for _, model := range supportedModels { + t.Run(model, func(t *testing.T) { + raw := []byte(fmt.Sprintf(`{ + "model": %q, + "input": [ + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "prefill text"}]} + ] + }`, model)) + out := ConvertOpenAIResponsesRequestToClaude(model, raw, false) + messages := gjson.GetBytes(out, "messages").Array() + if len(messages) != 2 { + t.Fatalf("expected 2 messages preserving assistant prefill for model %s, got %d: %s", model, 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 content = %q, got %q", "prefill text", got) + } + }) } } -- 2.54.0