Skip to content

Commit 52f1f38

Browse files
authored
fix(conversation): send tool_choice to anthropic as obj not str- #4531 (#4543)
2 parents c29b207 + 45ff2dd commit 52f1f38

6 files changed

Lines changed: 288 additions & 7 deletions

File tree

conversation/anthropic/anthropic.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func NewAnthropic(logger logger.Logger) conversation.Conversation {
3838
logger: logger,
3939
LLM: langchaingokit.New(logger),
4040
}
41+
a.SetProvider(langchaingokit.ProviderAnthropic)
4142

4243
return a
4344
}

conversation/langchaingokit/model.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,18 @@ import (
2525
"github.com/dapr/components-contrib/conversation"
2626
)
2727

28+
// Provider identifies the upstream LLM API so that request options can be translated
29+
// into the wire format it expects. The zero value means no translation is applied.
30+
type Provider string
31+
32+
const ProviderAnthropic Provider = "anthropic"
33+
2834
// LLM is a helper struct that wraps a LangChain Go model
2935
type LLM struct {
3036
llms.Model
31-
model string
32-
logger logger.Logger
37+
model string
38+
provider Provider
39+
logger logger.Logger
3340
}
3441

3542
func New(logger logger.Logger) LLM {
@@ -38,8 +45,18 @@ func New(logger logger.Logger) LLM {
3845
}
3946
}
4047

