lex-llm-bedrock 0.3.19 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33ab4ab57335007d1ffd9ef1c641e9be4fa657d83129f0b51382f624c3279fb3
4
- data.tar.gz: 74e962cef7be3018f94a296d84ff248de09d9c0dc88ce1ddb9de8fcb3bf32e21
3
+ metadata.gz: c0c19835470c757fe2f6ea3a1708884b0145694e921e34b22fca0b89c8aac69b
4
+ data.tar.gz: b0fe049d5182e0760f5740e42fc7488eb80239aabba7a753e34d486d915e3c2d
5
5
  SHA512:
6
- metadata.gz: 0d70d7113fa2b52ded5f4f64a49711052de15f8389c55840e8b4dcbc576b4b7cb55ad4f4bdb5255eb31c9e3e04b83a05415b56e6a4f8b65be31541ff3e5d4ad1
7
- data.tar.gz: 0ee5829d8287dec6cfa825454a2b36aef4218e7e14517cbc1cf358eac689fdee82462d7031e45c22e3f76160dca2a2b0f1e3de291d6f95c597a97eccef483044
6
+ metadata.gz: 23604b77e5b16449e74d08a5fe14e9ee9a0e0bbb3ed164103e42f3005d7c483f3b65ea58d976701429a33632f89269757e1e2e33cf1f0289d04c8a1f231c325f
7
+ data.tar.gz: 6b6f898e45dfc824daa1d3ca96381af08b40e7ed72b1fd90f57d378285db0cdecc1ee90ebbeb227c52d4494bbfc7be4b5752e8cbdd1b9bb616019cec09f2db02
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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-06-10
4
+
5
+ ### Added
6
+ - **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.
7
+ - **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.
8
+ - **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.
9
+ - **Canonical capabilities declaration** — `capabilities` declares `provider: 'bedrock'`, `render_targets: [:converse, :invoke_model]`, `thinking: :budget_tokens`, `stop_reasons` mapping (including `guardrail_intervened` → `content_filter`).
10
+
11
+ ### Changed
12
+ - **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.
13
+ - **Version bump** — Minor version 0.3.x → 0.4.0 (additive feature release).
14
+
15
+ ### Fixed
16
+ - **Params handling** — Translator uses `Canonical::Params.from_hash` instead of non-existent `Params.build`. All 187 specs pass.
17
+ - **Content block type/Access** — Translator correctly handles both Hash-inputs and `Canonical::ContentBlock` Data-struct objects with `.type`/`.text` attribute accessors.
18
+
3
19
  ## 0.3.19 - 2026-06-10
4
20
 
5
21
  ### Fixed
data/Gemfile CHANGED
@@ -2,14 +2,16 @@
2
2
 
3
3
  source 'https://rubygems.org'
4
4
 
5
+ gemspec
6
+
5
7
  group :test do
6
- llm_base_path = ENV.fetch('LEX_LLM_PATH', File.expand_path('../lex-llm', __dir__))
7
8
  transport_path = ENV.fetch('LEGION_TRANSPORT_PATH', File.expand_path('../../legion-transport', __dir__))
8
9
  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
10
  end
11
11
 
12
- gemspec
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')
13
15
 
14
16
  group :development do
15
17
  gem 'bundler', '>= 2.0'
