lex-llm-bedrock 0.4.4 → 0.4.10

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: 3aa9de1a3f3c07848fe253d6f8627c54b03bbc74bb96ab5032a407b109aad13a
4
- data.tar.gz: a3f680546d15739bdfb84f79daa70a45beae2650893aead380e23b0e9086b38e
3
+ metadata.gz: ee19df358e0fe9b9c6020be38aca9500e1053aee94afd929e83b6a041dca82d5
4
+ data.tar.gz: 6d1ccabe1fa7709a941b1f679dad26ec49fa62950ca764d2fb07ffed1fa63c7b
5
5
  SHA512:
6
- metadata.gz: 7b6ade385af00bbcd329278658f4fe829b312735f0141514353e4da0912d1002a9213cbd6f7411f4ba8dcad9dc9284adefe3518c17ff12297dd18d3f9db36d92
7
- data.tar.gz: 0ffcee037a6efb318802b3ec1ad1684f0516ee133799dc420bd7a74883320c0c2029fc0df916757c5c4584386d4cc2f7cc5f5f8e7bfca46a8def152adfdb9e1f
6
+ metadata.gz: 9849e1efcf3a3d35ce2c9ea5c4b2fa61c5232a361b0a33c29f3deb6d98c1aad66672ffccafbebfdd94b4c353218befc12f39f0f13eba2b7429a0ab000d6ea4ab
7
+ data.tar.gz: db563c25558e547ac90bacc8d8dd4d0eb8628d8e3135063b32eaff6c48eddbcb128967cda735b4204eee6946b91d912852268f16167e261d8dfcbbfbcedd0f6d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.10] - 2026-08-04
4
+
5
+ ### Fixed
6
+ - **Claude models now advertise the `:thinking` capability.** Discovery passed `provider_catalog: {}` to `CapabilityPolicy.resolve`, so per-model capabilities from the shared lex-llm catalog (which correctly tags Claude 3.7 / 4+ models `reasoning` → `:thinking`) were ignored — every Bedrock Claude model reported no thinking capability, so the router's thinking filter could not route thinking requests correctly. `offering_from_model` now consults the shared catalog via `catalog_capabilities`.
7
+ - **Thinking payload no longer sends unsupported `adaptive` mode.** `invoke_model_thinking` / `build_invoke_thinking` (and the converse-path `bedrock_additional_fields` / `build_additional_fields`) previously emitted `{type: 'adaptive'}` for every non-`claude-sonnet-4` model, which Bedrock rejects with `ValidationException: adaptive thinking is not supported on this model` (HTTP 500, e.g. claude-opus-4-5). A new `ThinkingModes` module is the single source of truth shared by provider and translator: budgeted-thinking models emit `{type: 'enabled', budget_tokens: N}`; known non-thinking models omit thinking entirely. `adaptive` is never emitted.
8
+
9
+ ## [0.4.9] - 2026-06-20
10
+
11
+ ### Fixed
12
+ - Stub shared registry publishing through `RegistryPublisher#schedule` in specs so async availability-event coverage stays stable after the shared publisher moved off raw `Thread.new`.
13
+
14
+ ## [0.4.8] - 2026-06-20
15
+
16
+ ### Fixed
17
+ - Stop bulk-publishing Bedrock model availability from `list_models`; discovery now emits one registry event per seen model from the shared `lex-llm` policy-filter path so blocked models stay observable without duplicate publishes.
18
+
19
+ ## [0.4.7] - 2026-06-20
20
+
21
+ ### Fixed
22
+ - Stop deriving Bedrock `us.`/`eu.`/`ap.` inference-profile prefixes from AWS regions. Model invocation now strips any existing geo prefix and prepends only an explicit Bedrock geo prefix setting, defaulting to `us`.
23
+
24
+ ## [0.4.6] - 2026-06-20
25
+
26
+ ### Fixed
27
+ - Canonicalize Bedrock embedding discovery to the shared singular `:embedding` capability and route provider/instance/model override extraction through the `lex-llm` base provider contract.
28
+
29
+ ## [0.4.5] - 2026-06-19
30
+
31
+ ### Changed
32
+ - Adopt `Legion::Extensions::Llm::Inventory::ScopedRefresher` mixin (lex-llm 0.6.0). Discovery
33
+ refresh actors now write directly to the live `Inventory` catalog via `Inventory.write_lane`.
34
+ - Pin `lex-llm >= 0.6.0` and `legion-llm >= 0.14.0` in gemspec.
35
+ - Standard `weight: 100` default added to provider instance settings schema.
36
+
3
37
  ## 0.4.4 - 2026-06-17
4
38
 
5
39
  ### Fixed
@@ -29,5 +29,5 @@ Gem::Specification.new do |spec|
29
29
  spec.add_dependency 'legion-logging', '>= 1.3.2'
30
30
  spec.add_dependency 'legion-settings', '>= 1.3.14'
31
31
  spec.add_dependency 'legion-transport', '>= 1.4.14'
32
- spec.add_dependency 'lex-llm', '>= 0.5.4'
32
+ spec.add_dependency 'lex-llm', '>= 0.6.0'
33
33
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+
3
5
  begin
4
6
  require 'legion/extensions/actors/every'
5
7
  rescue LoadError => e
@@ -8,15 +10,27 @@ end
8
10
 
