lex-llm-bedrock 0.4.0 → 0.4.3

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: c0c19835470c757fe2f6ea3a1708884b0145694e921e34b22fca0b89c8aac69b
4
- data.tar.gz: b0fe049d5182e0760f5740e42fc7488eb80239aabba7a753e34d486d915e3c2d
3
+ metadata.gz: 05badbea355fec879dbd270070928472682c35d8709e3e6336e0080ca00aeb36
4
+ data.tar.gz: ec34813a7de565ad347c95777715054d27bd53ce71b04d8ddfd57de149852c9e
5
5
  SHA512:
6
- metadata.gz: 23604b77e5b16449e74d08a5fe14e9ee9a0e0bbb3ed164103e42f3005d7c483f3b65ea58d976701429a33632f89269757e1e2e33cf1f0289d04c8a1f231c325f
7
- data.tar.gz: 6b6f898e45dfc824daa1d3ca96381af08b40e7ed72b1fd90f57d378285db0cdecc1ee90ebbeb227c52d4494bbfc7be4b5752e8cbdd1b9bb616019cec09f2db02
6
+ metadata.gz: '073218f79b092a425703aa62f45b8d07d69afcf01d1249b66435fa4dce7211f74cbfab184e7df61f506f92c0ca4322d1d8448e6317ed5d52870d699ff42757e9'
7
+ data.tar.gz: 415524d66dcb863b18036935b15b7965996adb249d78779845b6f53679e22745b9316a438de386e490051cf7c46af6bc3264cf3db24d2b7517a7c5c4d49c9375
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.3 - 2026-06-16
4
+
5
+ - Dependency updates and code quality improvements.
6
+
7
+ ## 0.4.2 - 2026-06-15
8
+
9
+ - **CapabilityPolicy integration** — AWS model summaries used as `:model_metadata`; Converse tool use from `:provider_envelope`. Settings overrides at provider/instance/model level supported.
10
+
11
+ ## 0.4.1 - 2026-06-13
12
+
13
+ - **Gemfile cleanup** — Remove local path overrides; dependencies resolve from gemspec via rubygems.
14
+ - **RuboCop fixes** — Auto-corrected 6 offenses (style/layout).
15
+ - 199 examples, 0 failures; 17 files, 0 rubocop offenses.
16
+
3
17
  ## 0.4.0 - 2026-06-10
4
18
 
5
19
  ### Added
data/Gemfile CHANGED
@@ -4,15 +4,6 @@ source 'https://rubygems.org'
4
4
 
5
5
  gemspec
6
6
 
7
- group :test do
8
- transport_path = ENV.fetch('LEGION_TRANSPORT_PATH', File.expand_path('../../legion-transport', __dir__))
9
- gem 'legion-transport', path: transport_path if File.directory?(transport_path)
10
- end
11
-
12
- # lex-llm (>= 0.5.0) comes from gemspec with canonical types + conformance kit.
13
- # Override with a path/branch reference for local development only:
14
- # gem 'lex-llm', path: ENV.fetch('LEX_LLM_PATH', '../lex-llm')
15
-
16
7
  group :development do
17
8
  gem 'bundler', '>= 2.0'
18
9
  gem 'rake', '>= 13.0'
@@ -36,6 +36,13 @@ module Legion
36
36
  return unless defined?(Legion::LLM::Discovery)
37
37
 
38
38
  Legion::LLM::Discovery.refresh_discovered_models!(provider: :bedrock)
39
+
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!
45
+ end
39
46
  rescue StandardError => e
40
47
  handle_exception(e, level: :warn, handled: true, operation: 'bedrock.actor.discovery_refresh')
41
48
  end
@@ -11,6 +11,8 @@ module Legion
11
11
  module Extensions
12
12
  module Llm
13
13
  module Bedrock
14
+ class StaticCredentialsBlockedError < Legion::Extensions::Llm::ConfigurationError; end
15
+
14
16
  # Amazon Bedrock provider implementation for the Legion::Extensions::Llm contract.
15
17
  class Provider < Legion::Extensions::Llm::Provider # rubocop:disable Metrics/ClassLength
16
18
  include Legion::Logging::Helper
