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  }