9
11
  return unless defined?(Legion::Extensions::Actors::Every)
10
12
 
13
+ begin
14
+ require 'legion/extensions/llm/inventory/scoped_refresher'
15
+ rescue LoadError => e
16
+ warn(e.message) if $VERBOSE
17
+ end
18
+
11
19
  module Legion
12
20
  module Extensions
13
21
  module Llm
14
22
  module Bedrock
15
23
  module Actor
16
- class DiscoveryRefresh < Legion::Extensions::Actors::Every # rubocop:disable Style/Documentation
24
+ class DiscoveryRefresh < Legion::Extensions::Actors::Every # rubocop:disable Style/Documentation,Metrics/ClassLength
17
25
  include Legion::Logging::Helper
18
26
 
19
- REFRESH_INTERVAL = 1800
27
+ if defined?(Legion::Extensions::Llm::Inventory::ScopedRefresher)
28
+ include Legion::Extensions::Llm::Inventory::ScopedRefresher
29
+ end
30
+
31
+ EMBED_TYPES = %i[embed embedding].freeze
32
+
33
+ def self.every_seconds = 3600
20
34
 
21
35
  def runner_class = self.class
22
36
  def runner_function = 'manual'
@@ -26,26 +40,117 @@ module Legion
26
40
  def generate_task? = false
27
41
 
28
42
  def time
29
- return REFRESH_INTERVAL unless defined?(Legion::Settings)
43
+ return self.class.every_seconds unless defined?(Legion::Settings)
44
+
45
+ Legion::Settings.dig(:extensions, :llm, :bedrock, :discovery_interval) || self.class.every_seconds
46
+ end
30
47
 
31
- Legion::Settings.dig(:extensions, :llm, :bedrock, :discovery_interval) || REFRESH_INTERVAL
48
+ def scope_key(**)
49
+ { provider: :bedrock }
32
50
  end
33
51
 
34
- def manual
35
- log.debug('[bedrock][discovery_refresh] refreshing model list')
36
- return unless defined?(Legion::LLM::Discovery)
52
+ def compute_lanes_for_scope(**)
53
+ return [] unless defined?(Legion::LLM::Call::Registry)
37
54
 
38
- Legion::LLM::Discovery.refresh_discovered_models!(provider: :bedrock)
55
+ settings = Legion::Settings.dig(:extensions, :llm, :bedrock) || {}
56
+ fleet_enabled = settings.dig(:fleet, :dispatch, :enabled)
39
57
 
40
- if defined?(Legion::LLM::Router) && Legion::LLM::Router.respond_to?(:populate_auto_rules)
41
- Legion::LLM::Router.populate_auto_rules(Legion::LLM::Discovery.discovered_instances)
42
- end
43
- if defined?(Legion::LLM::Inventory) && Legion::LLM::Inventory.respond_to?(:invalidate_offerings_cache!)
44
- Legion::LLM::Inventory.invalidate_offerings_cache!
58
+ instances = Legion::LLM::Call::Registry.all_instances.select do |e|
59
+ (e[:provider] || '').to_sym == :bedrock
45
60
  end
61
+
62
+ instances.flat_map { |inst| lanes_for_instance(inst, fleet_enabled: fleet_enabled) }
63
+ rescue StandardError => e
64
+ handle_exception(e, level: :warn, handled: true,
65
+ operation: 'bedrock.actor.discovery_refresh.compute_lanes')
66
+ []
67
+ end
68
+
69
+ def credential_hash(**)
70
+ raw = Legion::Settings.dig(:extensions, :llm, :bedrock) || {}
71
+ Digest::SHA256.hexdigest(raw[:api_key].to_s + raw[:instances].to_s)[0, 16]
72
+ end
73
+
74
+ def manual(**)
75
+ tick if defined?(Legion::Extensions::Llm::Inventory::ScopedRefresher) &&
76
+ self.class.ancestors.include?(Legion::Extensions::Llm::Inventory::ScopedRefresher)
46
77
  rescue StandardError => e
47
78
  handle_exception(e, level: :warn, handled: true, operation: 'bedrock.actor.discovery_refresh')
48
79
  end