@@ -18,6 +20,10 @@ module Legion
18
20
  STATIC_MODELS = [
19
21
  { model: 'anthropic.claude-3-haiku-20240307-v1:0', alias: 'claude-3-haiku' },
20
22
  { model: 'anthropic.claude-sonnet-4-20250514-v1:0', alias: 'anthropic.claude-sonnet-4' },
23
+ { model: 'anthropic.claude-sonnet-4-20250514-v1:0', alias: 'claude-sonnet-4-6' },
24
+ { model: 'anthropic.claude-sonnet-4-20250514-v1:0', alias: 'claude-sonnet-4-5-20241022' },
25
+ { model: 'anthropic.claude-opus-4-20250515-v1:0', alias: 'claude-opus-4-8' },
26
+ { model: 'anthropic.claude-haiku-4-20250506-v1:0', alias: 'claude-haiku-4-5' },
21
27
  { model: 'amazon.titan-text-express-v1', alias: 'titan-text-express' },
22
28
  { model: 'amazon.titan-embed-text-v2:0', alias: 'titan-embed-text-v2', usage_type: :embedding },
23
29
  { model: 'meta.llama3-2-11b-instruct-v1:0', alias: 'llama-3.2-11b-instruct' },
@@ -117,6 +123,10 @@ module Legion
117
123
  end
118
124
  end
119
125
 
126
+ def translator
127
+ @translator ||= Translator.new(region: region)
128
+ end
129
+
120
130
  def api_base
121
131
  config.bedrock_endpoint || "https://bedrock-runtime.#{region}.amazonaws.com"
122
132
  end
@@ -499,8 +509,15 @@ module Legion
499
509
  state[:raw_events] << { event: event_type, data: raw_event } if dump_path
500
510
  handle_invoke_model_stream_json(raw_event, state, mid) { |chunk| yield chunk if block_given? }
501
511
  end
512
+ rescue Legion::JSON::ParseError => e
513
+ handle_exception(e, level: :warn, handled: true,
514
+ operation: 'bedrock.provider.invoke_model_stream.chunk_decode')
502
515
  rescue StandardError => e
503
- log.warn { "bedrock.provider.invoke_model_stream: chunk decode error=#{sanitize_log(e.message)}" }
516
+ # Never swallow non-parse errors here — a silent rescue in this
517
+ # event handler previously hid streaming bugs as dead-air streams.
518
+ handle_exception(e, level: :error, handled: false,
519
+ operation: 'bedrock.provider.invoke_model_stream.chunk_event')
520
+ raise
504
521
  end
505
522
 
506
523
  stream.on_error_event do |event|
@@ -555,12 +572,14 @@ module Legion
555
572
  Legion::Extensions::Llm::Message.new(**msg_attrs)
556
573
  end
557
574
 
558
- def build_invoke_model_body(messages:, temperature:, max_tokens:, tools:, tool_prefs:, thinking:, **_rest)
575
+ def build_invoke_model_body(messages:, temperature:, max_tokens:, tools:, tool_prefs:, thinking:, **rest)
576
+ system_content = extract_invoke_model_system(messages, system: rest[:system])
559
577
  body = {
560
578
  max_tokens: max_tokens || 4096,
561
579
  messages: format_invoke_model_messages(messages),
562
580
  anthropic_version: 'bedrock-2023-05-31'
563
581
  }
582
+ body[:system] = system_content if system_content
564
583
  body[:temperature] = temperature if temperature
565
584
  if tools && !tools.empty?
566
585
  tool_format = format_invoke_model_tools(tools, tool_prefs)
@@ -568,11 +587,25 @@ module Legion
568
587
  body[:tool_choice] = tool_format[:tool_choice] if tool_format[:tool_choice]
569
588
  end
570
589
  body[:thinking] = invoke_model_thinking(thinking) if thinking
571
- # NOTE: Don't include body[:stream] = true in the JSON body for invoke_model_with_response_stream.
572
- # The endpoint itself implies streaming; Bedrock rejects the extra field.
573
590
  body
574
591
  end
575
592
 
593
+ def extract_invoke_model_system(messages, system: nil)
594
+ parts = []
595
+ parts << system.to_s unless system.to_s.empty?
596
+ messages.each do |msg|
597
+ role = msg.respond_to?(:role) ? msg.role.to_s : (msg[:role] || msg['role']).to_s
598
+ next unless role == 'system'
599
+
600
+ content = msg.respond_to?(:content) ? msg.content : (msg[:content] || msg['content'])
601
+ text = content.is_a?(Array) ? content.filter_map { |b| b[:text] || b['text'] }.join("\n") : content.to_s
602
+ parts << text unless text.empty?
603
+ end
604
+ return nil if parts.empty?
605
+
606
+ parts.map { |t| { type: 'text', text: t } }
607
+ end
608
+
576
609
  # Strip provider-specific keys (e.g. effort from OpenAI) that Bedrock/Anthropic APIs don't accept.
577
610
  def invoke_model_thinking(thinking)
578
611
  return thinking unless thinking.is_a?(Hash)
@@ -581,7 +614,7 @@ module Legion
581
614
  end
582
615
 
583
616
  def format_invoke_model_messages(messages)
584
- messages.filter_map do |msg|
617
+ formatted = messages.filter_map do |msg|
585
618
  role = msg.respond_to?(:role) ? msg.role.to_s : (msg[:role] || msg['role']).to_s
586
619
  next if role == 'system'
587
620
 
@@ -598,6 +631,19 @@ module Legion
598
631
 
599
632
  { role: role == 'tool' ? 'user' : role, content: content }
600
633
  end
634
+ consolidate_adjacent_roles(formatted)
635
+ end
636
+
637
+ def consolidate_adjacent_roles(messages)
638
+ return messages if messages.size < 2
639
+
640
+ messages.each_with_object([]) do |msg, result|
641
+ if result.last && result.last[:role] == msg[:role]
642
+ result.last[:content] = Array(result.last[:content]) + Array(msg[:content])
643
+ else
644
+ result << msg
645
+ end
646
+ end
601
647
  end
602
648
 
603
649
  def format_invoke_model_content(msg)
@@ -664,11 +710,11 @@ module Legion
664
710
 
665
711
  def format_invoke_model_tools(tools, tool_prefs)
666
712
  tool_list = tools.values.map do |tool|
713
+ raw_schema = tool[:params_schema] || tool['params_schema'] || tool[:parameters] || tool['parameters']
667
714
  {
668
715
  name: tool[:name] || tool['name'],
669
716
  description: tool[:description] || tool['description'] || '',
670
- input_schema: tool[:params_schema] || tool['params_schema'] ||
671
- { type: 'object', properties: {} }
717
+ input_schema: Legion::Extensions::Llm::Canonical::ToolDefinition.normalize_parameters(raw_schema)
672
718
  }
673
719
  end
674
720
 
@@ -802,7 +848,11 @@ module Legion
802
848
  state[:stop_reason] = delta['stop_reason']
803
849
  end
804
850
  rescue StandardError => e
805
- log.warn { "bedrock.provider.invoke_model_stream_json: error=#{e.message}" }
851
+ # Re-raise a swallowed error here turns a streaming bug into a
852
+ # silent dead-air stream (message_start then nothing).
853
+ handle_exception(e, level: :error, handled: false,
854
+ operation: 'bedrock.provider.invoke_model_stream_json')
855
+ raise
806
856
  end
807
857
 
808
858
  def static_offerings(**filters)
@@ -816,12 +866,24 @@ module Legion
816
866
 
817
867
  def offering_from_summary(summary)
818
868
  model = value(summary, :model_id)
869
+ real = real_capabilities_from_summary(summary)
870
+ policy = Legion::Extensions::Llm::CapabilityPolicy.resolve(
871
+ real: real,
872
+ provider_catalog: {},
873
+ probe: {},
874
+ provider_envelope: provider_envelope_capabilities,
875
+ provider_config: provider_capability_config,
876
+ instance_config: instance_capability_config,
877
+ model_config: model_capability_config(model)
878
+ )
879
+
819
880
  build_offering(
820
881
  model: model,
821
882
  alias_name: alias_for(model),
822
883
  model_family: normalize_provider(value(summary, :provider_name)) || model_family_for(model),
823
884
  usage_type: usage_type_from_modalities(value(summary, :output_modalities)),
824
- capabilities: capabilities_from_summary(summary),
885
+ capabilities: policy[:capabilities],
886
+ capability_sources: policy[:sources],
825
887
  metadata: normalize_response(summary)
826
888
  )
827
889
  end
@@ -844,7 +906,7 @@ module Legion
844
906
  end
845
907
 
846
908
  def build_offering(model:, model_family:, usage_type:, instance_id: :default, alias_name: nil,
847
- capabilities: nil, metadata: {})
909
+ capabilities: nil, capability_sources: nil, metadata: {})
848
910
  limits = infer_limits(model)