48+
// SetProvider records which upstream API this model talks to so that request
49+
// options needing a provider-specific wire format are translated correctly.
50+
func (a *LLM) SetProvider(provider Provider) {
51+
a.provider = provider
52+
}
53+
54+
func (a *LLM) GetProvider() Provider {
55+
return a.provider
56+
}
57+
4158
func (a *LLM) Converse(ctx context.Context, r *conversation.Request) (res *conversation.Response, err error) {
42-
opts := getOptionsFromRequest(r, a.logger)
59+
opts := getOptionsFromRequest(r, a.provider, a.logger)
4360

4461
var messages []llms.MessageContent
4562
if r.Message != nil {
@@ -58,7 +75,7 @@ func (a *LLM) Converse(ctx context.Context, r *conversation.Request) (res *conve
5875

5976
// If tools were provided but the LLM returned neither content nor tool calls
6077
// across any choice, treat it as a retriable error rather than silently succeeding.
61-
if r.ToolChoice != nil && *r.ToolChoice == "required" && r.Tools != nil && len(*r.Tools) > 0 {
78+
if r.ToolChoice != nil && (*r.ToolChoice == "required" || *r.ToolChoice == "any") && r.Tools != nil && len(*r.Tools) > 0 {
6279
hasUsefulResponse := false
6380
for _, output := range outputs {
6481
for _, choice := range output.Choices {
@@ -134,7 +151,7 @@ func (a *LLM) NormalizeConverseResult(choices []*llms.ContentChoice) ([]conversa
134151
return outputs, usage, nil
135152
}
136153

137-
func getOptionsFromRequest(r *conversation.Request, logger logger.Logger, opts ...llms.CallOption) []llms.CallOption {
154+
func getOptionsFromRequest(r *conversation.Request, provider Provider, logger logger.Logger, opts ...llms.CallOption) []llms.CallOption {
138155
if opts == nil {
139156
opts = make([]llms.CallOption, 0)
140157
}
@@ -148,7 +165,10 @@ func getOptionsFromRequest(r *conversation.Request, logger logger.Logger, opts .
148165
}
149166

150167
if r.ToolChoice != nil {
151-
opts = append(opts, llms.WithToolChoice(r.ToolChoice))
168+
hasTools := r.Tools != nil && len(*r.Tools) > 0
169+
if toolChoice := translateToolChoice(*r.ToolChoice, provider, hasTools); toolChoice != nil {
170+
opts = append(opts, llms.WithToolChoice(toolChoice))
171+
}
152172
}
153173

154174
if r.ResponseFormatAsJSONSchema != nil {

conversation/langchaingokit/model_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,24 @@ func TestConverseEmptyResponseWithTools(t *testing.T) {
164164
wantErr: true,
165165
errSubstr: "LLM returned empty response with no tool calls",
166166
},
167+
{
168+
name: "empty content no tool calls with tools provided and tool_choice=any - returns error",
169+
choices: []*llms.ContentChoice{
170+
{Content: "", StopReason: "stop"},
171+
},
172+
tools: &tools,
173+
toolChoice: strPtr("any"),
174+
wantErr: true,
175+
errSubstr: "LLM returned empty response with no tool calls",
176+
},
177+
{
178+
name: "empty choices slice with tool_choice=any - returns error",
179+
choices: []*llms.ContentChoice{},
180+
tools: &tools,
181+
toolChoice: strPtr("any"),
182+
wantErr: true,
183+
errSubstr: "LLM returned empty response with no tool calls",
184+
},
167185
}
168186

169187
for _, tt := range tests {

conversation/langchaingokit/translate.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,62 @@ const (
3636
promptCachedKey = "PromptCachedTokens"
3737
)
3838

39+
// Provider-agnostic tool choice values accepted on conversation.Request.ToolChoice.
40+
const (
41+
toolChoiceAuto = "auto"
42+
toolChoiceRequired = "required"
43+
toolChoiceAny = "any"
44+
toolChoiceNone = "none"
45+
)
46+
47+
// Keys and type values of Anthropic's tool_choice object.
48+
// ref: https://docs.claude.com/en/api/messages
49+
const (
50+
anthropicToolChoiceTypeKey = "type"
51+
anthropicToolChoiceNameKey = "name"
52+
anthropicToolChoiceAny = "any"
53+
anthropicToolChoiceNone = "none"
54+
anthropicToolChoiceTool = "tool"
55+
)
56+
57+
// translateToolChoice converts the provider-agnostic tool choice into the shape the
58+
// provider's API expects, where a nil result means the option must be omitted.
59+
// Anthropic requires an object and rejects the bare string that OpenAI accepts.
60+
// The result depends only on its arguments: Anthropic invalidates cached message blocks
61+
// whenever tool_choice changes between turns, so a value must not be omitted on one turn
62+
// and sent on the next.
63+
// Caveat: with manual extended thinking enabled, Anthropic accepts only the auto and none
64+
// forms and 400s on the forced any and tool forms. This package never enables extended
65+
// thinking, as it does not pass llms.WithReasoning, so that combination is unreachable.
66+
func translateToolChoice(toolChoice string, provider Provider, hasTools bool) any {
67+
if provider != ProviderAnthropic {
68+
if toolChoice == toolChoiceAny {
69+
return toolChoiceRequired
70+
}
71+
return toolChoice
72+
}
73+
74+
// Anthropic rejects any tool_choice when the request carries no tools.
75+
if !hasTools {
76+
return nil
77+
}
78+
79+
switch toolChoice {
80+
case toolChoiceAuto:
81+
// Anthropic already defaults to {"type": "auto"} when tools are present.
82+
return nil
83+
case toolChoiceRequired, toolChoiceAny:
84+
return map[string]any{anthropicToolChoiceTypeKey: anthropicToolChoiceAny}
85+
case toolChoiceNone:
86+
return map[string]any{anthropicToolChoiceTypeKey: anthropicToolChoiceNone}
87+
default:
88+
return map[string]any{
89+
anthropicToolChoiceTypeKey: anthropicToolChoiceTool,
90+
anthropicToolChoiceNameKey: toolChoice,
91+
}
92+
}
93+
}
94+
3995
// extractUint64FromGenInfo extracts a uint64 value from genInfo map to extract usage data from langchaingo's GenerationInfo map in the choices response.
4096
func extractUint64FromGenInfo(genInfo map[string]any, key string) (uint64, error) {
4197
if v, ok := genInfo[key]; ok {

conversation/langchaingokit/translate_test.go

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,10 +279,103 @@ func TestExtractUsageFromLangchainGenerationInfo(t *testing.T) {
279279
}
280280
}
281281

282+
// resolveCallOptions applies the built options so assertions can inspect the resolved values.
283+
func resolveCallOptions(opts []llms.CallOption) llms.CallOptions {
284+
var resolved llms.CallOptions
285+
for _, opt := range opts {
286+
opt(&resolved)
287+
}
288+
return resolved
289+
}
290+
291+
func TestTranslateToolChoice(t *testing.T) {
292+
tests := map[string]struct {
293+
toolChoice string
294+
provider Provider
295+
hasTools bool
296+
expected any
297+
}{
298+
"non-anthropic provider passes the bare string through": {
299+
toolChoice: "required",
300+
hasTools: true,
301+
expected: "required",
302+
},
303+
"non-anthropic provider passes a named tool through": {
304+
toolChoice: "get_weather",
305+
hasTools: true,
306+
expected: "get_weather",
307+
},
308+
"non-anthropic provider passes through without tools": {
309+
toolChoice: "required",
310+
expected: "required",
311+
},
312+
"anthropic auto is omitted": {
313+
toolChoice: "auto",
314+
provider: ProviderAnthropic,
315+
hasTools: true,
316+
expected: nil,
317+
},
318+
"anthropic required maps to any": {
319+
toolChoice: "required",
320+
provider: ProviderAnthropic,
321+
hasTools: true,
322+
expected: map[string]any{"type": "any"},
323+
},
324+
"anthropic any maps to any": {
325+
toolChoice: "any",
326+
provider: ProviderAnthropic,
327+
hasTools: true,
328+
expected: map[string]any{"type": "any"},
329+
},
330+
"anthropic none maps to none": {
331+
toolChoice: "none",
332+
provider: ProviderAnthropic,
333+
hasTools: true,
334+
expected: map[string]any{"type": "none"},
335+
},
336+
"anthropic named tool maps to tool": {
337+
toolChoice: "get_weather",
338+
provider: ProviderAnthropic,
339+
hasTools: true,
340+
expected: map[string]any{"type": "tool", "name": "get_weather"},
341+
},
342+
"anthropic omits required without tools": {
343+
toolChoice: "required",
344+
provider: ProviderAnthropic,
345+
expected: nil,
346+
},
347+
"anthropic omits a named tool without tools": {
348+
toolChoice: "get_weather",
349+
provider: ProviderAnthropic,
350+
expected: nil,
351+
},
352+
}
353+
354+
for name, tt := range tests {
355+
t.Run(name, func(t *testing.T) {
356+
assert.Equal(t, tt.expected, translateToolChoice(tt.toolChoice, tt.provider, tt.hasTools))
357+
})
358+
}
359+
}
360+
361+
// TestTranslateToolChoiceIsDeterministic guards the caching contract: Anthropic invalidates
362+
// cached message blocks when tool_choice changes, so repeated calls must not vary.
363+
func TestTranslateToolChoiceIsDeterministic(t *testing.T) {
364+
for _, toolChoice := range []string{"auto", "required", "any", "none", "get_weather"} {
365+
t.Run(toolChoice, func(t *testing.T) {
366+
first := translateToolChoice(toolChoice, ProviderAnthropic, true)
367+
for range 3 {
368+
assert.Equal(t, first, translateToolChoice(toolChoice, ProviderAnthropic, true))
369+
}
370+
})
371+
}
372+
}
373+
282374
func TestGetOptionsFromRequest(t *testing.T) {
283375
log := logger.NewLogger("test")
284376

285377
toolChoice := "auto"
378+
requiredToolChoice := "required"
286379
tools := []llms.Tool{
287380
{
288381
Type: "function",
@@ -296,6 +389,7 @@ func TestGetOptionsFromRequest(t *testing.T) {
296389

297390
tests := map[string]struct {
298391
request *conversation.Request
392+
provider Provider
299393
existingOpts []llms.CallOption
300394
validate func(t *testing.T, r *conversation.Request, opts []llms.CallOption)
301395
}{
@@ -346,6 +440,38 @@ func TestGetOptionsFromRequest(t *testing.T) {
346440
},
347441
validate: func(t *testing.T, r *conversation.Request, opts []llms.CallOption) {
348442
assert.Len(t, opts, 2)
443+
assert.Equal(t, "auto", resolveCallOptions(opts).ToolChoice)
444+
},
445+
},
446+
"anthropic auto tool choice omits the option": {
447+
request: &conversation.Request{
448+
Tools: &tools,
449+
ToolChoice: &toolChoice,
450+
},
451+
provider: ProviderAnthropic,
452+
validate: func(t *testing.T, r *conversation.Request, opts []llms.CallOption) {
453+
assert.Len(t, opts, 1)
454+
assert.Nil(t, resolveCallOptions(opts).ToolChoice)
455+
},
456+
},
457+
"anthropic required tool choice sets the object form": {
458+
request: &conversation.Request{
459+
Tools: &tools,
460+
ToolChoice: &requiredToolChoice,
461+
},
462+
provider: ProviderAnthropic,
463+
validate: func(t *testing.T, r *conversation.Request, opts []llms.CallOption) {
464+
assert.Len(t, opts, 2)
465+
assert.Equal(t, map[string]any{"type": "any"}, resolveCallOptions(opts).ToolChoice)
466+
},
467+
},
468+
"anthropic tool choice without tools omits the option": {
469+
request: &conversation.Request{
470+
ToolChoice: &requiredToolChoice,
471+
},
472+
provider: ProviderAnthropic,
473+
validate: func(t *testing.T, r *conversation.Request, opts []llms.CallOption) {
474+
assert.Empty(t, opts)
349475
},
350476
},
351477
"metadata sets option": {
@@ -382,7 +508,7 @@ func TestGetOptionsFromRequest(t *testing.T) {
382508
for name, tt := range tests {
383509
t.Run(name, func(t *testing.T) {
384510
assert.NotPanics(t, func() {
385-
opts := getOptionsFromRequest(tt.request, log, tt.existingOpts...)
511+
opts := getOptionsFromRequest(tt.request, tt.provider, log, tt.existingOpts...)
386512
tt.validate(t, tt.request, opts)
387513
})
388514
})

tests/conformance/conversation/conversation.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,66 @@ func ConformanceTests(t *testing.T, props map[string]string, conv conversation.C
407407
}
408408
})
409409

410+
t.Run("test tool calling with an explicit tool choice", func(t *testing.T) {
411+
tools := []llms.Tool{
412+
{
413+
Type: "function",
414+
Function: &llms.FunctionDefinition{
415+
Name: "get_project_name",
416+
Description: "Get the name of an open source project",
417+
Parameters: map[string]any{
418+
"type": "object",
419+
"properties": map[string]any{
420+
"repo_link": map[string]any{
421+
"type": "string",
422+
"description": "The repository link",
423+
},
424+
},
425+
"required": []string{"repo_link"},
426+
},
427+
},
428+
},
429+
}
430+
431+
toolChoices := []string{"auto", "required"}
432+
// A named tool must be sent as an object to OpenAI too, which this layer
433+
// does not translate yet, so only Anthropic exercises that form for now.
434+
if component == "anthropic" {
435+
toolChoices = append(toolChoices, "get_project_name")
436+
}
437+
438+
for _, toolChoice := range toolChoices {
439+
t.Run(toolChoice, func(t *testing.T) {
440+
ctx, cancel := context.WithTimeout(t.Context(), 25*time.Second)
441+
defer cancel()
442+
443+
messages := []llms.MessageContent{
444+
{
445+
Role: llms.ChatMessageTypeHuman,
446+
Parts: []llms.ContentPart{
447+
llms.TextContent{Text: "What is this open source project called?"},
448+
},
449+
},
450+
}
451+
452+
req := &conversation.Request{
453+
Message: &messages,
454+
Tools: &tools,
455+
ToolChoice: &toolChoice,
456+
}
457+
if component == "openai" {
458+
req.Temperature = 1
459+
}
460+
461+
// Anthropic rejects a bare string tool_choice with a 400, so the
462+
// request succeeding at all is what this guards against.
463+
resp, err := conv.Converse(ctx, req)
464+
require.NoError(t, err)
465+
require.NotEmpty(t, resp.Outputs)
466+
})
467+
}
468+
})
469+
410470
t.Run("test conversation history with tool calls", func(t *testing.T) {
411471
ctx, cancel := context.WithTimeout(t.Context(), 25*time.Second)
412472
defer cancel()

0 commit comments

Comments
 (0)