80
+
81
+ private
82
+
83
+ def lanes_for_instance(instance, fleet_enabled: false)
84
+ adapter = instance[:adapter]
85
+ return [] unless adapter.respond_to?(:discover_offerings)
86
+
87
+ Array(adapter.discover_offerings(live: true)).flat_map do |raw_offering|
88
+ offering = offering_to_hash(raw_offering)
89
+ next [] unless offering
90
+
91
+ build_offering_lanes(offering, instance, fleet_enabled: fleet_enabled)
92
+ end
93
+ end
94
+
95
+ def offering_to_hash(offering)
96
+ return nil if offering.nil?
97
+ return offering if offering.is_a?(Hash)
98
+
99
+ hash = offering.to_h
100
+ hash[:type] ||= hash[:usage_type]
101
+ hash[:enabled] = offering.respond_to?(:enabled?) ? offering.enabled? : true
102
+ hash
103
+ end
104
+
105
+ def build_offering_lanes(offering, instance, fleet_enabled: false)
106
+ raw_tier = offering[:tier] || :cloud
107
+ type = EMBED_TYPES.include?(offering[:type]&.to_sym) ? :embedding : :inference
108
+
109
+ lane_fields = {
110
+ tier: raw_tier,
111
+ provider_family: :bedrock,
112
+ instance_id: instance[:instance] || instance[:instance_id] || instance[:id] || 'default',
113
+ type: type,
114
+ model: offering[:model]
115
+ }
116
+
117
+ lane = build_lane(offering, lane_fields)
118
+ result = [lane]
119
+
120
+ if fleet_enabled && type == :inference
121
+ fleet_fields = lane_fields.merge(tier: :fleet)
122
+ result << lane.merge(
123
+ id: Legion::Extensions::Llm::Inventory::ScopedRefresher.compose_id(fleet_fields),
124
+ tier: :fleet
125
+ )
126
+ end
127
+
128
+ result
129
+ end
130
+
131
+ def build_lane(offering, lane_fields)
132
+ capabilities = normalize_capabilities(offering[:capabilities])
133
+ {
134
+ id: Legion::Extensions::Llm::Inventory::ScopedRefresher.compose_id(lane_fields),
135
+ tier: lane_fields[:tier],
136
+ provider_family: :bedrock,
137
+ instance_id: lane_fields[:instance_id],
138
+ model: offering[:model],
139
+ canonical_model_alias: offering[:canonical_model_alias],
140
+ type: lane_fields[:type],
141
+ capabilities: capabilities,
142
+ limits: offering[:limits] || {},
143
+ enabled: offering.fetch(:enabled, true),
144
+ cost: offering[:cost] || {}
145
+ }
146
+ end
147
+
148
+ def normalize_capabilities(caps)
149
+ return [] unless defined?(Legion::Extensions::Llm::Inventory::Capabilities)
150
+ return [] unless Legion::Extensions::Llm::Inventory::Capabilities.respond_to?(:normalize)
151
+
152
+ Legion::Extensions::Llm::Inventory::Capabilities.normalize(caps)
153
+ end
49
154
  end
50
155
  end
51
156
  end
@@ -6,6 +6,7 @@ require 'aws-sdk-bedrockruntime'
6
6
  require 'legion/json'
7
7
  require 'legion/logging/helper'
8
8
  require 'legion/extensions/llm'
9
+ require_relative 'thinking_modes'
9
10
 
10
11
  module Legion
11
12
  module Extensions
@@ -66,6 +67,7 @@ module Legion
66
67
  bedrock_access_key_id
67
68
  bedrock_secret_access_key
68
69
  bedrock_session_token
70
+ bedrock_geo_prefix
69
71
  bedrock_profile
70
72
  bedrock_stub_responses
71
73
  bearer_token
@@ -85,24 +87,19 @@ module Legion
85
87
 
86
88
  INFERENCE_PROFILE_PREFIXES = %w[anthropic. meta. mistral. cohere. ai21.].freeze
87
89
 
88
- def inference_profile_id(model, region: nil)
89
- return model if model.start_with?('us.', 'eu.', 'ap.', 'arn:')
90
- return model unless INFERENCE_PROFILE_PREFIXES.any? { |p| model.start_with?(p) }
90
+ def inference_profile_id(model, geo_prefix: 'us', region: nil)
91
+ return model if model.start_with?('arn:')
91
92
 
92
- prefix = region ? region_prefix(region) : 'us'
93
- "#{prefix}.#{model}"
94
- end
93
+ canonical = model.sub(/\A(?:us|eu|ap)\./, '')
94
+ return canonical unless INFERENCE_PROFILE_PREFIXES.any? { |p| canonical.start_with?(p) }
95
95
 
96
- # Region-based inference profile prefix mapping.
97
- # Bare model IDs (e.g. anthropic.claude-sonnet-4) get the region prefix.
98
- REGION_PREFIX = {
99
- 'us-east-1' => 'us', 'us-east-2' => 'us', 'us-west-1' => 'us', 'us-west-2' => 'us',
100
- 'eu-central-1' => 'eu', 'eu-west-1' => 'eu', 'eu-west-2' => 'eu', 'eu-west-3' => 'eu',
101
- 'ap-south-1' => 'ap', 'ap-southeast-1' => 'ap', 'ap-southeast-2' => 'ap', 'ap-northeast-1' => 'ap'
102
- }.freeze
96
+ prefix = normalize_geo_prefix(geo_prefix || region)
97
+ "#{prefix}.#{canonical}"
98
+ end
103
99
 
104
- def region_prefix(region)
105
- REGION_PREFIX.fetch(region.to_s, 'us')
100
+ def normalize_geo_prefix(value)
101
+ candidate = value.to_s.downcase
102
+ %w[us eu ap].include?(candidate) ? candidate : 'us'
106
103
  end
107
104
  end
108
105
 
@@ -127,6 +124,10 @@ module Legion
127
124
  @translator ||= Translator.new(region: region)
128
125
  end
129
126
 
127
+ def settings
128
+ Bedrock.default_settings
129
+ end
130
+
130
131
  def api_base
131
132
  config.bedrock_endpoint || "https://bedrock-runtime.#{region}.amazonaws.com"
132
133
  end
@@ -141,25 +142,9 @@ module Legion
141
142
  config.bedrock_region || settings[:region] || 'us-east-1'
142
143
  end
143
144
 