849
911
  Legion::Extensions::Llm::Routing::ModelOffering.new(
850
912
  provider_family: :bedrock,
@@ -854,6 +916,7 @@ module Legion
854
916
  model: model,
855
917
  usage_type: usage_type,
856
918
  capabilities: capabilities || default_capabilities(model),
919
+ capability_sources: capability_sources,
857
920
  limits: limits,
858
921
  metadata: metadata.merge(model_family: model_family, alias: alias_name).compact
859
922
  )
@@ -903,13 +966,14 @@ module Legion
903
966
 
904
967
  def format_messages(messages)
905
968
  total = messages.size
906
- messages.filter_map.with_index do |message, idx|
969
+ formatted = messages.filter_map.with_index do |message, idx|
907
970
  blocks = build_content_blocks(message)
908
971
  next if blocks.empty?
909
972
 
910
973
  cache_blocks = should_cache_message?(idx, total) ? add_cache_control_to_blocks(blocks) : blocks
911
974
  { role: bedrock_role(message.role), content: cache_blocks }
912
975
  end
976
+ consolidate_adjacent_roles(formatted)
913
977
  end
914
978
 
915
979
  def tool_result_blocks(message)
@@ -967,7 +1031,9 @@ module Legion
967
1031
  text = content_text(message.content)
