Module: Legion::Extensions::Llm::Bedrock::ThinkingModes

Defined in:
lib/legion/extensions/llm/bedrock/thinking_modes.rb

Overview

Single source of truth for how each Bedrock model expresses extended thinking on the wire. Shared by both the Provider (invoke_model / converse paths) and the Translator (canonical render path) so the two never diverge.

Bedrock supports TWO thinking wire shapes for Anthropic Claude:

  1. BUDGETED (Claude 3.7, sonnet-4 base, opus-4 base, opus-4-5, haiku-4): { type: 'enabled', budget_tokens: N }

  2. ADAPTIVE (Claude opus-4-6/4-7/4-8/5, sonnet-4-6/5): { type: 'adaptive' } + output_config: { effort: <low|medium|high|xhigh|max> } Gated by beta header 'effort-2025-11-24' in the anthropic_beta list. These models REJECT { type: 'enabled', budget_tokens: N } with ValidationException: "thinking.type.enabled" is not supported for this model. Use "thinking.type.adaptive" and "output_config.effort". The effort value is passed through directly from the canonical Thinking::Config#resolved_effort (the full ladder matches 1:1). 'none' or nil effort → omit output_config (API defaults to high).

PRECEDENCE: adaptive fragments are checked BEFORE budgeted because claude-opus-4 (budgeted) is a substring of claude-opus-4-7 (adaptive). An adaptive-fragment match wins.

The match is a substring so it tolerates the many Bedrock model-id decorations (geo prefixes us./eu./ap., anthropic. provider prefix, -vN:0 version suffixes, :200k context suffixes).

Constant Summary collapse

MINIMUM_BUDGET =

Minimum thinking budget Anthropic will accept (API floor).

1024
OUTPUT_RESERVE =

Tokens reserved for actual model output when clamping the thinking budget against max_tokens. Ensures max_tokens > budget_tokens holds with room for at least a short reply.

128
EFFORT_BETA_HEADER =

Beta header required for the adaptive effort API on Bedrock.

'effort-2025-11-24'
ADAPTIVE_EFFORT_FRAGMENTS =

Model-id fragments for Claude families that use the adaptive/effort thinking wire: { type: 'adaptive' } + output_config: { effort: ... }. These MUST be checked BEFORE BUDGETED_THINKING_FRAGMENTS because 'claude-opus-4' is a substring of 'claude-opus-4-7'.

%w[
  claude-opus-4-6
  claude-opus-4-7
  claude-opus-4-8
  claude-opus-5
  claude-sonnet-4-6
  claude-sonnet-5
].freeze
BUDGETED_THINKING_FRAGMENTS =

Model-id fragments for Claude families that support explicit budgeted extended thinking via { type: 'enabled', budget_tokens: N }.

%w[
  claude-3-7-sonnet
  claude-sonnet-4
  claude-opus-4
  claude-haiku-4
].freeze
VALID_EFFORTS =

Valid Bedrock effort values — the canonical effort ladder (low|medium|high|xhigh|max) maps 1:1 to the Bedrock wire. 'none' is NOT a valid Bedrock effort; it means "omit effort".

%w[low medium high xhigh max].freeze

Class Method Summary collapse

Class Method Details

.adaptive_thinking?(model_id) ⇒ Boolean

Checked BEFORE budgeted_thinking? to ensure precedence.

Returns:

  • (Boolean)

    true when the model uses adaptive thinking + effort.



79
80
81
82
83
84
# File 'lib/legion/extensions/llm/bedrock/thinking_modes.rb', line 79

def adaptive_thinking?(model_id)
  return false if model_id.nil? || model_id.to_s.strip.empty?

  mid = model_id.to_s
  ADAPTIVE_EFFORT_FRAGMENTS.any? { |fragment| mid.include?(fragment) }
end

.adaptive_wire(thinking:, model_id:) ⇒ Object

Full adaptive thinking descriptor for a model that requires the adaptive/effort wire. Returns a Hash with :thinking, :beta_header, and optionally :output_config — or nil when thinking is not enabled or the model is not adaptive.

When resolved_effort is 'none' or nil, output_config is OMITTED (the API defaults to high). The effort ladder (low|medium|high| xhigh|max) is passed through 1:1 — no clamping.

The caller is responsible for placing each key at the correct wire position (invoke_model: top-level fields + anthropic_beta array; Converse: additionalModelRequestFields + beta array mechanism).



186
187
188
189
190
191
192
193
194
# File 'lib/legion/extensions/llm/bedrock/thinking_modes.rb', line 186

def adaptive_wire(thinking:, model_id:)
  return nil unless thinking_enabled?(thinking)
  return nil unless adaptive_thinking?(model_id)

  effort = wire_effort(thinking.resolved_effort)
  result = { thinking: { type: 'adaptive' }, beta_header: EFFORT_BETA_HEADER }
  result[:output_config] = { effort: effort } if effort
  result
end

.anthropic_model?(model_id) ⇒ Boolean

The single anthropic-model-id predicate (was duplicated in the provider invoke helpers and the translator read helpers).

Returns:

  • (Boolean)


117
118
119
120
121
# File 'lib/legion/extensions/llm/bedrock/thinking_modes.rb', line 117

def anthropic_model?(model_id)
  return false unless model_id

  model_id.to_s.start_with?('anthropic.', 'us.anthropic.', 'eu.anthropic.', 'ap.anthropic.')
end

.budgeted_thinking?(model_id) ⇒ Boolean

but NOT adaptive thinking (adaptive wins when both would substring-match).

Returns:

  • (Boolean)

    true when the model supports { type: 'enabled', budget_tokens: N }



88
89
90
91
92
93
94
95
96
# File 'lib/legion/extensions/llm/bedrock/thinking_modes.rb', line 88