@@ -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
@@ -0,0 +1,880 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/json'
4
+ require 'legion/logging/helper'
5
+ require 'legion/extensions/llm/canonical'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Llm
10
+ module Bedrock
11
+ # Canonical provider translator for Bedrock.
12
+ #
13
+ # Converts between Canonical::Request/Response/Chunk and Bedrock wire formats.
14
+ # Supports two render targets:
15
+ # - :converse (default) — Bedrock Converse API
16
+ # - :invoke_model — Bedrock invoke_model with Anthropic Messages payload
17
+
18
+ class Translator # rubocop:disable Metrics/ClassLength, Style/Documentation
19
+ include Legion::Logging::Helper
20
+
21
+ DEFAULT_MAX_TOKENS = 4096
22
+
23
+ def initialize(region: nil)
24
+ @region = region
25
+ end
26
+
27
+ def capabilities
28
+ {
29
+ provider: 'bedrock',
30
+ render_targets: %i[converse invoke_model],
31
+ thinking: :budget_tokens,
32
+ streaming: true,
33
+ tool_calls: true,
34
+ cache_control: false,
35
+ stop_reasons: {
36
+ 'end_turn' => :end_turn,
37
+ 'tool_use' => :tool_use,
38
+ 'max_tokens' => :max_tokens,
39
+ 'guardrail_intervened' => :content_filter
40
+ }
41
+ }
42
+ end
43
+
44
+ # @param canonical [Canonical::Request]
45
+ # @param target [Symbol, nil] :converse, :invoke_model, or nil (auto)
46
+ # @return [Hash] Bedrock wire-format payload
47
+ def render_request(canonical, target: nil)
48
+ target ||= target_for(canonical)
49
+ case target
50
+ when :converse then render_converse(canonical)
51
+ when :invoke_model then render_invoke_model(canonical)
52
+ else raise ArgumentError, "Unknown render target: #{target.inspect}"
53
+ end
54
+ end
55
+
56
+ # @param wire [Hash] Raw wire response (String or Symbol keyed)
57
+ # @param model [String, nil]
58
+ # @return [Canonical::Response]
59
+ def parse_response(wire, model: nil)
60
+ if wire.nil? || wire.empty?
61
+ return Canonical::Response.build(
62
+ text: '',
63
+ tool_calls: [],
64
+ usage: Canonical::Usage.from_hash({}),
65
+ stop_reason: nil,
66
+ model: model,
67
+ routing: {},
68
+ metadata: {}
69
+ )
70
+ end
71
+
72
+ # Canonical form passthrough (for conformance kit self-test)
73
+ if wire.key?('text') || wire.key?(:text)
74
+ Legion::Extensions::Llm::Canonical::Response.from_hash(wire)
75
+ elsif wire.key?('output') || wire.key?(:output)
76
+ parse_converse_response(wire, model)
77
+ else
78
+ parse_invoke_model_response(wire, model)
79
+ end
80
+ end
81
+
82
+ # @param raw [Hash] Raw streaming event
83
+ # @param model [String, nil]
84
+ # @return [Canonical::Chunk, nil]
85
+ def parse_chunk(raw, model: nil) # rubocop:disable Lint/UnusedMethodArgument
86
+ return nil unless raw.is_a?(Hash) && !raw.empty?
87
+
88
+ type = (raw['type'] || raw[:type] || '').to_s
89
+
90
+ case type
91
+ when 'text_delta' then parse_text_delta(raw)
92
+ when 'thinking_delta' then parse_thinking_delta(raw)
93
+ when 'tool_call_delta' then parse_tool_call_delta(raw)
94
+ when 'done' then parse_done_chunk(raw)
95
+ when 'error' then parse_error_chunk(raw)
96
+ else parse_anthropic_event(raw)
97
+ end
98
+ end
99
+
100
+ # @param canonical [Canonical::Request]
101
+ # @return [Symbol] :converse or :invoke_model
102
+ def target_for(canonical)
103
+ mid = model_from_request(canonical)
104
+ has_thinking = canonical.thinking.respond_to?(:enabled?) && canonical.thinking.enabled?
105
+ has_tools = canonical.tools && !canonical.tools.empty?
106
+
107
+ anthropic_model?(mid) && (has_thinking || has_tools) ? :invoke_model : :converse
108
+ end
109
+
110
+ private
111
+
112
+ STOP_REASON_MAP = {
113
+ 'end_turn' => :end_turn,
114
+ 'tool_use' => :tool_use,
115
+ 'max_tokens' => :max_tokens,
116
+ 'stop_sequence' => :stop_sequence,
117
+ 'content_filter' => :content_filter,
118
+ 'guardrail_intervened' => :content_filter,
119
+ 'error' => :error
120
+ }.freeze
121
+
122
+ MODEL_PREFIXED_FAMILIES = %w[anthropic. meta. mistral. cohere. ai21.].freeze
123
+
124
+ # ── render: converse ───────────────────────────────────────
125
+
126
+ def render_converse(canonical)
127
+ mid = model_from_request(canonical)
128
+ payload = {
129
+ model_id: inference_profile_id(mid),
130
+ messages: render_converse_messages(canonical.messages),
131
+ inference_config: build_inference_config(canonical)
132
+ }
133
+
134
+ payload[:system] = [{ text: canonical.system.to_s }] if canonical.system && !canonical.system.to_s.empty?
135
+
136
+ tool_cfg = build_converse_tool_config(canonical)
137
+ payload[:tool_config] = tool_cfg if tool_cfg
138
+
139
+ additional = build_additional_fields(canonical)
140
+ payload[:additional_model_request_fields] = additional if additional
141
+
142
+ params = canonical.params
143
+ if params
144
+ payload[:stop_sequences] = params.stop_sequences if params.stop_sequences
145
+ payload[:seed] = params.seed if params.seed
146
+ end
147
+
148
+ payload[:stream] = true if canonical.stream
149
+ payload.compact
150
+ end
151
+
152
+ def inference_profile_id(model_id)
153
+ return model_id if model_id.nil? || model_id.start_with?('us.', 'eu.', 'ap.', 'arn:')
154
+
155
+ return model_id unless MODEL_PREFIXED_FAMILIES.any? { |p| model_id.start_with?(p) }
156
+
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}"
164
+ end
165
+
166
+ def build_inference_config(canonical)
167
+ return {} unless canonical.params
168
+
169
+ cfg = {
170
+ max_tokens: canonical.params.max_tokens,
171
+ temperature: canonical.params.temperature
172
+ }
173
+
174
+ cfg[:top_p] = canonical.params.top_p if canonical.params.top_p
175
+ if canonical.params.top_k && anthropic_model?(model_from_request(canonical))
176
+ cfg[:top_k] =
177
+ canonical.params.top_k
178
+ end
179
+ cfg.compact
180
+ end
181
+
182
+ def build_additional_fields(canonical)
183
+ return nil unless canonical.thinking
184
+
185
+ budget = canonical_thinking_budget(canonical)
186
+ budget ||= DEFAULT_MAX_TOKENS / 4
187
+ { thinking: { type: 'enabled', budget_tokens: budget } }
188
+ end
189
+
190
+ def canonical_thinking_budget(canonical)
191
+ return nil unless canonical.thinking
192
+
193
+ if canonical.thinking.respond_to?(:budget) && canonical.thinking.budget
194
+ canonical.thinking.budget
195
+ elsif canonical.params.respond_to?(:max_thinking_tokens) && canonical.params.max_thinking_tokens
196
+ canonical.params.max_thinking_tokens
197
+ end
198
+ end
199
+
200
+ def build_converse_tool_config(canonical)
201
+ return nil unless canonical.tools && !canonical.tools.empty?
202
+
203
+ tools = canonical.tools.values.map do |tool|
204
+ {
205
+ tool_spec: {
206
+ name: tool.name,
207
+ description: tool.description.to_s,
208
+ input_schema: { json: tool.parameters || { type: 'object', properties: {} } }
209
+ }
210
+ }
211
+ end
212
+
213
+ result = { tools: tools }
214
+ choice = canonical.tool_choice
215
+ result[:tool_choice] = converse_tool_choice(choice) if choice
216
+ result.compact
217
+ end
218
+
219
+ def converse_tool_choice(choice)
220
+ return { auto: {} } if choice.nil? || choice == :auto
221
+
222
+ case choice
223
+ when :required, 'required' then { any: {} }
224
+ else { tool: { name: choice.to_s } }
225
+ end
226
+ end
227
+
228
+ # ── render: invoke_model ───────────────────────────────────
229
+
230
+ def render_invoke_model(canonical)
231
+ body = {
232
+ max_tokens: canonical.params&.max_tokens || DEFAULT_MAX_TOKENS,
233
+ messages: render_invoke_messages(canonical.messages),
234
+ anthropic_version: 'bedrock-2023-05-31'
235
+ }
236
+
237
+ temp = canonical.params&.temperature
238
+ body[:temperature] = temp if temp
239
+
240
+ tool_data = build_invoke_tools(canonical)
241
+ body[:tools] = tool_data[:tools] if tool_data && tool_data[:tools]
242
+ body[:tool_choice] = tool_data[:tool_choice] if tool_data && tool_data[:tool_choice]
243
+
244
+ thinking_cfg = build_invoke_thinking(canonical)
245
+ body[:thinking] = thinking_cfg if thinking_cfg
246
+
247
+ body[:stream] = true if canonical.stream
248
+ body.compact
249
+ end
250
+
251
+ def build_invoke_thinking(canonical)
252
+ return nil unless canonical.thinking
253
+
254
+ budget = canonical_thinking_budget(canonical)
255
+ budget ||= DEFAULT_MAX_TOKENS / 4
256
+ { type: 'enabled', budget_tokens: budget }
257
+ end
258
+
259
+ def build_invoke_tools(canonical)
260
+ return nil unless canonical.tools && !canonical.tools.empty?
261
+
262
+ tools = canonical.tools.values.map do |tool|
263
+ {
264
+ name: tool.name,
265
+ description: (tool.description || '').to_s,
266
+ input_schema: tool.parameters || { type: 'object', properties: {} }
267
+ }
268
+ end
269
+
270
+ result = { tools: tools }
271
+ choice = canonical.tool_choice
272
+ result[:tool_choice] = invoke_tool_choice(choice) if choice
273
+ result
274
+ end
275
+
276
+ def invoke_tool_choice(choice)
277
+ return { type: 'auto' } if choice.nil? || choice == :auto
278
+
279
+ case choice
280
+ when :required, 'required' then { type: 'any' }
281
+ else { type: 'tool', name: choice.to_s }
282
+ end
283
+ end
284
+
285
+ # ── message rendering ─────────────────────────────────────
286
+
287
+ def render_converse_messages(messages)
288
+ return [] unless messages
289
+
290
+ messages.filter_map.with_index do |msg, _idx|
291
+ next if msg.role == :system
292
+
293
+ blocks = convers_content_for(msg)
294
+ next if blocks.empty?
295
+
296
+ {
297
+ role: converse_role(msg.role),
298
+ content: blocks
299
+ }
300
+ end
301
+ end
302
+
303
+ def render_invoke_messages(messages)
304
+ return [] unless messages
305
+
306
+ messages.filter_map do |msg|
307
+ role = msg.role.to_s
308
+ next if role == 'system'
309
+
310
+ content = case role
311
+ when 'tool' then invoke_tool_result_content(msg)
312
+ when 'assistant' then invoke_assistant_content(msg)
313
+ else invoke_user_content(msg)
314
+ end
315
+
316
+ next if content.nil? || (content.is_a?(Array) && content.empty?)
317
+
318
+ { role: role == 'tool' ? 'user' : role, content: content }
319
+ end
320
+ end
321
+
322
+ # ── convers_content building (converse API) ───────────────
323
+
324
+ def convers_content_for(msg)
325
+ return convers_tool_result_blocks(msg) if msg.role == :tool
326
+ return convers_assistant_blocks(msg) if msg.role == :assistant && msg.tool_calls && !msg.tool_calls.empty?
327
+
328
+ if msg.content.is_a?(Array)
329
+ msg.content.filter_map { |cb| extract_text_block(cb) }.compact
330
+ else
331
+ text = msg.content.to_s.strip
332
+ text.empty? ? [] : [{ text: text }]
333
+ end
334
+ end
335
+
336
+ def extract_text_block(block)
337
+ return nil unless block
338
+
339
+ type = block.respond_to?(:type) ? block.type.to_s : (block[:type] || block['type'] || 'text').to_s
340
+
341
+ case type
342
+ when 'text'
343
+ text = block.respond_to?(:text) ? block.text : (block[:text] || block['text'])
344
+ text && !text.to_s.strip.empty? ? { text: text.to_s.strip } : nil
345
+ when 'tool_result'
346
+ build_convers_tool_result(block)
347
+ end
348
+ end
349
+
350
+ def build_convers_tool_result(block)
351
+ tool_use_id = if block.respond_to?(:tool_use_id)
352
+ block.tool_use_id
353
+ else
354
+ block[:tool_use_id] || block['tool_use_id']
355
+ end
356
+ content_text = if block.respond_to?(:text)
357
+ block.text.to_s
358
+ elsif block.respond_to?(:content)
359
+ Array(block.content).filter_map do |c|
360
+ c.respond_to?(:text) ? c.text : (c['text'] || c[:text])
361
+ end.join
362
+ else
363
+ block.to_s
364
+ end
365
+ { tool_result: { tool_use_id: tool_use_id, content: [{ text: content_text.to_s }] } }
366
+ end
367
+
368
+ def convers_tool_result_blocks(msg)
369
+ return [] unless msg
370
+
371
+ tool_call_id = if msg.respond_to?(:tool_call_id)
372
+ msg.tool_call_id.to_s
373
+ elsif msg.is_a?(Hash)
374
+ (msg[:tool_call_id] || msg['tool_call_id']).to_s
375
+ end
376
+ result_text = if msg.respond_to?(:content)
377
+ msg.content.to_s
378
+ elsif msg.respond_to?(:tool_results)
379
+ msg.tool_results.to_s
380
+ elsif msg.is_a?(Hash)
381
+ (msg[:content] || msg['content']).to_s
382
+ end
383
+
384
+ [{ tool_result: { tool_use_id: tool_call_id, content: [{ text: result_text.to_s }] } }]
385
+ end
386
+
387
+ def convers_assistant_blocks(msg)
388
+ blocks = []
389
+ text = if msg.respond_to?(:content)
390
+ convert_to_text(msg.content)
391
+ else
392
+ msg.to_s
393
+ end
394
+ blocks << { text: text } if text && !text.strip.empty?
395
+
396
+ tc_array = msg.tool_calls.is_a?(Hash) ? msg.tool_calls.values : Array(msg.tool_calls)
397
+ tc_array&.each do |tc|
398
+ tc_h = tc.is_a?(Hash) ? tc : tc.to_h
399
+ blocks << {
400
+ tool_use: {
401
+ tool_use_id: (tc_h[:id] || '').to_s,
402
+ name: (tc_h[:name] || '').to_s,
403
+ input: tc_h[:arguments] || {}
404
+ }
405
+ }
406
+ end
407
+
408
+ blocks
409
+ end
410
+
411
+ # ── invoke model content building ──────────────────────────
412
+
413
+ def invoke_user_content(msg)
414
+ content = msg.respond_to?(:content) ? msg.content : (msg[:content] || msg['content'])
415
+
416
+ if content.is_a?(String)
417
+ [{ type: 'text', text: content }]
418
+ elsif content.is_a?(Array)
419
+ content.filter_map do |block|
420
+ type = content_block_type(block)
421
+ next { type: 'text', text: content_block_text(block) } if type == 'text'
422
+
423
+ block
424
+ end
425
+ else
426
+ [{ type: 'text', text: content.to_s }]
427
+ end
428
+ end
429
+
430
+ def content_block_type(block)
431
+ block.respond_to?(:type) ? block.type.to_s : (block[:type] || block['type'] || 'text').to_s
432
+ end
433
+
434
+ def content_block_text(block)
435
+ block.respond_to?(:text) ? block.text : (block[:text] || block['text'])
436
+ end
437
+
438
+ def invoke_tool_result_content(msg)
439
+ tool_call_id = if msg.respond_to?(:tool_call_id)
440
+ msg.tool_call_id.to_s
441
+ elsif msg.is_a?(Hash)
442
+ (msg[:tool_call_id] || msg['tool_call_id']).to_s
443
+ end
444
+ result_text = if msg.respond_to?(:content)
445
+ msg.content.to_s
446
+ elsif msg.respond_to?(:tool_results)
447
+ msg.tool_results.to_s
448
+ elsif msg.is_a?(Hash)
449
+ (msg[:content] || msg['content']).to_s
450
+ end
451
+ [{ type: 'tool_result', tool_use_id: tool_call_id, content: [{ type: 'text', text: result_text }] }]
452
+ end
453
+
454
+ def invoke_assistant_content(msg)
455
+ blocks = []
456
+ text = if msg.respond_to?(:content)
457
+ convert_to_text(msg.content)
458
+ else
459
+ (msg[:content] || (msg['content'] || '')).to_s
460
+ end
461
+ blocks << { type: 'text', text: text } unless text.strip.empty?
462
+
463
+ tc_raw = if msg.respond_to?(:tool_calls)
464
+ msg.tool_calls
465
+ elsif msg.is_a?(Hash)
466
+ msg[:tool_calls] || msg['tool_calls'] || {}
467
+ end
468
+ Array(tc_raw.is_a?(Hash) ? tc_raw.values : tc_raw).each do |tc|
469
+ tc_h = tc.is_a?(Hash) ? tc : tc.to_h
470
+ blocks << {
471
+ type: 'tool_use',
472
+ id: (tc_h[:id] || '').to_s,
473
+ name: (tc_h[:name] || '').to_s,
474
+ input: tc_h[:arguments] || {}
475
+ }
476
+ end
477
+
478
+ blocks
479
+ end
480
+
481
+ # ── helpers ───────────────────────────────────────────────
482
+
483
+ def converse_role(role)
484
+ case role
485
+ when :assistant then 'assistant'
486
+ else 'user'
487
+ end
488
+ end
489
+
490
+ def convert_to_text(content)
491
+ if content.is_a?(String)
492
+ content.strip
493
+ elsif content.is_a?(Array)
494
+ content.filter_map { |c| c.respond_to?(:text) ? c.text : c['text'] || c[:text] }.join
495
+ else
496
+ content.to_s.strip
497
+ end
498
+ end
499
+
500
+ def map_stop_reason(raw)
501
+ return nil if raw.nil? || raw.to_s.empty?
502
+
503
+ STOP_REASON_MAP.fetch(raw.to_s, raw.to_sym)
504
+ end
505
+
506
+ # ── parse: converse response ──────────────────────────────
507
+
508
+ def parse_converse_response(wire, model)
509
+ output = read_from(wire, 'output', :output)
510
+ message = read_from(output, 'message', :message)
511
+ content = read_from(message, 'content', :content)
512
+ usage_raw = read_from(wire, 'usage', :usage) || {}
513
+ additional = read_from(wire, 'additional_model_response_fields', :additional_model_response_fields)
514
+
515
+ text = extract_text_from(content)
516
+ thinking_text = extract_thinking_from_content(content)
517
+ thinking_text ||= extract_thinking_from_fields(additional)
518
+ thinking_obj = if thinking_text && !thinking_text.to_s.empty?
519
+ Canonical::Thinking.new(content: thinking_text.to_s, signature: nil)
520
+ end
521
+ tool_calls_list = extract_tool_calls_from(content)
522
+ usage = parse_usage(usage_raw)
523
+ sr = extract_stop_reason_from(message)
524
+
525
+ Canonical::Response.build(
526
+ text: text,
527
+ thinking: thinking_obj,
528
+ tool_calls: tool_calls_list,
529
+ usage: usage,
530
+ stop_reason: map_stop_reason(sr),
531
+ model: model,
532
+ routing: {},
533
+ metadata: {}
534
+ )
535
+ end
536
+
537
+ def extract_text_from(content_blocks)
538
+ Array(content_blocks).filter_map do |block|
539
+ read_from(block, 'text', :text).to_s
540
+ end.join
541
+ end
542
+
543
+ def extract_thinking_from_content(content_blocks)
544
+ Array(content_blocks).each do |block|
545
+ reasoning = read_from(block, 'reasoning', :reasoning)
546
+ next if reasoning.nil?
547
+
548
+ text = if reasoning.is_a?(Hash)
549
+ reasoning[:text] || reasoning['text']
550
+ elsif reasoning.respond_to?(:text)
551
+ begin; reasoning.text; rescue StandardError; nil; end
552
+ else
553
+ safe_key_read(reasoning, :text)
554
+ end
555
+ return text if text && !text.to_s.empty?
556
+ end
557
+ nil
558
+ end
559
+
560
+ def extract_thinking_from_fields(additional)
561
+ return nil unless additional.is_a?(Hash)
562
+
563
+ thinking = additional[:thinking] || additional['thinking']
564
+ return nil unless thinking.is_a?(Hash)
565
+
566
+ text = thinking[:text] || thinking['text'] ||
567
+ thinking[:reasoningText] || thinking['reasoningText'] ||
568
+ thinking[:reasoning] || thinking['reasoning'] ||
569
+ resolve_reasoning_content(thinking)
570
+ text if text && !text.to_s.empty?
571
+ end
572
+
573
+ def resolve_reasoning_content(thinking)
574
+ rc = thinking[:reasoningContent] || thinking['reasoningContent']
575
+ return nil unless rc.is_a?(Hash)
576
+
577
+ chunk = rc[:chunk] || rc['chunk']
578
+ if chunk.is_a?(Hash)
579
+ chunk[:text] || chunk['text']
580
+ else
581
+ rc[:text] || rc['text']
582
+ end
583
+ end
584
+
585
+ def extract_tool_calls_from(content_blocks)
586
+ calls = Array(content_blocks).filter_map do |block|
587
+ read_from(block, 'tool_use', :tool_use)
588
+ end
589
+ return [] if calls.empty?
590
+
591
+ calls.map do |call|
592
+ tc_id = safe_read(call, :tool_use_id, 'tool_use_id', '')
593
+ name = safe_read(call, :name, 'name', '')
594
+ input = safe_read(call, :input, 'input', {})
595
+
596
+ if input.is_a?(String)
597
+ begin
598
+ input = Legion::JSON.load(input)
599
+ rescue Legion::JSON::ParseError
600
+ input = {}
601
+ end
602
+ end
603
+ input = {} unless input.is_a?(Hash)
604
+
605
+ Canonical::ToolCall.build(
606
+ id: tc_id.to_s,
607
+ name: name.to_s,
608
+ arguments: input,
609
+ source: :client,
610
+ status: :pending
611
+ )
612
+ end
613
+ end
614
+
615
+ def extract_stop_reason_from(message)
616
+ return nil unless message
617
+
618
+ read_from(message, 'stop_reason', :stop_reason)
619
+ rescue StandardError
620
+ nil
621
+ end
622
+
623
+ # ── parse: invoke_model response ──────────────────────────
624
+
625
+ def parse_invoke_model_response(wire, model)
626
+ content = wire['content'] || wire[:content] || []
627
+ usage_raw = wire['usage'] || wire[:usage] || {}
628
+ stop_raw = wire['stop_reason'] || wire[:stop_reason]
629
+
630
+ text = Array(content).filter_map { |b| b['type'] == 'text' ? b['text'] : nil }.join
631
+
632
+ thinking_parts = Array(content).select { |b| b['type'] == 'thinking' }
633
+ thinking_obj = if thinking_parts.any?
634
+ tp = thinking_parts.last
635
+ Canonical::Thinking.new(content: tp['thinking'], signature: tp['signature'])
636
+ end
637
+
638
+ tool_calls_list = Array(content).select { |b| b['type'] == 'tool_use' }.map do |b|
639
+ args = b['input'] || {}
640
+ if args.is_a?(String)
641
+ begin
642
+ args = Legion::JSON.load(args)
643
+ rescue Legion::JSON::ParseError
644
+ args = {}
645
+ end
646
+ end
647
+
648
+ Canonical::ToolCall.build(
649
+ id: b['id'],
650
+ name: b['name'],
651
+ arguments: args.is_a?(Hash) ? args : {},
652
+ source: :client,
653
+ status: :pending
654
+ )
655
+ end
656
+
657
+ usage = parse_usage(usage_raw)
658
+
659
+ Canonical::Response.build(
660
+ text: text,
661
+ thinking: thinking_obj,
662
+ tool_calls: tool_calls_list,
663
+ usage: usage,
664
+ stop_reason: map_stop_reason(stop_raw),
665
+ model: model,
666
+ routing: {},
667
+ metadata: {}
668
+ )
669
+ end
670
+
671
+ # ── parse: streaming chunks ──────────────────────────────
672
+
673
+ def parse_text_delta(raw)
674
+ delta = raw['delta'] || raw[:delta] || {}
675
+ text = delta.is_a?(Hash) ? (delta['text'] || delta[:text] || '') : delta.to_s
676
+ return nil if text.to_s.empty?
677
+
678
+ Canonical::Chunk.text_delta(
679
+ delta: text.to_s,
680
+ request_id: raw['request_id'] || raw[:request_id] || ''
681
+ )
682
+ end
683
+
684
+ def parse_thinking_delta(raw)
685
+ delta = raw['delta'] || raw[:delta] || {}
686
+ text = if delta.is_a?(Hash)
687
+ delta['thinking'] || delta[:thinking] || delta['text'] || delta[:text] || ''
688
+ else
689
+ (raw['delta'] || '').to_s
690
+ end
691
+ return nil if text.to_s.empty?
692
+
693
+ Canonical::Chunk.thinking_delta(
694
+ delta: text.to_s,
695
+ request_id: raw['request_id'] || raw[:request_id] || '',
696
+ signature: raw['signature'] || raw[:signature]
697
+ )
698
+ end
699
+
700
+ def parse_tool_call_delta(raw)
701
+ tc_hash = raw['tool_call'] || raw[:tool_call]
702
+ return nil unless tc_hash
703
+
704
+ tc = Canonical::ToolCall.build(
705
+ id: tc_hash[:id] || tc_hash['id'] || '',
706
+ name: tc_hash[:name] || tc_hash['name'] || '',
707
+ arguments: tc_hash[:arguments] || tc_hash['arguments'] || {},
708
+ source: tc_hash[:source] || tc_hash['source'] || :client,
709
+ status: tc_hash[:status] || tc_hash['status'] || :pending
710
+ )
711
+
712
+ Canonical::Chunk.tool_call_delta(
713
+ tool_call: tc,
714
+ request_id: raw['request_id'] || raw[:request_id] || ''
715
+ )
716
+ end
717
+
718
+ def parse_done_chunk(raw)
719
+ usage_raw = raw['usage'] || raw[:usage]
720
+ usage = usage_raw ? parse_usage(usage_raw) : nil
721
+ stop = map_stop_reason(raw['stop_reason'] || raw[:stop_reason])
722
+
723
+ Canonical::Chunk.done(
724
+ request_id: raw['request_id'] || raw[:request_id] || '',
725
+ usage: usage,
726
+ stop_reason: stop
727
+ )
728
+ end
729
+
730
+ def parse_error_chunk(raw)
731
+ metadata = raw['metadata'] || raw[:metadata] || {}
732
+ error_data = metadata[:error] || metadata['error'] || { message: 'Stream error' }
733
+
734
+ Canonical::Chunk.error_chunk(
735
+ error: error_data.is_a?(Hash) ? error_data : { message: error_data.to_s },
736
+ request_id: raw['request_id'] || raw[:request_id] || '',
737
+ metadata: metadata
738
+ )
739
+ end
740
+
741
+ def parse_anthropic_event(raw)
742
+ event_type = raw['type'] || raw[:type]
743
+ return nil if event_type.nil?
744
+
745
+ request_id = raw['request_id'] || raw[:request_id] || ''
746
+
747
+ case event_type
748
+ when 'text_delta'
749
+ delta = nested_read(raw, 'delta', 'text', :text) ||
750
+ nested_read(raw, 'delta', 'text', :delta, :text)
751
+ return nil unless delta && !delta.to_s.empty?
752
+
753
+ Canonical::Chunk.text_delta(delta: delta.to_s, request_id: request_id)
754
+ when 'thinking_delta'
755
+ delta = nested_read(raw, 'delta', 'thinking', :thinking) ||
756
+ nested_read(raw, 'delta', 'thinking', :delta, :thinking)
757
+ sig = raw['signature'] || raw[:signature]
758
+ return nil unless delta && !delta.to_s.empty?
759
+
760
+ Canonical::Chunk.thinking_delta(delta: delta.to_s, request_id: request_id, signature: sig)
761
+ when 'input_json_delta'
762
+ tc_hash = raw['tool_call'] || raw[:tool_call]
763
+ return nil unless tc_hash
764
+
765
+ tc = Canonical::ToolCall.build(
766
+ id: tc_hash[:id] || tc_hash['id'] || '',
767
+ name: tc_hash[:name] || tc_hash['name'] || '',
768
+ arguments: tc_hash[:arguments] || tc_hash['arguments'] || {},
769
+ status: :pending
770
+ )
771
+ Canonical::Chunk.tool_call_delta(tool_call: tc, request_id: request_id)
772
+ when 'message_delta'
773
+ stop = nested_read(raw, 'delta', 'stop_reason', :stop_reason) || ''
774
+ Canonical::Chunk.done(request_id: request_id, stop_reason: map_stop_reason(stop))
775
+ end
776
+ end
777
+
778
+ # ── parse: usage ──────────────────────────────────────────
779
+
780
+ def parse_usage(usage_raw)
781
+ return Canonical::Usage.from_hash({}) unless usage_raw
782
+
783
+ h = {}
784
+ if usage_raw.is_a?(Hash)
785
+ h[:input_tokens] = usage_raw[:input_tokens] || usage_raw['input_tokens']
786
+ h[:output_tokens] = usage_raw[:output_tokens] || usage_raw['output_tokens']
787
+ h[:cache_read_tokens] = usage_raw[:cache_read_input_tokens] || usage_raw['cache_read_input_tokens']
788
+ h[:cache_write_tokens] =
789
+ usage_raw[:cache_creation_input_tokens] || usage_raw['cache_creation_input_tokens']
790
+ h[:thinking_tokens] = usage_raw[:thinking_tokens] || usage_raw['thinking_tokens']
791
+ else
792
+ h[:input_tokens] = safe_key_read(usage_raw, :input_tokens)
793
+ h[:output_tokens] = safe_key_read(usage_raw, :output_tokens)
794
+ h[:cache_read_tokens] = safe_key_read(usage_raw, :cache_read_input_tokens)
795
+ h[:cache_write_tokens] = safe_key_read(usage_raw, :cache_creation_input_tokens)
796
+ h[:thinking_tokens] = safe_key_read(usage_raw, :thinking_tokens)
797
+ end
798
+
799
+ Canonical::Usage.from_hash(h)
800
+ end
801
+
802
+ # ── read helpers ──────────────────────────────────────────
803
+
804
+ def read_from(obj, *keys)
805
+ return nil unless obj
806
+
807
+ keys.each do |key|
808
+ val = nil
809
+ if obj.is_a?(Hash)
810
+ val = obj[key]
811
+ elsif obj.respond_to?(key)
812
+ begin
813
+ val = obj.public_send(key)
814
+ rescue StandardError
815
+ val = nil
816
+ end
817
+ elsif obj.respond_to?(:to_h) && obj.to_h.key?(key)
818
+ val = obj.to_h[key]
819
+ end
820
+ return val unless val.nil?
821
+ end
822
+ nil
823
+ end
824
+
825
+ def safe_read(obj, sym_key, str_key, default = nil)
826
+ return obj[sym_key] || obj[str_key] || default if obj.is_a?(Hash)
827
+
828
+ safe_key_read(obj, sym_key) || default
829
+ end
830
+
831
+ def safe_key_read(obj, key)
832
+ return nil unless obj
833
+
834
+ if obj.is_a?(Hash)
835
+ obj[key] || obj[key.to_s]
836
+ elsif obj.respond_to?(:key?) && obj.key?(key)
837
+ begin
838
+ obj[key]
839
+ rescue ::NameError, ::NoMethodError => _e
840
+ nil
841
+ end
842
+ elsif obj.respond_to?(key)
843
+ begin
844
+ obj.public_send(key)
845
+ rescue ::NameError, ::NoMethodError => _e
846
+ nil
847
+ end
848
+ end
849
+ end
850
+
851
+ def nested_read(obj, *keys)
852
+ current = obj
853
+ keys.each do |key|
854
+ return nil unless current.is_a?(Hash)
855
+
856
+ current = current[key]
857
+ end
858
+ current
859
+ end
860
+
861
+ # ── canonical params ──────────────────────────────────────
862
+
863
+ def model_from_request(canonical)
864
+ canonical.routing[:model] || canonical.metadata[:model] ||
865
+ canonical.messages&.first&.model
866
+ end
867
+
868
+ # ── model detection ───────────────────────────────────────
869
+
870
+ def anthropic_model?(model_id)
871
+ return false unless model_id
872
+
873
+ mid = model_id.to_s
874
+ mid.start_with?('anthropic.', 'us.anthropic.', 'eu.anthropic.', 'ap.anthropic.')
875
+ end
876
+ end
877
+ end
878
+ end
879
+ end
880
+ end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Bedrock
7
- VERSION = '0.3.19'
7
+ VERSION = '0.4.0'
8
8
  end
9
9
  end
10
10
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'legion/extensions/llm'
4
4
  require 'legion/extensions/llm/bedrock/provider'
5
+ require 'legion/extensions/llm/bedrock/translator'
5
6
  require 'legion/extensions/llm/bedrock/version'
6
7
  require 'legion/logging/helper'
7
8
 
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.3.19
4
+ version: 0.4.0
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.4.3
102
+ version: 0.5.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.4.3
109
+ version: 0.5.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/translator.rb
132
133
  - lib/legion/extensions/llm/bedrock/version.rb
133
134
  homepage: https://github.com/LegionIO/lex-llm-bedrock
134
135
  licenses: