anthropic 1.53.0 → 1.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e43b01c86b0bf82403e7b5bf6f370b96709d992362d8785f2510ac2bafadf116
4
- data.tar.gz: 90056a9212f774f71ed5a7352a970d185d19d8e298f18aca6bdd2569f6f41e90
3
+ metadata.gz: e9ca7e380442d7abed6048df2f97b8e433050c70b5059ab39336a1ae23756721
4
+ data.tar.gz: a024415de2debc831abfc58a0c4c6568af691a2273d25bf3ac3571a4a3f1eb64
5
5
  SHA512:
6
- metadata.gz: 8ad9ae5f0804c2d73482375353a4fc81ade9f2d354dc5b3cec8f221be7f54b504a3dac8cab79e400e4f966bf4910a31847d4a8bd43f17e93da21857377c21e3c
7
- data.tar.gz: 48c3d3ec2d83f9e4d94f9bcae37dbe63212acd27882730ac8f47285e0e1d1fbcf3e0568e1df5234cc595fe1961cf4fd259a4e068f736f2fb0aff438a5886d974
6
+ metadata.gz: d5b653770331cefb105622a73100a27eee59b88d248956ca6633f031bb28b681724259f50302b4a404560ba0d3862686db17c4b148f704dcb49e7d076a00390a
7
+ data.tar.gz: 63b9c7b7edbba3c7eacc95a860d287e188290782be1c63d809fe7e85ecb566121ec4bbeceb8d4c12478195cf5e7467a2e9bd1679ddb62f57223945eff8437c68
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.54.0 (2026-07-01)
4
+
5
+ Full Changelog: [v1.53.0...v1.54.0](https://github.com/anthropics/anthropic-sdk-ruby/compare/v1.53.0...v1.54.0)
6
+
7
+ ### Features
8
+
9
+ * **client:** add client-side fallbacks middleware for API providers that do not support server-side fallbacks ([#35](https://github.com/anthropics/anthropic-sdk-ruby/issues/35)) ([880a6b9](https://github.com/anthropics/anthropic-sdk-ruby/commit/880a6b9ec0482540da338cc3afb6fdb82eca887f))
10
+
3
11
  ## 1.53.0 (2026-06-30)
4
12
 
5
13
  Full Changelog: [v1.52.0...v1.53.0](https://github.com/anthropics/anthropic-sdk-ruby/compare/v1.52.0...v1.53.0)
data/README.md CHANGED
@@ -15,7 +15,7 @@ Add to your application's Gemfile:
15
15
  <!-- x-release-please-start-version -->
16
16
 
17
17
  ```ruby
18
- gem "anthropic", "~> 1.53.0"
18
+ gem "anthropic", "~> 1.54.0"
19
19
  ```
20
20
 
21
21
  <!-- x-release-please-end -->
@@ -0,0 +1,633 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anthropic
4
+ # Tracks which fallback a sequence of requests is pinned to.
5
+ #
6
+ # Create one (`Anthropic::BetaFallbackState.new`) and pass it via the
7
+ # `fallback_state` request option on every request that should share the
8
+ # pin — the turns of one conversation, or any wider scope the stickiness
9
+ # should apply to; {BetaRefusalFallbackMiddleware} mutates it in place when
10
+ # a model refuses.
11
+ class BetaFallbackState
12
+ # Index into the fallback chain the requests are pinned to.
13
+ #
14
+ # `nil` (or `-1`) targets the original request params; the middleware sets
15
+ # it to the index of the fallback that accepted the request.
16
+ #
17
+ # @return [Integer, nil]
18
+ attr_accessor :index
19
+ end
20
+
21
+ # Middleware that retries refused `/v1/messages` requests down a fallback
22
+ # chain.
23
+ #
24
+ # Non-streaming: when a response comes back with `stop_reason: "refusal"`,
25
+ # the request is retried with each entry of `fallbacks` merged over the
26
+ # original params — passing along the refusal's `fallback_credit_token` —
27
+ # until a model accepts or the chain is exhausted. A message served by a
28
+ # fallback carries a `fallback` content block prepended at each model
29
+ # boundary; an exhausted chain surfaces the final refusal verbatim.
30
+ #
31
+ # Streaming: when the stream ends in `stop_reason: "refusal"`, a second
32
+ # request is issued to the fallback model — carrying the refused model's
33
+ # partial output as a trailing assistant prefill when the refusal grants one
34
+ # (`fallback_has_prefill_claim`), plus the refusal's `fallback_credit_token`
35
+ # — and the fallback's events are spliced onto the still-open stream, so the
36
+ # client sees one continuous message: a `fallback` content block at each
37
+ # model boundary, monotonic block indices, and per-hop `usage.iterations` on
38
+ # the final `message_delta`. Only `model` is honored from each entry on this
39
+ # path: the credit token is redeemable only against the refused request's
40
+ # body, so the other per-entry overrides would be rejected.
41
+ #
42
+ # The fallback-credit beta the credit tokens require is sent by default on
43
+ # every request the middleware handles; the `betas:` option controls this.
44
+ #
45
+ # To keep later requests on the model that accepted, pass a
46
+ # {BetaFallbackState} via the `fallback_state` request option; requests
47
+ # sharing that state start directly at the pinned fallback.
48
+ #
49
+ # @example
50
+ # client = Anthropic::Client.new(
51
+ # middleware: [Anthropic::BetaRefusalFallbackMiddleware.new([{model: "claude-opus-4-8"}])]
52
+ # )
53
+ #
54
+ # state = Anthropic::BetaFallbackState.new
55
+ # message = client.beta.messages.create(**params, request_options: {fallback_state: state})
56
+ class BetaRefusalFallbackMiddleware
57
+ include Anthropic::Middleware
58
+
59
+ # Betas sent by default; override with the `betas:` option.
60
+ DEFAULT_BETAS = ["fallback-credit-2026-06-01"].freeze
61
+
62
+ # @param fallbacks [Array<Hash, Anthropic::Models::Beta::BetaFallbackParam>]
63
+ # the fallback chain, tried in order. Each entry must carry `model:`; on
64
+ # the non-streaming path the remaining keys (`max_tokens:`, `thinking:`,
65
+ # …) are merged over the original request body for that hop.
66
+ # @param betas [Array<String>] betas added to the `anthropic-beta` header
67
+ # of every `/v1/messages` request this middleware handles. Defaults to
68
+ # `["fallback-credit-2026-06-01"]`; pass `[]` to send none.
69
+ def initialize(fallbacks, betas: DEFAULT_BETAS)
70
+ @fallbacks = fallbacks.map { _1.respond_to?(:to_h) ? _1.to_h : _1 }.freeze
71
+ @betas = betas.freeze
72
+ @warned_missing_state = false
73
+ end
74
+
75
+ # @param req [Anthropic::APIRequest]
76
+ # @param nxt [#call]
77
+ # @return [Anthropic::APIResponse]
78
+ def call(req, nxt)
79
+ return nxt.call(req) unless applies?(req)
80
+
81
+ if req.body.key?(:fallbacks)
82
+ raise Anthropic::Errors::Error,
83
+ "Sending the `fallbacks:` request param is not supported when using " \
84
+ "`BetaRefusalFallbackMiddleware`. Either remove the middleware and send " \
85
+ "`fallbacks:` with the `server-side-fallback-2026-06-01` beta header to " \
86
+ "let the API handle refusal fallbacks, or omit `fallbacks:` to let the " \
87
+ "middleware handle them on the client side."
88
+ end
89
+
90
+ req = with_middleware_headers(req)
91
+ body = strip_fallback_blocks(req.body)
92
+ state = req.options[:fallback_state]
93
+
94
+ start_index = state&.index || -1
95
+ unless start_index.is_a?(Integer) && start_index >= -1 && start_index < @fallbacks.length
96
+ raise Anthropic::Errors::Error,
97
+ "fallback_state.index #{start_index} is out of bounds for a chain of " \
98
+ "#{@fallbacks.length} fallback(s); was the state shared with a different middleware?"
99
+ end
100
+
101
+ pin = lambda do |index|
102
+ if state
103
+ state.index = index
104
+ elsif !@warned_missing_state
105
+ @warned_missing_state = true
106
+ warn(
107
+ "anthropic-sdk: BetaRefusalFallbackMiddleware fell back without a `fallback_state` " \
108
+ "request option; follow-up requests will retry models that already refused. Pass a " \
109
+ "shared `request_options: {fallback_state: Anthropic::BetaFallbackState.new}` to pin " \
110
+ "them to the accepted model."
111
+ )
112
+ end
113
+ end
114
+
115
+ streaming = req.streaming?
116
+ initial_body =
117
+ if start_index == -1
118
+ body
119
+ elsif streaming
120
+ # Only `model` is honored on the streaming path so the credit token
121
+ # — redeemable only against the refused request's body — stays valid
122
+ # for the spliced hops.
123
+ body.merge(model: @fallbacks.fetch(start_index).fetch(:model))
124
+ else
125
+ body.merge(@fallbacks.fetch(start_index))
126
+ end
127
+ res = nxt.call(req.with(body: initial_body))
128
+ return res unless res.status < 300
129
+
130
+ if streaming
131
+ first_hop = start_index + 1
132
+ return res unless first_hop < @fallbacks.length
133
+ return splice_fallback_stream(req, res, nxt, body, first_hop, pin)
134
+ end
135
+
136
+ handle_non_streaming(req, res, nxt, body, start_index, pin)
137
+ end
138
+
139
+ private
140
+
141
+ # @return [Boolean] whether `req` is a `client.beta.messages` create call
142
+ # with a `Hash` body and a non-empty fallback chain.
143
+ def applies?(req)
144
+ return false if @fallbacks.empty?
145
+ return false unless req.method == :post
146
+ return false unless req.body.is_a?(Hash)
147
+ return false unless req.url.path&.end_with?("/v1/messages")
148
+ query = URI.decode_www_form(req.url.query.to_s).to_h
149
+ query["beta"] == "true"
150
+ end
151
+
152
+ # @return [Anthropic::APIRequest] `req` with `@betas` appended to the
153
+ # `anthropic-beta` header (skipping values already present) and the
154
+ # fallback helper tag appended to `x-stainless-helper` — every request
155
+ # the middleware handles, original and each hop, reuses these headers.
156
+ def with_middleware_headers(req)
157
+ headers = {
158
+ Helpers::StainlessHelperHeader::HEADER =>
159
+ Helpers::StainlessHelperHeader.merged_value(
160
+ req.headers,
161
+ Helpers::StainlessHelperHeader::FALLBACK_REFUSAL_MIDDLEWARE
162
+ )
163
+ }
164
+ unless @betas.empty?
165
+ existing = req.headers["anthropic-beta"].to_s.split(",").map(&:strip)
166
+ headers["anthropic-beta"] = (existing + @betas).reject(&:empty?).uniq.join(",")
167
+ end
168
+ req.with(headers: req.headers.merge(headers))
169
+ end
170
+
171
+ # Remove `fallback` blocks replayed in history. They only parse under the
172
+ # server-side fallback beta, which this middleware never sends, so a
173
+ # request replaying them would 400. An assistant turn left empty is dropped
174
+ # whole.
175
+ #
176
+ # @param body [Hash{Symbol=>Object}]
177
+ # @return [Hash{Symbol=>Object}]
178
+ def strip_fallback_blocks(body)
179
+ messages = body.fetch(:messages, []).filter_map do |msg|
180
+ content = msg[:content]
181
+ next msg unless content.is_a?(Array)
182
+ filtered = content.reject do |b|
183
+ type = b.is_a?(Hash) ? (b[:type] || b["type"]) : b.type
184
+ type.to_s == "fallback"
185
+ end
186
+ next nil if filtered.empty?
187
+ msg.merge(content: filtered)
188
+ end
189
+ body.merge(messages: messages)
190
+ end
191
+
192
+ # --- non-streaming ------------------------------------------------------
193
+
194
+ # @param req [Anthropic::APIRequest]
195
+ # @param res [Anthropic::APIResponse]
196
+ # @param nxt [#call]
197
+ # @param body [Hash{Symbol=>Object}] the stripped request body
198
+ # @param start_index [Integer]
199
+ # @param pin [Proc]
200
+ # @return [Anthropic::APIResponse]
201
+ def handle_non_streaming(req, res, nxt, body, start_index, pin)
202
+ index = start_index
203
+ from_model = (start_index == -1 ? body : @fallbacks.fetch(start_index))[:model]
204
+ fallback_blocks = []
205
+ last_refusal = res
206
+
207
+ while index < @fallbacks.length - 1
208
+ message = decode_message(res)
209
+ break unless message && message["type"] == "message" && message["stop_reason"] == "refusal"
210
+ last_refusal = res
211
+ token = message.dig("stop_details", "fallback_credit_token")
212
+
213
+ index += 1
214
+ entry = @fallbacks.fetch(index)
215
+ hop_body = body.merge(entry)
216
+ hop_body = hop_body.merge(fallback_credit_token: token) if token
217
+
218
+ begin
219
+ hop_res = nxt.call(req.with(body: hop_body))
220
+ rescue StandardError
221
+ hop_res = nil
222
+ end
223
+
224
+ if hop_res.nil? || hop_res.status >= 300
225
+ next if index < @fallbacks.length - 1
226
+ return last_refusal
227
+ end
228
+
229
+ fallback_blocks << {
230
+ type: "fallback",
231
+ from: {model: (from_model || message["model"]).to_s},
232
+ to: {model: entry.fetch(:model).to_s}
233
+ }
234
+ from_model = entry.fetch(:model)
235
+ res = hop_res
236
+ end
237
+
238
+ return res if fallback_blocks.empty?
239
+
240
+ served = decode_message(res)
241
+ if served.nil? || served["type"] != "message" || served["stop_reason"] == "refusal" ||
242
+ !served["content"].is_a?(Array)
243
+ return res
244
+ end
245
+
246
+ pin.call(index)
247
+ served = served.merge("content" => fallback_blocks + served["content"])
248
+ Anthropic::APIResponse.new(
249
+ status: res.status,
250
+ headers: res.headers.merge("content-type" => "application/json").except("content-length"),
251
+ body: JSON.generate(served),
252
+ raw: res.raw,
253
+ request: res.request
254
+ )
255
+ end
256
+
257
+ # @param res [Anthropic::APIResponse]
258
+ # @return [Hash{String=>Object}, nil] the JSON body decoded with String
259
+ # keys, or `nil` on a non-JSON or unparseable body.
260
+ def decode_message(res)
261
+ res.buffer!
262
+ JSON.parse(res.body.to_a.join)
263
+ rescue JSON::ParserError
264
+ nil
265
+ end
266
+
267
+ # --- streaming ----------------------------------------------------------
268
+
269
+ # Wrap stream A in a response whose body passes events through until a
270
+ # retryable refusal, then splices the fallback chain's events on.
271
+ #
272
+ # @param req [Anthropic::APIRequest]
273
+ # @param res [Anthropic::APIResponse]
274
+ # @param nxt [#call]
275
+ # @param body [Hash{Symbol=>Object}] the stripped request body
276
+ # @param first_hop [Integer]
277
+ # @param pin [Proc]
278
+ # @return [Anthropic::APIResponse]
279
+ def splice_fallback_stream(req, res, nxt, body, first_hop, pin)
280
+ active_hop = [nil]
281
+ res.wrap_body do |upstream|
282
+ iter = Enumerator.new do |y|
283
+ spliced_events(y, upstream, req, nxt, body, first_hop, pin, active_hop)
284
+ end
285
+ Anthropic::Internal::Util.fused_enum(iter) do
286
+ Anthropic::Internal::Util.close_fused!(active_hop[0])
287
+ Anthropic::Internal::Util.close_fused!(upstream)
288
+ end
289
+ end
290
+ end
291
+
292
+ # rubocop:disable Metrics/BlockLength, Lint/NonLocalExitFromIterator
293
+
294
+ # @param y [Enumerator::Yielder]
295
+ def spliced_events(y, upstream, req, nxt, body, first_hop, pin, active_hop)
296
+ a = consume_hop(y, upstream, index_base: 0, has_next: true, splice: nil)
297
+ return unless a[:refused]
298
+ Anthropic::Internal::Util.close_fused!(upstream)
299
+
300
+ next_index = a[:next_index]
301
+ token = a.dig(:refused, :token)
302
+ base = []
303
+ partial = a.dig(:refused, :has_prefill_claim) ? to_prefill_blocks(a[:blocks]) : []
304
+ from_model = a[:model] || ""
305
+ last_usage = a.dig(:refused, :usage)
306
+ refusal_details = a.dig(:refused, :stop_details)
307
+
308
+ iterations = [iteration_usage("message", a[:model] || "", a.dig(:refused, :usage))]
309
+
310
+ (first_hop...@fallbacks.length).each do |hop|
311
+ model = @fallbacks.fetch(hop).fetch(:model).to_s
312
+ has_next = hop + 1 < @fallbacks.length
313
+
314
+ res_b, sent, failed = issue_hop(req, nxt, body, model, token, base, partial)
315
+
316
+ if failed
317
+ next if has_next
318
+ stop_details = refusal_details.merge("recommended_model" => model)
319
+ y << emit(
320
+ "message_delta",
321
+ {
322
+ type: "message_delta",
323
+ context_management: nil,
324
+ delta: {
325
+ stop_reason: "refusal",
326
+ stop_sequence: nil,
327
+ container: nil,
328
+ stop_details: stop_details
329
+ },
330
+ usage: last_usage || {}
331
+ }
332
+ )
333
+ y << emit("message_stop", {type: "message_stop"})
334
+ return
335
+ end
336
+
337
+ fb_index = next_index
338
+ next_index += 1
339
+ y << emit(
340
+ "content_block_start",
341
+ {
342
+ type: "content_block_start",
343
+ index: fb_index,
344
+ content_block: {type: "fallback", from: {model: from_model}, to: {model: model}}
345
+ }
346
+ )
347
+ y << emit("content_block_stop", {type: "content_block_stop", index: fb_index})
348
+
349
+ active_hop[0] = res_b.body
350
+ b = consume_hop(
351
+ y,
352
+ res_b.body,
353
+ index_base: next_index,
354
+ has_next: has_next,
355
+ splice: {iterations: iterations, model: model}
356
+ )
357
+ unless b[:refused]
358
+ active_hop[0] = nil
359
+ pin.call(hop) if b[:accepted]
360
+ return
361
+ end
362
+ Anthropic::Internal::Util.close_fused!(res_b.body)
363
+ active_hop[0] = nil
364
+
365
+ token = b.dig(:refused, :token)
366
+ refusal_details = b.dig(:refused, :stop_details)
367
+ base = sent
368
+ partial = b.dig(:refused, :has_prefill_claim) ? to_prefill_blocks(b[:blocks]) : []
369
+ iterations << iteration_usage("message", model, b.dig(:refused, :usage))
370
+ last_usage = b.dig(:refused, :usage)
371
+ from_model = model
372
+ next_index = b[:next_index]
373
+ end
374
+ end
375
+
376
+ # Issue one fallback hop with a single 400-on-prefill retry.
377
+ #
378
+ # @return [Array(Anthropic::APIResponse, Array<Hash>, false), Array(nil, Array<Hash>, true)]
379
+ # the response, the continuation actually sent (`base + partial`, or
380
+ # `base` if the prefill was dropped on retry), and whether the hop failed.
381
+ def issue_hop(req, nxt, body, model, token, base, partial)
382
+ continuation = base + partial
383
+ 2.times do |attempt|
384
+ hop_body = body.merge(model: model, fallback_credit_token: token)
385
+ unless continuation.empty?
386
+ hop_body = hop_body.merge(
387
+ messages: body.fetch(:messages) + [{role: :assistant, content: continuation}]
388
+ )
389
+ end
390
+
391
+ begin
392
+ res = nxt.call(req.with(body: hop_body))
393
+ rescue StandardError
394
+ return [nil, continuation, true]
395
+ end
396
+ return [res, continuation, false] if res.status < 300
397
+
398
+ if attempt.zero? && res.status == 400 && !partial.empty?
399
+ err_body =
400
+ begin
401
+ JSON.parse(res.buffer!(force: true).body.to_a.join)
402
+ rescue JSON::ParserError, ArgumentError
403
+ nil
404
+ end
405
+ warn(
406
+ "anthropic-sdk: BetaRefusalFallbackMiddleware: fallback request with the partial " \
407
+ "output appended was rejected (HTTP 400: #{JSON.generate(err_body)}); retrying without it"
408
+ )
409
+ continuation = base
410
+ partial = []
411
+ next
412
+ end
413
+ return [nil, continuation, true]
414
+ end
415
+ end
416
+
417
+ # Consume one hop's SSE events, forwarding them to `y` while accumulating
418
+ # its content blocks. See the TypeScript SDK's `consumeHop` for the full
419
+ # contract.
420
+ #
421
+ # @param y [Enumerator::Yielder]
422
+ # @param upstream [Enumerable<String>]
423
+ # @param index_base [Integer]
424
+ # @param has_next [Boolean]
425
+ # @param splice [Hash, nil]
426
+ # @return [Hash{Symbol=>Object}]
427
+ def consume_hop(y, upstream, index_base:, has_next:, splice:)
428
+ tracker = BlockTracker.new(index_base)
429
+ model = nil
430
+ start_usage = nil
431
+ accepted = true
432
+
433
+ lines = Anthropic::Internal::Util.decode_lines(upstream)
434
+ Anthropic::Internal::Util.decode_sse(lines).each do |sse|
435
+ data = sse[:data]
436
+ p = data && JSON.parse(data) rescue nil # rubocop:disable Style/RescueModifier
437
+
438
+ case p && p["type"]
439
+ when "message_start"
440
+ model = p.dig("message", "model")
441
+ start_usage = p.dig("message", "usage")
442
+ next if splice
443
+ when "content_block_start"
444
+ tracker.start(p)
445
+ (y << emit(p["type"], p)) && next if splice
446
+ when "content_block_delta"
447
+ tracker.delta(p)
448
+ (y << emit(p["type"], p)) && next if splice
449
+ when "content_block_stop"
450
+ tracker.stop(p)
451
+ (y << emit(p["type"], p)) && next if splice
452
+ when "message_delta"
453
+ refused = p.dig("delta", "stop_reason") == "refusal"
454
+ accepted = false if refused
455
+ if refused
456
+ details = p.dig("delta", "stop_details")
457
+ details = nil unless details.is_a?(Hash) && details["type"] == "refusal"
458
+ if details && details["fallback_credit_token"] && has_next
459
+ usage = backfill(p["usage"], start_usage)
460
+ tracker.close_open_blocks(y)
461
+ return {
462
+ refused: {
463
+ token: details["fallback_credit_token"],
464
+ has_prefill_claim: details["fallback_has_prefill_claim"] == true,
465
+ usage: usage,
466
+ stop_details: details
467
+ },
468
+ model: model,
469
+ blocks: tracker.content_blocks,
470
+ next_index: tracker.next_index
471
+ }
472
+ end
473
+ end
474
+ if splice
475
+ usage = backfill(p["usage"], start_usage)
476
+ iter_type = refused ? "message" : "fallback_message"
477
+ usage["iterations"] = splice[:iterations] + [iteration_usage(iter_type, splice[:model], usage)]
478
+ p["usage"] = usage
479
+ y << emit("message_delta", p)
480
+ next
481
+ end
482
+ end
483
+
484
+ y << passthrough_sse(sse)
485
+ end
486
+
487
+ {
488
+ refused: nil,
489
+ accepted: accepted,
490
+ model: model,
491
+ blocks: tracker.content_blocks,
492
+ next_index: tracker.next_index
493
+ }
494
+ end
495
+ # rubocop:enable Metrics/BlockLength, Lint/NonLocalExitFromIterator
496
+
497
+ # @param blocks [Array<Hash>]
498
+ # @return [Array<Hash>]
499
+ def to_prefill_blocks(blocks)
500
+ blocks.map do |b|
501
+ next b unless b.key?("_partial_json")
502
+ partial = b.delete("_partial_json")
503
+ b["input"] = JSON.parse(partial) rescue b["input"] # rubocop:disable Style/RescueModifier
504
+ b
505
+ end
506
+ end
507
+
508
+ # @return [Hash{String=>Object}]
509
+ def iteration_usage(type, model, u)
510
+ u ||= {}
511
+ {
512
+ "type" => type,
513
+ "model" => model,
514
+ "input_tokens" => u["input_tokens"] || 0,
515
+ "output_tokens" => u["output_tokens"] || 0,
516
+ "cache_read_input_tokens" => u["cache_read_input_tokens"] || 0,
517
+ "cache_creation_input_tokens" => u["cache_creation_input_tokens"] || 0,
518
+ "cache_creation" => u["cache_creation"]
519
+ }
520
+ end
521
+
522
+ # Fill `nil`/missing fields on `primary` from `fallback`.
523
+ #
524
+ # @return [Hash{String=>Object}]
525
+ def backfill(primary, fallback)
526
+ out = (fallback || {}).merge(primary || {})
527
+ out.each { |k, v| out[k] = fallback[k] if v.nil? && fallback&.key?(k) }
528
+ out
529
+ end
530
+
531
+ # @return [String] the SSE wire bytes for `payload`.
532
+ def emit(event, payload)
533
+ "event: #{event}\ndata: #{JSON.generate(payload)}\n\n"
534
+ end
535
+
536
+ # Forward a decoded event in (close to) its original wire bytes. SSE
537
+ # `id:`/`retry:` fields and comment lines are not preserved — the decoder
538
+ # in `Util.decode_sse` does not surface raw lines — but `event:` and the
539
+ # full multi-line `data:` payload are.
540
+ #
541
+ # @param sse [Hash{Symbol=>Object}]
542
+ # @return [String]
543
+ def passthrough_sse(sse)
544
+ out = +""
545
+ out << "event: #{sse[:event]}\n" if sse[:event]
546
+ sse[:data].to_s.chomp.split("\n", -1).each { out << "data: #{_1}\n" } if sse[:data]
547
+ out << "id: #{sse[:id]}\n" if sse[:id]
548
+ out << "retry: #{sse[:retry]}\n" if sse[:retry]
549
+ out << "\n"
550
+ end
551
+
552
+ # Block bookkeeping for one stream of the splice: accumulates each content
553
+ # block from its deltas (for the continuation prefill), shifts wire indices
554
+ # by `index_base` so they stay monotonic across hops, and tracks which
555
+ # blocks are still open so a refusal that cuts mid-block can close them.
556
+ class BlockTracker
557
+ # @return [Integer] one past the highest shifted block index seen
558
+ attr_reader :next_index
559
+
560
+ # @param index_base [Integer]
561
+ def initialize(index_base = 0)
562
+ @index_base = index_base
563
+ @next_index = index_base
564
+ @blocks = []
565
+ @open = []
566
+ end
567
+
568
+ # @return [Array<Hash>] the accumulated content blocks, in start order
569
+ def content_blocks = @blocks.map { _1[:block] }
570
+
571
+ # @param event [Hash]
572
+ def start(event)
573
+ @blocks << {index: event["index"], block: deep_dup(event["content_block"])}
574
+ event["index"] += @index_base
575
+ @open << event["index"]
576
+ @next_index = [@next_index, event["index"] + 1].max
577
+ end
578
+
579
+ # @param event [Hash]
580
+ def delta(event)
581
+ apply_delta(event["index"], event["delta"])
582
+ event["index"] += @index_base
583
+ end
584
+
585
+ # @param event [Hash]
586
+ def stop(event)
587
+ event["index"] += @index_base
588
+ @open.delete(event["index"])
589
+ @next_index = [@next_index, event["index"] + 1].max
590
+ end
591
+
592
+ # @param y [Enumerator::Yielder]
593
+ def close_open_blocks(y)
594
+ @open.each do |index|
595
+ y << "event: content_block_stop\ndata: #{JSON.generate(
596
+ {
597
+ type: 'content_block_stop',
598
+ index: index
599
+ }
600
+ )}\n\n"
601
+ end
602
+ @open.clear
603
+ end
604
+
605
+ private
606
+
607
+ def apply_delta(index, delta)
608
+ block = @blocks.find { _1[:index] == index }&.fetch(:block)
609
+ return unless block
610
+ case delta["type"]
611
+ when "text_delta"
612
+ block["text"] = (block["text"] || +"") << delta["text"]
613
+ when "input_json_delta"
614
+ block["_partial_json"] = (block["_partial_json"] || +"") << delta["partial_json"]
615
+ when "citations_delta"
616
+ (block["citations"] ||= []) << delta["citation"]
617
+ when "thinking_delta"
618
+ block["thinking"] = (block["thinking"] || +"") << delta["thinking"]
619
+ when "signature_delta"
620
+ block["signature"] = delta["signature"]
621
+ end
622
+ end
623
+
624
+ def deep_dup(obj)
625
+ case obj
626
+ when Hash then obj.transform_values { deep_dup(_1) }
627
+ when Array then obj.map { deep_dup(_1) }
628
+ else obj
629
+ end
630
+ end
631
+ end
632
+ end
633
+ end
@@ -17,6 +17,7 @@ module Anthropic
17
17
  # telemetry consumers match on them, so renames lose history. New tags are
18
18
  # hyphenated lowercase.
19
19
  BETA_TOOL_RUNNER = "BetaToolRunner"
20
+ FALLBACK_REFUSAL_MIDDLEWARE = "fallback-refusal-middleware"
20
21
 
21
22
  class << self
22
23
  # The {HEADER} value to set after appending `value` to whatever is
@@ -74,6 +74,15 @@ module Anthropic
74
74
  # @return [Array<#call>, #call, nil]
75
75
  optional :middleware, Anthropic::Internal::Type::Unknown
76
76
 
77
+ # @!attribute fallback_state
78
+ # Shared {Anthropic::BetaFallbackState} for
79
+ # {Anthropic::BetaRefusalFallbackMiddleware}. Requests that pass the same
80
+ # instance start at the fallback the previous refusal pinned, so a
81
+ # conversation stays on the model that accepted.
82
+ #
83
+ # @return [Anthropic::BetaFallbackState, nil]
84
+ optional :fallback_state, Anthropic::Internal::Type::Unknown
85
+
77
86
  # @!method initialize(values = {})
78
87
  # Returns a new instance of RequestOptions.
79
88
  #
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Anthropic
4
- VERSION = "1.53.0"
4
+ VERSION = "1.54.0"
5
5
  end
data/lib/anthropic.rb CHANGED
@@ -88,6 +88,7 @@ require_relative "anthropic/helpers/input_schema/union_of"
88
88
  require_relative "anthropic/helpers/input_schema/parsed_json"
89
89
  require_relative "anthropic/input_schema"
90
90
  require_relative "anthropic/helpers/messages"
91
+ require_relative "anthropic/helpers/refusal_fallback"
91
92
  require_relative "anthropic/internal/stream"
92
93
  require_relative "anthropic/internal/jsonl_stream"
93
94
  require_relative "anthropic/internal/bidirectional_page_cursor"
@@ -0,0 +1,46 @@
1
+ # typed: strong
2
+
3
+ module Anthropic
4
+ # Tracks which fallback a sequence of requests is pinned to.
5
+ #
6
+ # Create one and pass it via the `fallback_state` request option on every
7
+ # request that should share the pin; {BetaRefusalFallbackMiddleware} mutates
8
+ # it in place when a model refuses.
9
+ class BetaFallbackState
10
+ # Index into the fallback chain the requests are pinned to. `nil`/`-1`
11
+ # targets the original request params.
12
+ sig { returns(T.nilable(Integer)) }
13
+ attr_accessor :index
14
+ end
15
+
16
+ # Middleware that retries refused `/v1/messages` requests down a fallback
17
+ # chain. See {BetaRefusalFallbackMiddleware} in the gem source for the full
18
+ # contract.
19
+ class BetaRefusalFallbackMiddleware
20
+ include Anthropic::Middleware
21
+
22
+ DEFAULT_BETAS = T.let(T.unsafe(nil), T::Array[String])
23
+
24
+ sig do
25
+ params(
26
+ fallbacks: T::Array[
27
+ T.any(Anthropic::Models::Beta::BetaFallbackParam, T::Hash[Symbol, T.anything])
28
+ ],
29
+ betas: T::Array[String]
30
+ ).void
31
+ end
32
+ def initialize(fallbacks, betas: DEFAULT_BETAS)
33
+ end
34
+
35
+ sig do
36
+ override
37
+ .params(
38
+ request: Anthropic::APIRequest,
39
+ nxt: T.proc.params(req: Anthropic::APIRequest).returns(Anthropic::APIResponse)
40
+ )
41
+ .returns(Anthropic::APIResponse)
42
+ end
43
+ def call(request, nxt)
44
+ end
45
+ end
46
+ end
@@ -54,6 +54,12 @@ module Anthropic
54
54
  sig { returns(T.nilable(Anthropic::Middleware::EntryOrArray)) }
55
55
  attr_accessor :middleware
56
56
 
57
+ # Shared {Anthropic::BetaFallbackState} for
58
+ # {Anthropic::BetaRefusalFallbackMiddleware}. Requests that pass the same
59
+ # instance start at the fallback the previous refusal pinned.
60
+ sig { returns(T.nilable(Anthropic::BetaFallbackState)) }
61
+ attr_accessor :fallback_state
62
+
57
63
  # Returns a new instance of RequestOptions.
58
64
  sig do
59
65
  params(values: Anthropic::Internal::AnyHash).returns(T.attached_class)
@@ -7,6 +7,8 @@ module Anthropic
7
7
 
8
8
  BETA_TOOL_RUNNER = T.let("BetaToolRunner", String)
9
9
 
10
+ FALLBACK_REFUSAL_MIDDLEWARE = T.let("fallback-refusal-middleware", String)
11
+
10
12
  class << self
11
13
  sig { params(headers: T::Hash[T.any(String, Symbol), T.untyped], value: String).returns(String) }
12
14
  def merged_value(headers, value)
@@ -0,0 +1,24 @@
1
+ module Anthropic
2
+ class BetaFallbackState
3
+ attr_accessor index: Integer?
4
+ end
5
+
6
+ class BetaRefusalFallbackMiddleware
7
+ include Anthropic::Middleware
8
+
9
+ DEFAULT_BETAS: ::Array[String]
10
+
11
+ type fallback_entry =
12
+ Anthropic::Models::Beta::BetaFallbackParam | ::Hash[Symbol, top]
13
+
14
+ def initialize: (
15
+ ::Array[fallback_entry] fallbacks,
16
+ ?betas: ::Array[String]
17
+ ) -> void
18
+
19
+ def call: (
20
+ Anthropic::APIRequest request,
21
+ ^(Anthropic::APIRequest) -> Anthropic::APIResponse nxt
22
+ ) -> Anthropic::APIResponse
23
+ end
24
+ end
@@ -10,7 +10,8 @@ module Anthropic
10
10
  extra_body: top?,
11
11
  max_retries: Integer?,
12
12
  timeout: Float?,
13
- middleware: Anthropic::Middleware::entry_or_array?
13
+ middleware: Anthropic::Middleware::entry_or_array?,
14
+ fallback_state: Anthropic::BetaFallbackState?
14
15
  }
15
16
 
16
17
  class RequestOptions < Anthropic::Internal::Type::BaseModel
@@ -30,6 +31,8 @@ module Anthropic
30
31
 
31
32
  attr_accessor middleware: Anthropic::Middleware::entry_or_array?
32
33
 
34
+ attr_accessor fallback_state: Anthropic::BetaFallbackState?
35
+
33
36
  def initialize: (
34
37
  ?Anthropic::request_options | ::Hash[Symbol, top] values
35
38
  ) -> void
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: anthropic
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.53.0
4
+ version: 1.54.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anthropic
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-30 00:00:00.000000000 Z
11
+ date: 2026-07-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: cgi
@@ -96,6 +96,7 @@ files:
96
96
  - lib/anthropic/helpers/input_schema/supported_schemas.rb
97
97
  - lib/anthropic/helpers/input_schema/union_of.rb
98
98
  - lib/anthropic/helpers/messages.rb
99
+ - lib/anthropic/helpers/refusal_fallback.rb
99
100
  - lib/anthropic/helpers/stainless_helper_header.rb
100
101
  - lib/anthropic/helpers/streaming.rb
101
102
  - lib/anthropic/helpers/streaming/events.rb
@@ -1094,6 +1095,7 @@ files:
1094
1095
  - rbi/anthropic/helpers/input_schema/enum_of.rbi
1095
1096
  - rbi/anthropic/helpers/input_schema/json_schema_converter.rbi
1096
1097
  - rbi/anthropic/helpers/input_schema/union_of.rbi
1098
+ - rbi/anthropic/helpers/refusal_fallback.rbi
1097
1099
  - rbi/anthropic/helpers/streaming/events.rbi
1098
1100
  - rbi/anthropic/helpers/streaming/message_stream.rbi
1099
1101
  - rbi/anthropic/helpers/tools.rbi
@@ -2075,6 +2077,7 @@ files:
2075
2077
  - sig/anthropic/errors.rbs
2076
2078
  - sig/anthropic/file_part.rbs
2077
2079
  - sig/anthropic/helpers/bedrock/client.rbs
2080
+ - sig/anthropic/helpers/refusal_fallback.rbs
2078
2081
  - sig/anthropic/helpers/streaming/events.rbs
2079
2082
  - sig/anthropic/helpers/streaming/message_stream.rbs
2080
2083
  - sig/anthropic/helpers/vertex/client.rbs