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 }