github.com/anthropics/anthropic-sdk-go@v1.70.1/lib/betafallback/runner_test.go (about)

     1  package betafallback_test
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"os"
     8  	"path/filepath"
     9  	"testing"
    10  
    11  	"github.com/anthropics/anthropic-sdk-go"
    12  	"github.com/anthropics/anthropic-sdk-go/lib/betafallback"
    13  	"github.com/anthropics/anthropic-sdk-go/toolrunner"
    14  	"github.com/stretchr/testify/assert"
    15  	"github.com/stretchr/testify/require"
    16  )
    17  
    18  // Composition tests: the tool runner drives the conversation loop while the
    19  // fallback middleware handles refusals underneath it. The runner must never
    20  // see a refusal — only the served (possibly spliced) message — and the turns
    21  // it echoes back must be valid on the wire.
    22  
    23  type echoInput struct {
    24  	Text string `json:"text" jsonschema:"required,description=Text to echo"`
    25  }
    26  
    27  func echoTool(t *testing.T) anthropic.BetaTool {
    28  	tool, err := toolrunner.NewBetaToolFromJSONSchema("echo", "Echoes text",
    29  		func(ctx context.Context, in echoInput) (anthropic.BetaToolResultBlockParamContentUnion, error) {
    30  			return anthropic.BetaToolResultBlockParamContentUnion{
    31  				OfText: &anthropic.BetaTextBlockParam{Text: in.Text},
    32  			}, nil
    33  		})
    34  	require.NoError(t, err)
    35  	return tool
    36  }
    37  
    38  func recordingEchoTool(t *testing.T, calls *[]string) anthropic.BetaTool {
    39  	tool, err := toolrunner.NewBetaToolFromJSONSchema("echo", "Echoes text",
    40  		func(ctx context.Context, in echoInput) (anthropic.BetaToolResultBlockParamContentUnion, error) {
    41  			*calls = append(*calls, in.Text)
    42  			return anthropic.BetaToolResultBlockParamContentUnion{
    43  				OfText: &anthropic.BetaTextBlockParam{Text: in.Text},
    44  			}, nil
    45  		})
    46  	require.NoError(t, err)
    47  	return tool
    48  }
    49  
    50  // splicedResponse is a message as the middleware's stream splice produces it:
    51  // the refused model's blocks, a fallback boundary, then the serving model's.
    52  func splicedResponse(blocks string, stopReason string) string {
    53  	return fmt.Sprintf(`{
    54  		"id": "msg_s", "type": "message", "role": "assistant", "model": "fallback-model",
    55  		"content": [%s], "stop_reason": %q, "stop_sequence": null,
    56  		"usage": {"input_tokens": 1, "output_tokens": 1}
    57  	}`, blocks, stopReason)
    58  }
    59  
    60  func toolUseResponse(model string) string {
    61  	return fmt.Sprintf(`{
    62  		"id": "msg_t", "type": "message", "role": "assistant", "model": %q,
    63  		"content": [{"type": "tool_use", "id": "toolu_1", "name": "echo", "input": {"text": "hi"}}],
    64  		"stop_reason": "tool_use", "stop_sequence": null,
    65  		"usage": {"input_tokens": 1, "output_tokens": 1}
    66  	}`, model)
    67  }
    68  
    69  func fixtureSSE(t *testing.T, name string) string {
    70  	t.Helper()
    71  	buf, err := os.ReadFile(filepath.Join("testdata", "fallbackstream", name))
    72  	require.NoError(t, err)
    73  	return string(buf)
    74  }
    75  
    76  func TestToolRunnerWithFallbackMiddlewareMidLoopRefusal(t *testing.T) {
    77  	// The runner drives the loop; the middleware handles a refusal on the
    78  	// tool-result turn invisibly, and the pin carries on the shared state.
    79  	client, transport := fallbackTestClient(t,
    80  		[]string{
    81  			toolUseResponse("primary-model"),
    82  			refusalResponse("primary-model", "credit-token"),
    83  			messageResponse("fallback-model"),
    84  		},
    85  		betafallback.BetaRefusalFallbackMiddleware(
    86  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
    87  		),
    88  	)
    89  
    90  	state := &betafallback.BetaFallbackState{}
    91  	runner := client.Beta.Messages.NewToolRunner(
    92  		[]anthropic.BetaTool{echoTool(t)},
    93  		anthropic.BetaToolRunnerParams{BetaMessageNewParams: fallbackTestParams},
    94  		betafallback.WithBetaFallbackState(state),
    95  	)
    96  	final, err := runner.RunToCompletion(context.Background())
    97  	require.NoError(t, err)
    98  	require.NotNil(t, final)
    99  	assert.Equal(t, anthropic.Model("fallback-model"), final.Model)
   100  
   101  	// Three wire requests: opening turn, tool-result turn (refused), retry.
   102  	require.Len(t, transport.bodies, 3)
   103  	refusedTurn, _ := json.Marshal(transport.bodies[1]["messages"])
   104  	retryTurn, _ := json.Marshal(transport.bodies[2]["messages"])
   105  	assert.JSONEq(t, string(refusedTurn), string(retryTurn), "the retry resends the refused turn's messages — tool results included")
   106  	assert.Equal(t, creditTokenBody("credit-token"), transport.bodies[2]["fallback_credit_token"], "the retry redeems the refusal's token")
   107  	assert.Equal(t, "fallback-model", transport.bodies[2]["model"])
   108  	assert.Equal(t, 0, state.Index(), "the pin carries across runner turns")
   109  }
   110  
   111  func TestStreamingToolRunnerEchoesTheSplicedTurn(t *testing.T) {
   112  	// A spliced turn (refused partial + boundary + fallback's tool_use) is
   113  	// echoed by the runner's next request and survives the wire intact.
   114  	transport := &sseTransport{responses: []string{
   115  		fixtureSSE(t, "refusal.sse"),
   116  		fixtureSSE(t, "fallback-tooluse.sse"),
   117  		fixtureSSE(t, "fallback-end.sse"),
   118  	}}
   119  	client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}})
   120  
   121  	runner := client.Beta.Messages.NewToolRunnerStreaming(
   122  		[]anthropic.BetaTool{echoTool(t)},
   123  		anthropic.BetaToolRunnerParams{BetaMessageNewParams: fallbackTestParams},
   124  	)
   125  	for events, err := range runner.AllStreaming(context.Background()) {
   126  		require.NoError(t, err)
   127  		for _, eventErr := range events {
   128  			require.NoError(t, eventErr)
   129  		}
   130  	}
   131  	require.NoError(t, runner.Err())
   132  
   133  	// Three wire requests: refused opening turn, the middleware's hop, the
   134  	// runner's tool-result turn echoing the spliced assistant message.
   135  	require.Len(t, transport.bodies, 3)
   136  	assert.Equal(t, creditTokenBody("credit-token-a"), transport.bodies[1]["fallback_credit_token"], "the hop redeems the token")
   137  	echoed, err := json.Marshal(transport.bodies[2]["messages"])
   138  	require.NoError(t, err)
   139  	echo := string(echoed)
   140  	// The echoed history is trimmed: the fallback block and the refused
   141  	// model's thinking are removed; the tool call that got a result stays.
   142  	assert.NotContains(t, echo, `"type":"fallback"`, "the fallback block is removed from the echo")
   143  	assert.NotContains(t, echo, `"thinking":"Simple educational question. Benign."`, "the refused model's thinking is removed from the echo")
   144  	assert.Contains(t, echo, `"type":"tool_use"`, "the fallback's tool call is echoed")
   145  	assert.Contains(t, echo, `"type":"tool_result"`)
   146  }
   147  
   148  func TestNextTurnEchoStripsARefusalCutToolUse(t *testing.T) {
   149  	// A refusal that fires after a completed tool_use leaves it orphaned in
   150  	// the spliced turn — stop_reason is not tool_use, so no result follows.
   151  	// ToParam keeps the orphan, but echoing the seam-bearing turn back must
   152  	// strip it before the wire (conformance: multiturn-echo-orphan-tooluse).
   153  	cut := event("message_start", `{"type":"message_start","message":{"type":"message","id":"msg_primary","role":"assistant","content":[],"model":"primary-model","stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":9,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}`) +
   154  		event("content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_orphan","name":"echo","input":{"text":"hi"}}}`) +
   155  		event("content_block_stop", `{"type":"content_block_stop","index":0}`) +
   156  		event("message_delta", fmt.Sprintf(`{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null,"stop_details":{"type":"refusal","category":null,"explanation":null,%s}},"usage":{"input_tokens":9,"output_tokens":12,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}`, tokenNoClaim)) +
   157  		event("message_stop", `{"type":"message_stop"}`)
   158  	transport := &sseTransport{responses: []string{
   159  		cut, servedStream("fallback-model"), servedStream("fallback-model"),
   160  	}}
   161  	client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}})
   162  
   163  	msg, _, _ := collectStream(t, client, context.Background(), fallbackTestParams)
   164  	assert.Equal(t, anthropic.BetaStopReasonEndTurn, msg.StopReason)
   165  	spliced, err := json.Marshal(msg.ToParam())
   166  	require.NoError(t, err)
   167  	assert.Contains(t, string(spliced), `"id":"toolu_orphan"`, "the spliced turn itself still carries the cut tool_use")
   168  
   169  	// Echo the turn into the next request: the orphan must not reach the wire.
   170  	params := fallbackTestParams
   171  	params.Messages = append(append([]anthropic.BetaMessageParam{}, params.Messages...),
   172  		msg.ToParam(), anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("continue")))
   173  	_, _, _ = collectStream(t, client, context.Background(), params)
   174  
   175  	require.Len(t, transport.bodies, 3)
   176  	next, err := json.Marshal(transport.bodies[2]["messages"])
   177  	require.NoError(t, err)
   178  	assert.NotContains(t, string(next), `"type":"fallback"`, "the fallback block is removed from the echo")
   179  	assert.NotContains(t, string(next), "toolu_orphan", "the orphaned tool_use is stripped from the echoed turn")
   180  }
   181  
   182  func TestToolRunnerSkipsTheRefusedAttemptsPreSeamToolUse(t *testing.T) {
   183  	// A refusal cut after a complete tool_use leaves it before the seam in
   184  	// the spliced turn. The runner must not execute it: the middleware
   185  	// strips pre-seam tool calls from the echoed history, so answering one
   186  	// would put an orphaned tool_result on the wire.
   187  	client, transport := fallbackTestClient(t,
   188  		[]string{
   189  			splicedResponse(`
   190  				{"type": "tool_use", "id": "toolu_orphan", "name": "echo", "input": {"text": "pre"}},
   191  				{"type": "fallback", "from": {"model": "primary-model"}, "to": {"model": "fallback-model"}},
   192  				{"type": "text", "text": "done"}`, "end_turn"),
   193  			messageResponse("fallback-model"),
   194  		},
   195  		betafallback.BetaRefusalFallbackMiddleware(
   196  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   197  		),
   198  	)
   199  
   200  	var calls []string
   201  	runner := client.Beta.Messages.NewToolRunner(
   202  		[]anthropic.BetaTool{recordingEchoTool(t, &calls)},
   203  		anthropic.BetaToolRunnerParams{BetaMessageNewParams: fallbackTestParams},
   204  	)
   205  	final, err := runner.RunToCompletion(context.Background())
   206  	require.NoError(t, err)
   207  	require.NotNil(t, final)
   208  
   209  	assert.Empty(t, calls, "the pre-seam tool call is not executed")
   210  	assert.Len(t, transport.bodies, 1, "no tool-result turn follows a turn whose only tool calls are pre-seam")
   211  }
   212  
   213  func TestToolRunnerAnswersThePostSeamToolUse(t *testing.T) {
   214  	// Control: the serving model's own tool call — after the seam — is
   215  	// executed and answered as usual; the pre-seam orphan never reaches
   216  	// the wire in any form.
   217  	client, transport := fallbackTestClient(t,
   218  		[]string{
   219  			splicedResponse(`
   220  				{"type": "tool_use", "id": "toolu_orphan", "name": "echo", "input": {"text": "pre"}},
   221  				{"type": "fallback", "from": {"model": "primary-model"}, "to": {"model": "fallback-model"}},
   222  				{"type": "tool_use", "id": "toolu_live", "name": "echo", "input": {"text": "post"}}`, "tool_use"),
   223  			messageResponse("fallback-model"),
   224  		},
   225  		betafallback.BetaRefusalFallbackMiddleware(
   226  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   227  		),
   228  	)
   229  
   230  	var calls []string
   231  	runner := client.Beta.Messages.NewToolRunner(
   232  		[]anthropic.BetaTool{recordingEchoTool(t, &calls)},
   233  		anthropic.BetaToolRunnerParams{BetaMessageNewParams: fallbackTestParams},
   234  	)
   235  	_, err := runner.RunToCompletion(context.Background())
   236  	require.NoError(t, err)
   237  
   238  	assert.Equal(t, []string{"post"}, calls, "only the post-seam tool call executes")
   239  	require.Len(t, transport.bodies, 2)
   240  	echoed, err := json.Marshal(transport.bodies[1]["messages"])
   241  	require.NoError(t, err)
   242  	echo := string(echoed)
   243  	assert.Contains(t, echo, `"tool_use_id":"toolu_live"`, "the post-seam tool call is answered")
   244  	assert.NotContains(t, echo, "toolu_orphan", "nothing in the next turn references the pre-seam tool call")
   245  }
   246  
   247  // refusalToolUseResponse is a refusal that fired after the model emitted a
   248  // complete tool_use — e.g. the last hop of an exhausted fallback chain.
   249  func refusalToolUseResponse(model string) string {
   250  	return fmt.Sprintf(`{
   251  		"id": "msg_r", "type": "message", "role": "assistant", "model": %q,
   252  		"content": [{"type": "tool_use", "id": "toolu_dead", "name": "echo", "input": {"text": "dead"}}],
   253  		"stop_reason": "refusal", "stop_sequence": null,
   254  		"stop_details": {"type": "refusal", "category": null, "explanation": null,
   255  			"fallback_credit_token": "credit-token", "recommended_model": null},
   256  		"usage": {"input_tokens": 1, "output_tokens": 1}
   257  	}`, model)
   258  }
   259  
   260  func TestToolRunnerEndsTheLoopOnARefusalTerminatedTurn(t *testing.T) {
   261  	// An exhausted fallback chain surfaces the last refusal as the final
   262  	// message. Its tool calls belong to a dead conversation: the runner must
   263  	// not execute them, and the loop ends with the refusal as the final
   264  	// message instead of sending another turn.
   265  	client, transport := fallbackTestClient(t,
   266  		[]string{
   267  			refusalToolUseResponse("primary-model"),
   268  			refusalToolUseResponse("fallback-model"),
   269  		},
   270  		betafallback.BetaRefusalFallbackMiddleware(
   271  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   272  		),
   273  	)
   274  
   275  	var calls []string
   276  	runner := client.Beta.Messages.NewToolRunner(
   277  		[]anthropic.BetaTool{recordingEchoTool(t, &calls)},
   278  		anthropic.BetaToolRunnerParams{BetaMessageNewParams: fallbackTestParams},
   279  	)
   280  	final, err := runner.RunToCompletion(context.Background())
   281  	require.NoError(t, err)
   282  	require.NotNil(t, final)
   283  
   284  	assert.Equal(t, anthropic.BetaStopReasonRefusal, final.StopReason, "the refusal is the final message")
   285  	assert.Empty(t, calls, "tool calls on a refusal-terminated turn are not executed")
   286  	assert.Len(t, transport.bodies, 2, "no wire request follows the surfaced refusal (both bodies are the middleware's own hops)")
   287  }