968
1032
  blocks << { text: text } if text && !text.strip.empty?
969
1033
 
970
- message.tool_calls.each_value do |call|
1034
+ # Array is canonical (name-keyed hashes dropped parallel same-name calls)
1035
+ calls = message.tool_calls.is_a?(Hash) ? message.tool_calls.values : Array(message.tool_calls)
1036
+ calls.each do |call|
971
1037
  blocks << {
972
1038
  tool_use: {
973
1039
  tool_use_id: call.id,
@@ -1062,9 +1128,12 @@ module Legion
1062
1128
  end
1063
1129
 
1064
1130
  def tool_schema(tool)
1065
- return tool.params_schema if tool.respond_to?(:params_schema) && tool.params_schema
1066
-
1067
- { type: 'object', properties: {} }
1131
+ raw = if tool.respond_to?(:params_schema) && tool.params_schema
1132
+ tool.params_schema
1133
+ elsif tool.respond_to?(:parameters)
1134
+ tool.parameters
1135
+ end
1136
+ Legion::Extensions::Llm::Canonical::ToolDefinition.normalize_parameters(raw)
1068
1137
  end
1069
1138
 
1070
1139
  def tool_choice(tool_prefs)
@@ -1273,8 +1342,8 @@ module Legion
1273
1342
  # Bedrock streaming: text blocks use delta.text,
1274
1343
  # reasoning/thinking blocks use delta.reasoning.text or delta.thinking.text
1275
1344
  text = value(delta, :text) ||
1276
- (value(delta, :reasoning) ? value(reasoning_delta, :text) : nil) ||
1277
- (value(delta, :thinking) ? value(thinking_delta, :text) : nil)
1345
+ value(value(delta, :reasoning), :text) ||
1346
+ value(value(delta, :thinking), :text)
1278
1347
  if text
1279
1348
  if state[:in_thinking]
1280
1349
  state[:thinking] << text
@@ -1399,7 +1468,7 @@ module Legion
1399
1468
  return nil unless config.bedrock_access_key_id
1400
1469
 
1401
1470
  if static_credentials_blocked?
1402
- raise SecurityError,
1471
+ raise StaticCredentialsBlockedError,
1403
1472
  'Static AWS credentials are disabled (security.block_static_aws_credentials=true); use IAM roles'
1404
1473
  end
1405
1474
  log.warn('[bedrock] Using static AWS credentials — prefer IAM roles for production')
@@ -1467,6 +1536,59 @@ module Legion
1467
1536
  caps
1468
1537
  end
1469
1538
 
1539
+ def real_capabilities_from_summary(summary)
1540
+ caps = {}
1541
+ caps[:streaming] = true if value(summary, :response_streaming_supported)
1542
+ input_mods = Array(value(summary, :input_modalities)).map { |m| m.to_s.upcase }
1543
+ caps[:vision] = true if input_mods.include?('IMAGE')
1544
+ output_mods = Array(value(summary, :output_modalities)).map { |m| m.to_s.upcase }
1545
+ caps[:embeddings] = true if output_mods.include?('EMBEDDING')
1546
+ caps
1547
+ end
1548
+
1549
+ def provider_envelope_capabilities
1550
+ # Bedrock Converse API supports tool use across all active chat model families
1551
+ { tools: true }
1552
+ end
1553
+
1554
+ def provider_capability_config
1555
+ return {} unless defined?(Legion::Extensions::Llm::CredentialSources)
1556
+
1557
+ conf = Legion::Extensions::Llm::CredentialSources.setting(:extensions, :llm, :bedrock)
1558
+ conf.is_a?(Hash) ? conf.to_h.except(:instances, 'instances') : {}
1559
+ rescue StandardError => e
1560
+ handle_exception(e, level: :debug, handled: true, operation: 'bedrock.provider_capability_config')
1561
+ {}
1562
+ end
1563
+
1564
+ def instance_capability_config
1565
+ cfg = config
1566
+ result = {}
1567
+ %i[capabilities enable_thinking enable_tools enable_streaming enable_vision enable_embeddings
1568
+ thinking_flag tools_flag streaming_flag vision_flag embedding_flag embeddings_flag
1569
+ tool_flag images_flag image_flag].each do |key|
1570
+ next unless cfg.respond_to?(key)
1571
+
1572
+ val = cfg.send(key)
1573
+ result[key] = val unless val.nil?
1574
+ rescue StandardError
1575
+ next
1576
+ end
1577
+ result
1578
+ end
1579
+
1580
+ def model_capability_config(model_id)
1581
+ models_conf = nil
1582
+ models_conf = config.models if config.respond_to?(:models)
1583
+ models_conf ||= config[:models] if config.respond_to?(:[])
1584
+ return {} unless models_conf.respond_to?(:to_h)
1585
+
1586
+ models_conf.to_h[model_id.to_s] || models_conf.to_h[model_id.to_sym] || {}
1587
+ rescue StandardError => e
1588
+ handle_exception(e, level: :debug, handled: true, operation: 'bedrock.model_capability_config')
1589
+ {}
1590
+ end
1591
+
1470
1592
  def model_family_for(model)
1471
1593
  normalize_provider(model.to_s.split('.').first)
1472
1594
  end
@@ -205,7 +205,7 @@ module Legion
205
205
  tool_spec: {
206
206
  name: tool.name,
207
207
  description: tool.description.to_s,
208
- input_schema: { json: tool.parameters || { type: 'object', properties: {} } }
208
+ input_schema: { json: tool.parameters }
209
209
  }
210
210
  }
211
211
  end
@@ -234,6 +234,9 @@ module Legion
234
234
  anthropic_version: 'bedrock-2023-05-31'
235
235
  }
