lex-llm-bedrock 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 59c2c3fda768fc58435dbe327fd8314e4b9dbfdbdf978e9220e3ad4999e42bc1
4
+ data.tar.gz: 8b448d4dbb18e1c8d2d3dde0c817cc49591142d5bc88c6057cf8f8f28d3cab8d
5
+ SHA512:
6
+ metadata.gz: 47d5d1f35bd64d93db2e068b988a4ff9bb3d8622726f23cfe897b282f8cd1c4f454a44b82c170c04831c108226f373869121ac9a61f574bf023a13a8d9c92c8b
7
+ data.tar.gz: 2c9679fabbce22abde6a5f7e58da92c34e113afe5b6f166e3956ee6c91034f7178737e95897a93ba348750c85358822b3dc464841dfcbbf37d74ceb66469ef03
@@ -0,0 +1 @@
1
+ * @LegionIO/maintainers
@@ -0,0 +1,10 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: bundler
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ - package-ecosystem: github-actions
8
+ directory: /
9
+ schedule:
10
+ interval: weekly
@@ -0,0 +1,16 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+
7
+ jobs:
8
+ ci:
9
+ uses: LegionIO/.github/.github/workflows/ci.yml@main
10
+
11
+ release:
12
+ needs: ci
13
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
14
+ uses: LegionIO/.github/.github/workflows/release.yml@main
15
+ secrets:
16
+ rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }}
data/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /coverage/
3
+ /pkg/
4
+ /tmp/
5
+ Gemfile.lock
6
+ *.gem
7
+
8
+ .rspec_status*
9
+ .env
10
+ .claude
11
+ AGENTS.md
12
+ CLAUDE.md
data/.rubocop.yml ADDED
@@ -0,0 +1,32 @@
1
+ plugins:
2
+ - rubocop-performance
3
+ - rubocop-rake
4
+ - rubocop-rspec
5
+
6
+ AllCops:
7
+ NewCops: enable
8
+ TargetRubyVersion: 3.4
9
+ SuggestExtensions: false
10
+
11
+ Metrics/BlockLength:
12
+ Exclude:
13
+ - "*.gemspec"
14
+ - spec/**/*
15
+ Metrics/MethodLength:
16
+ Enabled: false
17
+ Metrics/ParameterLists:
18
+ Enabled: false
19
+ Metrics/AbcSize:
20
+ Enabled: false
21
+ Metrics/CyclomaticComplexity:
22
+ Enabled: false
23
+ Metrics/PerceivedComplexity:
24
+ Enabled: false
25
+ RSpec/MultipleExpectations:
26
+ Enabled: false
27
+ RSpec/ExampleLength:
28
+ Enabled: false
29
+ RSpec/LeakyConstantDeclaration:
30
+ Enabled: false
31
+ RSpec/InstanceVariable:
32
+ Enabled: false
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-04-28
4
+
5
+ - Initial Legion::Extensions::Llm Bedrock provider extension scaffold.
6
+ - Add offline provider defaults, model offering mapping, AWS SDK client construction, chat, streaming, embeddings, token counting, health, and live discovery entrypoints.
7
+ - Add README, gemspec, CI, and stubbed unit specs for Bedrock routing behavior.
data/Gemfile ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ group :test do
6
+ llm_base_path = ENV.fetch('LEX_LLM_PATH', File.expand_path('../lex-llm', __dir__))
7
+ gem 'lex-llm', path: llm_base_path if File.directory?(llm_base_path)
8
+ end
9
+
10
+ gemspec
11
+
12
+ group :development do
13
+ gem 'bundler', '>= 2.0'
14
+ gem 'rake', '>= 13.0'
15
+ gem 'rspec', '~> 3.12'
16
+ gem 'rubocop', '>= 1.0'
17
+ gem 'rubocop-performance'
18
+ gem 'rubocop-rake', '>= 0.6'
19
+ gem 'rubocop-rspec'
20
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LegionIO
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # lex-llm-bedrock
2
+
3
+ Amazon Bedrock provider extension for `Legion::Extensions::Llm`.
4
+
5
+ This gem adds a hosted Bedrock provider surface for Legion LLM routing without depending on the old `legion-llm` gem. It uses the official AWS SDK for Ruby and keeps discovery offline by default, so loading the extension or running tests does not require live AWS credentials.
6
+
7
+ ## Install
8
+
9
+ ```ruby
10
+ gem 'lex-llm-bedrock'
11
+ ```
12
+
13
+ ## Configuration
14
+
15
+ The provider registers the `:bedrock` provider family with `Legion::Extensions::Llm::Provider`.
16
+
17
+ ```ruby
18
+ require 'legion/extensions/llm/bedrock'
19
+
20
+ Legion::Extensions::Llm.configure do |config|
21
+ config.bedrock_region = ENV.fetch('AWS_REGION', 'us-east-1')
22
+ config.bedrock_access_key_id = ENV['AWS_ACCESS_KEY_ID']
23
+ config.bedrock_secret_access_key = ENV['AWS_SECRET_ACCESS_KEY']
24
+ config.bedrock_session_token = ENV['AWS_SESSION_TOKEN']
25
+ end
26
+ ```
27
+
28
+ If explicit keys are not configured, the AWS SDK default credential provider chain is used. Default settings expose `env://` credential references and mark live discovery disabled:
29
+
30
+ ```ruby
31
+ Legion::Extensions::Llm::Bedrock.default_settings
32
+ ```
33
+
34
+ ## Provider Surface
35
+
36
+ ```ruby
37
+ provider = Legion::Extensions::Llm::Bedrock::Provider.new(Legion::Extensions::Llm.config)
38
+
39
+ provider.discover_offerings(live: false)
40
+ provider.offering_for(model: 'anthropic.claude-3-haiku-20240307-v1:0')
41
+ provider.health(live: false)
42
+ provider.chat(messages, model: model)
43
+ provider.stream(messages, model: model) { |chunk| chunk.content }
44
+ provider.embed('hello', model: 'amazon.titan-embed-text-v2:0')
45
+ provider.count_tokens(messages, model: model)
46
+ ```
47
+
48
+ `discover_offerings(live: false)` returns a small static catalog that is useful for routing defaults and unit tests. `discover_offerings(live: true)` calls Bedrock `ListFoundationModels` and maps the returned model summaries into `Legion::Extensions::Llm::Routing::ModelOffering` records.
49
+
50
+ ## Model Offerings
51
+
52
+ Every offering uses:
53
+
54
+ - `provider_family: :bedrock`
55
+ - `transport: :aws_sdk`
56
+ - the Bedrock model ID as `model`
57
+ - `metadata[:model_family]` inferred from the provider prefix or accepted from the caller
58
+
59
+ Known aliases are intentionally small and conservative. For example, `claude-3-haiku` resolves to `anthropic.claude-3-haiku-20240307-v1:0`, while the preserved Bedrock model ID remains the routing model.
60
+
61
+ ## API Contract
62
+
63
+ The implementation is intentionally limited to Bedrock operations documented by AWS:
64
+
65
+ - `ListFoundationModels` for live model discovery
66
+ - `Converse` for chat-style inference
67
+ - `ConverseStream` for streaming chat responses
68
+ - `CountTokens` for token estimates
69
+ - `InvokeModel` only for the Titan text embedding request shape implemented here
70
+
71
+ Provider-specific request bodies are not guessed. Non-Titan embedding models raise until their documented body shape is added explicitly.
72
+
73
+ AWS references:
74
+
75
+ - [Converse](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html)
76
+ - [ConverseStream](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ConverseStream.html)
77
+ - [CountTokens](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CountTokens.html)
78
+ - [ListFoundationModels](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_ListFoundationModels.html)
79
+ - [Foundation model information](https://docs.aws.amazon.com/bedrock/latest/userguide/foundation-models-reference.html)
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/llm/bedrock/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-llm-bedrock'
7
+ spec.version = Legion::Extensions::Llm::Bedrock::VERSION
8
+ spec.authors = ['LegionIO']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+ spec.summary = 'LegionIO LLM Amazon Bedrock provider extension'
11
+ spec.description = 'Amazon Bedrock provider integration for the LegionIO LLM routing framework.'
12
+ spec.homepage = 'https://github.com/LegionIO/lex-llm-bedrock'
13
+ spec.license = 'MIT'
14
+ spec.required_ruby_version = '>= 3.4'
15
+
16
+ spec.metadata['homepage_uri'] = spec.homepage
17
+ spec.metadata['source_code_uri'] = spec.homepage
18
+ spec.metadata['documentation_uri'] = spec.homepage
19
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+ spec.metadata['bug_tracker_uri'] = "#{spec.homepage}/issues"
21
+ spec.metadata['rubygems_mfa_required'] = 'true'
22
+
23
+ spec.files = `git ls-files -z`.split("\x0").reject { |file| file.match(%r{^(spec|test|features|tmp|coverage)/}) }
24
+ spec.require_paths = ['lib']
25
+
26
+ spec.add_dependency 'aws-sdk-bedrock'
27
+ spec.add_dependency 'aws-sdk-bedrockruntime'
28
+ spec.add_dependency 'legion-json', '>= 1.2.1'
29
+ spec.add_dependency 'legion-logging', '>= 1.3.2'
30
+ spec.add_dependency 'legion-settings', '>= 1.3.14'
31
+ spec.add_dependency 'lex-llm', '>= 0.1.3'
32
+ end
@@ -0,0 +1,508 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aws-sdk-bedrock'
4
+ require 'aws-sdk-bedrockruntime'
5
+ require 'legion/json'
6
+ require 'legion/logging'
7
+ require 'legion/settings'
8
+ require 'legion/extensions/llm'
9
+
10
+ module Legion
11
+ module Extensions
12
+ module Llm
13
+ module Bedrock
14
+ # Amazon Bedrock provider implementation for the Legion::Extensions::Llm contract.
15
+ class Provider < Legion::Extensions::Llm::Provider # rubocop:disable Metrics/ClassLength
16
+ DEFAULT_REGION = 'us-east-1'
17
+
18
+ STATIC_MODELS = [
19
+ { model: 'anthropic.claude-3-haiku-20240307-v1:0', alias: 'claude-3-haiku' },
20
+ { model: 'amazon.titan-text-express-v1', alias: 'titan-text-express' },
21
+ { model: 'amazon.titan-embed-text-v2:0', alias: 'titan-embed-text-v2', usage_type: :embedding },
22
+ { model: 'meta.llama3-2-11b-instruct-v1:0', alias: 'llama-3.2-11b-instruct' },
23
+ { model: 'mistral.mistral-large-3-675b-instruct', alias: 'mistral-large-3' }
24
+ ].freeze
25
+
26
+ ALIASES = STATIC_MODELS.to_h { |entry| [entry.fetch(:alias), entry.fetch(:model)] }.freeze
27
+
28
+ class << self
29
+ def slug = 'bedrock'
30
+
31
+ def configuration_options
32
+ %i[
33
+ bedrock_region
34
+ bedrock_endpoint
35
+ bedrock_access_key_id
36
+ bedrock_secret_access_key
37
+ bedrock_session_token
38
+ bedrock_profile
39
+ bedrock_stub_responses
40
+ ]
41
+ end
42
+
43
+ def configuration_requirements = []
44
+ def capabilities = Capabilities
45
+
46
+ def resolve_model_id(model_id, config: nil) # rubocop:disable Lint/UnusedMethodArgument
47
+ ALIASES.fetch(model_id.to_s, model_id.to_s)
48
+ end
49
+ end
50
+
51
+ # Capability predicates inferred from Bedrock model IDs and API modalities.
52
+ module Capabilities
53
+ module_function
54
+
55
+ def chat?(model) = !embeddings?(model)
56
+ def streaming?(model) = chat?(model)
57
+ def vision?(model) = model_id(model).match?(/(claude-3|llama3-2-(11|90)b)/)
58
+ def functions?(model) = chat?(model)
59
+ def embeddings?(model) = model_id(model).match?(/embed|embedding/)
60
+
61
+ def model_id(model)
62
+ return model.fetch('model', model.fetch('id', '')) if model.is_a?(Hash)
63
+
64
+ model.respond_to?(:id) ? model.id.to_s : model.to_s
65
+ end
66
+ end
67
+
68
+ def api_base
69
+ config.bedrock_endpoint || "https://bedrock-runtime.#{region}.amazonaws.com"
70
+ end
71
+
72
+ def completion_url = 'Converse'
73
+ def stream_url = 'ConverseStream'
74
+ def models_url = 'ListFoundationModels'
75
+ def embedding_url(**) = 'InvokeModel'
76
+ def count_tokens_url = 'CountTokens'
77
+
78
+ def region
79
+ config.bedrock_region || DEFAULT_REGION
80
+ end
81
+
82
+ def discover_offerings(live: false, **filters)
83
+ return static_offerings(**filters) unless live
84
+
85
+ response = bedrock_client.list_foundation_models(**filters)
86
+ Array(value(response, :model_summaries)).map { |summary| offering_from_summary(summary) }
87
+ end
88
+
89
+ def offering_for(model:, model_family: nil, instance_id: :default, **metadata)
90
+ model_id = self.class.resolve_model_id(model)
91
+ build_offering(
92
+ model: model_id,
93
+ alias_name: alias_for(model_id),
94
+ model_family: model_family || model_family_for(model_id),
95
+ instance_id: instance_id,
96
+ usage_type: metadata.delete(:usage_type) || usage_type_for(model_id),
97
+ metadata: metadata
98
+ )
99
+ end
100
+
101
+ def health(live: false)
102
+ baseline = {
103
+ provider: :bedrock,
104
+ region: region,
105
+ configured: true,
106
+ ready: true,
107
+ live: live,
108
+ credentials: credential_source
109
+ }
110
+ return baseline.merge(checked: false) unless live
111
+
112
+ bedrock_client.list_foundation_models
113
+ baseline.merge(checked: true)
114
+ rescue StandardError => e
115
+ baseline.merge(checked: true, ready: false, error: e.class.name, message: e.message)
116
+ end
117
+
118
+ def readiness(live: false)
119
+ health(live: live).merge(local: false, remote: true, api_base: api_base, endpoints: endpoint_manifest)
120
+ end
121
+
122
+ def list_models
123
+ discover_offerings(live: true).map do |offering|
124
+ Legion::Extensions::Llm::Model::Info.new(
125
+ id: offering.model,
126
+ name: offering.metadata[:alias] || offering.model,
127
+ provider: :bedrock,
128
+ family: offering.metadata[:model_family],
129
+ capabilities: offering.capabilities.map(&:to_s),
130
+ metadata: offering.to_h
131
+ )
132
+ end
133
+ end
134
+
135
+ def chat(messages, model:, temperature: nil, max_tokens: nil, tools: {}, tool_prefs: nil, params: {})
136
+ request = Utils.deep_merge(
137
+ converse_request(messages, model:, temperature:, max_tokens:, tools:, tool_prefs:),
138
+ params
139
+ )
140
+ parse_converse_response(runtime_client.converse(**request), model_id(model))
141
+ end
142
+
143
+ def stream(messages, model:, temperature: nil, max_tokens: nil, tools: {}, tool_prefs: nil, params: {},
144
+ &)
145
+ request = Utils.deep_merge(
146
+ converse_request(messages, model:, temperature:, max_tokens:, tools:, tool_prefs:),
147
+ params
148
+ )
149
+ stream_converse(request, model_id(model), &)
150
+ end
151
+
152
+ def count_tokens(messages, model:, system: nil, params: {})
153
+ request = Utils.deep_merge(
154
+ {
155
+ model_id: model_id(model),
156
+ input: { converse: { messages: format_messages(messages), system: system_blocks(system) }.compact }
157
+ },
158
+ params
159
+ )
160
+ response = runtime_client.count_tokens(**request)
161
+ { input_tokens: value(response, :input_tokens), raw: normalize_response(response) }
162
+ end
163
+
164
+ def embed(text, model:, dimensions: nil)
165
+ model_id = model_id(model)
166
+ unless titan_embed?(model_id)
167
+ raise NotImplementedError,
168
+ "Bedrock embedding payload for #{model_id} is not standardized"
169
+ end
170
+
171
+ body = { inputText: text, dimensions: dimensions }.compact
172
+ response = runtime_client.invoke_model(
173
+ model_id: model_id,
174
+ content_type: 'application/json',
175
+ accept: 'application/json',
176
+ body: Legion::JSON.generate(body)
177
+ )
178
+ parse_embedding_response(response, model: model_id)
179
+ end
180
+
181
+ def complete(messages, tools:, temperature:, model:, params: {}, schema: nil, thinking: nil, tool_prefs: nil,
182
+ &)
183
+ payload = params.dup
184
+ payload[:additional_model_request_fields] ||= {}
185
+ payload[:additional_model_request_fields][:thinking] = thinking if thinking
186
+ payload[:additional_model_request_fields][:response_format] = schema if schema
187
+
188
+ if block_given?
189
+ stream(messages, model:, temperature:, tools:, tool_prefs:, params: payload, &)
190
+ else
191
+ chat(messages, model:, temperature:, tools:, tool_prefs:, params: payload)
192
+ end
193
+ end
194
+
195
+ private
196
+
197
+ def static_offerings(**filters)
198
+ STATIC_MODELS.filter_map do |entry|
199
+ provider_filter = normalize_provider(filters[:by_provider])
200
+ next if provider_filter && model_family_for(entry.fetch(:model)) != provider_filter
201
+
202
+ offering_for(**entry.slice(:model, :usage_type))
203
+ end
204
+ end
205
+
206
+ def offering_from_summary(summary)
207
+ model = value(summary, :model_id)
208
+ build_offering(
209
+ model: model,
210
+ alias_name: alias_for(model),
211
+ model_family: normalize_provider(value(summary, :provider_name)) || model_family_for(model),
212
+ usage_type: usage_type_from_modalities(value(summary, :output_modalities)),
213
+ capabilities: capabilities_from_summary(summary),
214
+ metadata: normalize_response(summary)
215
+ )
216
+ end
217
+
218
+ def build_offering(model:, model_family:, usage_type:, instance_id: :default, alias_name: nil,
219
+ capabilities: nil, metadata: {})
220
+ Legion::Extensions::Llm::Routing::ModelOffering.new(
221
+ provider_family: :bedrock,
222
+ instance_id: instance_id,
223
+ transport: :aws_sdk,
224
+ tier: :frontier,
225
+ model: model,
226
+ usage_type: usage_type,
227
+ capabilities: capabilities || default_capabilities(model),
228
+ metadata: metadata.merge(model_family: model_family, alias: alias_name).compact
229
+ )
230
+ end
231
+
232
+ def converse_request(messages, model:, temperature:, max_tokens:, tools:, tool_prefs:)
233
+ {
234
+ model_id: model_id(model),
235
+ messages: format_messages(messages.reject { |message| message.role == :system }),
236
+ system: format_system(messages),
237
+ inference_config: { temperature: temperature, max_tokens: max_tokens || model_max_tokens(model) }.compact,
238
+ tool_config: format_tool_config(tools, tool_prefs)
239
+ }.compact
240
+ end
241
+
242
+ def format_messages(messages)
243
+ messages.map do |message|
244
+ {
245
+ role: bedrock_role(message.role),
246
+ content: content_blocks(message.content)
247
+ }
248
+ end
249
+ end
250
+
251
+ def format_system(messages)
252
+ system_messages = messages.select { |message| message.role == :system }
253
+ system_text = system_messages.map { |message| content_text(message.content) }
254
+ system_blocks(system_text.join("\n"))
255
+ end
256
+
257
+ def system_blocks(system)
258
+ return nil if system.to_s.empty?
259
+
260
+ [{ text: system }]
261
+ end
262
+
263
+ def bedrock_role(role)
264
+ role == :assistant ? 'assistant' : 'user'
265
+ end
266
+
267
+ def content_blocks(content)
268
+ raw_content(content) || [{ text: content_text(content) }]
269
+ end
270
+
271
+ def raw_content(content)
272
+ return nil unless content.is_a?(Legion::Extensions::Llm::Content::Raw)
273
+
274
+ Array(content.format)
275
+ end
276
+
277
+ def content_text(content)
278
+ return content.text.to_s if content.respond_to?(:text)
279
+
280
+ content.to_s
281
+ end
282
+
283
+ def format_tool_config(tools, tool_prefs)
284
+ return nil if tools.empty?
285
+
286
+ { tools: tools.values.map { |tool| tool_definition(tool) }, tool_choice: tool_choice(tool_prefs) }.compact
287
+ end
288
+
289
+ def tool_definition(tool)
290
+ {
291
+ tool_spec: {
292
+ name: tool.name,
293
+ description: tool.description,
294
+ input_schema: { json: tool_schema(tool) }
295
+ }
296
+ }
297
+ end
298
+
299
+ def tool_schema(tool)
300
+ return tool.params_schema if tool.respond_to?(:params_schema) && tool.params_schema
301
+
302
+ { type: 'object', properties: {} }
303
+ end
304
+
305
+ def tool_choice(tool_prefs)
306
+ return nil unless tool_prefs
307
+
308
+ choice = tool_prefs[:choice] || tool_prefs['choice']
309
+ case choice
310
+ when :auto, 'auto'
311
+ { auto: {} }
312
+ when :required, 'required'
313
+ { any: {} }
314
+ else
315
+ { tool: { name: choice.to_s } }
316
+ end
317
+ end
318
+
319
+ def parse_converse_response(response, fallback_model)
320
+ output = value(response, :output)
321
+ message = value(output, :message)
322
+ usage = value(response, :usage) || {}
323
+
324
+ Legion::Extensions::Llm::Message.new(
325
+ role: :assistant,
326
+ content: text_from(value(message, :content)),
327
+ model_id: fallback_model,
328
+ tool_calls: parse_tool_calls(value(message, :content)),
329
+ input_tokens: value(usage, :input_tokens),
330
+ output_tokens: value(usage, :output_tokens),
331
+ raw: normalize_response(response)
332
+ )
333
+ end
334
+
335
+ def stream_converse(request, fallback_model)
336
+ accumulated = +''
337
+ final_usage = nil
338
+
339
+ runtime_client.converse_stream(**request) do |stream|
340
+ stream.on_content_block_delta_event do |event|
341
+ text = value(value(event, :delta), :text)
342
+ next if text.nil?
343
+
344
+ accumulated << text
345
+ if block_given?
346
+ yield Legion::Extensions::Llm::Chunk.new(role: :assistant, content: text,
347
+ model_id: fallback_model)
348
+ end
349
+ end
350
+ stream.on_metadata_event { |event| final_usage = value(event, :usage) }
351
+ end
352
+
353
+ Legion::Extensions::Llm::Message.new(
354
+ role: :assistant,
355
+ content: accumulated,
356
+ model_id: fallback_model,
357
+ input_tokens: value(final_usage, :input_tokens),
358
+ output_tokens: value(final_usage, :output_tokens)
359
+ )
360
+ end
361
+
362
+ def parse_embedding_response(response, model:)
363
+ body = parse_body(value(response, :body))
364
+ vectors = body['embedding'] || body['embeddings'] || body.dig('data', 0, 'embedding')
365
+ Legion::Extensions::Llm::Embedding.new(vectors: vectors, model: model,
366
+ input_tokens: body['inputTextTokenCount'])
367
+ end
368
+
369
+ def text_from(content)
370
+ Array(content).filter_map { |block| value(block, :text) }.join
371
+ end
372
+
373
+ def parse_tool_calls(content)
374
+ calls = Array(content).filter_map { |block| value(block, :tool_use) }
375
+ return nil if calls.empty?
376
+
377
+ calls.to_h do |call|
378
+ name = value(call, :name)
379
+ [
380
+ value(call, :tool_use_id) || name,
381
+ Legion::Extensions::Llm::ToolCall.new(id: value(call, :tool_use_id) || name, name: name,
382
+ arguments: value(call, :input) || {})
383
+ ]
384
+ end
385
+ end
386
+
387
+ def bedrock_client
388
+ Aws::Bedrock::Client.new(client_options)
389
+ end
390
+
391
+ def runtime_client
392
+ Aws::BedrockRuntime::Client.new(client_options)
393
+ end
394
+
395
+ def client_options
396
+ {
397
+ region: region,
398
+ endpoint: config.bedrock_endpoint,
399
+ credentials: credentials,
400
+ stub_responses: config.bedrock_stub_responses
401
+ }.compact
402
+ end
403
+
404
+ def credentials
405
+ return Aws::SharedCredentials.new(profile_name: config.bedrock_profile) if config.bedrock_profile
406
+ return nil unless config.bedrock_access_key_id
407
+
408
+ Aws::Credentials.new(config.bedrock_access_key_id, config.bedrock_secret_access_key,
409
+ config.bedrock_session_token)
410
+ end
411
+
412
+ def credential_source
413
+ return :static if config.bedrock_access_key_id
414
+ return :profile if config.bedrock_profile
415
+
416
+ :aws_sdk_default_chain
417
+ end
418
+
419
+ def model_id(model)
420
+ id = model.respond_to?(:id) ? model.id : model
421
+ self.class.resolve_model_id(id)
422
+ end
423
+
424
+ def model_max_tokens(model)
425
+ model.respond_to?(:max_tokens) ? model.max_tokens : nil
426
+ end
427
+
428
+ def usage_type_for(model)
429
+ titan_embed?(model) ? :embedding : :inference
430
+ end
431
+
432
+ def usage_type_from_modalities(output_modalities)
433
+ Array(output_modalities).map(&:to_s).include?('EMBEDDING') ? :embedding : :inference
434
+ end
435
+
436
+ def default_capabilities(model)
437
+ return %i[embedding] if titan_embed?(model)
438
+
439
+ capabilities = %i[chat streaming]
440
+ capabilities << :vision if Capabilities.vision?(model)
441
+ capabilities << :functions if Capabilities.functions?(model)
442
+ capabilities
443
+ end
444
+
445
+ def capabilities_from_summary(summary)
446
+ capabilities = []
447
+ capabilities << :embedding if usage_type_from_modalities(value(summary, :output_modalities)) == :embedding
448
+ capabilities << :chat if capabilities.empty?
449
+ capabilities << :streaming if value(summary, :response_streaming_supported)
450
+ capabilities << :vision if Array(value(summary, :input_modalities)).map(&:to_s).include?('IMAGE')
451
+ capabilities
452
+ end
453
+
454
+ def model_family_for(model)
455
+ normalize_provider(model.to_s.split('.').first)
456
+ end
457
+
458
+ def normalize_provider(provider)
459
+ value = provider.to_s.downcase.tr(' ', '_').tr('-', '_')
460
+ return nil if value.empty?
461
+
462
+ case value
463
+ when 'mistral_ai'
464
+ :mistral
465
+ else
466
+ value.to_sym
467
+ end
468
+ end
469
+
470
+ def titan_embed?(model)
471
+ model.to_s.include?('titan-embed')
472
+ end
473
+
474
+ def alias_for(model)
475
+ ALIASES.key(model)
476
+ end
477
+
478
+ def parse_body(body)
479
+ body = body.read if body.respond_to?(:read)
480
+ body = body.string if body.respond_to?(:string)
481
+ body.is_a?(String) ? Legion::JSON.parse(body, symbolize_names: false) : body.to_h
482
+ end
483
+
484
+ def normalize_response(response)
485
+ response.respond_to?(:to_h) ? response.to_h : {}
486
+ end
487
+
488
+ def value(object, key)
489
+ return nil if object.nil?
490
+
491
+ string_key = key.to_s
492
+ return object[key] if object.respond_to?(:key?) && object.key?(key)
493
+ return object[string_key] if object.respond_to?(:key?) && object.key?(string_key)
494
+ return object.public_send(key) if object.respond_to?(key)
495
+
496
+ if object.respond_to?(:to_h)
497
+ hash = object.to_h
498
+ return hash[key] if hash.key?(key)
499
+ return hash[string_key] if hash.key?(string_key)
500
+ end
501
+
502
+ nil
503
+ end
504
+ end
505
+ end
506
+ end
507
+ end
508
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Bedrock
7
+ VERSION = '0.1.0'
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/llm'
4
+ require 'legion/extensions/llm/bedrock/provider'
5
+ require 'legion/extensions/llm/bedrock/version'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Llm
10
+ # Amazon Bedrock provider extension namespace.
11
+ module Bedrock
12
+ extend ::Legion::Extensions::Core if ::Legion::Extensions.const_defined?(:Core, false)
13
+
14
+ PROVIDER_FAMILY = :bedrock
15
+
16
+ def self.default_settings
17
+ ::Legion::Extensions::Llm.provider_settings(
18
+ family: PROVIDER_FAMILY,
19
+ discovery: { enabled: false, live: false, regions: %w[us-east-1 us-west-2] },
20
+ instance: {
21
+ endpoint: 'https://bedrock-runtime.us-east-1.amazonaws.com',
22
+ region: 'us-east-1',
23
+ tier: :frontier,
24
+ transport: :aws_sdk,
25
+ credentials: {
26
+ provider: 'aws-sdk-default-chain',
27
+ access_key_id: 'env://AWS_ACCESS_KEY_ID',
28
+ secret_access_key: 'env://AWS_SECRET_ACCESS_KEY',
29
+ session_token: 'env://AWS_SESSION_TOKEN',
30
+ profile: 'env://AWS_PROFILE'
31
+ },
32
+ usage: { inference: true, embedding: true, token_counting: true },
33
+ limits: { concurrency: 4 }
34
+ }
35
+ )
36
+ end
37
+
38
+ def self.provider_class
39
+ Provider
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ Legion::Extensions::Llm::Provider.register(Legion::Extensions::Llm::Bedrock::PROVIDER_FAMILY,
47
+ Legion::Extensions::Llm::Bedrock::Provider)
metadata ADDED
@@ -0,0 +1,143 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-llm-bedrock
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - LegionIO
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: aws-sdk-bedrock
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: aws-sdk-bedrockruntime
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: legion-json
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 1.2.1
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 1.2.1
54
+ - !ruby/object:Gem::Dependency
55
+ name: legion-logging
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 1.3.2
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 1.3.2
68
+ - !ruby/object:Gem::Dependency
69
+ name: legion-settings
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 1.3.14
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 1.3.14
82
+ - !ruby/object:Gem::Dependency
83
+ name: lex-llm
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 0.1.3
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 0.1.3
96
+ description: Amazon Bedrock provider integration for the LegionIO LLM routing framework.
97
+ email:
98
+ - matthewdiverson@gmail.com
99
+ executables: []
100
+ extensions: []
101
+ extra_rdoc_files: []
102
+ files:
103
+ - ".github/CODEOWNERS"
104
+ - ".github/dependabot.yml"
105
+ - ".github/workflows/ci.yml"
106
+ - ".gitignore"
107
+ - ".rubocop.yml"
108
+ - CHANGELOG.md
109
+ - Gemfile
110
+ - LICENSE
111
+ - README.md
112
+ - lex-llm-bedrock.gemspec
113
+ - lib/legion/extensions/llm/bedrock.rb
114
+ - lib/legion/extensions/llm/bedrock/provider.rb
115
+ - lib/legion/extensions/llm/bedrock/version.rb
116
+ homepage: https://github.com/LegionIO/lex-llm-bedrock
117
+ licenses:
118
+ - MIT
119
+ metadata:
120
+ homepage_uri: https://github.com/LegionIO/lex-llm-bedrock
121
+ source_code_uri: https://github.com/LegionIO/lex-llm-bedrock
122
+ documentation_uri: https://github.com/LegionIO/lex-llm-bedrock
123
+ changelog_uri: https://github.com/LegionIO/lex-llm-bedrock/blob/main/CHANGELOG.md
124
+ bug_tracker_uri: https://github.com/LegionIO/lex-llm-bedrock/issues
125
+ rubygems_mfa_required: 'true'
126
+ rdoc_options: []
127
+ require_paths:
128
+ - lib
129
+ required_ruby_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '3.4'
134
+ required_rubygems_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ requirements: []
140
+ rubygems_version: 3.6.9
141
+ specification_version: 4
142
+ summary: LegionIO LLM Amazon Bedrock provider extension
143
+ test_files: []