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

     1  package betafallback_test
     2  
     3  import (
     4  	"context"
     5  	"crypto/sha256"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"maps"
    10  	"net/http"
    11  	"strings"
    12  	"sync"
    13  	"testing"
    14  
    15  	"github.com/anthropics/anthropic-sdk-go"
    16  	"github.com/anthropics/anthropic-sdk-go/lib/betafallback"
    17  	"github.com/anthropics/anthropic-sdk-go/option"
    18  	"github.com/anthropics/anthropic-sdk-go/packages/param"
    19  	"github.com/stretchr/testify/assert"
    20  	"github.com/stretchr/testify/require"
    21  )
    22  
    23  // scriptedTransport returns canned JSON responses in order and records every
    24  // request body it sees. Statuses and headers parallel responses; missing
    25  // entries are 200.
    26  type scriptedTransport struct {
    27  	t         *testing.T
    28  	responses []string
    29  	statuses  []int
    30  	headers   []http.Header
    31  	bodies    []map[string]any
    32  	betas     [][]string
    33  	helpers   [][]string
    34  }
    35  
    36  func (s *scriptedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    37  	buf, err := io.ReadAll(req.Body)
    38  	require.NoError(s.t, err)
    39  	var body map[string]any
    40  	require.NoError(s.t, json.Unmarshal(buf, &body))
    41  	s.bodies = append(s.bodies, body)
    42  	s.betas = append(s.betas, req.Header.Values("anthropic-beta"))
    43  	s.helpers = append(s.helpers, req.Header.Values("x-stainless-helper"))
    44  	require.NotEmpty(s.t, s.responses, "more requests than scripted responses")
    45  	next := s.responses[0]
    46  	s.responses = s.responses[1:]
    47  	status := http.StatusOK
    48  	if len(s.statuses) > 0 {
    49  		status = s.statuses[0]
    50  		s.statuses = s.statuses[1:]
    51  	}
    52  	header := http.Header{"Content-Type": []string{"application/json"}}
    53  	if len(s.headers) > 0 {
    54  		maps.Copy(header, s.headers[0])
    55  		s.headers = s.headers[1:]
    56  	}
    57  	return &http.Response{
    58  		StatusCode: status,
    59  		Header:     header,
    60  		Body:       io.NopCloser(strings.NewReader(next)),
    61  	}, nil
    62  }
    63  
    64  func (s *scriptedTransport) models() []string {
    65  	models := make([]string, len(s.bodies))
    66  	for i, body := range s.bodies {
    67  		models[i], _ = body["model"].(string)
    68  	}
    69  	return models
    70  }
    71  
    72  func messageResponse(model string) string {
    73  	return fmt.Sprintf(`{
    74  		"id": "msg_1", "type": "message", "role": "assistant", "model": %q,
    75  		"content": [], "stop_reason": "end_turn", "stop_sequence": null,
    76  		"usage": {"input_tokens": 1, "output_tokens": 1}
    77  	}`, model)
    78  }
    79  
    80  // creditTokenBody is the decoded object form the middleware sends when it
    81  // redeems a refusal's fallback_credit_token: best_effort mode, so a token-layer
    82  // failure still serves the retry.
    83  func creditTokenBody(token string) map[string]any {
    84  	return map[string]any{"token": token, "mode": "best_effort"}
    85  }
    86  
    87  func refusalResponse(model string, creditToken any) string {
    88  	token, _ := json.Marshal(creditToken)
    89  	return fmt.Sprintf(`{
    90  		"id": "msg_1", "type": "message", "role": "assistant", "model": %q,
    91  		"content": [], "stop_reason": "refusal", "stop_sequence": null,
    92  		"stop_details": {"type": "refusal", "category": null, "explanation": null,
    93  			"fallback_credit_token": %s, "recommended_model": null},
    94  		"usage": {"input_tokens": 1, "output_tokens": 1}
    95  	}`, model, token)
    96  }
    97  
    98  func fallbackTestClient(t *testing.T, responses []string, middleware option.Middleware) (anthropic.Client, *scriptedTransport) {
    99  	transport := &scriptedTransport{t: t, responses: responses}
   100  	client := anthropic.NewClient(
   101  		option.WithAPIKey("my-anthropic-api-key"),
   102  		option.WithHTTPClient(&http.Client{Transport: transport}),
   103  		option.WithMaxRetries(0),
   104  		option.WithMiddleware(middleware),
   105  	)
   106  	return client, transport
   107  }
   108  
   109  var fallbackTestParams = anthropic.BetaMessageNewParams{
   110  	Model:     "primary-model",
   111  	MaxTokens: 1024,
   112  	Messages:  []anthropic.BetaMessageParam{anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("hi"))},
   113  }
   114  
   115  func TestRefusalFallbackMiddlewareRetriesWithFallbackParamsAndCreditToken(t *testing.T) {
   116  	client, transport := fallbackTestClient(t,
   117  		[]string{refusalResponse("primary-model", "credit-token"), messageResponse("fallback-model")},
   118  		betafallback.BetaRefusalFallbackMiddleware(
   119  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   120  		),
   121  	)
   122  
   123  	message, err := client.Beta.Messages.New(context.Background(), fallbackTestParams)
   124  	require.NoError(t, err)
   125  	assert.Equal(t, anthropic.Model("fallback-model"), message.Model)
   126  	assert.Equal(t, anthropic.BetaStopReasonEndTurn, message.StopReason)
   127  	assert.Equal(t, []string{"primary-model", "fallback-model"}, transport.models())
   128  	assert.Equal(t, creditTokenBody("credit-token"), transport.bodies[1]["fallback_credit_token"])
   129  	_, hasToken := transport.bodies[0]["fallback_credit_token"]
   130  	assert.False(t, hasToken)
   131  }
   132  
   133  func TestRefusalFallbackMiddlewareTagsTheOriginalAndFallbackRequests(t *testing.T) {
   134  	client, transport := fallbackTestClient(t,
   135  		[]string{refusalResponse("primary-model", "credit-token"), messageResponse("fallback-model")},
   136  		betafallback.BetaRefusalFallbackMiddleware(
   137  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   138  		),
   139  	)
   140  
   141  	_, err := client.Beta.Messages.New(context.Background(), fallbackTestParams)
   142  	require.NoError(t, err)
   143  	assert.Equal(t, [][]string{
   144  		{"fallback-refusal-middleware"},
   145  		{"fallback-refusal-middleware"},
   146  	}, transport.helpers)
   147  }
   148  
   149  func TestRefusalFallbackMiddlewareAppendsToAnExistingHelperTag(t *testing.T) {
   150  	client, transport := fallbackTestClient(t,
   151  		[]string{messageResponse("primary-model")},
   152  		betafallback.BetaRefusalFallbackMiddleware(
   153  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   154  		),
   155  	)
   156  
   157  	_, err := client.Beta.Messages.New(context.Background(), fallbackTestParams,
   158  		option.WithHeader("X-Stainless-Helper", "BetaToolRunner"))
   159  	require.NoError(t, err)
   160  	assert.Equal(t, [][]string{{"BetaToolRunner, fallback-refusal-middleware"}}, transport.helpers)
   161  }
   162  
   163  func TestRefusalFallbackMiddlewareDoesNotTagRequestsItPassesThrough(t *testing.T) {
   164  	client, transport := fallbackTestClient(t,
   165  		[]string{messageResponse("primary-model")},
   166  		betafallback.BetaRefusalFallbackMiddleware(
   167  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   168  		),
   169  	)
   170  
   171  	// the GA surface is not applicable to this middleware
   172  	_, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{
   173  		Model:     "primary-model",
   174  		MaxTokens: 1024,
   175  		Messages:  []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock("hi"))},
   176  	})
   177  	require.NoError(t, err)
   178  	assert.Equal(t, [][]string{nil}, transport.helpers)
   179  }
   180  
   181  func TestRefusalFallbackMiddlewarePinsTheConversationToTheAcceptedFallback(t *testing.T) {
   182  	client, transport := fallbackTestClient(t,
   183  		[]string{refusalResponse("primary-model", nil), messageResponse("fallback-model"), messageResponse("fallback-model")},
   184  		betafallback.BetaRefusalFallbackMiddleware(
   185  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   186  		),
   187  	)
   188  
   189  	conversation := betafallback.WithBetaFallbackState(&betafallback.BetaFallbackState{})
   190  	_, err := client.Beta.Messages.New(context.Background(), fallbackTestParams, conversation)
   191  	require.NoError(t, err)
   192  
   193  	// The follow-up goes straight to the pinned fallback in a single request.
   194  	_, err = client.Beta.Messages.New(context.Background(), fallbackTestParams, conversation)
   195  	require.NoError(t, err)
   196  	assert.Equal(t, []string{"primary-model", "fallback-model", "fallback-model"}, transport.models())
   197  }
   198  
   199  func TestRefusalFallbackMiddlewareKeepsSeparateConversationsIndependent(t *testing.T) {
   200  	client, transport := fallbackTestClient(t,
   201  		[]string{refusalResponse("primary-model", nil), messageResponse("fallback-model"), messageResponse("primary-model")},
   202  		betafallback.BetaRefusalFallbackMiddleware(
   203  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   204  		),
   205  	)
   206  
   207  	_, err := client.Beta.Messages.New(context.Background(), fallbackTestParams,
   208  		betafallback.WithBetaFallbackState(&betafallback.BetaFallbackState{}))
   209  	require.NoError(t, err)
   210  
   211  	_, err = client.Beta.Messages.New(context.Background(), fallbackTestParams,
   212  		betafallback.WithBetaFallbackState(&betafallback.BetaFallbackState{}))
   213  	require.NoError(t, err)
   214  	assert.Equal(t, []string{"primary-model", "fallback-model", "primary-model"}, transport.models())
   215  }
   216  
   217  func TestRefusalFallbackMiddlewareLeavesAcceptedRequestsUntouched(t *testing.T) {
   218  	client, transport := fallbackTestClient(t,
   219  		[]string{messageResponse("primary-model")},
   220  		betafallback.BetaRefusalFallbackMiddleware(
   221  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   222  		),
   223  	)
   224  
   225  	message, err := client.Beta.Messages.New(context.Background(), fallbackTestParams,
   226  		betafallback.WithBetaFallbackState(&betafallback.BetaFallbackState{}))
   227  	require.NoError(t, err)
   228  	assert.Equal(t, anthropic.Model("primary-model"), message.Model)
   229  	require.Len(t, transport.bodies, 1)
   230  	_, hasToken := transport.bodies[0]["fallback_credit_token"]
   231  	assert.False(t, hasToken)
   232  }
   233  
   234  func TestRefusalFallbackMiddlewareReportsEachHopThroughTheChain(t *testing.T) {
   235  	client, transport := fallbackTestClient(t,
   236  		[]string{
   237  			refusalResponse("primary-model", "token-1"),
   238  			refusalResponse("fallback-1", "token-2"),
   239  			messageResponse("fallback-2"),
   240  		},
   241  		betafallback.BetaRefusalFallbackMiddleware(
   242  			[]anthropic.BetaFallbackParam{{Model: "fallback-1"}, {Model: "fallback-2"}},
   243  		),
   244  	)
   245  
   246  	message, err := client.Beta.Messages.New(context.Background(), fallbackTestParams)
   247  	require.NoError(t, err)
   248  	assert.Equal(t, anthropic.Model("fallback-2"), message.Model)
   249  	assert.Equal(t, []string{"primary-model", "fallback-1", "fallback-2"}, transport.models())
   250  	assert.Equal(t, creditTokenBody("token-1"), transport.bodies[1]["fallback_credit_token"])
   251  	assert.Equal(t, creditTokenBody("token-2"), transport.bodies[2]["fallback_credit_token"])
   252  }
   253  
   254  func TestRefusalFallbackMiddlewareReturnsTheLastRefusalWhenTheChainIsExhausted(t *testing.T) {
   255  	client, transport := fallbackTestClient(t,
   256  		[]string{refusalResponse("primary-model", nil), refusalResponse("fallback-model", nil)},
   257  		betafallback.BetaRefusalFallbackMiddleware(
   258  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   259  		),
   260  	)
   261  
   262  	message, err := client.Beta.Messages.New(context.Background(), fallbackTestParams)
   263  	require.NoError(t, err)
   264  	assert.Equal(t, anthropic.BetaStopReasonRefusal, message.StopReason)
   265  	assert.Equal(t, anthropic.Model("fallback-model"), message.Model)
   266  	assert.Len(t, transport.bodies, 2)
   267  }
   268  
   269  func TestRefusalFallbackMiddlewareAppliesFallbackOverridesAndPreservesOtherFields(t *testing.T) {
   270  	client, transport := fallbackTestClient(t,
   271  		[]string{refusalResponse("primary-model", nil), messageResponse("fallback-model")},
   272  		betafallback.BetaRefusalFallbackMiddleware(
   273  			[]anthropic.BetaFallbackParam{{
   274  				Model:     "fallback-model",
   275  				MaxTokens: anthropic.Int(2048),
   276  				Thinking: anthropic.BetaFallbackParamThinkingUnion{
   277  					OfDisabled: &anthropic.BetaThinkingConfigDisabledParam{},
   278  				},
   279  			}},
   280  		),
   281  	)
   282  
   283  	params := fallbackTestParams
   284  	params.Temperature = anthropic.Float(0.5)
   285  	_, err := client.Beta.Messages.New(context.Background(), params)
   286  	require.NoError(t, err)
   287  
   288  	require.Len(t, transport.bodies, 2)
   289  	retry := transport.bodies[1]
   290  	assert.Equal(t, "fallback-model", retry["model"])
   291  	assert.Equal(t, float64(2048), retry["max_tokens"])
   292  	assert.Equal(t, map[string]any{"type": "disabled"}, retry["thinking"])
   293  	assert.Equal(t, 0.5, retry["temperature"])
   294  	assert.Equal(t, transport.bodies[0]["messages"], retry["messages"])
   295  }
   296  
   297  func TestRefusalFallbackMiddlewareNullOverrideUnsetsTheOriginalField(t *testing.T) {
   298  	client, transport := fallbackTestClient(t,
   299  		[]string{refusalResponse("primary-model", nil), messageResponse("fallback-model")},
   300  		betafallback.BetaRefusalFallbackMiddleware(
   301  			[]anthropic.BetaFallbackParam{{
   302  				Model:     "fallback-model",    // set: overrides the original
   303  				MaxTokens: param.Null[int64](), // null: unsets the original
   304  				// temperature absent: the original value is kept
   305  			}},
   306  		),
   307  	)
   308  
   309  	params := fallbackTestParams
   310  	params.Temperature = anthropic.Float(0.5)
   311  	_, err := client.Beta.Messages.New(context.Background(), params)
   312  	require.NoError(t, err)
   313  
   314  	require.Len(t, transport.bodies, 2)
   315  	assert.Equal(t, float64(1024), transport.bodies[0]["max_tokens"])
   316  	retry := transport.bodies[1]
   317  	assert.Equal(t, "fallback-model", retry["model"])
   318  	_, hasMaxTokens := retry["max_tokens"]
   319  	assert.False(t, hasMaxTokens, "null override must remove the key, not send null or keep the original")
   320  	assert.Equal(t, 0.5, retry["temperature"])
   321  }
   322  
   323  func TestRefusalFallbackMiddlewareEachHopPatchesTheOriginalParams(t *testing.T) {
   324  	client, transport := fallbackTestClient(t,
   325  		[]string{
   326  			refusalResponse("primary-model", nil),
   327  			refusalResponse("fallback-1", nil),
   328  			messageResponse("fallback-2"),
   329  		},
   330  		betafallback.BetaRefusalFallbackMiddleware(
   331  			[]anthropic.BetaFallbackParam{
   332  				{Model: "fallback-1", MaxTokens: anthropic.Int(2048), Speed: anthropic.BetaFallbackParamSpeedFast},
   333  				{Model: "fallback-2"},
   334  			},
   335  		),
   336  	)
   337  
   338  	_, err := client.Beta.Messages.New(context.Background(), fallbackTestParams)
   339  	require.NoError(t, err)
   340  
   341  	require.Len(t, transport.bodies, 3)
   342  	hop1, hop2 := transport.bodies[1], transport.bodies[2]
   343  	assert.Equal(t, "fallback-1", hop1["model"])
   344  	assert.Equal(t, float64(2048), hop1["max_tokens"])
   345  	assert.Equal(t, "fast", hop1["speed"])
   346  	// Hop 2 patches the original request, never hop 1's patched request.
   347  	assert.Equal(t, "fallback-2", hop2["model"])
   348  	assert.Equal(t, float64(1024), hop2["max_tokens"])
   349  	_, hasSpeed := hop2["speed"]
   350  	assert.False(t, hasSpeed, "hop 1's override must not leak into hop 2")
   351  }
   352  
   353  // outputConfigOverride builds an output_config overlay from raw subfields,
   354  // since the typed effort field cannot spell an explicit null.
   355  func outputConfigOverride(fields map[string]any) anthropic.BetaOutputConfigParam {
   356  	var config anthropic.BetaOutputConfigParam
   357  	config.SetExtraFields(fields)
   358  	return config
   359  }
   360  
   361  func TestRefusalFallbackMiddlewareOutputConfigSubfieldsMergeOneLevelDeep(t *testing.T) {
   362  	client, transport := fallbackTestClient(t,
   363  		[]string{refusalResponse("primary-model", nil), messageResponse("fallback-model")},
   364  		betafallback.BetaRefusalFallbackMiddleware(
   365  			[]anthropic.BetaFallbackParam{{
   366  				Model: "fallback-model",
   367  				// effort set: overrides; task_budget null: unset; format
   368  				// absent: kept from the original.
   369  				OutputConfig: outputConfigOverride(map[string]any{"effort": "high", "task_budget": nil}),
   370  			}},
   371  		),
   372  	)
   373  
   374  	params := fallbackTestParams
   375  	params.OutputConfig = anthropic.BetaOutputConfigParam{
   376  		Effort:     anthropic.BetaOutputConfigEffortLow,
   377  		Format:     anthropic.BetaJSONOutputFormatParam{Schema: map[string]any{"type": "object"}},
   378  		TaskBudget: anthropic.BetaTokenTaskBudgetParam{Total: 4096},
   379  	}
   380  	_, err := client.Beta.Messages.New(context.Background(), params)
   381  	require.NoError(t, err)
   382  
   383  	require.Len(t, transport.bodies, 2)
   384  	original, retry := transport.bodies[0]["output_config"], transport.bodies[1]["output_config"]
   385  	assert.Equal(t, "low", original.(map[string]any)["effort"])
   386  	assert.Equal(t, map[string]any{
   387  		"effort": "high",
   388  		"format": original.(map[string]any)["format"],
   389  	}, retry, "set subfields override, null subfields unset, absent subfields survive")
   390  }
   391  
   392  func TestRefusalFallbackMiddlewareNullOutputConfigUnsetsTheWholeObject(t *testing.T) {
   393  	client, transport := fallbackTestClient(t,
   394  		[]string{refusalResponse("primary-model", nil), messageResponse("fallback-model")},
   395  		betafallback.BetaRefusalFallbackMiddleware(
   396  			[]anthropic.BetaFallbackParam{{
   397  				Model:        "fallback-model",
   398  				OutputConfig: param.NullStruct[anthropic.BetaOutputConfigParam](),
   399  			}},
   400  		),
   401  	)
   402  
   403  	params := fallbackTestParams
   404  	params.OutputConfig = anthropic.BetaOutputConfigParam{Effort: anthropic.BetaOutputConfigEffortLow}
   405  	_, err := client.Beta.Messages.New(context.Background(), params)
   406  	require.NoError(t, err)
   407  
   408  	require.Len(t, transport.bodies, 2)
   409  	assert.Contains(t, transport.bodies[0], "output_config")
   410  	assert.NotContains(t, transport.bodies[1], "output_config")
   411  }
   412  
   413  func TestRefusalFallbackMiddlewareOutputConfigCreatedWhenTheOriginalHasNone(t *testing.T) {
   414  	client, transport := fallbackTestClient(t,
   415  		[]string{refusalResponse("primary-model", nil), messageResponse("fallback-model")},
   416  		betafallback.BetaRefusalFallbackMiddleware(
   417  			[]anthropic.BetaFallbackParam{{
   418  				Model:        "fallback-model",
   419  				OutputConfig: outputConfigOverride(map[string]any{"effort": "high", "task_budget": nil}),
   420  			}},
   421  		),
   422  	)
   423  
   424  	_, err := client.Beta.Messages.New(context.Background(), fallbackTestParams)
   425  	require.NoError(t, err)
   426  
   427  	require.Len(t, transport.bodies, 2)
   428  	assert.NotContains(t, transport.bodies[0], "output_config")
   429  	assert.Equal(t, map[string]any{"effort": "high"}, transport.bodies[1]["output_config"],
   430  		"set subfields land, null subfields are dropped")
   431  }
   432  
   433  func TestRefusalFallbackMiddlewareDropsOutputConfigWhenNoSubfieldsRemain(t *testing.T) {
   434  	for _, test := range []struct {
   435  		name     string
   436  		original *anthropic.BetaOutputConfigParam
   437  	}{
   438  		// The original object's only subfield is unset by the overlay: the
   439  		// key must be dropped, never sent as {}.
   440  		{"UnsetsEveryOriginalSubfield", &anthropic.BetaOutputConfigParam{Effort: anthropic.BetaOutputConfigEffortLow}},
   441  		// No original object and the overlay's only subfield is null: no
   442  		// key is added.
   443  		{"OverlayOnAnAbsentOriginal", nil},
   444  	} {
   445  		t.Run(test.name, func(t *testing.T) {
   446  			client, transport := fallbackTestClient(t,
   447  				[]string{refusalResponse("primary-model", nil), messageResponse("fallback-model")},
   448  				betafallback.BetaRefusalFallbackMiddleware(
   449  					[]anthropic.BetaFallbackParam{{
   450  						Model:        "fallback-model",
   451  						OutputConfig: outputConfigOverride(map[string]any{"effort": nil}),
   452  					}},
   453  				),
   454  			)
   455  
   456  			params := fallbackTestParams
   457  			if test.original != nil {
   458  				params.OutputConfig = *test.original
   459  			}
   460  			_, err := client.Beta.Messages.New(context.Background(), params)
   461  			require.NoError(t, err)
   462  
   463  			require.Len(t, transport.bodies, 2)
   464  			assert.NotContains(t, transport.bodies[1], "output_config",
   465  				"an output_config left with no subfields is dropped, not sent as {}")
   466  		})
   467  	}
   468  }
   469  
   470  func TestRefusalFallbackMiddlewareEachHopPatchesTheOriginalOutputConfig(t *testing.T) {
   471  	client, transport := fallbackTestClient(t,
   472  		[]string{
   473  			refusalResponse("primary-model", nil),
   474  			refusalResponse("fallback-1", nil),
   475  			messageResponse("fallback-2"),
   476  		},
   477  		betafallback.BetaRefusalFallbackMiddleware(
   478  			[]anthropic.BetaFallbackParam{
   479  				{Model: "fallback-1", OutputConfig: outputConfigOverride(map[string]any{"effort": "max", "task_budget": nil})},
   480  				{Model: "fallback-2", OutputConfig: outputConfigOverride(map[string]any{"effort": "high"})},
   481  			},
   482  		),
   483  	)
   484  
   485  	params := fallbackTestParams
   486  	params.OutputConfig = anthropic.BetaOutputConfigParam{
   487  		Effort:     anthropic.BetaOutputConfigEffortLow,
   488  		TaskBudget: anthropic.BetaTokenTaskBudgetParam{Total: 4096},
   489  	}
   490  	_, err := client.Beta.Messages.New(context.Background(), params)
   491  	require.NoError(t, err)
   492  
   493  	require.Len(t, transport.bodies, 3)
   494  	budget := transport.bodies[0]["output_config"].(map[string]any)["task_budget"]
   495  	assert.Equal(t, map[string]any{"effort": "max"}, transport.bodies[1]["output_config"])
   496  	// Hop 2 patches the original output_config: task_budget (unset only by
   497  	// hop 1) returns, and hop 1's effort does not leak.
   498  	assert.Equal(t, map[string]any{"effort": "high", "task_budget": budget}, transport.bodies[2]["output_config"])
   499  }
   500  
   501  func TestBetaFallbackStateIsConcurrencySafe(t *testing.T) {
   502  	state := &betafallback.BetaFallbackState{}
   503  	var wg sync.WaitGroup
   504  	for i := 0; i < 8; i++ {
   505  		i := i
   506  		wg.Add(1)
   507  		go func() {
   508  			defer wg.Done()
   509  			for n := 0; n < 100; n++ {
   510  				state.SetIndex(i % 2)
   511  				_ = state.Index()
   512  			}
   513  		}()
   514  	}
   515  	wg.Wait()
   516  	assert.Contains(t, []int{0, 1}, state.Index())
   517  }
   518  
   519  func TestRefusalFallbackMiddlewareErrorsOnAPinPastTheConfiguredChain(t *testing.T) {
   520  	// A pin past the chain means the state was shared with a different
   521  	// middleware; erroring beats silently picking another model.
   522  	client, _ := fallbackTestClient(t,
   523  		[]string{messageResponse("fallback-model")},
   524  		betafallback.BetaRefusalFallbackMiddleware(
   525  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   526  		),
   527  	)
   528  
   529  	stale := &betafallback.BetaFallbackState{}
   530  	stale.SetIndex(1)
   531  	_, err := client.Beta.Messages.New(context.Background(), fallbackTestParams,
   532  		betafallback.WithBetaFallbackState(stale))
   533  	require.ErrorContains(t, err, "out of range")
   534  }
   535  
   536  func TestRefusalFallbackMiddlewareSkipsRequestsItDoesNotApplyTo(t *testing.T) {
   537  	middleware := betafallback.BetaRefusalFallbackMiddleware(
   538  		[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   539  	)
   540  
   541  	for _, test := range []struct {
   542  		name   string
   543  		method string
   544  		path   string
   545  		body   string
   546  	}{
   547  		{"streaming request", http.MethodPost, "/v1/messages", `{"model": "primary-model", "stream": true}`},
   548  		{"count_tokens shape", http.MethodPost, "/v1/messages/count_tokens", `{"model": "primary-model", "messages": []}`},
   549  		{"non-POST", http.MethodGet, "/v1/messages", `{"model": "primary-model"}`},
   550  		{"non-JSON body", http.MethodPost, "/v1/messages", `not json`},
   551  	} {
   552  		t.Run(test.name, func(t *testing.T) {
   553  			calls := 0
   554  			req, err := http.NewRequest(test.method, "https://api.anthropic.com"+test.path, strings.NewReader(test.body))
   555  			require.NoError(t, err)
   556  			req.Header.Set("Content-Type", "application/json")
   557  			res, err := middleware(req, func(req *http.Request) (*http.Response, error) {
   558  				calls++
   559  				body, err := io.ReadAll(req.Body)
   560  				require.NoError(t, err)
   561  				assert.Equal(t, test.body, string(body))
   562  				return &http.Response{
   563  					StatusCode: http.StatusOK,
   564  					Body:       io.NopCloser(strings.NewReader(refusalResponse("primary-model", nil))),
   565  				}, nil
   566  			})
   567  			require.NoError(t, err)
   568  			assert.Equal(t, 1, calls)
   569  			assert.NotNil(t, res)
   570  		})
   571  	}
   572  }
   573  
   574  func TestRefusalFallbackMiddlewareErrorsWhenAFallbackHasNoModel(t *testing.T) {
   575  	// A zero-model fallback merges to a no-op that silently re-sends the
   576  	// refused request.
   577  	middleware := betafallback.BetaRefusalFallbackMiddleware(
   578  		[]anthropic.BetaFallbackParam{{MaxTokens: anthropic.Int(2048)}},
   579  	)
   580  
   581  	req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true",
   582  		strings.NewReader(`{"model": "primary-model", "messages": [], "max_tokens": 16}`))
   583  	require.NoError(t, err)
   584  	req.Header.Set("Content-Type", "application/json")
   585  	_, err = middleware(req, func(*http.Request) (*http.Response, error) {
   586  		t.Fatal("request should not be sent")
   587  		return nil, nil
   588  	})
   589  	require.ErrorContains(t, err, "fallbacks[0] has no model")
   590  
   591  	// Requests the middleware would not handle are unaffected by the bad config.
   592  	other, err := http.NewRequest(http.MethodGet, "https://api.anthropic.com/v1/models", nil)
   593  	require.NoError(t, err)
   594  	res, err := middleware(other, func(*http.Request) (*http.Response, error) {
   595  		return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("{}"))}, nil
   596  	})
   597  	require.NoError(t, err)
   598  	require.NotNil(t, res)
   599  
   600  	// Streaming requests are handled too; the bad chain fails them just as
   601  	// loudly.
   602  	streaming, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true",
   603  		strings.NewReader(`{"model": "primary-model", "messages": [], "max_tokens": 16, "stream": true}`))
   604  	require.NoError(t, err)
   605  	streaming.Header.Set("Content-Type", "application/json")
   606  	_, err = middleware(streaming, func(*http.Request) (*http.Response, error) {
   607  		t.Fatal("request should not be sent")
   608  		return nil, nil
   609  	})
   610  	require.ErrorContains(t, err, "fallbacks[0] has no model")
   611  }
   612  
   613  func TestRefusalFallbackMiddlewareWithAnEmptyChainDoesNotOptIntoTheBeta(t *testing.T) {
   614  	// The opt-in changes what the server returns; an empty chain gets no
   615  	// benefit from it.
   616  	client, transport := fallbackTestClient(t,
   617  		[]string{messageResponse("primary-model")},
   618  		betafallback.BetaRefusalFallbackMiddleware(nil),
   619  	)
   620  
   621  	_, err := client.Beta.Messages.New(context.Background(), fallbackTestParams)
   622  	require.NoError(t, err)
   623  	require.Len(t, transport.betas, 1)
   624  	assert.NotContains(t, transport.betas[0], string(anthropic.AnthropicBetaFallbackCredit2026_07_01))
   625  }
   626  
   627  func TestRefusalFallbackMiddlewareDropsAStaleCreditToken(t *testing.T) {
   628  	// A token on the original params was minted for an earlier refusal; it
   629  	// must not survive onto a retry whose refusal supplied none.
   630  	client, transport := fallbackTestClient(t,
   631  		[]string{refusalResponse("primary-model", nil), messageResponse("fallback-model")},
   632  		betafallback.BetaRefusalFallbackMiddleware(
   633  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   634  		),
   635  	)
   636  
   637  	params := fallbackTestParams
   638  	params.FallbackCreditToken = anthropic.BetaMessageNewParamsFallbackCreditTokenUnion{
   639  		OfString: anthropic.String("stale-token"),
   640  	}
   641  	_, err := client.Beta.Messages.New(context.Background(), params)
   642  	require.NoError(t, err)
   643  
   644  	require.Len(t, transport.bodies, 2)
   645  	assert.Equal(t, "stale-token", transport.bodies[0]["fallback_credit_token"])
   646  	_, hasToken := transport.bodies[1]["fallback_credit_token"]
   647  	assert.False(t, hasToken)
   648  }
   649  
   650  func TestRefusalFallbackMiddlewareRehydratesAPersistedPin(t *testing.T) {
   651  	// A pin persisted by a previous process is restored with SetIndex and
   652  	// readable with Index after the turn.
   653  	client, transport := fallbackTestClient(t,
   654  		[]string{refusalResponse("primary-model", nil), messageResponse("fallback-model"), messageResponse("fallback-model")},
   655  		betafallback.BetaRefusalFallbackMiddleware(
   656  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   657  		),
   658  	)
   659  
   660  	state := &betafallback.BetaFallbackState{}
   661  	assert.Equal(t, -1, state.Index())
   662  	_, err := client.Beta.Messages.New(context.Background(), fallbackTestParams,
   663  		betafallback.WithBetaFallbackState(state))
   664  	require.NoError(t, err)
   665  	assert.Equal(t, 0, state.Index())
   666  
   667  	rehydrated := &betafallback.BetaFallbackState{}
   668  	rehydrated.SetIndex(state.Index())
   669  	_, err = client.Beta.Messages.New(context.Background(), fallbackTestParams,
   670  		betafallback.WithBetaFallbackState(rehydrated))
   671  	require.NoError(t, err)
   672  	assert.Equal(t, []string{"primary-model", "fallback-model", "fallback-model"}, transport.models())
   673  
   674  	reset := &betafallback.BetaFallbackState{}
   675  	reset.SetIndex(-5)
   676  	assert.Equal(t, -1, reset.Index())
   677  }
   678  
   679  func TestRefusalFallbackMiddlewareSendsTheTokenWithAllOverrides(t *testing.T) {
   680  	// The server adjudicates what a redemption permits; the client always
   681  	// attaches the token.
   682  	client, transport := fallbackTestClient(t,
   683  		[]string{
   684  			refusalResponse("primary-model", "token-1"),
   685  			refusalResponse("fallback-1", "token-2"),
   686  			messageResponse("fallback-2"),
   687  		},
   688  		betafallback.BetaRefusalFallbackMiddleware(
   689  			[]anthropic.BetaFallbackParam{
   690  				{Model: "fallback-1", Thinking: anthropic.BetaFallbackParamThinkingUnion{
   691  					OfDisabled: &anthropic.BetaThinkingConfigDisabledParam{},
   692  				}},
   693  				{Model: "fallback-2", MaxTokens: anthropic.Int(2048)},
   694  			},
   695  		),
   696  	)
   697  
   698  	_, err := client.Beta.Messages.New(context.Background(), fallbackTestParams)
   699  	require.NoError(t, err)
   700  	require.Len(t, transport.bodies, 3)
   701  	assert.Equal(t, creditTokenBody("token-1"), transport.bodies[1]["fallback_credit_token"], "the token rides with every override")
   702  	assert.Equal(t, creditTokenBody("token-2"), transport.bodies[2]["fallback_credit_token"])
   703  }
   704  
   705  func TestRefusalFallbackMiddlewareErrorsWhenServerSideFallbacksAreRequested(t *testing.T) {
   706  	// Only one chain can adjudicate refusals; erroring beats silently
   707  	// picking one.
   708  	client, transport := fallbackTestClient(t,
   709  		[]string{refusalResponse("primary-model", "credit-token")},
   710  		betafallback.BetaRefusalFallbackMiddleware(
   711  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   712  		),
   713  	)
   714  
   715  	params := fallbackTestParams
   716  	params.Fallbacks = anthropic.BetaFallbacksParamUnion{
   717  		OfBetaFallbackArray: []anthropic.BetaFallbackParam{{Model: "server-side-fallback"}},
   718  	}
   719  	_, err := client.Beta.Messages.New(context.Background(), params)
   720  	require.ErrorContains(t, 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.")
   721  	assert.Empty(t, transport.bodies, "no request reaches the server")
   722  }
   723  func TestRefusalFallbackMiddlewareRetriesARejectedTokenOnceWithoutIt(t *testing.T) {
   724  	// The credit contract's documented recovery for a redemption-failure 400
   725  	// is to retry without the token.
   726  	rejection := `{"type": "error", "error": {"type": "invalid_request_error", "message": "fallback_credit_token: does not match"}}`
   727  	transport := &scriptedTransport{t: t}
   728  	client := anthropic.NewClient(
   729  		option.WithAPIKey("my-anthropic-api-key"),
   730  		option.WithHTTPClient(&http.Client{Transport: transport}),
   731  		option.WithMaxRetries(0),
   732  		option.WithMiddleware(betafallback.BetaRefusalFallbackMiddleware(
   733  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   734  		)),
   735  	)
   736  	transport.statuses = []int{http.StatusOK, http.StatusBadRequest, http.StatusOK}
   737  	transport.responses = []string{
   738  		refusalResponse("primary-model", "credit-token"),
   739  		rejection,
   740  		messageResponse("fallback-model"),
   741  	}
   742  
   743  	message, err := client.Beta.Messages.New(context.Background(), fallbackTestParams)
   744  	require.NoError(t, err)
   745  	assert.Equal(t, anthropic.Model("fallback-model"), message.Model)
   746  	require.Len(t, transport.bodies, 3)
   747  	assert.Equal(t, creditTokenBody("credit-token"), transport.bodies[1]["fallback_credit_token"])
   748  	_, hasToken := transport.bodies[2]["fallback_credit_token"]
   749  	assert.False(t, hasToken, "recovery retry must omit the token")
   750  }
   751  
   752  func TestRefusalFallbackMiddlewareSurfacesAnUnrelatedRejectionAfterOneResend(t *testing.T) {
   753  	// The middleware cannot tell a token rejection from any other 400, so an
   754  	// unrelated one costs one duplicate tokenless request.
   755  	rejection := `{"type": "error", "error": {"type": "invalid_request_error", "message": "max_tokens: too large"}}`
   756  	transport := &scriptedTransport{
   757  		t:         t,
   758  		responses: []string{refusalResponse("primary-model", "credit-token"), rejection, rejection},
   759  		statuses:  []int{http.StatusOK, http.StatusBadRequest, http.StatusBadRequest},
   760  	}
   761  	client := anthropic.NewClient(
   762  		option.WithAPIKey("my-anthropic-api-key"),
   763  		option.WithHTTPClient(&http.Client{Transport: transport}),
   764  		option.WithMaxRetries(0),
   765  		option.WithMiddleware(betafallback.BetaRefusalFallbackMiddleware(
   766  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   767  		)),
   768  	)
   769  
   770  	_, err := client.Beta.Messages.New(context.Background(), fallbackTestParams)
   771  	var apierr *anthropic.Error
   772  	require.ErrorAs(t, err, &apierr)
   773  	assert.Equal(t, http.StatusBadRequest, apierr.StatusCode)
   774  	require.Len(t, transport.bodies, 3)
   775  	_, hasToken := transport.bodies[2]["fallback_credit_token"]
   776  	assert.False(t, hasToken, "the resend must omit the token")
   777  }
   778  
   779  func TestRefusalFallbackMiddlewareReentersTheChainWhenTheClientRetries(t *testing.T) {
   780  	overloaded := `{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}`
   781  	transport := &scriptedTransport{
   782  		t: t,
   783  		responses: []string{
   784  			refusalResponse("primary-model", "token-1"),
   785  			overloaded,
   786  			refusalResponse("primary-model", "token-2"),
   787  			messageResponse("fallback-model"),
   788  		},
   789  		statuses: []int{http.StatusOK, 529, http.StatusOK, http.StatusOK},
   790  		headers:  []http.Header{nil, {"Retry-After": []string{"0"}}},
   791  	}
   792  	client := anthropic.NewClient(
   793  		option.WithAPIKey("my-anthropic-api-key"),
   794  		option.WithHTTPClient(&http.Client{Transport: transport}),
   795  		option.WithMaxRetries(1),
   796  		option.WithMiddleware(betafallback.BetaRefusalFallbackMiddleware(
   797  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   798  		)),
   799  	)
   800  
   801  	message, err := client.Beta.Messages.New(context.Background(), fallbackTestParams)
   802  	require.NoError(t, err)
   803  	assert.Equal(t, anthropic.Model("fallback-model"), message.Model)
   804  	assert.Equal(t, []string{"primary-model", "fallback-model", "primary-model", "fallback-model"}, transport.models())
   805  	assert.Equal(t, creditTokenBody("token-1"), transport.bodies[1]["fallback_credit_token"])
   806  	assert.Equal(t, creditTokenBody("token-2"), transport.bodies[3]["fallback_credit_token"], "the re-entered chain redeems the fresh token")
   807  }
   808  
   809  func TestRefusalFallbackMiddlewareReentersAtThePinWhenTheClientRetries(t *testing.T) {
   810  	overloaded := `{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}`
   811  	transport := &scriptedTransport{
   812  		t: t,
   813  		responses: []string{
   814  			refusalResponse("primary-model", "credit-token"),
   815  			overloaded,
   816  			messageResponse("fallback-model"),
   817  		},
   818  		statuses: []int{http.StatusOK, 529, http.StatusOK},
   819  		headers:  []http.Header{nil, {"Retry-After": []string{"0"}}},
   820  	}
   821  	client := anthropic.NewClient(
   822  		option.WithAPIKey("my-anthropic-api-key"),
   823  		option.WithHTTPClient(&http.Client{Transport: transport}),
   824  		option.WithMaxRetries(1),
   825  		option.WithMiddleware(betafallback.BetaRefusalFallbackMiddleware(
   826  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   827  		)),
   828  	)
   829  
   830  	message, err := client.Beta.Messages.New(context.Background(), fallbackTestParams,
   831  		betafallback.WithBetaFallbackState(&betafallback.BetaFallbackState{}))
   832  	require.NoError(t, err)
   833  	assert.Equal(t, anthropic.Model("fallback-model"), message.Model)
   834  	assert.Equal(t, []string{"primary-model", "fallback-model", "fallback-model"}, transport.models())
   835  	_, hasToken := transport.bodies[2]["fallback_credit_token"]
   836  	assert.False(t, hasToken, "the pinned re-entry has no refusal to redeem")
   837  }
   838  
   839  func TestRefusalFallbackMiddlewareSkipsEncodedResponseBodies(t *testing.T) {
   840  	// A still-encoded body (caller-set Accept-Encoding) can't be inspected;
   841  	// pass it through.
   842  	middleware := betafallback.BetaRefusalFallbackMiddleware(
   843  		[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   844  	)
   845  
   846  	calls := 0
   847  	req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true", strings.NewReader(`{"model": "primary-model"}`))
   848  	require.NoError(t, err)
   849  	req.Header.Set("Content-Type", "application/json")
   850  	res, err := middleware(req, func(*http.Request) (*http.Response, error) {
   851  		calls++
   852  		return &http.Response{
   853  			StatusCode: http.StatusOK,
   854  			Header:     http.Header{"Content-Encoding": []string{"gzip"}},
   855  			Body:       io.NopCloser(strings.NewReader("\x1f\x8b not really gzip")),
   856  		}, nil
   857  	})
   858  	require.NoError(t, err)
   859  	assert.Equal(t, 1, calls)
   860  	assert.NotNil(t, res)
   861  }
   862  
   863  func TestRefusalFallbackMiddlewareOptsEveryAttemptIntoTheCreditBeta(t *testing.T) {
   864  	// Minting and redeeming both require the fallback-credit beta header;
   865  	// installing the middleware is the opt-in.
   866  	client, transport := fallbackTestClient(t,
   867  		[]string{refusalResponse("primary-model", "credit-token"), messageResponse("fallback-model")},
   868  		betafallback.BetaRefusalFallbackMiddleware(
   869  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   870  		),
   871  	)
   872  
   873  	_, err := client.Beta.Messages.New(context.Background(), fallbackTestParams)
   874  	require.NoError(t, err)
   875  	require.Len(t, transport.betas, 2)
   876  	for i, betas := range transport.betas {
   877  		assert.Contains(t, betas, string(anthropic.AnthropicBetaFallbackCredit2026_07_01), "request %d", i)
   878  	}
   879  }
   880  
   881  func TestRefusalFallbackMiddlewareKeepsACallerSuppliedCreditBeta(t *testing.T) {
   882  	client, transport := fallbackTestClient(t,
   883  		[]string{messageResponse("primary-model")},
   884  		betafallback.BetaRefusalFallbackMiddleware(
   885  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   886  		),
   887  	)
   888  
   889  	_, err := client.Beta.Messages.New(context.Background(), fallbackTestParams,
   890  		option.WithHeaderAdd("anthropic-beta", string(anthropic.AnthropicBetaFallbackCredit2026_07_01)))
   891  	require.NoError(t, err)
   892  	require.Len(t, transport.betas, 1)
   893  	assert.Equal(t, []string{string(anthropic.AnthropicBetaFallbackCredit2026_07_01)}, transport.betas[0])
   894  }
   895  
   896  func TestRefusalFallbackMiddlewareDoesNotAddTheBetaToPassthroughRequests(t *testing.T) {
   897  	middleware := betafallback.BetaRefusalFallbackMiddleware(
   898  		[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   899  	)
   900  
   901  	req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true",
   902  		strings.NewReader(`{"model": "primary-model", "stream": true}`))
   903  	require.NoError(t, err)
   904  	req.Header.Set("Content-Type", "application/json")
   905  	_, err = middleware(req, func(seen *http.Request) (*http.Response, error) {
   906  		assert.Empty(t, seen.Header.Values("anthropic-beta"))
   907  		return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("{}"))}, nil
   908  	})
   909  	require.NoError(t, err)
   910  }
   911  
   912  func TestRefusalFallbackMiddlewarePassesErrorResponsesThrough(t *testing.T) {
   913  	middleware := betafallback.BetaRefusalFallbackMiddleware(
   914  		[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   915  	)
   916  
   917  	calls := 0
   918  	req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true", strings.NewReader(`{"model": "primary-model", "messages": [], "max_tokens": 16}`))
   919  	require.NoError(t, err)
   920  	req.Header.Set("Content-Type", "application/json")
   921  	errorBody := `{"type": "error", "error": {"type": "invalid_request_error", "message": "nope"}}`
   922  	res, err := middleware(req, func(*http.Request) (*http.Response, error) {
   923  		calls++
   924  		return &http.Response{
   925  			StatusCode: http.StatusBadRequest,
   926  			Body:       io.NopCloser(strings.NewReader(errorBody)),
   927  		}, nil
   928  	})
   929  	require.NoError(t, err)
   930  	assert.Equal(t, 1, calls)
   931  	body, err := io.ReadAll(res.Body)
   932  	require.NoError(t, err)
   933  	assert.Equal(t, errorBody, string(body))
   934  }
   935  
   936  func TestRefusalFallbackMiddlewarePassesNonJSONContentTypesThroughUnread(t *testing.T) {
   937  	middleware := betafallback.BetaRefusalFallbackMiddleware(
   938  		[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   939  	)
   940  
   941  	req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true",
   942  		strings.NewReader("--boundary--"))
   943  	require.NoError(t, err)
   944  	req.Header.Set("Content-Type", "multipart/form-data; boundary=boundary")
   945  	calls := 0
   946  	_, err = middleware(req, func(seen *http.Request) (*http.Response, error) {
   947  		calls++
   948  		body, err := io.ReadAll(seen.Body)
   949  		require.NoError(t, err)
   950  		assert.Equal(t, "--boundary--", string(body))
   951  		assert.Empty(t, seen.Header.Values("anthropic-beta"))
   952  		return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("{}"))}, nil
   953  	})
   954  	require.NoError(t, err)
   955  	assert.Equal(t, 1, calls)
   956  }
   957  
   958  // opaqueReader hides the concrete reader type so http.NewRequest cannot set
   959  // GetBody, modeling requests built from plain io.Readers.
   960  type opaqueReader struct{ io.Reader }
   961  
   962  func TestRefusalFallbackMiddlewareWorksWithUnbufferableRequestBodies(t *testing.T) {
   963  	middleware := betafallback.BetaRefusalFallbackMiddleware(
   964  		[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
   965  	)
   966  
   967  	req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true",
   968  		opaqueReader{strings.NewReader(`{"model": "primary-model", "max_tokens": 16, "messages": []}`)})
   969  	require.NoError(t, err)
   970  	require.Nil(t, req.GetBody, "test requires an unbufferable body")
   971  	req.Header.Set("Content-Type", "application/json")
   972  
   973  	var bodies []map[string]any
   974  	responses := []string{refusalResponse("primary-model", "credit-token"), messageResponse("fallback-model")}
   975  	res, err := middleware(req, func(seen *http.Request) (*http.Response, error) {
   976  		buf, err := io.ReadAll(seen.Body)
   977  		require.NoError(t, err)
   978  		var body map[string]any
   979  		require.NoError(t, json.Unmarshal(buf, &body))
   980  		bodies = append(bodies, body)
   981  		next := responses[0]
   982  		responses = responses[1:]
   983  		return &http.Response{
   984  			StatusCode: http.StatusOK,
   985  			Header:     http.Header{"Content-Type": []string{"application/json"}},
   986  			Body:       io.NopCloser(strings.NewReader(next)),
   987  		}, nil
   988  	})
   989  	require.NoError(t, err)
   990  	require.Len(t, bodies, 2)
   991  	assert.Equal(t, "fallback-model", bodies[1]["model"])
   992  	assert.Equal(t, creditTokenBody("credit-token"), bodies[1]["fallback_credit_token"])
   993  	body, err := io.ReadAll(res.Body)
   994  	require.NoError(t, err)
   995  	assert.Contains(t, string(body), "end_turn")
   996  }
   997  
   998  // fakePlatformTransform stands in for the Bedrock/Vertex adapters below user
   999  // middleware: it moves the model from the body into the URL and "signs" the
  1000  // final body.
  1001  func fakePlatformTransform(req *http.Request, next option.MiddlewareNext) (*http.Response, error) {
  1002  	buf, err := io.ReadAll(req.Body)
  1003  	if err != nil {
  1004  		return nil, err
  1005  	}
  1006  	var body map[string]json.RawMessage
  1007  	if err := json.Unmarshal(buf, &body); err != nil {
  1008  		return nil, err
  1009  	}
  1010  	var model string
  1011  	_ = json.Unmarshal(body["model"], &model)
  1012  	delete(body, "model")
  1013  	rewritten, err := json.Marshal(body)
  1014  	if err != nil {
  1015  		return nil, err
  1016  	}
  1017  	r := req.Clone(req.Context())
  1018  	r.URL.Path = "/model/" + model + "/invoke"
  1019  	r.Body = io.NopCloser(strings.NewReader(string(rewritten)))
  1020  	r.ContentLength = int64(len(rewritten))
  1021  	r.Header.Set("X-Fake-Signature", fmt.Sprintf("%x", sha256.Sum256(rewritten)))
  1022  	return next(r)
  1023  }
  1024  
  1025  func TestRefusalFallbackMiddlewareRedirectsThroughAPlatformTransform(t *testing.T) {
  1026  	// The fallback middleware runs above the platform transform: its model
  1027  	// swap must redirect the attempt through the transform and the signature
  1028  	// must cover the rewritten body.
  1029  	var paths []string
  1030  	transport := &scriptedTransport{t: t, responses: []string{
  1031  		refusalResponse("primary-model", "credit-token"), messageResponse("fallback-model"),
  1032  	}}
  1033  	client := anthropic.NewClient(
  1034  		option.WithAPIKey("my-anthropic-api-key"),
  1035  		option.WithHTTPClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
  1036  			paths = append(paths, req.URL.Path)
  1037  			sig := req.Header.Get("X-Fake-Signature")
  1038  			buf, err := io.ReadAll(req.Body)
  1039  			require.NoError(t, err)
  1040  			assert.Equal(t, fmt.Sprintf("%x", sha256.Sum256(buf)), sig, "signature must cover the body the middleware produced")
  1041  			req.Body = io.NopCloser(strings.NewReader(string(buf)))
  1042  			return transport.RoundTrip(req)
  1043  		})}),
  1044  		option.WithMaxRetries(0),
  1045  		option.WithMiddleware(
  1046  			betafallback.BetaRefusalFallbackMiddleware(
  1047  				[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
  1048  			),
  1049  			fakePlatformTransform,
  1050  		),
  1051  	)
  1052  
  1053  	message, err := client.Beta.Messages.New(context.Background(), fallbackTestParams)
  1054  	require.NoError(t, err)
  1055  	assert.Equal(t, anthropic.Model("fallback-model"), message.Model)
  1056  	assert.Equal(t, []string{"/model/primary-model/invoke", "/model/fallback-model/invoke"}, paths)
  1057  	require.Len(t, transport.bodies, 2)
  1058  	_, hasModel := transport.bodies[1]["model"]
  1059  	assert.False(t, hasModel, "the transform moved the model out of the body")
  1060  	assert.Equal(t, creditTokenBody("credit-token"), transport.bodies[1]["fallback_credit_token"])
  1061  }
  1062  
  1063  // roundTripFunc adapts a function to http.RoundTripper.
  1064  type roundTripFunc func(*http.Request) (*http.Response, error)
  1065  
  1066  func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
  1067  
  1068  func TestRefusalFallbackMiddlewareLeavesTokenCountingAloneEvenWhenPinned(t *testing.T) {
  1069  	// Token-counting bodies share model+messages but lack max_tokens; a
  1070  	// pinned state must not rewrite them.
  1071  	client, transport := fallbackTestClient(t,
  1072  		[]string{`{"input_tokens": 10}`},
  1073  		betafallback.BetaRefusalFallbackMiddleware(
  1074  			[]anthropic.BetaFallbackParam{{Model: "fallback-model", MaxTokens: anthropic.Int(2048)}},
  1075  		),
  1076  	)
  1077  
  1078  	state := &betafallback.BetaFallbackState{}
  1079  	state.SetIndex(0)
  1080  	_, err := client.Beta.Messages.CountTokens(context.Background(), anthropic.BetaMessageCountTokensParams{
  1081  		Model:    "primary-model",
  1082  		Messages: []anthropic.BetaMessageParam{anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("hi"))},
  1083  	}, betafallback.WithBetaFallbackState(state))
  1084  	require.NoError(t, err)
  1085  	require.Len(t, transport.bodies, 1)
  1086  	assert.Equal(t, "primary-model", transport.bodies[0]["model"], "pin must not rewrite token counting")
  1087  	_, hasMax := transport.bodies[0]["max_tokens"]
  1088  	assert.False(t, hasMax)
  1089  	assert.Empty(t, transport.betas[0])
  1090  }
  1091  
  1092  func TestRefusalFallbackMiddlewareArmsTheCreditBetaOnEveryLeg(t *testing.T) {
  1093  	client, transport := fallbackTestClient(t,
  1094  		[]string{refusalResponse("primary-model", "credit-token"), messageResponse("fallback-model")},
  1095  		betafallback.BetaRefusalFallbackMiddleware(
  1096  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
  1097  		),
  1098  	)
  1099  
  1100  	_, err := client.Beta.Messages.New(context.Background(), fallbackTestParams)
  1101  	require.NoError(t, err)
  1102  	for i := range transport.betas {
  1103  		assert.Contains(t, transport.betas[i], "fallback-credit-2026-07-01", "request %d", i)
  1104  	}
  1105  }
  1106  
  1107  func TestRefusalFallbackMiddlewareTrimsReplayedFallbackTurns(t *testing.T) {
  1108  	// A fallback block in replayed history is rejected by the server; the
  1109  	// request goes out with the turn trimmed.
  1110  	client, transport := fallbackTestClient(t,
  1111  		[]string{messageResponse("primary-model")},
  1112  		betafallback.BetaRefusalFallbackMiddleware(
  1113  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
  1114  		),
  1115  	)
  1116  
  1117  	history := []map[string]any{
  1118  		{"role": "user", "content": "hi"},
  1119  		{"role": "assistant", "content": []map[string]any{
  1120  			{"type": "fallback", "from": map[string]any{"model": "primary-model"}, "to": map[string]any{"model": "fallback-model"}},
  1121  			{"type": "text", "text": "answer"},
  1122  		}},
  1123  		{"role": "user", "content": "continue"},
  1124  	}
  1125  	_, err := client.Beta.Messages.New(context.Background(), fallbackTestParams,
  1126  		option.WithJSONSet("messages", history))
  1127  	require.NoError(t, err)
  1128  	assert.Equal(t, []string{"primary-model"}, transport.models(), "a fallback block does not pin")
  1129  	sent := transport.bodies[0]["messages"].([]any)
  1130  	for _, block := range sent[1].(map[string]any)["content"].([]any) {
  1131  		assert.NotEqual(t, "fallback", block.(map[string]any)["type"], "the fallback block is removed")
  1132  	}
  1133  }
  1134  
  1135  func TestRefusalFallbackMiddlewareStandsDownOffTheBetaSurface(t *testing.T) {
  1136  	// The plain (non-beta) Messages surface gets stock behavior: one wire
  1137  	// request, no credit beta armed, the refusal surfaces verbatim.
  1138  	middleware := betafallback.BetaRefusalFallbackMiddleware(
  1139  		[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
  1140  	)
  1141  	req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages",
  1142  		strings.NewReader(`{"model": "primary-model", "messages": [], "max_tokens": 16}`))
  1143  	require.NoError(t, err)
  1144  	req.Header.Set("Content-Type", "application/json")
  1145  	calls := 0
  1146  	res, err := middleware(req, func(seen *http.Request) (*http.Response, error) {
  1147  		calls++
  1148  		assert.Empty(t, seen.Header.Values("anthropic-beta"))
  1149  		return &http.Response{
  1150  			StatusCode: http.StatusOK,
  1151  			Header:     http.Header{"Content-Type": []string{"application/json"}},
  1152  			Body:       io.NopCloser(strings.NewReader(refusalResponse("primary-model", "credit-token"))),
  1153  		}, nil
  1154  	})
  1155  	require.NoError(t, err)
  1156  	assert.Equal(t, 1, calls)
  1157  	body, err := io.ReadAll(res.Body)
  1158  	require.NoError(t, err)
  1159  	assert.Contains(t, string(body), `"refusal"`)
  1160  }
  1161  
  1162  func TestRefusalFallbackMiddlewarePrependsFallbackBlockWhenCallerArmedTheBeta(t *testing.T) {
  1163  	// A caller already opted into the credit beta sees the same stitched
  1164  	// envelope the server's own chain produces: a fallback boundary block
  1165  	// prepended to the serving hop's content.
  1166  	middleware := betafallback.BetaRefusalFallbackMiddleware(
  1167  		[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
  1168  	)
  1169  	req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true",
  1170  		strings.NewReader(`{"model": "primary-model", "messages": [], "max_tokens": 16}`))
  1171  	require.NoError(t, err)
  1172  	req.Header.Set("Content-Type", "application/json")
  1173  	req.Header.Set("anthropic-beta", string(anthropic.AnthropicBetaFallbackCredit2026_07_01))
  1174  
  1175  	served := `{
  1176  		"id": "msg_2", "type": "message", "role": "assistant", "model": "fallback-model",
  1177  		"content": [{"type": "text", "text": "served"}], "stop_reason": "end_turn",
  1178  		"stop_sequence": null, "usage": {"input_tokens": 1, "output_tokens": 1}
  1179  	}`
  1180  	responses := []string{refusalResponse("primary-model", "credit-token"), served}
  1181  	res, err := middleware(req, func(*http.Request) (*http.Response, error) {
  1182  		next := responses[0]
  1183  		responses = responses[1:]
  1184  		return &http.Response{
  1185  			StatusCode: http.StatusOK,
  1186  			Header:     http.Header{"Content-Type": []string{"application/json"}},
  1187  			Body:       io.NopCloser(strings.NewReader(next)),
  1188  		}, nil
  1189  	})
  1190  	require.NoError(t, err)
  1191  	buf, err := io.ReadAll(res.Body)
  1192  	require.NoError(t, err)
  1193  	var message struct {
  1194  		Content []map[string]any `json:"content"`
  1195  	}
  1196  	require.NoError(t, json.Unmarshal(buf, &message))
  1197  	require.Len(t, message.Content, 2)
  1198  	assert.Equal(t, map[string]any{
  1199  		"type":    "fallback",
  1200  		"from":    map[string]any{"model": "primary-model"},
  1201  		"to":      map[string]any{"model": "fallback-model"},
  1202  		"trigger": map[string]any{"type": "refusal", "category": nil},
  1203  	}, message.Content[0])
  1204  	assert.Equal(t, "served", message.Content[1]["text"])
  1205  	assert.Equal(t, int64(len(buf)), res.ContentLength)
  1206  }
  1207  
  1208  func TestRefusalFallbackMiddlewarePrependsFallbackBlockWhenMiddlewareArmedTheBeta(t *testing.T) {
  1209  	// The seam is unconditional: an auto-armed retry stitches the same
  1210  	// envelope a caller-armed one does.
  1211  	client, transport := fallbackTestClient(t,
  1212  		[]string{refusalResponse("primary-model", "credit-token"), `{
  1213  			"id": "msg_2", "type": "message", "role": "assistant", "model": "fallback-model",
  1214  			"content": [{"type": "text", "text": "served"}], "stop_reason": "end_turn",
  1215  			"stop_sequence": null, "usage": {"input_tokens": 1, "output_tokens": 1}
  1216  		}`},
  1217  		betafallback.BetaRefusalFallbackMiddleware(
  1218  			[]anthropic.BetaFallbackParam{{Model: "fallback-model"}},
  1219  		),
  1220  	)
  1221  	message, err := client.Beta.Messages.New(context.Background(), fallbackTestParams)
  1222  	require.NoError(t, err)
  1223  	require.Len(t, transport.bodies, 2)
  1224  	require.Len(t, message.Content, 2)
  1225  	assert.Equal(t, "fallback", string(message.Content[0].Type))
  1226  	assert.Equal(t, "served", message.Content[1].Text)
  1227  }