From 8ee9add75daa4613535fafdefd221b66b310413c Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 27 Aug 2026 18:04:56 +0800 Subject: [PATCH] fix(claude): drop trailing assistant prefill for fable models - Drop unsupported trailing assistant prefill messages when converting requests for Claude Fable models. - Fall back to an empty user message turn when dropping assistant messages leaves the request empty. Closes: #5279 --- .../claude_openai-responses_request.go | 24 +++++- .../claude_openai-responses_request_test.go | 77 +++++++++++++++++++ 2 files changed, 98 insertions(+), 3 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 00515eae..db79e3a2 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -450,9 +450,13 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte }) } flushPendingMessage() - // Preserve a minimal conversational turn for system-only inputs so downstream - // validation still sees a Claude-shaped request. - if len(messageBlocks) == 0 && len(systemBlocks) > 0 { + hadMessages := len(messageBlocks) > 0 + if !preserveEmptyThinkingBlocks { + messageBlocks = dropUnsupportedFableAssistantPrefill(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. + if len(messageBlocks) == 0 && (len(systemBlocks) > 0 || hadMessages) { messageBlocks = append(messageBlocks, []byte(`{"role":"user","content":[{"type":"text","text":""}]}`)) } out = common.SetRawArrayItems(out, "messages", messageBlocks) @@ -551,6 +555,20 @@ 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 { + return messages + } + last := gjson.ParseBytes(messages[len(messages)-1]) + if !strings.EqualFold(strings.TrimSpace(last.Get("role").String()), "assistant") { + return messages + } + return messages[:len(messages)-1] +} + // 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 9799aa83..46191f05 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 @@ -1452,3 +1452,80 @@ func TestConvertOpenAIResponsesRequestToClaude_DifferentUserContentWithSameSyste t.Fatalf("different user questions with same system prompt produced identical metadata.user_id: %q", idA) } } + +func TestConvertOpenAIResponsesRequestToClaude_FableStripsTrailingAssistantPrefill(t *testing.T) { + raw := []byte(`{ + "model": "claude-fable-5", + "input": [ + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "progress update"}]} + ] + }`) + out := ConvertOpenAIResponsesRequestToClaude("claude-fable-5", raw, false) + messages := gjson.GetBytes(out, "messages").Array() + if len(messages) != 1 { + t.Fatalf("expected 1 message after stripping trailing assistant prefill, got %d: %s", 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) + } +} + +func TestConvertOpenAIResponsesRequestToClaude_FableOnlyAssistantMessageYieldsFallbackUser(t *testing.T) { + raw := []byte(`{ + "model": "claude-fable-5", + "input": [ + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "orphan progress"}]} + ] + }`) + out := ConvertOpenAIResponsesRequestToClaude("claude-fable-5", raw, false) + messages := gjson.GetBytes(out, "messages").Array() + if len(messages) != 1 { + t.Fatalf("expected 1 fallback user message, got %d: %s", len(messages), string(out)) + } + if got := messages[0].Get("role").String(); got != "user" { + t.Fatalf("expected role = user, got %q", got) + } +} + +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) + } + if got := messages[1].Get("content").String(); got != "prefill text" { + t.Fatalf("expected content = %q, got %q", "prefill text", got) + } +} + +func TestConvertOpenAIResponsesRequestToClaudeWithCompat_FablePreservesAssistantPrefill(t *testing.T) { + raw := []byte(`{ + "model": "claude-fable-5", + "input": [ + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "prefill text"}]} + ] + }`) + out := ConvertOpenAIResponsesRequestToClaudeWithCompat("claude-fable-5", 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)) + } + if got := messages[1].Get("role").String(); got != "assistant" { + t.Fatalf("expected second message role = assistant, got %q", got) + } +} -- 2.54.0