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 }