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 +4 -4
- data/.rubocop.yml +4 -0
- data/CHANGELOG.md +16 -0
- data/Gemfile +5 -3
- data/lex-llm-bedrock.gemspec +1 -1
- data/lib/legion/extensions/llm/bedrock/translator.rb +880 -0
- data/lib/legion/extensions/llm/bedrock/version.rb +1 -1
- data/lib/legion/extensions/llm/bedrock.rb +1 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c0c19835470c757fe2f6ea3a1708884b0145694e921e34b22fca0b89c8aac69b
|
|
4
|
+
data.tar.gz: b0fe049d5182e0760f5740e42fc7488eb80239aabba7a753e34d486d915e3c2d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 23604b77e5b16449e74d08a5fe14e9ee9a0e0bbb3ed164103e42f3005d7c483f3b65ea58d976701429a33632f89269757e1e2e33cf1f0289d04c8a1f231c325f
|
|
7
|
+
data.tar.gz: 6b6f898e45dfc824daa1d3ca96381af08b40e7ed72b1fd90f57d378285db0cdecc1ee90ebbeb227c52d4494bbfc7be4b5752e8cbdd1b9bb616019cec09f2db02
|
data/.rubocop.yml
CHANGED
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'
|
data/lex-llm-bedrock.gemspec
CHANGED
|
@@ -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.
|
|
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
|
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
|
+
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.
|
|
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.
|
|
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:
|