236
236
 
237
+ sys = render_invoke_system(canonical)
238
+ body[:system] = sys if sys
239
+
237
240
  temp = canonical.params&.temperature
238
241
  body[:temperature] = temp if temp
239
242
 
@@ -256,6 +259,22 @@ module Legion
256
259
  { type: 'enabled', budget_tokens: budget }
257
260
  end
258
261
 
262
+ def render_invoke_system(canonical)
263
+ sys = canonical.system
264
+ return nil if sys.nil? || sys.to_s.strip.empty?
265
+
266
+ if sys.is_a?(Array)
267
+ sys.map do |block|
268
+ wire = { type: 'text', text: (block[:text] || block['text'] || block.to_s).to_s }
269
+ cc = block[:cache_control] || block['cache_control']
270
+ wire[:cache_control] = cc if cc
271
+ wire
272
+ end
273
+ else
274
+ [{ type: 'text', text: sys.to_s }]
275
+ end
276
+ end
277
+
259
278
  def build_invoke_tools(canonical)
260
279
  return nil unless canonical.tools && !canonical.tools.empty?
261
280
 
@@ -263,7 +282,7 @@ module Legion
263
282
  {
264
283
  name: tool.name,
265
284
  description: (tool.description || '').to_s,
266
- input_schema: tool.parameters || { type: 'object', properties: {} }
285
+ input_schema: tool.parameters
267
286
  }
268
287
  end
269
288
 
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Bedrock
7
- VERSION = '0.4.0'
7
+ VERSION = '0.4.3'
8
8
  end
9
9
  end
10
10
  end
@@ -5,6 +5,7 @@ require 'legion/extensions/llm/bedrock/provider'
5
5
  require 'legion/extensions/llm/bedrock/translator'
6
6
  require 'legion/extensions/llm/bedrock/version'
7
7
  require 'legion/logging/helper'
8
+ require_relative 'bedrock/actors/discovery_refresh'
8
9
 
9
10
  module Legion
10
11
  module Extensions
@@ -44,10 +45,7 @@ module Legion
44
45
  fleet: {
45
46
  enabled: false,
46
47
  respond_to_requests: false,
47
- capabilities: %i[chat stream_chat embed],
48
- lanes: [],
49
- concurrency: 4,
50
- queue_suffix: nil
48
+ capabilities: %i[chat stream_chat embed tools]
51
49
  }
52
50
  }
53
51
  )
@@ -223,8 +221,7 @@ module Legion
223
221
  config.except(:api_key)
224
222
  end
225
223
 
226
- Legion::Extensions::Llm::Configuration.register_provider_options(Provider.configuration_options) if
227
- Legion::Extensions::Llm::Configuration.respond_to?(:register_provider_options)
224
+ Legion::Extensions::Llm::Configuration.register_provider_options(Provider.configuration_options)
228
225
  end
229
226
  end
230
227
  end
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.0
4
+ version: 0.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO