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:
-
BUDGETED (Claude 3.7, sonnet-4 base, opus-4 base, opus-4-5, haiku-4): { type: 'enabled', budget_tokens: N }
-
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
-
.adaptive_thinking?(model_id) ⇒ Boolean
Checked BEFORE budgeted_thinking? to ensure precedence.
-
.adaptive_wire(thinking:, model_id:) ⇒ Object
Full adaptive thinking descriptor for a model that requires the adaptive/effort wire.
-
.anthropic_model?(model_id) ⇒ Boolean
The single anthropic-model-id predicate (was duplicated in the provider invoke helpers and the translator read helpers).
-
.budgeted_thinking?(model_id) ⇒ Boolean
but NOT adaptive thinking (adaptive wins when both would substring-match).
-
.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.
-
.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)enabledshape rather than dropping it. -
.reconcile_budget(budget, effective_max_tokens) ⇒ Object
Clamp budget so that budget_tokens < effective_max_tokens (Bedrock wire constraint).
-
.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.
-
.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.
-
.wire_effort(resolved_effort) ⇒ Object
Resolves the Bedrock wire effort value from a canonical resolved_effort string.
Class Method Details
.adaptive_thinking?(model_id) ⇒ Boolean
Checked BEFORE budgeted_thinking? to ensure precedence.
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).
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).
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.
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.
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.
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 |