144
- def discover_offerings(live: false, **filters)
145
- unless live
146
- return @cached_offerings if @cached_offerings&.any?
147
-
148
- log.debug { 'bedrock.provider.discover_offerings: returning static catalog' }
149
- return static_offerings(**filters)
150
- end
151
-
152
- log.info { "bedrock.provider.discover_offerings: listing foundation models (region=#{region})" }
153
- response = bedrock_client.list_foundation_models(**filters)
154
- @cached_offerings = Array(value(response, :model_summaries)).filter_map do |summary|
155
- offering = offering_from_summary(summary)
156
- model_id = offering.respond_to?(:model) ? offering.model : (offering[:model] || offering[:id])
157
- next unless model_allowed?(model_id.to_s)
158
-
159
- offering
160
- end
161
- log.info { "bedrock.provider.discover_offerings: found #{@cached_offerings.size} models" }
162
- @cached_offerings
145
+ def geo_prefix
146
+ configured = config.bedrock_geo_prefix if config.respond_to?(:bedrock_geo_prefix)
147
+ self.class.normalize_geo_prefix(configured || settings[:geo_prefix])
163
148
  end
164
149
 
165
150
  def offering_for(model:, model_family: nil, instance_id: :default, **metadata)
@@ -205,15 +190,26 @@ module Legion
205
190
  end
206
191
  end
207
192
 
208
- def list_models(**)
193
+ def list_models(**filters)
194
+ request_filters = {}
195
+ request_filters[:by_provider] = filters[:by_provider] if filters[:by_provider]
196
+
209
197
  log.info { 'bedrock.provider.list_models: fetching live model list' }
210
- response = bedrock_client.list_foundation_models
198
+ response = bedrock_client.list_foundation_models(**request_filters)
211
199
  models = Array(value(response, :model_summaries)).filter_map { |summary| model_info_from_summary(summary) }
212
200
  log.info { "bedrock.provider.list_models: found #{models.size} models" }
213
- self.class.registry_publisher.publish_models_async(models, readiness: readiness(live: false))
214
201
  models
215
202
  end
216
203
 
