mirror of
https://github.com/vxcontrol/langchaingo.git
synced 2026-08-24 11:53:01 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user