From 2ab7977c19e00bcac7aceed0da6cf278facd51db Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Sun, 23 Aug 2026 23:55:13 +0700 Subject: [PATCH] fix(bedrock): refuse the turns the primary Anthropic door already refuses Two conversations are rejected locally on the Anthropic door and were sent anyway on both Bedrock doors: one ending in an assistant turn on a model that does not take a prefill, and a forced tool choice combined with manual budget thinking. Each cost a round trip to come back as a documented 400. Both refusals now run once in the Bedrock entry point, ahead of the split between InvokeModel and Converse, so the two transports answer alike. The predicates are model-keyed, so a non-Claude id such as Amazon Nova passes through untouched. The two error types move to llms/reasoning, which both doors already import, and the anthropic package keeps its names as aliases so existing callers and their errors.As checks are unaffected. The prefill and forced-tool detectors move to llms beside the types they inspect, replacing the anthropic-private copies. Verified by mutation: removing the guard reddens all four rows. PAGI-132 Co-Authored-By: Claude Opus 5 --- llms/anthropic/anthropicllm.go | 29 ++--------------- llms/anthropic/errors.go | 18 ++-------- llms/bedrock/bedrockllm.go | 19 +++++++++++ llms/bedrock/turn_limits_test.go | 56 ++++++++++++++++++++++++++++++++ llms/reasoning/errors_claude.go | 23 +++++++++++++ llms/turn.go | 28 ++++++++++++++++ 6 files changed, 132 insertions(+), 41 deletions(-) create mode 100644 llms/bedrock/turn_limits_test.go create mode 100644 llms/reasoning/errors_claude.go create mode 100644 llms/turn.go diff --git a/llms/anthropic/anthropicllm.go b/llms/anthropic/anthropicllm.go index 6f32f419..65d0f11d 100644 --- a/llms/anthropic/anthropicllm.go +++ b/llms/anthropic/anthropicllm.go @@ -232,11 +232,11 @@ func generateMessagesContent(ctx context.Context, o *LLM, messages []llms.Messag } } - if thinking != nil && thinking.Type == "enabled" && forcesToolUse(opts.ToolChoice) { + if thinking != nil && thinking.Type == "enabled" && llms.ForcesToolUse(opts.ToolChoice) { return nil, &ErrForcedToolUseWithThinking{Model: model} } - if reasoning.ClaudeRejectsAssistantPrefill(model) && anthropicHasAssistantPrefill(messages) { + if reasoning.ClaudeRejectsAssistantPrefill(model) && llms.HasAssistantPrefill(messages) { return nil, &ErrAssistantPrefillUnsupported{Model: model} } @@ -811,22 +811,6 @@ func handleHumanMessage(msg llms.MessageContent) (anthropicclient.ChatMessage, e }, nil } -func forcesToolUse(choice any) bool { - forced := func(t string) bool { return t == "any" || t == "tool" } - switch c := choice.(type) { - case string: - return forced(c) - case llms.ToolChoice: - return forced(c.Type) - case *llms.ToolChoice: - return c != nil && forced(c.Type) - case map[string]any: - t, _ := c["type"].(string) - return forced(t) - } - return false -} - func handleAIMessage(msg llms.MessageContent) (anthropicclient.ChatMessage, error) { message := anthropicclient.ChatMessage{ Role: RoleAssistant, @@ -959,7 +943,7 @@ func applyAnthropicStructuredOutput( Reason: "model predates the output_config.format JSON Schema mode", } } - if anthropicHasAssistantPrefill(messages) { + if llms.HasAssistantPrefill(messages) { return nil, &llms.ErrStructuredOutputConflict{ Provider: providerAnthropic, Detail: "structured output is incompatible with assistant message prefilling", @@ -980,13 +964,6 @@ func applyAnthropicStructuredOutput( return cfg, nil } -func anthropicHasAssistantPrefill(messages []llms.MessageContent) bool { - if len(messages) == 0 { - return false - } - return messages[len(messages)-1].Role == llms.ChatMessageTypeAI -} - // validateAnthropicStructuredOutput validates the concatenation of all final text // blocks against the original schema, but only for a normal-final turn. A tool_use // or max_tokens/refusal turn is intermediate/aborted and is not validated. On a diff --git a/llms/anthropic/errors.go b/llms/anthropic/errors.go index d0206cd1..12a0c394 100644 --- a/llms/anthropic/errors.go +++ b/llms/anthropic/errors.go @@ -1,10 +1,10 @@ package anthropic import ( - "fmt" "strings" "github.com/vxcontrol/langchaingo/llms" + "github.com/vxcontrol/langchaingo/llms/reasoning" ) // errorMapping represents a mapping from error patterns to error codes. @@ -82,21 +82,9 @@ func MapError(err error) error { // ErrAssistantPrefillUnsupported reports that the model rejects a conversation // ending with an assistant turn. -type ErrAssistantPrefillUnsupported struct{ Model string } - -func (e *ErrAssistantPrefillUnsupported) Error() string { - return fmt.Sprintf( - "anthropic: model %q does not support assistant message prefill; the conversation must end with a user message", - e.Model) -} +type ErrAssistantPrefillUnsupported = reasoning.ErrAssistantPrefillUnsupported // ErrForcedToolUseWithThinking reports the documented gap that manual (budget) // thinking accepts only tool_choice "auto" or "none": forcing a tool with "any" // or a named tool is rejected. Adaptive thinking has no such limit. -type ErrForcedToolUseWithThinking struct{ Model string } - -func (e *ErrForcedToolUseWithThinking) Error() string { - return fmt.Sprintf( - "anthropic: model %q runs manual thinking, which rejects a forced tool choice; use tool_choice auto or none", - e.Model) -} +type ErrForcedToolUseWithThinking = reasoning.ErrForcedToolUseWithThinking diff --git a/llms/bedrock/bedrockllm.go b/llms/bedrock/bedrockllm.go index 6f54035b..f45ac3e0 100644 --- a/llms/bedrock/bedrockllm.go +++ b/llms/bedrock/bedrockllm.go @@ -11,6 +11,7 @@ import ( "github.com/vxcontrol/langchaingo/callbacks" "github.com/vxcontrol/langchaingo/llms" "github.com/vxcontrol/langchaingo/llms/bedrock/internal/bedrockclient" + "github.com/vxcontrol/langchaingo/llms/reasoning" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" @@ -108,6 +109,10 @@ func (l *LLM) GenerateContent(ctx context.Context, messages []llms.MessageConten return nil, err } + if err := checkAnthropicTurnLimits(&opts, messages); err != nil { + return nil, err + } + // Use Converse API if enabled if l.useConverseAPI { resp, err = l.generateContentWithConverseAPI(ctx, messages, opts) @@ -371,3 +376,17 @@ func (l *LLM) supportsCaching(modelID string) bool { } var _ llms.Model = (*LLM)(nil) + +func checkAnthropicTurnLimits(opts *llms.CallOptions, messages []llms.MessageContent) error { + model := opts.GetModel() + manualThinking := opts.Reasoning.ResolveMode() == llms.ReasoningOn && + !reasoning.ResolveClaudeAdaptive(model, opts.Reasoning.Adaptive) + if manualThinking && llms.ForcesToolUse(opts.ToolChoice) { + return &reasoning.ErrForcedToolUseWithThinking{Model: model} + } + + if reasoning.ClaudeRejectsAssistantPrefill(model) && llms.HasAssistantPrefill(messages) { + return &reasoning.ErrAssistantPrefillUnsupported{Model: model} + } + return nil +} diff --git a/llms/bedrock/turn_limits_test.go b/llms/bedrock/turn_limits_test.go new file mode 100644 index 00000000..a8d3c832 --- /dev/null +++ b/llms/bedrock/turn_limits_test.go @@ -0,0 +1,56 @@ +package bedrock_test + +import ( + "context" + "errors" + "testing" + + "github.com/vxcontrol/langchaingo/llms" + "github.com/vxcontrol/langchaingo/llms/bedrock" + "github.com/vxcontrol/langchaingo/llms/reasoning" +) + +func turnLimitMessages(last llms.ChatMessageType) []llms.MessageContent { + msgs := []llms.MessageContent{llms.TextParts(llms.ChatMessageTypeHuman, "hi")} + if last == llms.ChatMessageTypeAI { + msgs = append(msgs, llms.TextParts(llms.ChatMessageTypeAI, "half an ")) + } + return msgs +} + +func TestBedrockRefusesTheSameTurnsAsThePrimaryDoor(t *testing.T) { + t.Parallel() + + for _, converse := range []bool{false, true} { + name := "invoke-model" + opts := []bedrock.Option{} + if converse { + name = "converse" + opts = append(opts, bedrock.WithConverseAPI()) + } + + t.Run(name+"/assistant prefill is refused before the request", func(t *testing.T) { + t.Parallel() + llm := truncationLLMWithBody(t, `{}`, + append([]bedrock.Option{bedrock.WithModel("us.anthropic.claude-opus-4-6-v1:0")}, opts...)...) + _, err := llm.GenerateContent(context.Background(), turnLimitMessages(llms.ChatMessageTypeAI)) + var target *reasoning.ErrAssistantPrefillUnsupported + if !errors.As(err, &target) { + t.Errorf("want ErrAssistantPrefillUnsupported, got %v", err) + } + }) + + t.Run(name+"/a forced tool with manual thinking is refused", func(t *testing.T) { + t.Parallel() + llm := truncationLLMWithBody(t, `{}`, + append([]bedrock.Option{bedrock.WithModel("us.anthropic.claude-sonnet-4-5-v1:0")}, opts...)...) + _, err := llm.GenerateContent(context.Background(), turnLimitMessages(llms.ChatMessageTypeHuman), + llms.WithReasoning(llms.ReasoningMedium, 2048), + llms.WithToolChoice(llms.ToolChoice{Type: "any"})) + var target *reasoning.ErrForcedToolUseWithThinking + if !errors.As(err, &target) { + t.Errorf("want ErrForcedToolUseWithThinking, got %v", err) + } + }) + } +} diff --git a/llms/reasoning/errors_claude.go b/llms/reasoning/errors_claude.go new file mode 100644 index 00000000..04266290 --- /dev/null +++ b/llms/reasoning/errors_claude.go @@ -0,0 +1,23 @@ +package reasoning + +import "fmt" + +// ErrAssistantPrefillUnsupported reports that the model rejects a conversation +// ending with an assistant turn. +type ErrAssistantPrefillUnsupported struct{ Model string } + +func (e *ErrAssistantPrefillUnsupported) Error() string { + return fmt.Sprintf( + "model %q does not support assistant message prefill; the conversation must end with a user message", + e.Model) +} + +// ErrForcedToolUseWithThinking reports that a forced tool choice was combined +// with manual (budget) thinking. +type ErrForcedToolUseWithThinking struct{ Model string } + +func (e *ErrForcedToolUseWithThinking) Error() string { + return fmt.Sprintf( + "model %q runs manual thinking, which rejects a forced tool choice; use tool_choice auto or none", + e.Model) +} diff --git a/llms/turn.go b/llms/turn.go new file mode 100644 index 00000000..20d4358a --- /dev/null +++ b/llms/turn.go @@ -0,0 +1,28 @@ +package llms + +// ForcesToolUse reports whether a tool choice demands a tool call rather than +// leaving the decision to the model. +func ForcesToolUse(choice any) bool { + forced := func(t string) bool { return t == "any" || t == "tool" } + switch c := choice.(type) { + case string: + return forced(c) + case ToolChoice: + return forced(c.Type) + case *ToolChoice: + return c != nil && forced(c.Type) + case map[string]any: + t, _ := c["type"].(string) + return forced(t) + } + return false +} + +// HasAssistantPrefill reports whether the conversation ends with an assistant +// turn, which some models reject. +func HasAssistantPrefill(messages []MessageContent) bool { + if len(messages) == 0 { + return false + } + return messages[len(messages)-1].Role == ChatMessageTypeAI +}