204
+ def discover_offerings(live: false, **filters)
205
+ return static_offerings(**filters) unless live
206
+
207
+ provider_health = health(live:)
208
+ @cached_offerings = discover_live_offerings(filters, provider_health, live:)
209
+ log_discover_complete(@cached_offerings)
210
+ @cached_offerings
211
+ end
212
+
217
213
  def chat(
218
214
  messages:,
219
215
  model:,
@@ -310,6 +306,42 @@ module Legion
310
306
  parse_converse_response(response, model_id(model))
311
307
  end
312
308
 
309
+ def discovery_registry_readiness(provider_health, live:)
310
+ {
311
+ provider: slug.to_sym,
312
+ configured: configured?,
313
+ ready: provider_health[:ready] == true,
314
+ live: live,
315
+ health: provider_health
316
+ }
317
+ end
318
+
319
+ def discover_live_offerings(filters, provider_health, live:)
320
+ readiness = discovery_registry_readiness(provider_health, live:)
321
+ Array(list_models(live:, **filters)).filter_map do |model|
322
+ self.class.registry_publisher.publish_models_async([model], readiness:)
323
+ next unless model_matches_filters?(model, filters)
324
+ next unless model_allowed?(model.id)
325
+
326
+ log_model_discovered(model)
327
+ offering_from_model(model, health: provider_health)
328
+ end
329
+ end
330
+
331
+ def log_model_discovered(model)
332
+ log.debug(
333
+ "[#{slug}] instance=#{provider_instance_id} action=model_discovered " \
334
+ "model=#{model.id} family=#{model.family}"
335
+ )
336
+ end
337
+
338
+ def log_discover_complete(offerings)
339
+ log.info(
340
+ "[#{slug}] instance=#{provider_instance_id} action=discover_complete " \
341
+ "model_count=#{Array(offerings).size}"
342
+ )
343
+ end
344
+
313
345
  def stream(messages:, model:, temperature: nil, max_tokens: nil, tools: {}, tool_prefs: nil, params: {},
314
346
  thinking: nil, **_provider_options, &)
315
347
  enforce_model_allowed!(model_id(model))
@@ -353,7 +385,7 @@ module Legion
353
385
  log.debug { "bedrock.provider.count_tokens: model=#{model_id(model)}" }
354
386
  request = Utils.deep_merge(
355
387
  {
356
- model_id: self.class.inference_profile_id(model_id(model), region: region),
388
+ model_id: self.class.inference_profile_id(model_id(model), geo_prefix: geo_prefix),
357
389
  input: { converse: { messages: format_messages(messages), system: system_blocks(system) }.compact }
358
390
  },
359
391
  params
@@ -425,7 +457,7 @@ module Legion
425
457
  log.debug { "bedrock.provider.invoke_model_chat: model=#{mid} thinking=#{thinking.inspect}" }
426
458
 
427
459
  response = runtime_client.invoke_model(
428
- model_id: self.class.inference_profile_id(mid, region: region),
460
+ model_id: self.class.inference_profile_id(mid, geo_prefix: geo_prefix),
429
461
  content_type: 'application/json',
430
462
  accept: 'application/json',
431
463
  body: Legion::JSON.generate(body)
@@ -479,7 +511,7 @@ module Legion
479
511
 
480
512
  # rubocop:disable Metrics/BlockLength
481
513
  runtime_client.invoke_model_with_response_stream(
482
- model_id: self.class.inference_profile_id(mid, region: region),
514
+ model_id: self.class.inference_profile_id(mid, geo_prefix: geo_prefix),
483
515
  content_type: 'application/json',
484
516
  accept: 'application/json',
485
517
  body: Legion::JSON.generate(body)
@@ -589,7 +621,10 @@ module Legion
589
621
  body[:tools] = tool_format[:tools]
590
622
  body[:tool_choice] = tool_format[:tool_choice] if tool_format[:tool_choice]
591
623
  end
592
- body[:thinking] = invoke_model_thinking(thinking) if thinking
624
+ if thinking
625
+ thinking_cfg = invoke_model_thinking(model: rest[:model] || model_id(rest[:model]), thinking: thinking)
626
+ body[:thinking] = thinking_cfg if thinking_cfg
627
+ end
593
628
  body
594
629
  end
595
630
 
@@ -609,11 +644,19 @@ module Legion
609
644
  parts.map { |t| { type: 'text', text: t } }
610
645
  end
611
646
 
612
- # Strip provider-specific keys (e.g. effort from OpenAI) that Bedrock/Anthropic APIs don't accept.
613
- def invoke_model_thinking(thinking)
614
- return thinking unless thinking.is_a?(Hash)
647
+ # Emit the thinking wire shape the model actually supports.
648
+ # Budgeted-thinking Claude models get { type: 'enabled', budget_tokens: N }.
649
+ # Every other model returns nil so the caller OMITS the thinking field —
650
+ # Bedrock rejects { type: 'adaptive' } with a ValidationException (HTTP 500).
651
+ def invoke_model_thinking(model:, thinking:)
652
+ mid = model_id(model)
653
+ return nil if ThinkingModes.known_non_thinking?(mid)
615
654
 
616
- thinking.except(:effort, 'effort')
655
+ budget = if thinking.is_a?(Hash)
656
+ thinking[:budget_tokens] || thinking['budget_tokens'] ||
657
+ thinking[:budget] || thinking['budget']
658
+ end
659
+ { type: 'enabled', budget_tokens: budget }.compact
617
660
  end
618
661
 
619
662
  def format_invoke_model_messages(messages)
@@ -867,12 +910,19 @@ module Legion
867
910
  end
868
911
  end
869
912
 
870
- def offering_from_summary(summary)
871
- model = value(summary, :model_id)
872
- real = real_capabilities_from_summary(summary)
913
+ def offering_from_model(model_info, health: {})
914
+ model = model_info.respond_to?(:id) ? model_info.id : model_info
915
+ real = if model_info.respond_to?(:capabilities)
916
+ Array(model_info.capabilities).to_h do |capability|
917
+ [capability.to_s.downcase.tr('-', '_').to_sym, true]
918
+ end
919
+ else
920
+ {}
921
+ end
922
+ metadata = model_info.respond_to?(:metadata) && model_info.metadata.is_a?(Hash) ? model_info.metadata : {}
873
923
  policy = Legion::Extensions::Llm::CapabilityPolicy.resolve(
874
924
  real: real,
875
- provider_catalog: {},
925
+ provider_catalog: catalog_capabilities(model),
876
926
  probe: {},
877
927
  provider_envelope: provider_envelope_capabilities,
878
928
  provider_config: provider_capability_config,
@@ -883,11 +933,12 @@ module Legion
883
933
  build_offering(
884
934
  model: model,
885
935
  alias_name: alias_for(model),
886
- model_family: normalize_provider(value(summary, :provider_name)) || model_family_for(model),
887
- usage_type: usage_type_from_modalities(value(summary, :output_modalities)),
936
+ model_family: model_info.respond_to?(:family) ? model_info.family : model_family_for(model),
937
+ usage_type: model_info.respond_to?(:embedding?) && model_info.embedding? ? :embedding : :inference,
888
938
  capabilities: policy[:capabilities],
889
939
  capability_sources: policy[:sources],
890
- metadata: normalize_response(summary)
940
+ metadata: metadata,
941
+ health: health
891
942
  )
892
943
  end
893
944
 
@@ -908,9 +959,14 @@ module Legion
908
959
  )
909
960
  end
910
961
 
962
+ def offering_from_summary(summary, health: {})
963
+ offering_from_model(model_info_from_summary(summary), health:)
964
+ end
965
+
911
966
  def build_offering(model:, model_family:, usage_type:, instance_id: :default, alias_name: nil,
912
- capabilities: nil, capability_sources: nil, metadata: {})
967
+ capabilities: nil, capability_sources: nil, metadata: {}, health: {})
913
968
  limits = infer_limits(model)
969
+ normalized_family = model_family&.to_sym
914
970
  Legion::Extensions::Llm::Routing::ModelOffering.new(
915
971
  provider_family: :bedrock,
916
972
  instance_id: instance_id,
@@ -921,7 +977,8 @@ module Legion
921
977
  capabilities: capabilities || default_capabilities(model),
922
978
  capability_sources: capability_sources,
923
979
  limits: limits,
924
- metadata: metadata.merge(model_family: model_family, alias: alias_name).compact
980
+ health: health,
981
+ metadata: metadata.merge(model_family: normalized_family, alias: alias_name).compact
925
982
  )
926
983
  end
927
984
 
@@ -941,19 +998,19 @@ module Legion
941
998
  def converse_request(messages, model:, temperature:, max_tokens:, tools:, tool_prefs:, guardrail_config: nil,
942
999
  thinking: nil)
943
1000
  {
944
- model_id: self.class.inference_profile_id(model_id(model), region: region),
1001
+ model_id: self.class.inference_profile_id(model_id(model), geo_prefix: geo_prefix),
945
1002
  messages: format_messages(messages.reject { |message| message.role == :system }),
946
1003
  system: format_system(messages),
947
1004
  inference_config: { temperature: temperature, max_tokens: max_tokens || model_max_tokens(model) }.compact,
948
1005
  tool_config: format_tool_config(tools, tool_prefs),
949
1006
  guardrail_config: guardrail_config,
950
- additional_model_request_fields: bedrock_additional_fields(thinking)
1007
+ additional_model_request_fields: bedrock_additional_fields(thinking, model: model_id(model))
951
1008
  }.compact
952
1009
  end
953
1010
 
954
- def bedrock_additional_fields(thinking)
1011
+ def bedrock_additional_fields(thinking, model:)
955
1012
  fields = {}
956
- if thinking
1013
+ if thinking && !ThinkingModes.known_non_thinking?(model)
957
1014
  fields[:thinking] = {
958
1015
  type: 'enabled',
959
1016
  budget_tokens: if thinking.is_a?(Hash)
@@ -1535,7 +1592,6 @@ module Legion
1535
1592
  caps << :streaming if value(summary, :response_streaming_supported)
1536
1593
  end
1537
1594
  caps << :vision if input_mods.include?('image')
1538
- caps << :tools if caps.include?(:completion)
1539
1595
  caps
1540
1596
  end
1541
1597
 
@@ -1545,7 +1601,7 @@ module Legion
1545
1601
  input_mods = Array(value(summary, :input_modalities)).map { |m| m.to_s.upcase }
1546
1602
  caps[:vision] = true if input_mods.include?('IMAGE')
1547
1603
  output_mods = Array(value(summary, :output_modalities)).map { |m| m.to_s.upcase }
1548
- caps[:embeddings] = true if output_mods.include?('EMBEDDING')
1604
+ caps[:embedding] = true if output_mods.include?('EMBEDDING')
1549
1605
  caps
1550
1606
  end
1551
1607
 
@@ -1554,41 +1610,20 @@ module Legion
1554
1610
  { tools: true }
1555
1611
  end
1556
1612
 
1557
- def provider_capability_config
1558
- return {} unless defined?(Legion::Extensions::Llm::CredentialSources)
1559
-
1560
- conf = Legion::Extensions::Llm::CredentialSources.setting(:extensions, :llm, :bedrock)
1561
- conf.is_a?(Hash) ? conf.to_h.except(:instances, 'instances') : {}
1562
- rescue StandardError => e
1563
- handle_exception(e, level: :debug, handled: true, operation: 'bedrock.provider_capability_config')
1613
+ # Capability truth from the shared lex-llm catalog (models.json), keyed by
1614
+ # bedrock model id. This is the source of truth for per-model capabilities
1615
+ # (e.g. :thinking for Claude 4+) that the live AWS ListFoundationModels
1616
+ # summary does not expose. Surfaced to CapabilityPolicy as :provider_catalog
1617
+ # so operator/instance/model overrides still win, and the boolean map is fed
1618
+ # through the shared Capabilities alias table (reasoning -> :thinking).
1619
+ def catalog_capabilities(model)
1620
+ info = Legion::Extensions::Llm::Models.find(model.to_s, :bedrock)
1621
+ caps = Legion::Extensions::Llm::Capabilities.normalize(info.capabilities)
1622
+ caps.to_h { |cap| [cap, true] }
1623
+ rescue Legion::Extensions::Llm::ModelNotFoundError
1564
1624
  {}
1565
- end
1566
-
1567
- def instance_capability_config
1568
- cfg = config
1569
- result = {}
1570
- %i[capabilities enable_thinking enable_tools enable_streaming enable_vision enable_embeddings
1571
- thinking_flag tools_flag streaming_flag vision_flag embedding_flag embeddings_flag
1572
- tool_flag images_flag image_flag].each do |key|
1573
- next unless cfg.respond_to?(key)
1574
-
1575
- val = cfg.send(key)
1576
- result[key] = val unless val.nil?
1577
- rescue StandardError
1578
- next
1579
- end
1580
- result
1581
- end
1582
-
1583
- def model_capability_config(model_id)
1584
- models_conf = nil
1585
- models_conf = config.models if config.respond_to?(:models)
1586
- models_conf ||= config[:models] if config.respond_to?(:[])
1587
- return {} unless models_conf.respond_to?(:to_h)
1588
-
1589
- models_conf.to_h[model_id.to_s] || models_conf.to_h[model_id.to_sym] || {}
1590
1625
  rescue StandardError => e
1591
- handle_exception(e, level: :debug, handled: true, operation: 'bedrock.model_capability_config')
1626
+ handle_exception(e, level: :warn, handled: true, operation: 'bedrock.provider.catalog_capabilities')
1592
1627
  {}
1593
1628
  end
1594
1629
 
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Bedrock
7
+ # Single source of truth for how each Bedrock model expresses extended
8
+ # thinking on the wire. Shared by both the Provider (invoke_model /
9
+ # converse paths) and the Translator (canonical render path) so the two
10
+ # never diverge.
11
+ #
12
+ # Bedrock supports exactly one thinking wire shape for Anthropic Claude:
13
+ # { type: 'enabled', budget_tokens: N } (native Anthropic Messages API)
14
+ #
15
+ # There is NO Bedrock Claude model that accepts { type: 'adaptive' } —
16
+ # sending adaptive raises `ValidationException: adaptive thinking is not
17
+ # supported on this model` (observed live on opus-4-5) and surfaces as an
18
+ # HTTP 500. So a model either supports budgeted thinking (emit `enabled`)
19
+ # or it does not (OMIT the thinking field entirely — never `adaptive`).
20
+ #
21
+ # Budgeted extended thinking arrived with Claude 3.7 Sonnet and is
22
+ # supported across the entire Claude 4 family (sonnet-4, opus-4.x,
23
+ # haiku-4.5). The match is a substring so it tolerates the many Bedrock
24
+ # model-id decorations (geo prefixes `us.`/`eu.`/`ap.`, `anthropic.`
25
+ # provider prefix, `-vN:0` version suffixes, `:200k` context suffixes).
26
+ module ThinkingModes
27
+ module_function
28
+
29
+ # Model-id fragments for Claude families that support explicit budgeted
30
+ # extended thinking via { type: 'enabled', budget_tokens: N }.
31
+ BUDGETED_THINKING_FRAGMENTS = %w[
32
+ claude-3-7-sonnet
33
+ claude-sonnet-4
34
+ claude-opus-4
35
+ claude-haiku-4
36
+ ].freeze
37
+
38
+ # @return [Boolean] true when the model supports { type: 'enabled', budget_tokens: N }
39
+ def budgeted_thinking?(model_id)
40
+ return false if model_id.nil? || model_id.to_s.strip.empty?
41
+
42
+ mid = model_id.to_s
43
+ # Substring scan (String#include?), NOT array intersection: intersect?
44
+ # raises TypeError on a String argument.
45
+ BUDGETED_THINKING_FRAGMENTS.any? { |fragment| mid.include?(fragment) }
46
+ end
47
+
48
+ # @return [Boolean] true when the model is KNOWN not to support thinking.
49
+ # Distinct from `!budgeted_thinking?`: a nil/blank/unknown model id is
50
+ # NOT known-unsupported — we then honor an explicit thinking request and
51
+ # emit the (safe) `enabled` shape rather than dropping it. The router's
52
+ # capability filter (fed by the shared catalog) is the real guard that
53
+ # keeps thinking requests off non-thinking models; this method only
54
+ # strips thinking for a positively-identified non-thinking Claude model
55
+ # so we never emit an unsupported shape and 500.
56
+ def known_non_thinking?(model_id)
57
+ return false if model_id.nil? || model_id.to_s.strip.empty?
58
+
59
+ !budgeted_thinking?(model_id)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -3,6 +3,7 @@
3
3
  require 'legion/json'
4
4
  require 'legion/logging/helper'
5
5
  require 'legion/extensions/llm/canonical'
6
+ require_relative 'thinking_modes'
6
7
 
7
8
  module Legion
8
9
  module Extensions
@@ -20,8 +21,9 @@ module Legion
20
21
 
21
22
  DEFAULT_MAX_TOKENS = 4096
22
23
 
23
- def initialize(region: nil)
24
+ def initialize(region: nil, geo_prefix: nil)
24
25
  @region = region
26
+ @geo_prefix = geo_prefix
25
27
  end
26
28
 
27
29
  def capabilities
@@ -150,17 +152,13 @@ module Legion
150
152
  end
151
153
 
152
154
  def inference_profile_id(model_id)
153
- return model_id if model_id.nil? || model_id.start_with?('us.', 'eu.', 'ap.', 'arn:')
155
+ return model_id if model_id.nil? || model_id.start_with?('arn:')
154
156
 
155
- return model_id unless MODEL_PREFIXED_FAMILIES.any? { |p| model_id.start_with?(p) }
157
+ canonical = model_id.sub(/\A(?:us|eu|ap)\./, '')
158
+ return canonical unless MODEL_PREFIXED_FAMILIES.any? { |p| canonical.start_with?(p) }
156
159
 
157
- region = @region || 'us-east-1'
158
- prefix = if region.include?('eu')
159
- 'eu'
160
- else
161
- region.include?('ap') ? 'ap' : 'us'
162
- end
163
- "#{prefix}.#{model_id}"
160
+ prefix = normalize_geo_prefix(@geo_prefix)
161
+ "#{prefix}.#{canonical}"
164
162
  end
165
163
 
166
164
  def build_inference_config(canonical)
@@ -181,12 +179,18 @@ module Legion
181
179
 
182
180
  def build_additional_fields(canonical)
183
181
  return nil unless canonical.thinking
182
+ return nil if ThinkingModes.known_non_thinking?(model_from_request(canonical))
184
183
 
185
184
  budget = canonical_thinking_budget(canonical)
186
185
  budget ||= DEFAULT_MAX_TOKENS / 4
187
186
  { thinking: { type: 'enabled', budget_tokens: budget } }
188
187
  end
189
188
 
189
+ def normalize_geo_prefix(value)
190
+ candidate = value.to_s.downcase
191
+ %w[us eu ap].include?(candidate) ? candidate : 'us'
192
+ end
193
+
190
194
  def canonical_thinking_budget(canonical)
191
195
  return nil unless canonical.thinking
192
196
 
@@ -251,8 +255,13 @@ module Legion
251
255
  body.compact
252
256
  end
253
257
 
258
+ # Emit the thinking wire shape the model supports. Budgeted-thinking
259
+ # Claude models get { type: 'enabled', budget_tokens: N }; every other
260
+ # model returns nil so render_invoke_model OMITS the thinking field.
261
+ # Bedrock rejects { type: 'adaptive' } (ValidationException -> HTTP 500).
254
262
  def build_invoke_thinking(canonical)
255
263
  return nil unless canonical.thinking
264
+ return nil if ThinkingModes.known_non_thinking?(model_from_request(canonical))
256
265
 
257
266
  budget = canonical_thinking_budget(canonical)
258
267
  budget ||= DEFAULT_MAX_TOKENS / 4
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Bedrock
7
- VERSION = '0.4.4'
7
+ VERSION = '0.4.10'
8
8
  end
9
9
  end
10
10
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/extensions/llm'
4
+ require 'legion/extensions/llm/bedrock/thinking_modes'
4
5
  require 'legion/extensions/llm/bedrock/provider'
5
6
  require 'legion/extensions/llm/bedrock/translator'
6
7
  require 'legion/extensions/llm/bedrock/version'
@@ -30,6 +31,7 @@ module Legion
30
31
  instance: {
31
32
  default_model: DEFAULT_MODEL,
32
33
  region: 'us-east-1',
34
+ geo_prefix: 'us',
33
35
  tier: :cloud,
34
36
  transport: :aws_sdk,
35
37
  credentials: {
@@ -41,6 +43,7 @@ module Legion
41
43
  },
42
44
  provider: {
43
45
  region: DEFAULT_REGION,
46
+ geo_prefix: 'us',
44
47
  endpoint: nil,
45
48
  stub_responses: false
46
49
  },
@@ -85,9 +88,17 @@ module Legion
85
88
  # Resolve a default_model that never violates the configured model policy
86
89
  # (whitelist/blacklist stays authoritative over the DEFAULT_MODEL fallback).
87
90
  def self.resolve_default_model(config)
91
+ cfg = config.is_a?(Hash) ? config : {}
92
+ provider_conf = CredentialSources.setting(:extensions, :llm, PROVIDER_FAMILY)
93
+ provider_conf = {} unless provider_conf.is_a?(Hash)
94
+ global_conf = (::Legion::Settings.dig(:extensions, :llm) if defined?(::Legion::Settings))
95
+ global_conf = {} unless global_conf.is_a?(Hash)
96
+
88
97
  provider_class.policy_safe_default_model(
89
- configured: config[:default_model], fallback: DEFAULT_MODEL,
90
- **provider_class.model_policy(config, PROVIDER_FAMILY)
98
+ configured: cfg[:default_model],
99
+ fallback: DEFAULT_MODEL,
100
+ whitelist: provider_class.resolve_policy_value(cfg, provider_conf, global_conf, :model_whitelist),
101
+ blacklist: provider_class.resolve_policy_value(cfg, provider_conf, global_conf, :model_blacklist)
91
102
  )
92
103
  end
93
104
 
@@ -214,6 +225,7 @@ module Legion
214
225
 
215
226
  normalized = config.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
216
227
  normalized[:bedrock_region] ||= normalized.delete(:region)
228
+ normalized[:bedrock_geo_prefix] ||= normalized.delete(:geo_prefix)
217
229
  normalized[:bedrock_endpoint] ||= normalized.delete(:endpoint)
218
230
  normalized[:bedrock_endpoint] ||= normalized.delete(:base_url)
219
231
  normalized[:bedrock_endpoint] ||= normalized.delete(:api_base)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm-bedrock
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.4
4
+ version: 0.4.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO
@@ -99,14 +99,14 @@ dependencies:
99
99
  requirements:
100
100
  - - ">="
101
101
  - !ruby/object:Gem::Version
102
- version: 0.5.4
102
+ version: 0.6.0
103
103
  type: :runtime
104
104
  prerelease: false
105
105
  version_requirements: !ruby/object:Gem::Requirement
106
106
  requirements:
107
107
  - - ">="
108
108
  - !ruby/object:Gem::Version
109
- version: 0.5.4
109
+ version: 0.6.0
110
110
  description: Amazon Bedrock provider integration for the LegionIO LLM routing framework.
111
111
  email:
112
112
  - matthewdiverson@gmail.com
@@ -129,6 +129,7 @@ files:
129
129
  - lib/legion/extensions/llm/bedrock/actors/fleet_worker.rb
130
130
  - lib/legion/extensions/llm/bedrock/provider.rb
131
131
  - lib/legion/extensions/llm/bedrock/runners/fleet_worker.rb
132
+ - lib/legion/extensions/llm/bedrock/thinking_modes.rb
132
133
  - lib/legion/extensions/llm/bedrock/translator.rb
133
134
  - lib/legion/extensions/llm/bedrock/version.rb
134
135
  homepage: https://github.com/LegionIO/lex-llm-bedrock