lex-llm-bedrock 0.3.19 → 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: 33ab4ab57335007d1ffd9ef1c641e9be4fa657d83129f0b51382f624c3279fb3
4
- data.tar.gz: 74e962cef7be3018f94a296d84ff248de09d9c0dc88ce1ddb9de8fcb3bf32e21
3
+ metadata.gz: 05badbea355fec879dbd270070928472682c35d8709e3e6336e0080ca00aeb36
4
+ data.tar.gz: ec34813a7de565ad347c95777715054d27bd53ce71b04d8ddfd57de149852c9e
5
5
  SHA512:
6
- metadata.gz: 0d70d7113fa2b52ded5f4f64a49711052de15f8389c55840e8b4dcbc576b4b7cb55ad4f4bdb5255eb31c9e3e04b83a05415b56e6a4f8b65be31541ff3e5d4ad1
7
- data.tar.gz: 0ee5829d8287dec6cfa825454a2b36aef4218e7e14517cbc1cf358eac689fdee82462d7031e45c22e3f76160dca2a2b0f1e3de291d6f95c597a97eccef483044
6
+ metadata.gz: '073218f79b092a425703aa62f45b8d07d69afcf01d1249b66435fa4dce7211f74cbfab184e7df61f506f92c0ca4322d1d8448e6317ed5d52870d699ff42757e9'
7
+ data.tar.gz: 415524d66dcb863b18036935b15b7965996adb249d78779845b6f53679e22745b9316a438de386e490051cf7c46af6bc3264cf3db24d2b7517a7c5c4d49c9375
data/.rubocop.yml CHANGED
@@ -8,6 +8,10 @@ AllCops:
8
8
  TargetRubyVersion: 3.4
9
9
  SuggestExtensions: false
10
10
 
11
+ Lint/ShadowedException:
12
+ Enabled: false
13
+ Lint/UselessConstantScoping:
14
+ Enabled: false
11
15
  Metrics/BlockLength:
12
16
  Exclude:
13
17
  - "*.gemspec"
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
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
+
17
+ ## 0.4.0 - 2026-06-10
18
+
19
+ ### Added
20
+ - **Canonical provider translator** — New `Legion::Extensions::Llm::Bedrock::Translator` class implementing the canonical translator interface per Phase 3 of the N×N routing design. Supports `render_request`, `parse_response`, `parse_chunk`, and `capabilities`. Extracted from existing `format_messages`, `format_tools`, `parse_*` provider code (provider.rb) — move and normalize, no semantic rewrites.
21
+ - **Dual render targets** — Translator renders canonical requests to both Bedrock Converse API (`:converse`) and Bedrock invoke_model with Anthropic Messages payload (`:invoke_model`). Auto-selection: Anthropic models with thinking or tools route through invoke_model, all other requests use Converse.
22
+ - **Conformance kit adoption** — Loads `it_behaves_like 'a canonical provider translator'` shared examples from lex-llm. Full fixture-driven conformance with simple text, system prompt, params, tools, thinking, multi-turn continuation, streaming (text/thinking/tool call), error, stop_reason mapping, and round-trip consistency.
23
+ - **Canonical capabilities declaration** — `capabilities` declares `provider: 'bedrock'`, `render_targets: [:converse, :invoke_model]`, `thinking: :budget_tokens`, `stop_reasons` mapping (including `guardrail_intervened` → `content_filter`).
24
+
25
+ ### Changed
26
+ - **lex-llm dependency** — Bumped to `lex-llm >= 0.5.0` for canonical types (Request/Response/Chunk/Params/Thinking/ToolCall/Usage/Message/ContentBlock/ToolDefinition) and conformance kit availability.
27
+ - **Version bump** — Minor version 0.3.x → 0.4.0 (additive feature release).
28
+
29
+ ### Fixed
30
+ - **Params handling** — Translator uses `Canonical::Params.from_hash` instead of non-existent `Params.build`. All 187 specs pass.
31
+ - **Content block type/Access** — Translator correctly handles both Hash-inputs and `Canonical::ContentBlock` Data-struct objects with `.type`/`.text` attribute accessors.
32
+
3
33
  ## 0.3.19 - 2026-06-10
4
34
 
5
35
  ### Fixed
data/Gemfile CHANGED
@@ -2,13 +2,6 @@
2
2
 
3
3
  source 'https://rubygems.org'
4
4
 
5
- group :test do
6
- llm_base_path = ENV.fetch('LEX_LLM_PATH', File.expand_path('../lex-llm', __dir__))
7
- transport_path = ENV.fetch('LEGION_TRANSPORT_PATH', File.expand_path('../../legion-transport', __dir__))
8
- gem 'legion-transport', path: transport_path if File.directory?(transport_path)
9
- gem 'lex-llm', path: llm_base_path if File.directory?(llm_base_path)
10
- end
11
-
12
5
  gemspec
13
6
 
14
7
  group :development do
@@ -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.4.3'
32
+ spec.add_dependency 'lex-llm', '>= 0.5.0'
33
33
  end
@@ -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