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 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-08-23 23:55:13 +07:00
parent 5c7f33bd9c
commit 2ab7977c19
6 changed files with 132 additions and 41 deletions
+3 -26
View File
@@ -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
+3 -15
View File
@@ -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
+19
View File
@@ -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
}
+56
View File
@@ -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)
}
})
}
}
+23
View File
@@ -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)
}
+28
View File
@@ -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
}