def budgeted_thinking?(model_id)
  return false if model_id.nil? || model_id.to_s.strip.empty?
  return false if adaptive_thinking?(model_id)

  mid = model_id.to_s
  # Substring scan (String#include?), NOT array intersection: intersect?
  # raises TypeError on a String argument.
  BUDGETED_THINKING_FRAGMENTS.any? { |fragment| mid.include?(fragment) }
end

.invoke_model_target?(model_id:, thinking:, tools:) ⇒ Boolean

B1: the single Converse-vs-invoke_model selection predicate — one owner, one predicate, shared by the Provider dispatch path and the Translator. A present-but-disabled Thinking::Config (no effort, no budget) does NOT force the invoke dialect: enabled? is the law, the provider path's old object-truthiness fork is deleted.

Returns:

  • (Boolean)


128
129
130
# File 'lib/legion/extensions/llm/bedrock/thinking_modes.rb', line 128

def invoke_model_target?(model_id:, thinking:, tools:)
  anthropic_model?(model_id) && (thinking_enabled?(thinking) || (tools && !tools.empty?))
end

.known_non_thinking?(model_id) ⇒ Boolean

Distinct from !budgeted_thinking?: a nil/blank/unknown model id is NOT known-unsupported — we then honor an explicit thinking request and emit the (safe) enabled shape rather than dropping it. The router's capability filter (fed by the shared catalog) is the real guard that keeps thinking requests off non-thinking models; this method only strips thinking for a positively-identified non-thinking Claude model so we never emit an unsupported shape and 500.

Returns:

  • (Boolean)

    true when the model is KNOWN not to support thinking.



106
107
108
109
110
111
112
113
# File 'lib/legion/extensions/llm/bedrock/thinking_modes.rb', line 106

def known_non_thinking?(model_id)
  return false if model_id.nil? || model_id.to_s.strip.empty?

  mid = model_id.to_s
  return false unless mid.include?('anthropic') || mid.include?('claude')

  !adaptive_thinking?(model_id) && BUDGETED_THINKING_FRAGMENTS.none? { |f| mid.include?(f) }
end

.reconcile_budget(budget, effective_max_tokens) ⇒ Object

Clamp budget so that budget_tokens < effective_max_tokens (Bedrock wire constraint). Returns the reconciled budget Integer, or nil when max_tokens is too small to fit even MINIMUM_BUDGET.



209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/legion/extensions/llm/bedrock/thinking_modes.rb', line 209

def reconcile_budget(budget, effective_max_tokens)
  return budget unless effective_max_tokens && budget >= effective_max_tokens

  clamped = [budget, effective_max_tokens - OUTPUT_RESERVE].min
  clamped = [clamped, MINIMUM_BUDGET].max

  # If after applying the floor the budget still violates the
  # constraint, thinking cannot fit — omit it to keep the request
  # valid (respects the client's explicit max_output_tokens cap).
  return nil if clamped >= effective_max_tokens

  clamped
end

.thinking_enabled?(thinking) ⇒ Boolean

B3: the dispatch boundary carries Canonical::Thinking::Config only (the fleet wire hash is rehydrated at the W4 boundary, core side); a non-Config value here is a boundary violation.

Returns:

  • (Boolean)


135
136
137
# File 'lib/legion/extensions/llm/bedrock/thinking_modes.rb', line 135

def thinking_enabled?(thinking)
  thinking.is_a?(Legion::Extensions::Llm::Canonical::Thinking::Config) && thinking.enabled?
end

.thinking_wire(thinking:, model_id:, effective_max_tokens: nil) ⇒ Object

B2: the single thinking wire-shape builder — returns the correct wire shape for the model:

  • Adaptive models: { type: 'adaptive' } (effort is a sibling field)
  • Budgeted models: { type: 'enabled', budget_tokens: N }
  • Non-thinking models: nil

For adaptive models, use adaptive_wire to get the full descriptor (thinking shape + output_config + beta header requirement).

Budget/max_tokens reconciliation (budgeted path only): Bedrock requires max_tokens > budget_tokens. When effective_max_tokens is provided and the resolved budget would violate that constraint, the budget is clamped to (max_tokens - OUTPUT_RESERVE) with a floor of MINIMUM_BUDGET. If max_tokens is too small to accommodate even the minimum budget, thinking is omitted (nil) to keep the request valid rather than silently overriding the client's max_output_tokens cap.



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/legion/extensions/llm/bedrock/thinking_modes.rb', line 155

def thinking_wire(thinking:, model_id:, effective_max_tokens: nil, **)
  return nil unless thinking_enabled?(thinking)
  return nil if known_non_thinking?(model_id)

  # Adaptive models use a different wire shape — no budget_tokens.
  return { type: 'adaptive' } if adaptive_thinking?(model_id)

  budget = thinking.resolved_budget
  if budget.nil?
    raise ArgumentError,
          "bedrock.thinking_wire: enabled thinking has no resolvable budget_tokens for #{model_id}"
  end

  budget = reconcile_budget(budget, effective_max_tokens)
  return nil unless budget

  { type: 'enabled', budget_tokens: budget }
end

.wire_effort(resolved_effort) ⇒ Object

Resolves the Bedrock wire effort value from a canonical resolved_effort string. Returns the effort string for the wire, or nil when effort should be omitted (none / nil → API default = high). The canonical ladder (low|medium|high|xhigh|max) passes through 1:1.



200
201
202
203
204
# File 'lib/legion/extensions/llm/bedrock/thinking_modes.rb', line 200

def wire_effort(resolved_effort)
  return nil if resolved_effort.nil? || resolved_effort == 'none'

  resolved_effort
end