github.com/anthropics/anthropic-sdk-go@v1.70.1/lib/betafallback/streaming_test.go (about) 1 package betafallback_test 2 3 import ( 4 "context" 5 "crypto/sha256" 6 "encoding/json" 7 "fmt" 8 "io" 9 "net/http" 10 "strings" 11 "testing" 12 13 "github.com/anthropics/anthropic-sdk-go" 14 "github.com/anthropics/anthropic-sdk-go/lib/betafallback" 15 "github.com/anthropics/anthropic-sdk-go/option" 16 "github.com/stretchr/testify/assert" 17 "github.com/stretchr/testify/require" 18 ) 19 20 // sseTransport returns canned responses in order — SSE by default — and 21 // records request bodies and beta headers. 22 type sseTransport struct { 23 t *testing.T 24 responses []string 25 statuses []int // parallel to responses; missing entries are 200 26 contentTypes []string // parallel to responses; missing entries are text/event-stream 27 bodies []map[string]any 28 betas [][]string 29 } 30 31 func (s *sseTransport) RoundTrip(req *http.Request) (*http.Response, error) { 32 buf, err := io.ReadAll(req.Body) 33 require.NoError(s.t, err) 34 var body map[string]any 35 require.NoError(s.t, json.Unmarshal(buf, &body)) 36 s.bodies = append(s.bodies, body) 37 s.betas = append(s.betas, req.Header.Values("anthropic-beta")) 38 require.NotEmpty(s.t, s.responses, "more requests than scripted responses") 39 next := s.responses[0] 40 s.responses = s.responses[1:] 41 status := http.StatusOK 42 if len(s.statuses) > 0 { 43 status = s.statuses[0] 44 s.statuses = s.statuses[1:] 45 } 46 contentType := "text/event-stream" 47 if len(s.contentTypes) > 0 { 48 contentType = s.contentTypes[0] 49 s.contentTypes = s.contentTypes[1:] 50 } 51 return &http.Response{ 52 StatusCode: status, 53 Header: http.Header{"Content-Type": []string{contentType}}, 54 Body: io.NopCloser(strings.NewReader(next)), 55 Request: req, 56 }, nil 57 } 58 59 func event(name, data string) string { 60 return fmt.Sprintf("event: %s\ndata: %s\n\n", name, data) 61 } 62 63 // refusalStream is a stream from model that refuses mid-text with the given 64 // stop_details fields. 65 func refusalStream(model, stopDetails string) string { 66 return event("message_start", fmt.Sprintf(`{"type":"message_start","message":{"type":"message","id":"msg_primary","role":"assistant","content":[],"model":%q,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}`, model)) + 67 event("content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`) + 68 event("ping", `{"type": "ping"}`) + 69 event("content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial "}}`) + 70 event("content_block_stop", `{"type":"content_block_stop","index":0}`) + 71 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":10,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}`, stopDetails)) + 72 event("message_stop", `{"type":"message_stop"}`) 73 } 74 75 // cutRefusalStream refuses with content block 0 still open. 76 func cutRefusalStream(model, stopDetails string) string { 77 full := refusalStream(model, stopDetails) 78 return strings.Replace(full, event("content_block_stop", `{"type":"content_block_stop","index":0}`), "", 1) 79 } 80 81 func servedStream(model string) string { 82 return event("message_start", fmt.Sprintf(`{"type":"message_start","message":{"type":"message","id":"msg_retry","role":"assistant","content":[],"model":%q,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":12,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}`, model)) + 83 event("content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`) + 84 event("content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"served"}}`) + 85 event("content_block_stop", `{"type":"content_block_stop","index":0}`) + 86 event("message_delta", `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"input_tokens":12,"output_tokens":3,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}`) + 87 event("message_stop", `{"type":"message_stop"}`) 88 } 89 90 const tokenWithClaim = `"fallback_credit_token":"credit-token-a","fallback_has_prefill_claim":true` 91 const tokenNoClaim = `"fallback_credit_token":"credit-token-a","fallback_has_prefill_claim":false` 92 const noToken = `"fallback_credit_token":null,"fallback_has_prefill_claim":null` 93 94 func streamingFallbackClient(t *testing.T, transport *sseTransport, fallbacks []anthropic.BetaFallbackParam) anthropic.Client { 95 transport.t = t 96 return anthropic.NewClient( 97 option.WithAPIKey("my-anthropic-api-key"), 98 option.WithHTTPClient(&http.Client{Transport: transport}), 99 option.WithMaxRetries(0), 100 option.WithMiddleware(betafallback.BetaRefusalFallbackMiddleware(fallbacks)), 101 ) 102 } 103 104 // collectStream drains a streaming call, returning the accumulated message, 105 // the (type, index) sequence, and each message_delta's raw JSON. 106 func collectStream(t *testing.T, client anthropic.Client, ctx context.Context, params anthropic.BetaMessageNewParams, opts ...option.RequestOption) (anthropic.BetaMessage, []string, []string) { 107 stream := client.Beta.Messages.NewStreaming(ctx, params, opts...) 108 defer stream.Close() 109 var msg anthropic.BetaMessage 110 var sequence []string 111 var deltas []string 112 for stream.Next() { 113 event := stream.Current() 114 require.NoError(t, msg.Accumulate(event)) 115 switch event.Type { 116 case "content_block_start", "content_block_delta", "content_block_stop": 117 sequence = append(sequence, fmt.Sprintf("%s:%d", event.Type, event.Index)) 118 default: 119 sequence = append(sequence, string(event.Type)) 120 } 121 if event.Type == "message_delta" { 122 deltas = append(deltas, event.RawJSON()) 123 } 124 } 125 require.NoError(t, stream.Err()) 126 return msg, sequence, deltas 127 } 128 129 func deltaIterations(t *testing.T, rawDelta string) []struct { 130 Type string `json:"type"` 131 Model string `json:"model"` 132 } { 133 var delta struct { 134 Usage struct { 135 Iterations []struct { 136 Type string `json:"type"` 137 Model string `json:"model"` 138 } `json:"iterations"` 139 } `json:"usage"` 140 } 141 require.NoError(t, json.Unmarshal([]byte(rawDelta), &delta)) 142 return delta.Usage.Iterations 143 } 144 145 func TestStreamingRefusalSplicesIntoOneMessage(t *testing.T) { 146 transport := &sseTransport{responses: []string{ 147 refusalStream("primary-model", tokenWithClaim), servedStream("fallback-model"), 148 }} 149 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 150 151 msg, sequence, deltas := collectStream(t, client, context.Background(), fallbackTestParams) 152 153 // One continuous message: A's block, the boundary, B's block reindexed. 154 assert.Equal(t, []string{ 155 "message_start", 156 "content_block_start:0", "content_block_delta:0", "content_block_stop:0", 157 "content_block_start:1", "content_block_stop:1", // fallback boundary 158 "content_block_start:2", "content_block_delta:2", "content_block_stop:2", 159 "message_delta", "message_stop", 160 }, sequence) 161 162 assert.Equal(t, anthropic.BetaStopReasonEndTurn, msg.StopReason) 163 require.Len(t, msg.Content, 3) 164 boundary := msg.Content[1].AsFallback() 165 assert.Equal(t, anthropic.Model("primary-model"), boundary.From.Model) 166 assert.Equal(t, anthropic.Model("fallback-model"), boundary.To.Model) 167 assert.Equal(t, anthropic.Model("fallback-model"), msg.Model, "the seam relabels the accumulated model") 168 169 // Terminal usage.iterations carries the whole chain. 170 require.Len(t, deltas, 1) 171 iterations := deltaIterations(t, deltas[0]) 172 require.Len(t, iterations, 2) 173 assert.Equal(t, "message", iterations[0].Type) 174 assert.Equal(t, "primary-model", iterations[0].Model) 175 assert.Equal(t, "fallback_message", iterations[1].Type) 176 assert.Equal(t, "fallback-model", iterations[1].Model) 177 var terminal map[string]json.RawMessage 178 require.NoError(t, json.Unmarshal([]byte(deltas[0]), &terminal)) 179 assert.NotContains(t, terminal, "input_transformations", "a hop start without a list forwards no key") 180 181 // The hop request: model swapped, token redeemed, the advertised claim 182 // echoed verbatim as one trailing assistant turn. 183 require.Len(t, transport.bodies, 2) 184 retry := transport.bodies[1] 185 assert.Equal(t, "fallback-model", retry["model"]) 186 assert.Equal(t, creditTokenBody("credit-token-a"), retry["fallback_credit_token"]) 187 assert.Equal(t, true, retry["stream"]) 188 messages := retry["messages"].([]any) 189 require.Len(t, messages, 2, "the claim rides as one appended turn") 190 claim := messages[1].(map[string]any) 191 assert.Equal(t, "assistant", claim["role"]) 192 assert.Equal(t, []any{map[string]any{"type": "text", "text": "partial "}}, claim["content"], "echoed verbatim, whitespace included") 193 for i, betas := range transport.betas { 194 assert.Contains(t, betas, string(anthropic.AnthropicBetaFallbackCredit2026_07_01), "request %d", i) 195 } 196 } 197 198 func TestStreamingClaimFalseRetriesTheExactBody(t *testing.T) { 199 transport := &sseTransport{responses: []string{ 200 refusalStream("primary-model", tokenNoClaim), servedStream("fallback-model"), 201 }} 202 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 203 204 _, _, _ = collectStream(t, client, context.Background(), fallbackTestParams) 205 require.Len(t, transport.bodies, 2) 206 retry := transport.bodies[1] 207 assert.Equal(t, creditTokenBody("credit-token-a"), retry["fallback_credit_token"]) 208 assert.Len(t, retry["messages"].([]any), 1, "no claim advertised, no turn appended") 209 } 210 211 func TestStreamingMidStreamTokenlessRefusalSurfacesUntouched(t *testing.T) { 212 // Content already streamed and no token minted: no retry, the refusal 213 // passes through as-is. 214 transport := &sseTransport{responses: []string{refusalStream("primary-model", noToken)}} 215 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 216 217 msg, sequence, _ := collectStream(t, client, context.Background(), fallbackTestParams) 218 assert.Equal(t, anthropic.BetaStopReasonRefusal, msg.StopReason) 219 require.Len(t, transport.bodies, 1) 220 assert.Contains(t, sequence, "message_stop") 221 for _, block := range msg.Content { 222 assert.NotEqual(t, "fallback", string(block.Type)) 223 } 224 } 225 226 func TestStreamingPreStreamTokenlessRefusalRetries(t *testing.T) { 227 // Nothing streamed yet, so the retry is free and invisible — token or no 228 // token. 229 preStream := 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":10,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}`) + 230 event("message_delta", `{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null,"stop_details":{"type":"refusal","category":"x","explanation":null,"fallback_credit_token":null}},"usage":{"input_tokens":10,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}`) + 231 event("message_stop", `{"type":"message_stop"}`) 232 transport := &sseTransport{responses: []string{preStream, servedStream("fallback-model")}} 233 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 234 235 msg, sequence, _ := collectStream(t, client, context.Background(), fallbackTestParams) 236 assert.Equal(t, anthropic.BetaStopReasonEndTurn, msg.StopReason) 237 require.Len(t, transport.bodies, 2) 238 _, hasToken := transport.bodies[1]["fallback_credit_token"] 239 assert.False(t, hasToken, "no token minted, none sent") 240 241 // The refused attempt is invisible: one message_start, the boundary 242 // queued ahead of the serving hop's content, the primary's message id. 243 assert.Equal(t, "message_start", sequence[0]) 244 require.Len(t, msg.Content, 2) 245 assert.Equal(t, anthropic.Model("primary-model"), msg.Content[0].AsFallback().From.Model) 246 assert.Equal(t, "msg_primary", msg.ID, "the envelope keeps the primary's message id") 247 } 248 249 func TestStreamingFailedHopSkipsToTheNextEntry(t *testing.T) { 250 // An HTTP-failed hop was never reached: same token and continuation move 251 // to the next entry, and no boundary is left behind for it. 252 transport := &sseTransport{ 253 responses: []string{refusalStream("primary-model", tokenWithClaim), `{}`, servedStream("fallback-2")}, 254 statuses: []int{200, 500, 200}, 255 } 256 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-1"}, {Model: "fallback-2"}}) 257 258 msg, _, _ := collectStream(t, client, context.Background(), fallbackTestParams) 259 assert.Equal(t, anthropic.BetaStopReasonEndTurn, msg.StopReason) 260 require.Len(t, transport.bodies, 3) 261 assert.Equal(t, creditTokenBody("credit-token-a"), transport.bodies[2]["fallback_credit_token"]) 262 assert.Len(t, transport.bodies[2]["messages"].([]any), 2, "the claim carries to the next entry") 263 264 var boundaries []string 265 for _, block := range msg.Content { 266 if fb := block.AsFallback(); fb.To.Model != "" { 267 boundaries = append(boundaries, string(fb.From.Model)+"->"+string(fb.To.Model)) 268 } 269 } 270 assert.Equal(t, []string{"primary-model->fallback-2"}, boundaries, "no boundary for the hop that never engaged") 271 } 272 273 func TestStreamingExhaustedChainDegradesToTheHeldRefusal(t *testing.T) { 274 transport := &sseTransport{ 275 responses: []string{refusalStream("primary-model", tokenNoClaim), `{}`, `{}`}, 276 statuses: []int{200, 500, 503}, 277 } 278 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-1"}, {Model: "fallback-2"}}) 279 280 msg, sequence, deltas := collectStream(t, client, context.Background(), fallbackTestParams) 281 assert.Equal(t, anthropic.BetaStopReasonRefusal, msg.StopReason) 282 assert.Contains(t, sequence, "message_stop") 283 for _, block := range msg.Content { 284 assert.NotEqual(t, "fallback", string(block.Type), "failed hops leave no boundary") 285 } 286 require.Len(t, deltas, 1) 287 iterations := deltaIterations(t, deltas[0]) 288 require.Len(t, iterations, 1, "the held refusal's ledger survives exhaustion") 289 assert.Equal(t, "primary-model", iterations[0].Model) 290 assert.Equal(t, "message", iterations[0].Type) 291 assert.False(t, msg.StopDetails.JSON.RecommendedModel.Valid() && msg.StopDetails.RecommendedModel != "", "non-429 failures recommend nothing") 292 } 293 294 func TestStreamingRateLimitedHopRecommendsTheUnreachableModel(t *testing.T) { 295 transport := &sseTransport{ 296 responses: []string{refusalStream("primary-model", tokenNoClaim), `{}`}, 297 statuses: []int{200, 429}, 298 } 299 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 300 301 msg, _, _ := collectStream(t, client, context.Background(), fallbackTestParams) 302 assert.Equal(t, anthropic.BetaStopReasonRefusal, msg.StopReason) 303 assert.Equal(t, anthropic.Model("fallback-model"), msg.StopDetails.RecommendedModel) 304 } 305 306 func TestStreaming400OnAClaimedAttemptRetriesTheLastEntryWithoutTheClaim(t *testing.T) { 307 transport := &sseTransport{ 308 responses: []string{refusalStream("primary-model", tokenWithClaim), `{}`, servedStream("fallback-model")}, 309 statuses: []int{200, 400, 200}, 310 } 311 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 312 313 msg, _, _ := collectStream(t, client, context.Background(), fallbackTestParams) 314 assert.Equal(t, anthropic.BetaStopReasonEndTurn, msg.StopReason) 315 require.Len(t, transport.bodies, 3) 316 assert.Len(t, transport.bodies[1]["messages"].([]any), 2, "first attempt carries the claim") 317 assert.Len(t, transport.bodies[2]["messages"].([]any), 1, "the degrade drops the claim turn") 318 assert.Equal(t, "fallback-model", transport.bodies[2]["model"], "same entry retried") 319 assert.Equal(t, creditTokenBody("credit-token-a"), transport.bodies[2]["fallback_credit_token"], "the token is kept") 320 } 321 322 func TestStreamingHopSuppliedIterationsRideThroughLabeled(t *testing.T) { 323 // A server-tool run reports several iterations entries; unlabeled ones 324 // can only be the hop's own attempts and ride through with its model. 325 supplied := `,"iterations":[{"input_tokens":10,"output_tokens":2,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"type":"message"},{"input_tokens":20,"output_tokens":3,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"type":"message"}]` 326 refusal := strings.Replace(refusalStream("primary-model", tokenNoClaim), 327 `"cache_creation_input_tokens":0}}`, `"cache_creation_input_tokens":0`+supplied+`}}`, 1) 328 transport := &sseTransport{responses: []string{refusal, servedStream("fallback-model")}} 329 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 330 331 _, _, deltas := collectStream(t, client, context.Background(), fallbackTestParams) 332 require.NotEmpty(t, deltas) 333 iterations := deltaIterations(t, deltas[len(deltas)-1]) 334 require.Len(t, iterations, 3) 335 assert.Equal(t, "primary-model", iterations[0].Model) 336 assert.Equal(t, "primary-model", iterations[1].Model) 337 assert.Equal(t, "fallback-model", iterations[2].Model) 338 assert.Equal(t, "fallback_message", iterations[2].Type) 339 } 340 341 func TestStreaming400OnATokenedAttemptRetriesTheLastEntryTokenless(t *testing.T) { 342 // Blanket retry chain: the 400 never surfaces; the token is dropped and 343 // the same entry retried bare. 344 transport := &sseTransport{ 345 responses: []string{refusalStream("primary-model", tokenNoClaim), `{}`, servedStream("fallback-model")}, 346 statuses: []int{200, 400, 200}, 347 } 348 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 349 350 msg, _, _ := collectStream(t, client, context.Background(), fallbackTestParams) 351 assert.Equal(t, anthropic.BetaStopReasonEndTurn, msg.StopReason) 352 require.Len(t, transport.bodies, 3) 353 assert.Equal(t, creditTokenBody("credit-token-a"), transport.bodies[1]["fallback_credit_token"], "first attempt redeems") 354 assert.Equal(t, "fallback-model", transport.bodies[2]["model"], "same entry retried") 355 _, hasToken := transport.bodies[2]["fallback_credit_token"] 356 assert.False(t, hasToken, "the tokenless retry drops the token") 357 } 358 359 func TestStreamingTrimsReplayedFallbackTurns(t *testing.T) { 360 // The refused model's thinking and unfinished tool call are removed 361 // along with the fallback block; the serving model's text stays. 362 transport := &sseTransport{responses: []string{servedStream("fallback-model")}} 363 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 364 365 params := fallbackTestParams 366 msg, _, _ := collectStream(t, client, context.Background(), params, 367 option.WithJSONSet("messages", []map[string]any{ 368 {"role": "user", "content": "hi"}, 369 {"role": "assistant", "content": []map[string]any{ 370 {"type": "thinking", "thinking": "hmm", "signature": "sig"}, 371 {"type": "text", "text": "partial"}, 372 {"type": "tool_use", "id": "tu_cut", "name": "search", "input": map[string]any{}}, 373 {"type": "fallback", "from": map[string]any{"model": "primary-model"}, "to": map[string]any{"model": "fallback-model"}}, 374 {"type": "text", "text": "served"}, 375 }}, 376 {"role": "user", "content": "more"}, 377 })) 378 assert.Equal(t, anthropic.BetaStopReasonEndTurn, msg.StopReason) 379 380 require.Len(t, transport.bodies, 1) 381 sent := transport.bodies[0]["messages"].([]any)[1].(map[string]any)["content"].([]any) 382 var types []string 383 for _, block := range sent { 384 types = append(types, block.(map[string]any)["type"].(string)) 385 } 386 assert.Equal(t, []string{"text", "text"}, types) 387 } 388 389 func TestStreamingMidBlockRefusalClosesTheOpenBlock(t *testing.T) { 390 transport := &sseTransport{responses: []string{ 391 cutRefusalStream("primary-model", tokenWithClaim), servedStream("fallback-model"), 392 }} 393 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 394 395 msg, sequence, _ := collectStream(t, client, context.Background(), fallbackTestParams) 396 assert.Equal(t, anthropic.BetaStopReasonEndTurn, msg.StopReason) 397 assert.Contains(t, sequence, "content_block_stop:0", "the cut-open block is closed before the boundary") 398 399 // The synthetic close does not damage the claim: the partial still rides. 400 require.Len(t, transport.bodies, 2) 401 messages := transport.bodies[1]["messages"].([]any) 402 require.Len(t, messages, 2) 403 } 404 405 func TestStreamingSplicedTerminalForwardsTheHopInputTransformations(t *testing.T) { 406 // A mid-stream fallback suppresses the serving hop's message_start, so its 407 // input_transformations ride on the re-emitted terminal message_delta — 408 // unless the delta already carries them. 409 const transforms = `[{"type":"thinking_dropped","path":"messages.1.content.0","reason":"model_binding_mismatch"}]` 410 withStartTransforms := strings.Replace(servedStream("fallback-model"), 411 `"stop_details":null,"usage"`, `"stop_details":null,"input_transformations":`+transforms+`,"usage"`, 1) 412 deltaTransforms := func(t *testing.T, rawDelta string) (json.RawMessage, bool) { 413 var delta map[string]json.RawMessage 414 require.NoError(t, json.Unmarshal([]byte(rawDelta), &delta)) 415 raw, ok := delta["input_transformations"] 416 return raw, ok 417 } 418 419 t.Run("forwarded from the suppressed start", func(t *testing.T) { 420 transport := &sseTransport{responses: []string{refusalStream("primary-model", tokenWithClaim), withStartTransforms}} 421 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 422 423 _, _, deltas := collectStream(t, client, context.Background(), fallbackTestParams) 424 require.Len(t, deltas, 1) 425 raw, ok := deltaTransforms(t, deltas[0]) 426 require.True(t, ok, "the terminal carries the serving hop's input_transformations") 427 assert.JSONEq(t, transforms, string(raw)) 428 }) 429 430 t.Run("delta's own list wins", func(t *testing.T) { 431 withDeltaTransforms := strings.Replace(withStartTransforms, 432 `"stop_details":null},"usage"`, `"stop_details":null},"input_transformations":[],"usage"`, 1) 433 transport := &sseTransport{responses: []string{refusalStream("primary-model", tokenWithClaim), withDeltaTransforms}} 434 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 435 436 _, _, deltas := collectStream(t, client, context.Background(), fallbackTestParams) 437 require.Len(t, deltas, 1) 438 raw, ok := deltaTransforms(t, deltas[0]) 439 require.True(t, ok) 440 assert.JSONEq(t, `[]`, string(raw), "an already-present list is not overwritten") 441 }) 442 443 t.Run("forwarded on the degraded chain's held refusal", func(t *testing.T) { 444 // The spliced hop refuses with a token and every later hop fails: its 445 // held refusal surfaces as the terminal, still carrying its list. 446 refusingWithStartTransforms := strings.Replace(refusalStream("fallback-1", tokenNoClaim), 447 `"stop_details":null,"usage"`, `"stop_details":null,"input_transformations":`+transforms+`,"usage"`, 1) 448 transport := &sseTransport{ 449 responses: []string{refusalStream("primary-model", tokenWithClaim), refusingWithStartTransforms, `{}`}, 450 statuses: []int{200, 200, 503}, 451 } 452 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-1"}, {Model: "fallback-2"}}) 453 454 msg, _, deltas := collectStream(t, client, context.Background(), fallbackTestParams) 455 assert.Equal(t, anthropic.BetaStopReasonRefusal, msg.StopReason) 456 require.Len(t, deltas, 1) 457 raw, ok := deltaTransforms(t, deltas[0]) 458 require.True(t, ok, "the degraded terminal carries the spliced hop's input_transformations") 459 assert.JSONEq(t, transforms, string(raw)) 460 }) 461 462 t.Run("delta's own list wins on the degraded chain's held refusal", func(t *testing.T) { 463 refusingWithBoth := strings.Replace(refusalStream("fallback-1", tokenNoClaim), 464 `"stop_details":null,"usage"`, `"stop_details":null,"input_transformations":`+transforms+`,"usage"`, 1) 465 refusingWithBoth = strings.Replace(refusingWithBoth, 466 tokenNoClaim+`}},"usage"`, tokenNoClaim+`}},"input_transformations":[],"usage"`, 1) 467 transport := &sseTransport{ 468 responses: []string{refusalStream("primary-model", tokenWithClaim), refusingWithBoth, `{}`}, 469 statuses: []int{200, 200, 503}, 470 } 471 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-1"}, {Model: "fallback-2"}}) 472 473 msg, _, deltas := collectStream(t, client, context.Background(), fallbackTestParams) 474 assert.Equal(t, anthropic.BetaStopReasonRefusal, msg.StopReason) 475 require.Len(t, deltas, 1) 476 raw, ok := deltaTransforms(t, deltas[0]) 477 require.True(t, ok) 478 assert.JSONEq(t, `[]`, string(raw), "the held refusal delta's own list is not overwritten") 479 }) 480 481 t.Run("emitted start keeps the delta untouched", func(t *testing.T) { 482 // A pre-stream refusal: the serving hop's message_start opens the wire 483 // itself, so nothing needs forwarding. 484 preStream := 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":10,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}`) + 485 event("message_delta", `{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null,"stop_details":{"type":"refusal","category":"x","explanation":null,"fallback_credit_token":null}},"usage":{"input_tokens":10,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}`) + 486 event("message_stop", `{"type":"message_stop"}`) 487 transport := &sseTransport{responses: []string{preStream, withStartTransforms}} 488 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 489 490 msg, _, deltas := collectStream(t, client, context.Background(), fallbackTestParams) 491 require.Len(t, deltas, 1) 492 _, ok := deltaTransforms(t, deltas[0]) 493 assert.False(t, ok) 494 require.Len(t, msg.InputTransformations, 1, "the emitted message_start carried them") 495 }) 496 } 497 498 func TestStreamingPinnedConversationStartsAtTheFallback(t *testing.T) { 499 transport := &sseTransport{responses: []string{servedStream("fallback-model")}} 500 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 501 502 state := &betafallback.BetaFallbackState{} 503 state.SetIndex(0) 504 msg, _, _ := collectStream(t, client, context.Background(), fallbackTestParams, 505 betafallback.WithBetaFallbackState(state)) 506 assert.Equal(t, anthropic.BetaStopReasonEndTurn, msg.StopReason) 507 require.Len(t, transport.bodies, 1) 508 assert.Equal(t, "fallback-model", transport.bodies[0]["model"]) 509 _, hasToken := transport.bodies[0]["fallback_credit_token"] 510 assert.False(t, hasToken, "a pinned fresh turn redeems no token") 511 } 512 513 func TestStreamingPinPastTheChainErrors(t *testing.T) { 514 transport := &sseTransport{responses: []string{servedStream("fallback-model")}} 515 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 516 517 stale := &betafallback.BetaFallbackState{} 518 stale.SetIndex(1) 519 stream := client.Beta.Messages.NewStreaming(context.Background(), fallbackTestParams, 520 betafallback.WithBetaFallbackState(stale)) 521 for stream.Next() { 522 } 523 require.ErrorContains(t, stream.Err(), "out of range") 524 } 525 526 func TestStreamingServerSideFallbacksError(t *testing.T) { 527 // Only one chain can adjudicate refusals; erroring beats silently 528 // picking one. 529 transport := &sseTransport{responses: []string{servedStream("server-fallback")}} 530 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 531 532 params := fallbackTestParams 533 params.Fallbacks = anthropic.BetaFallbacksParamUnion{ 534 OfBetaFallbackArray: []anthropic.BetaFallbackParam{{Model: "server-fallback"}}, 535 } 536 stream := client.Beta.Messages.NewStreaming(context.Background(), params) 537 for stream.Next() { 538 } 539 require.ErrorContains(t, stream.Err(), "Sending the `fallbacks:` request param is not supported when using the `BetaRefusalFallbackMiddleware` middleware. You should either remove the middleware and send `fallbacks:` with the `server-side-fallback-2026-07-01` beta header to let the API handle refusal fallbacks, or omit the `fallbacks:` param if you'd like the `BetaRefusalFallbackMiddleware` middleware to handle fallbacks on the client side.") 540 assert.Empty(t, transport.bodies, "no request reaches the server") 541 } 542 543 func TestStreamingEmptyChainIgnoresAPin(t *testing.T) { 544 transport := &sseTransport{responses: []string{servedStream("primary-model")}} 545 client := streamingFallbackClient(t, transport, nil) 546 547 state := &betafallback.BetaFallbackState{} 548 state.SetIndex(3) 549 msg, _, _ := collectStream(t, client, context.Background(), fallbackTestParams, 550 betafallback.WithBetaFallbackState(state)) 551 assert.Equal(t, anthropic.BetaStopReasonEndTurn, msg.StopReason) 552 require.Len(t, transport.bodies, 1) 553 assert.Equal(t, "primary-model", transport.bodies[0]["model"], "no pin to apply against an empty chain") 554 assert.Empty(t, transport.betas[0]) 555 } 556 557 func TestStreamingNonSSEHopIsAFailureNotTruncation(t *testing.T) { 558 transport := &sseTransport{ 559 responses: []string{refusalStream("primary-model", tokenNoClaim), `{"type": "message"}`}, 560 contentTypes: []string{"text/event-stream", "application/json"}, 561 } 562 client := streamingFallbackClient(t, transport, []anthropic.BetaFallbackParam{{Model: "fallback-model"}}) 563 564 msg, sequence, _ := collectStream(t, client, context.Background(), fallbackTestParams) 565 assert.Equal(t, anthropic.BetaStopReasonRefusal, msg.StopReason, "must terminate, not truncate") 566 assert.Contains(t, sequence, "message_stop") 567 } 568 569 func TestStreamingEncodedResponsePassesThroughUnread(t *testing.T) { 570 // A still-encoded body (caller-set Accept-Encoding) can't be spliced. 571 middleware := betafallback.BetaRefusalFallbackMiddleware( 572 []anthropic.BetaFallbackParam{{Model: "fallback-model"}}, 573 ) 574 req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true", 575 strings.NewReader(`{"model": "primary-model", "messages": [], "max_tokens": 16, "stream": true}`)) 576 require.NoError(t, err) 577 req.Header.Set("Content-Type", "application/json") 578 calls := 0 579 res, err := middleware(req, func(*http.Request) (*http.Response, error) { 580 calls++ 581 return &http.Response{ 582 StatusCode: http.StatusOK, 583 Header: http.Header{"Content-Type": []string{"text/event-stream"}, "Content-Encoding": []string{"gzip"}}, 584 Body: io.NopCloser(strings.NewReader("\x1f\x8b not really gzip")), 585 }, nil 586 }) 587 require.NoError(t, err) 588 assert.Equal(t, 1, calls) 589 body, err := io.ReadAll(res.Body) 590 require.NoError(t, err) 591 assert.Equal(t, "\x1f\x8b not really gzip", string(body)) 592 } 593 594 func TestStreamingFallbackRedirectsThroughAPlatformTransform(t *testing.T) { 595 // The fallback middleware runs above the platform transform: each hop's 596 // model swap must redirect through it, and the signature must cover the 597 // body the middleware produced. 598 var paths []string 599 transport := &sseTransport{responses: []string{ 600 refusalStream("primary-model", tokenNoClaim), servedStream("fallback-model"), 601 }} 602 transport.t = t 603 client := anthropic.NewClient( 604 option.WithAPIKey("my-anthropic-api-key"), 605 option.WithHTTPClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { 606 paths = append(paths, req.URL.Path) 607 sig := req.Header.Get("X-Fake-Signature") 608 buf, err := io.ReadAll(req.Body) 609 require.NoError(t, err) 610 assert.Equal(t, fmt.Sprintf("%x", sha256.Sum256(buf)), sig, "signature must cover the body the middleware produced") 611 req.Body = io.NopCloser(strings.NewReader(string(buf))) 612 return transport.RoundTrip(req) 613 })}), 614 option.WithMaxRetries(0), 615 option.WithMiddleware( 616 betafallback.BetaRefusalFallbackMiddleware( 617 []anthropic.BetaFallbackParam{{Model: "fallback-model"}}, 618 ), 619 fakePlatformTransform, 620 ), 621 ) 622 623 msg, _, _ := collectStream(t, client, context.Background(), fallbackTestParams) 624 assert.Equal(t, anthropic.BetaStopReasonEndTurn, msg.StopReason) 625 assert.Equal(t, []string{"/model/primary-model/invoke", "/model/fallback-model/invoke"}, paths) 626 require.Len(t, transport.bodies, 2) 627 _, hasModel := transport.bodies[1]["model"] 628 assert.False(t, hasModel, "the transform moved the model out of the body") 629 }