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  }