github.com/anthropics/anthropic-sdk-go@v1.70.1/lib/betafallback/betafallback.go (about) 1 // Package betafallback retries refused Messages requests down a fallback 2 // chain, client-side. Beta surface; may change. 3 package betafallback 4 5 import ( 6 "bytes" 7 "context" 8 "encoding/json" 9 "fmt" 10 "io" 11 "mime" 12 "net/http" 13 "strconv" 14 "strings" 15 "sync/atomic" 16 17 "github.com/tidwall/gjson" 18 "github.com/tidwall/sjson" 19 20 "github.com/anthropics/anthropic-sdk-go" 21 "github.com/anthropics/anthropic-sdk-go/internal/requestconfig" 22 "github.com/anthropics/anthropic-sdk-go/internal/stainlessheader" 23 "github.com/anthropics/anthropic-sdk-go/option" 24 ) 25 26 // BetaFallbackState keeps the requests that share it on the model that 27 // accepted, so a conversation never re-asks a model that already refused. 28 // Use one zero-value state per conversation, passed with 29 // [WithBetaFallbackState] on each of its requests. Safe for concurrent use; 30 // when requests race, the last pin written wins. 31 type BetaFallbackState struct { 32 // 1-based; zero targets the original request params. 33 pinned atomic.Int64 34 } 35 36 // Index reports the pinned fallback's zero-based index, or -1 when no 37 // fallback is pinned. Use it with SetIndex to persist a pin across 38 // processes. The pin is positional: against a chain that may be reordered, 39 // persist the model instead. 40 func (s *BetaFallbackState) Index() int { return int(s.pinned.Load()) - 1 } 41 42 // SetIndex pins the fallback at zero-based index i. Negative resets to the 43 // original params; a pin past the configured chain errors when used. 44 func (s *BetaFallbackState) SetIndex(i int) { 45 s.pinned.Store(int64(max(i, -1) + 1)) 46 } 47 48 type betaFallbackStateKey struct{} 49 50 // WithBetaFallbackState returns a request option carrying state for [BetaRefusalFallbackMiddleware]. 51 // Pass the same state on every request that should share the pin — 52 // typically the turns of one conversation. 53 func WithBetaFallbackState(state *BetaFallbackState) option.RequestOption { 54 return requestconfig.RequestOptionFunc(func(cfg *requestconfig.RequestConfig) error { 55 ctx := context.WithValue(cfg.Request.Context(), betaFallbackStateKey{}, state) 56 cfg.Request = cfg.Request.WithContext(ctx) 57 return nil 58 }) 59 } 60 61 // BetaRefusalFallbackMiddleware retries a refused Messages request on each 62 // model of fallbacks in turn, so a refusal costs a retry instead of 63 // surfacing to the caller. Only stop_reason "refusal" triggers a retry — 64 // never transport or API errors. There are deliberately no hooks: what 65 // happened is readable from the response, and middleware registered after 66 // this one observes every attempt. 67 // 68 // Streaming requests are retried in place: the retry's events are spliced 69 // onto the open stream behind a `fallback` boundary block, with one 70 // message_start, monotonic block indices, and a terminal usage.iterations 71 // ledger covering every hop — the same framing the server's own fallback 72 // chain produces. A mid-stream refusal retries only when it minted a 73 // fallback_credit_token (a pre-stream refusal retries either way), and when 74 // the refusal advertises a prefill claim the retry continues from the 75 // refused partial instead of starting over. 76 // 77 // A non-streaming message served by a fallback retry opens with one 78 // `fallback` content block per model boundary, matching the streaming 79 // splice's block shape; an exhausted chain's final refusal is returned 80 // verbatim. 81 // 82 // client := anthropic.NewClient( 83 // option.WithMiddleware(betafallback.BetaRefusalFallbackMiddleware( 84 // []anthropic.BetaFallbackParam{{Model: anthropic.ModelClaudeOpus4_5}}, 85 // )), 86 // ) 87 // 88 // conversation := betafallback.WithBetaFallbackState(&betafallback.BetaFallbackState{}) 89 // message, err := client.Beta.Messages.New(ctx, params, conversation) 90 // 91 // Each fallback merges over the original params, overriding only the fields 92 // it sets; a field set to JSON null (for example param.Null) is removed 93 // from that attempt. output_config merges one level deep: its set subfields 94 // override, its null subfields are removed, and the rest of the original 95 // object survives (a null output_config removes it whole). Overrides never 96 // carry from one hop to the next. 97 // An exhausted chain returns the last refusal as a normal response; 98 // an empty chain disables the middleware. 99 // 100 // Only the beta Messages surface is handled (client.Beta.Messages, which 101 // routes ?beta=true); plain client.Messages requests pass through untouched. 102 // With the Bedrock or Vertex options, register this middleware first — it 103 // needs the Anthropic-shaped request, and after the platform transform it 104 // stands down. 105 // 106 // Handled requests are opted into the fallback-credit beta so retries 107 // redeem the refusal's fallback_credit_token. A request using server-side 108 // fallbacks errors: only one of the chains can adjudicate refusals. 109 // 110 // The client retry layer treats the chain as one try; a retried request 111 // re-enters it from the original params or the state's pin. 112 func BetaRefusalFallbackMiddleware(fallbacks []anthropic.BetaFallbackParam) option.Middleware { 113 // An empty chain can never act on a refusal; stand down rather than opt 114 // requests into the credit beta. 115 if len(fallbacks) == 0 { 116 return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { 117 return next(req) 118 } 119 } 120 // A zero-model fallback merges to a no-op that silently re-sends the 121 // refused request; fail loudly instead. 122 var initErr error 123 for i := range fallbacks { 124 if fallbacks[i].Model == "" { 125 initErr = fmt.Errorf("betafallback: fallbacks[%d] has no model", i) 126 break 127 } 128 } 129 return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { 130 // Platform middlewares run below user middleware, so this path is 131 // canonical; the suffix match tolerates gateway base-URL prefixes. 132 if req.Method != http.MethodPost || !strings.HasSuffix(req.URL.Path, "/v1/messages") || req.Body == nil { 133 return next(req) 134 } 135 // The middleware is a beta feature: only the beta surface 136 // (client.Beta.Messages, which requests v1/messages?beta=true) is 137 // handled. The plain surface passes through untouched — no credit 138 // beta armed, no retry on refusal. 139 if req.URL.Query().Get("beta") != "true" { 140 return next(req) 141 } 142 mediaType, _, _ := mime.ParseMediaType(req.Header.Get("Content-Type")) 143 if !strings.Contains(mediaType, "application/json") && !strings.HasSuffix(mediaType, "+json") { 144 return next(req) 145 } 146 147 orig, err := io.ReadAll(req.Body) 148 req.Body.Close() 149 if err != nil { 150 return nil, fmt.Errorf("betafallback: reading request body: %w", err) 151 } 152 153 // Unparseable bodies pass through for the server to reject. 154 var body map[string]json.RawMessage 155 if json.Unmarshal(orig, &body) != nil { 156 return next(requestWithBody(req, orig)) 157 } 158 // model+messages+max_tokens is the create shape (token counting lacks 159 // max_tokens; the platform transforms strip model). 160 for _, key := range []string{"model", "messages", "max_tokens"} { 161 if _, ok := body[key]; !ok { 162 return next(requestWithBody(req, orig)) 163 } 164 } 165 // Tag this and every hop derived from it (requestWithBody clones 166 // req.Header) with the middleware's helper telemetry. 167 stainlessheader.AppendHeaderValue(req.Header, stainlessheader.FallbackRefusalMiddleware) 168 169 // The two chains cannot adjudicate refusals together; fail loudly 170 // rather than letting them race. 171 if _, ok := body["fallbacks"]; ok { 172 return nil, fmt.Errorf("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.") 173 } 174 if string(bytes.TrimSpace(body["stream"])) == "true" { 175 // A bad chain fails only requests the middleware would act on. 176 if initErr != nil { 177 return nil, initErr 178 } 179 state, _ := req.Context().Value(betaFallbackStateKey{}).(*BetaFallbackState) 180 if state != nil && state.Index() >= len(fallbacks) { 181 return nil, fmt.Errorf("betafallback: pinned fallback %d is out of range for a chain of %d; was the state shared with a different middleware?", state.Index(), len(fallbacks)) 182 } 183 return handleStreaming(req, next, orig, body, fallbacks, state) 184 } 185 // A bad chain fails only requests the middleware would act on. 186 if initErr != nil { 187 return nil, initErr 188 } 189 190 state, _ := req.Context().Value(betaFallbackStateKey{}).(*BetaFallbackState) 191 192 // index targets fallbacks[index]; -1 targets the original params. 193 index := -1 194 if state != nil { 195 index = state.Index() 196 if index >= len(fallbacks) { 197 return nil, fmt.Errorf("betafallback: pinned fallback %d is out of range for a chain of %d; was the state shared with a different middleware?", index, len(fallbacks)) 198 } 199 } 200 201 // History from an earlier fallback contains blocks the server 202 // rejects; trim them before sending. 203 orig = trimHistory(body, orig) 204 205 attemptRequest := func(body []byte) *http.Request { 206 r := requestWithBody(req, body) 207 ensureFallbackCreditBeta(r.Header) 208 return r 209 } 210 211 attempt := orig 212 if index >= 0 { 213 if attempt, _, err = mergeFallback(body, fallbacks[index], ""); err != nil { 214 return nil, err 215 } 216 } 217 res, err := next(attemptRequest(attempt)) 218 219 // Seams mirror the streaming splice's block shape: one fallback 220 // boundary block per retried hop, prepended to the served message's 221 // content. They are recorded optimistically before each retry is 222 // sent; prependSeams' status/stop_reason guards ensure they attach 223 // only to a served 200, so a hop that errors or the exhausted 224 // chain's final refusal never carries them. 225 var seams []seam 226 fromModel := gjson.GetBytes(attempt, "model").String() 227 228 for err == nil && index < len(fallbacks)-1 { 229 refused, creditToken, category, perr := refusedMessage(res) 230 if perr != nil { 231 return nil, perr 232 } 233 if !refused { 234 break 235 } 236 237 index++ 238 fallback := fallbacks[index] 239 if state != nil { 240 state.SetIndex(index) 241 } 242 seams = append(seams, seam{from: fromModel, to: string(fallback.Model), category: category}) 243 fromModel = string(fallback.Model) 244 var tokenSent bool 245 if attempt, tokenSent, err = mergeFallback(body, fallback, creditToken); err != nil { 246 return nil, err 247 } 248 res, err = next(attemptRequest(attempt)) 249 250 // A rejected redemption recovers with one tokenless resend. 251 if err == nil && tokenSent && res.StatusCode == http.StatusBadRequest { 252 res.Body.Close() 253 if attempt, _, err = mergeFallback(body, fallback, ""); err != nil { 254 return nil, err 255 } 256 res, err = next(attemptRequest(attempt)) 257 } 258 } 259 if err == nil && len(seams) > 0 { 260 if perr := prependSeams(res, seams); perr != nil { 261 return nil, perr 262 } 263 } 264 return res, err 265 } 266 } 267 268 // prependSeams rewrites a served message's content to open with the fallback 269 // boundary blocks, mirroring the streaming splice's block shape. An 270 // exhausted chain's refusal — and anything that isn't an inspectable served 271 // message — is left as written. 272 func prependSeams(res *http.Response, seams []seam) error { 273 if res.StatusCode != http.StatusOK || res.Header.Get("Content-Encoding") != "" { 274 return nil 275 } 276 buf, err := io.ReadAll(res.Body) 277 res.Body.Close() 278 if err != nil { 279 return fmt.Errorf("betafallback: reading response body: %w", err) 280 } 281 res.Body = io.NopCloser(bytes.NewReader(buf)) 282 if gjson.GetBytes(buf, "stop_reason").String() == string(anthropic.BetaStopReasonRefusal) { 283 return nil 284 } 285 content := gjson.GetBytes(buf, "content") 286 if !content.IsArray() { 287 return nil 288 } 289 blocks := make([]json.RawMessage, 0, len(seams)+len(content.Array())) 290 for _, sm := range seams { 291 blocks = append(blocks, sm.block()) 292 } 293 for _, b := range content.Array() { 294 blocks = append(blocks, json.RawMessage(b.Raw)) 295 } 296 if patched, err := sjson.SetRawBytes(buf, "content", mustMarshal(blocks)); err == nil { 297 res.Body = io.NopCloser(bytes.NewReader(patched)) 298 res.ContentLength = int64(len(patched)) 299 if res.Header == nil { 300 res.Header = http.Header{} 301 } 302 res.Header.Set("Content-Length", strconv.Itoa(len(patched))) 303 } 304 return nil 305 } 306 307 // trimHistory rewrites body["messages"] through trimFallbackTurns and 308 // returns the request bytes to send. On no change — or if the body cannot 309 // be rebuilt — the original bytes are returned untouched. 310 func trimHistory(body map[string]json.RawMessage, orig []byte) []byte { 311 trimmed, changed := trimFallbackTurns(body["messages"]) 312 if !changed { 313 return orig 314 } 315 body["messages"] = trimmed 316 rebuilt, err := json.Marshal(body) 317 if err != nil { 318 return orig 319 } 320 return rebuilt 321 } 322 323 // trimFallbackTurns removes content the server would reject from assistant 324 // turns that contain a fallback block: the fallback block itself (only 325 // accepted under a beta this middleware does not send), and everything 326 // before it that belongs to the model that refused — thinking, connector 327 // text, and tool calls that never got a result. Blocks after the fallback 328 // block are what the serving model produced and stay as written. Turns 329 // without a fallback block are never touched. 330 func trimFallbackTurns(messages json.RawMessage) (json.RawMessage, bool) { 331 parsed := gjson.ParseBytes(messages) 332 if !parsed.IsArray() { 333 return messages, false 334 } 335 // Tool calls whose result appears anywhere in the history are kept. 336 resolved := map[string]bool{} 337 parsed.ForEach(func(_, msg gjson.Result) bool { 338 content := msg.Get("content") 339 if content.IsArray() { 340 content.ForEach(func(_, block gjson.Result) bool { 341 typ := block.Get("type").String() 342 if typ == "tool_result" || strings.HasSuffix(typ, "_tool_result") { 343 resolved[block.Get("tool_use_id").String()] = true 344 } 345 return true 346 }) 347 } 348 return true 349 }) 350 351 changed := false 352 var msgs []json.RawMessage 353 parsed.ForEach(func(_, msg gjson.Result) bool { 354 content := msg.Get("content") 355 hasFallback := false 356 if msg.Get("role").String() == "assistant" && content.IsArray() { 357 content.ForEach(func(_, block gjson.Result) bool { 358 if block.Get("type").String() == "fallback" { 359 hasFallback = true 360 } 361 return !hasFallback 362 }) 363 } 364 if !hasFallback { 365 msgs = append(msgs, json.RawMessage(msg.Raw)) 366 return true 367 } 368 all := content.Array() 369 lastFallback := -1 370 for i, block := range all { 371 if block.Get("type").String() == "fallback" { 372 lastFallback = i 373 } 374 } 375 var blocks []json.RawMessage 376 for i, block := range all { 377 typ := block.Get("type").String() 378 switch { 379 case typ == "fallback": 380 case i < lastFallback && (typ == "thinking" || typ == "redacted_thinking" || typ == "connector_text"): 381 case i < lastFallback && typ == "tool_use": 382 case i < lastFallback && typ == "server_tool_use" && !resolved[block.Get("id").String()]: 383 default: 384 blocks = append(blocks, json.RawMessage(block.Raw)) 385 } 386 } 387 changed = true 388 rebuilt, err := sjson.SetRawBytes([]byte(msg.Raw), "content", mustMarshal(blocks)) 389 if err != nil { 390 msgs = append(msgs, json.RawMessage(msg.Raw)) 391 return true 392 } 393 msgs = append(msgs, rebuilt) 394 return true 395 }) 396 if !changed { 397 return messages, false 398 } 399 return mustMarshal(msgs), true 400 } 401 402 // requestWithBody clones req around a replayable body. 403 func requestWithBody(req *http.Request, body []byte) *http.Request { 404 r := req.Clone(req.Context()) 405 r.Body = io.NopCloser(bytes.NewReader(body)) 406 r.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil } 407 r.ContentLength = int64(len(body)) 408 return r 409 } 410 411 // ensureFallbackCreditBeta opts a request into the fallback-credit beta. 412 func ensureFallbackCreditBeta(h http.Header) { 413 if hasFallbackCreditBeta(h) { 414 return 415 } 416 h.Add("anthropic-beta", string(anthropic.AnthropicBetaFallbackCredit2026_07_01)) 417 } 418 419 // hasFallbackCreditBeta reports whether the headers already carry the 420 // fallback-credit beta. 421 func hasFallbackCreditBeta(h http.Header) bool { 422 for _, value := range h.Values("anthropic-beta") { 423 for _, beta := range strings.Split(value, ",") { 424 if strings.TrimSpace(beta) == string(anthropic.AnthropicBetaFallbackCredit2026_07_01) { 425 return true 426 } 427 } 428 } 429 return false 430 } 431 432 // mergeFallback overlays fallback's fields and the credit token onto the 433 // original body, reporting whether the token was included. The body stays a 434 // raw map: a typed round-trip would drop unknown fields and rewrite 435 // encodings that prompt caching and the token's body match depend on. 436 func mergeFallback(body map[string]json.RawMessage, fallback anthropic.BetaFallbackParam, creditToken string) ([]byte, bool, error) { 437 overrides, err := json.Marshal(fallback) 438 if err != nil { 439 return nil, false, fmt.Errorf("betafallback: marshaling fallback params: %w", err) 440 } 441 var overlay map[string]json.RawMessage 442 if err := json.Unmarshal(overrides, &overlay); err != nil { 443 return nil, false, fmt.Errorf("betafallback: parsing fallback params: %w", err) 444 } 445 446 merged := make(map[string]json.RawMessage, len(body)+len(overlay)+1) 447 for k, v := range body { 448 merged[k] = v 449 } 450 for k, v := range overlay { 451 // A fallback field set to JSON null unsets the original field: the 452 // retry omits the key rather than sending null or keeping the 453 // original value. 454 if isJSONNull(v) { 455 delete(merged, k) 456 continue 457 } 458 // output_config patches one level deep: subfields the fallback sets 459 // override the original's, subfields it sets to null are unset, and 460 // unmentioned subfields survive. An object left with no subfields 461 // is dropped rather than sent as {}. 462 if k == "output_config" { 463 patched, ok, err := patchObject(body[k], v) 464 if err != nil { 465 return nil, false, fmt.Errorf("betafallback: merging output_config: %w", err) 466 } 467 if ok { 468 merged[k] = patched 469 } else { 470 delete(merged, k) 471 } 472 continue 473 } 474 merged[k] = v 475 } 476 477 // A token cannot combine with server-side fallback params, and a stale 478 // token from the original body cannot match this retry. 479 delete(merged, "fallback") 480 delete(merged, "fallbacks") 481 if creditToken != "" { 482 // The object form with best_effort mode: a token-layer redemption 483 // failure serves the retry at normal price instead of failing it. 484 token, err := json.Marshal(anthropic.BetaFallbackCreditTokenParam{ 485 Token: creditToken, 486 Mode: anthropic.BetaFallbackCreditTokenParamModeBestEffort, 487 }) 488 if err != nil { 489 return nil, false, fmt.Errorf("betafallback: marshaling credit token: %w", err) 490 } 491 merged["fallback_credit_token"] = token 492 } else { 493 delete(merged, "fallback_credit_token") 494 } 495 out, err := json.Marshal(merged) 496 return out, creditToken != "", err 497 } 498 499 func isJSONNull(v json.RawMessage) bool { 500 return string(bytes.TrimSpace(v)) == "null" 501 } 502 503 // patchObject applies overlay onto base one level deep: overlay keys set to 504 // a value override, keys set to null are removed, and base keys the overlay 505 // leaves out survive. A missing or null base starts empty. ok is false when 506 // no keys remain, so the caller drops the field instead of sending {}. 507 func patchObject(base, overlay json.RawMessage) (patched json.RawMessage, ok bool, err error) { 508 var merged map[string]json.RawMessage 509 if len(base) > 0 && !isJSONNull(base) { 510 if err := json.Unmarshal(base, &merged); err != nil { 511 return nil, false, err 512 } 513 } 514 if merged == nil { 515 merged = map[string]json.RawMessage{} 516 } 517 var patch map[string]json.RawMessage 518 if err := json.Unmarshal(overlay, &patch); err != nil { 519 return nil, false, err 520 } 521 for k, v := range patch { 522 if isJSONNull(v) { 523 delete(merged, k) 524 continue 525 } 526 merged[k] = v 527 } 528 if len(merged) == 0 { 529 return nil, false, nil 530 } 531 patched, err = json.Marshal(merged) 532 return patched, err == nil, err 533 } 534 535 // refusedMessage reports whether res is a message that stopped with a refusal, 536 // its credit token, and its policy category (nil when not surfaced), restoring 537 // res.Body for the caller. 538 func refusedMessage(res *http.Response) (refused bool, creditToken string, category any, err error) { 539 if res.StatusCode != http.StatusOK { 540 return false, "", nil, nil 541 } 542 // A still-encoded body (caller-set Accept-Encoding) can't be inspected. 543 if res.Header.Get("Content-Encoding") != "" { 544 return false, "", nil, nil 545 } 546 buf, err := io.ReadAll(res.Body) 547 res.Body.Close() 548 if err != nil { 549 return false, "", nil, fmt.Errorf("betafallback: reading response body: %w", err) 550 } 551 res.Body = io.NopCloser(bytes.NewReader(buf)) 552 if gjson.GetBytes(buf, "stop_reason").String() != string(anthropic.BetaStopReasonRefusal) { 553 return false, "", nil, nil 554 } 555 if cat := gjson.GetBytes(buf, "stop_details.category"); cat.Type == gjson.String { 556 category = cat.String() 557 } 558 return true, gjson.GetBytes(buf, "stop_details.fallback_credit_token").String(), category, nil 559 }