ruby_llm-bedrock_invoke 0.1.0.pre.1
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +194 -0
- data/lib/ruby_llm/bedrock_invoke/block_collector.rb +110 -0
- data/lib/ruby_llm/bedrock_invoke/capabilities.rb +38 -0
- data/lib/ruby_llm/bedrock_invoke/deferred.rb +37 -0
- data/lib/ruby_llm/bedrock_invoke/event_stream.rb +117 -0
- data/lib/ruby_llm/bedrock_invoke/protocol_v2.rb +162 -0
- data/lib/ruby_llm/bedrock_invoke/provider_v1.rb +215 -0
- data/lib/ruby_llm/bedrock_invoke/provider_v2.rb +71 -0
- data/lib/ruby_llm/bedrock_invoke/raw_content.rb +23 -0
- data/lib/ruby_llm/bedrock_invoke/signing.rb +106 -0
- data/lib/ruby_llm/bedrock_invoke/stream_state.rb +34 -0
- data/lib/ruby_llm/bedrock_invoke/tool_search.rb +115 -0
- data/lib/ruby_llm/bedrock_invoke/version.rb +7 -0
- data/lib/ruby_llm-bedrock_invoke.rb +114 -0
- metadata +124 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module BedrockInvoke
|
|
5
|
+
# The Anthropic Messages wire format over Bedrock InvokeModel (RubyLLM 2.0).
|
|
6
|
+
#
|
|
7
|
+
# Mirrors the in-tree VertexAI::Anthropic pattern: inherit the entire
|
|
8
|
+
# Anthropic protocol and override only the endpoints, the Bedrock body
|
|
9
|
+
# envelope (no top-level model/stream, anthropic_version marker), SigV4
|
|
10
|
+
# signing, and AWS event-stream decoding for streaming.
|
|
11
|
+
class Protocol < RubyLLM::Protocols::Anthropic
|
|
12
|
+
include EventStream
|
|
13
|
+
|
|
14
|
+
ANTHROPIC_VERSION = 'bedrock-2023-05-31'
|
|
15
|
+
|
|
16
|
+
def completion_url
|
|
17
|
+
"/model/#{RubyLLM::BedrockInvoke.escape_model_id(@model.id)}/invoke"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def stream_url
|
|
21
|
+
"/model/#{RubyLLM::BedrockInvoke.escape_model_id(@model.id)}/invoke-with-response-stream"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def render_payload(messages, **options)
|
|
25
|
+
payload = super
|
|
26
|
+
payload.delete(:model)
|
|
27
|
+
payload.delete(:stream)
|
|
28
|
+
payload[:anthropic_version] = ANTHROPIC_VERSION
|
|
29
|
+
ToolSearch.apply!(payload, options[:tools] || {})
|
|
30
|
+
payload
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def parse_completion_body(data, raw:)
|
|
34
|
+
message = super
|
|
35
|
+
preserve_server_blocks(message, data)
|
|
36
|
+
message
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Replays messages that carried server-managed tool-search blocks
|
|
40
|
+
# verbatim; everything else renders through the stock Anthropic format.
|
|
41
|
+
def format_message(msg, **)
|
|
42
|
+
blocks = RubyLLM::BedrockInvoke.raw_blocks(msg)
|
|
43
|
+
return super unless blocks
|
|
44
|
+
|
|
45
|
+
{ role: 'assistant', content: blocks }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# 2.0 gates thinking on registry reasoning_options, which assumed models
|
|
49
|
+
# never carry; Bedrock Claude supports budget thinking, so emit it
|
|
50
|
+
# directly. Adaptive/effort-only thinking is not available on Bedrock.
|
|
51
|
+
def build_thinking_payload(thinking, _model)
|
|
52
|
+
inner = RubyLLM::BedrockInvoke.budget_thinking_payload(thinking)
|
|
53
|
+
return nil if inner.nil?
|
|
54
|
+
|
|
55
|
+
{ thinking: inner }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Bedrock has no Anthropic Files API.
|
|
59
|
+
def supports_provider_file_references?
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def sync_response(payload, additional_headers = {})
|
|
66
|
+
ToolSearch.ensure_beta!(payload)
|
|
67
|
+
body = JSON.generate(payload)
|
|
68
|
+
response = @connection.post(completion_url, body) do |req|
|
|
69
|
+
req.headers['Content-Type'] = 'application/json'
|
|
70
|
+
req.headers.merge!(additional_headers) unless additional_headers.empty?
|
|
71
|
+
req.headers.merge!(@provider.sign_headers('POST', completion_url, body))
|
|
72
|
+
end
|
|
73
|
+
parse_completion_response(response)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def stream_response(payload, additional_headers = {}, &block)
|
|
77
|
+
ToolSearch.ensure_beta!(payload)
|
|
78
|
+
state = StreamState.new(self)
|
|
79
|
+
failure_buffer = +''
|
|
80
|
+
body = JSON.generate(payload)
|
|
81
|
+
|
|
82
|
+
response = @connection.post(stream_url, body) do |req|
|
|
83
|
+
req.headers['Content-Type'] = 'application/json'
|
|
84
|
+
req.headers['Accept'] = 'application/vnd.amazon.eventstream'
|
|
85
|
+
req.headers.merge!(additional_headers) unless additional_headers.empty?
|
|
86
|
+
req.headers.merge!(@provider.sign_headers('POST', stream_url, body))
|
|
87
|
+
install_stream_handler(req) do |bytes, env, received|
|
|
88
|
+
consume_stream_bytes(bytes, env, received, state, failure_buffer, &block)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
message = state.accumulator.to_message(response)
|
|
93
|
+
attach_collected_blocks(message, state.collector)
|
|
94
|
+
RubyLLM.logger.debug { "Stream completed: #{message.content}" }
|
|
95
|
+
message
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def install_stream_handler(req, &handler)
|
|
99
|
+
if Faraday::VERSION.start_with?('1')
|
|
100
|
+
req.options[:on_data] = proc { |chunk, size| handler.call(chunk, nil, size) }
|
|
101
|
+
else
|
|
102
|
+
req.options.on_data = proc { |chunk, received, env| handler.call(chunk, env, received) }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def consume_stream_bytes(bytes, env, received, state, failure_buffer, &block)
|
|
107
|
+
return handle_failed_stream_chunk(bytes, env, failure_buffer) if failed_stream_chunk?(bytes, env)
|
|
108
|
+
|
|
109
|
+
state.reset_if_new_attempt!(bytes, received)
|
|
110
|
+
each_event_stream_event(state.decoder, bytes) do |event|
|
|
111
|
+
state.collector.observe(event)
|
|
112
|
+
next if state.collector.suppress?(event)
|
|
113
|
+
|
|
114
|
+
chunk = build_chunk(event)
|
|
115
|
+
next unless chunk
|
|
116
|
+
|
|
117
|
+
state.accumulator.add(chunk)
|
|
118
|
+
block.call(chunk)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Faraday 1 gives on_data no response env, so non-200 JSON error bodies
|
|
123
|
+
# would otherwise be fed to the event-stream decoder (binary frames
|
|
124
|
+
# never start with '{' — the first bytes are a frame length prelude).
|
|
125
|
+
def failed_stream_chunk?(bytes, env)
|
|
126
|
+
return env.status != 200 if env
|
|
127
|
+
|
|
128
|
+
bytes.to_s.lstrip.start_with?('{')
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Error bodies can arrive split across reads; accumulate until they parse.
|
|
132
|
+
def handle_failed_stream_chunk(bytes, env, failure_buffer)
|
|
133
|
+
failure_buffer << bytes
|
|
134
|
+
data = JSON.parse(failure_buffer)
|
|
135
|
+
response = env ? env.merge(body: data) : Struct.new(:body, :status).new(data, 500)
|
|
136
|
+
ErrorMiddleware.parse_error(provider: @provider, response: response)
|
|
137
|
+
rescue JSON::ParserError
|
|
138
|
+
RubyLLM.logger.debug { "bedrock_invoke: accumulating failed stream chunk: #{bytes.inspect}" }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def error_middleware_provider
|
|
142
|
+
@provider
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Responses carrying server-managed tool-search blocks must be replayed
|
|
146
|
+
# verbatim. 2.0 Message content is String-only, so the block array rides
|
|
147
|
+
# in an ivar that format_message re-injects when rendering history.
|
|
148
|
+
def preserve_server_blocks(message, data)
|
|
149
|
+
blocks = data.is_a?(Hash) ? data['content'] : nil
|
|
150
|
+
return unless blocks && ToolSearch.contains_server_blocks?(blocks)
|
|
151
|
+
|
|
152
|
+
message.instance_variable_set(RubyLLM::BedrockInvoke::RAW_BLOCKS_IVAR, blocks)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def attach_collected_blocks(message, collector)
|
|
156
|
+
return unless collector.server_blocks?
|
|
157
|
+
|
|
158
|
+
message.instance_variable_set(RubyLLM::BedrockInvoke::RAW_BLOCKS_IVAR, collector.content_blocks)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Providers
|
|
5
|
+
# Anthropic Claude on AWS Bedrock via the InvokeModel API (RubyLLM 1.x).
|
|
6
|
+
#
|
|
7
|
+
# Inherits the entire Anthropic Messages wire format — message/tool/schema
|
|
8
|
+
# rendering, structured output via output_config, cache_control, response
|
|
9
|
+
# and chunk parsing — and swaps the transport underneath: per-request
|
|
10
|
+
# SigV4 signing against bedrock-runtime and AWS event-stream decoding for
|
|
11
|
+
# InvokeModelWithResponseStream.
|
|
12
|
+
class BedrockInvoke < Anthropic
|
|
13
|
+
include RubyLLM::BedrockInvoke::Signing
|
|
14
|
+
include RubyLLM::BedrockInvoke::EventStream
|
|
15
|
+
|
|
16
|
+
ANTHROPIC_VERSION = 'bedrock-2023-05-31'
|
|
17
|
+
|
|
18
|
+
def api_base
|
|
19
|
+
config_value(:bedrock_invoke_api_base) ||
|
|
20
|
+
"https://bedrock-runtime.#{bedrock_invoke_region}.amazonaws.com"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def headers
|
|
24
|
+
{}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def completion_url
|
|
28
|
+
"/model/#{RubyLLM::BedrockInvoke.escape_model_id(@model_id)}/invoke"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def stream_url
|
|
32
|
+
"/model/#{RubyLLM::BedrockInvoke.escape_model_id(@model_id)}/invoke-with-response-stream"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# rubocop:disable Metrics/ParameterLists
|
|
36
|
+
def render_payload(messages, tools:, temperature:, model:, stream: false,
|
|
37
|
+
schema: nil, thinking: nil, tool_prefs: nil)
|
|
38
|
+
@model_id = model.id
|
|
39
|
+
payload = super
|
|
40
|
+
payload.delete(:model)
|
|
41
|
+
payload.delete(:stream)
|
|
42
|
+
payload[:anthropic_version] = ANTHROPIC_VERSION
|
|
43
|
+
RubyLLM::BedrockInvoke::ToolSearch.apply!(payload, tools)
|
|
44
|
+
payload
|
|
45
|
+
end
|
|
46
|
+
# rubocop:enable Metrics/ParameterLists
|
|
47
|
+
|
|
48
|
+
def parse_completion_response(response)
|
|
49
|
+
message = super
|
|
50
|
+
preserve_server_blocks(message, response)
|
|
51
|
+
message
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse_error(response)
|
|
55
|
+
return if response.body.nil? || response.body.empty?
|
|
56
|
+
|
|
57
|
+
body = try_parse_json(response.body)
|
|
58
|
+
return body if body.is_a?(String)
|
|
59
|
+
|
|
60
|
+
body['message'] || body['Message'] || body['error'] || body['__type'] || super
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def list_models
|
|
64
|
+
[]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Messages preserving server tool-search blocks replay verbatim.
|
|
68
|
+
# Bypassing the stock formatter also avoids its thinking-block prepend,
|
|
69
|
+
# which would both duplicate thinking blocks (they are already in the
|
|
70
|
+
# raw array) and permanently mutate the preserved array via unshift.
|
|
71
|
+
def format_message(msg, **)
|
|
72
|
+
blocks = RubyLLM::BedrockInvoke.raw_blocks(msg)
|
|
73
|
+
return { role: 'assistant', content: blocks } if blocks
|
|
74
|
+
|
|
75
|
+
super
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# 1.16 gates thinking on registry reasoning_options, which assumed
|
|
79
|
+
# models never carry; Bedrock Claude supports budget thinking, so emit
|
|
80
|
+
# it directly. Called as (thinking) on 1.13-1.15 (bare payload spliced
|
|
81
|
+
# into payload[:thinking]) and as (thinking, model) on 1.16 (wrapped
|
|
82
|
+
# shape unwrapped by add_thinking_fields).
|
|
83
|
+
def build_thinking_payload(thinking, model = nil)
|
|
84
|
+
inner = RubyLLM::BedrockInvoke.budget_thinking_payload(thinking)
|
|
85
|
+
return nil if inner.nil?
|
|
86
|
+
|
|
87
|
+
model.nil? ? inner : { thinking: inner }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
class << self
|
|
91
|
+
def slug
|
|
92
|
+
'bedrock_invoke'
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def capabilities
|
|
96
|
+
RubyLLM::BedrockInvoke::Capabilities
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def configuration_requirements
|
|
100
|
+
[]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def configuration_options
|
|
104
|
+
RubyLLM::BedrockInvoke::CONFIGURATION_OPTIONS
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def configured?(config)
|
|
108
|
+
region = (config.bedrock_invoke_region if config.respond_to?(:bedrock_invoke_region))
|
|
109
|
+
!(region || ENV['AWS_REGION'] || ENV.fetch('AWS_DEFAULT_REGION', nil)).nil?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def assume_models_exist?
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def sync_response(connection, payload, additional_headers = {})
|
|
120
|
+
RubyLLM::BedrockInvoke::ToolSearch.ensure_beta!(payload)
|
|
121
|
+
body = JSON.generate(payload)
|
|
122
|
+
response = connection.post(completion_url, body) do |req|
|
|
123
|
+
req.headers['Content-Type'] = 'application/json'
|
|
124
|
+
req.headers.merge!(additional_headers) unless additional_headers.empty?
|
|
125
|
+
req.headers.merge!(sign_headers('POST', completion_url, body))
|
|
126
|
+
end
|
|
127
|
+
parse_completion_response(response)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def stream_response(connection, payload, additional_headers = {}, &block)
|
|
131
|
+
RubyLLM::BedrockInvoke::ToolSearch.ensure_beta!(payload)
|
|
132
|
+
state = RubyLLM::BedrockInvoke::StreamState.new(self)
|
|
133
|
+
failure_buffer = +''
|
|
134
|
+
body = JSON.generate(payload)
|
|
135
|
+
|
|
136
|
+
response = connection.post(stream_url, body) do |req|
|
|
137
|
+
req.headers['Content-Type'] = 'application/json'
|
|
138
|
+
req.headers['Accept'] = 'application/vnd.amazon.eventstream'
|
|
139
|
+
req.headers.merge!(additional_headers) unless additional_headers.empty?
|
|
140
|
+
req.headers.merge!(sign_headers('POST', stream_url, body))
|
|
141
|
+
install_stream_handler(req) do |bytes, env, received|
|
|
142
|
+
consume_stream_bytes(bytes, env, received, state, failure_buffer, &block)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
message = state.accumulator.to_message(response)
|
|
147
|
+
attach_collected_blocks(message, state.collector)
|
|
148
|
+
RubyLLM.logger.debug { "Stream completed: #{message.content}" }
|
|
149
|
+
message
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def install_stream_handler(req, &handler)
|
|
153
|
+
if Faraday::VERSION.start_with?('1')
|
|
154
|
+
req.options[:on_data] = proc { |chunk, size| handler.call(chunk, nil, size) }
|
|
155
|
+
else
|
|
156
|
+
req.options.on_data = proc { |chunk, received, env| handler.call(chunk, env, received) }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def consume_stream_bytes(bytes, env, received, state, failure_buffer, &block)
|
|
161
|
+
return handle_failed_stream_chunk(bytes, env, failure_buffer) if failed_stream_chunk?(bytes, env)
|
|
162
|
+
|
|
163
|
+
state.reset_if_new_attempt!(bytes, received)
|
|
164
|
+
each_event_stream_event(state.decoder, bytes) do |event|
|
|
165
|
+
state.collector.observe(event)
|
|
166
|
+
next if state.collector.suppress?(event)
|
|
167
|
+
|
|
168
|
+
chunk = build_chunk(event)
|
|
169
|
+
next unless chunk
|
|
170
|
+
|
|
171
|
+
state.accumulator.add(chunk)
|
|
172
|
+
block.call(chunk)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Faraday 1 gives on_data no response env, so non-200 JSON error bodies
|
|
177
|
+
# would otherwise be fed to the event-stream decoder (binary frames
|
|
178
|
+
# never start with '{' — the first bytes are a frame length prelude).
|
|
179
|
+
def failed_stream_chunk?(bytes, env)
|
|
180
|
+
return env.status != 200 if env
|
|
181
|
+
|
|
182
|
+
bytes.to_s.lstrip.start_with?('{')
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Error bodies can arrive split across reads; accumulate until they parse.
|
|
186
|
+
def handle_failed_stream_chunk(bytes, env, failure_buffer)
|
|
187
|
+
failure_buffer << bytes
|
|
188
|
+
data = JSON.parse(failure_buffer)
|
|
189
|
+
response = env ? env.merge(body: data) : Struct.new(:body, :status).new(data, 500)
|
|
190
|
+
ErrorMiddleware.parse_error(provider: self, response: response)
|
|
191
|
+
rescue JSON::ParserError
|
|
192
|
+
RubyLLM.logger.debug { "bedrock_invoke: accumulating failed stream chunk: #{bytes.inspect}" }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# When the response carries server-managed tool-search blocks, the whole
|
|
196
|
+
# content-block array must go back to the API verbatim on the next
|
|
197
|
+
# request. Message exposes no writer, so the content ivar is replaced
|
|
198
|
+
# in place — the safe cross-version seam given 1.13-1.16 constructor drift.
|
|
199
|
+
def preserve_server_blocks(message, response)
|
|
200
|
+
blocks = response.body.is_a?(Hash) ? response.body['content'] : nil
|
|
201
|
+
return unless blocks && RubyLLM::BedrockInvoke::ToolSearch.contains_server_blocks?(blocks)
|
|
202
|
+
|
|
203
|
+
raw = RubyLLM::BedrockInvoke::RawContent.new(blocks, text: message.content)
|
|
204
|
+
message.instance_variable_set(:@content, raw)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def attach_collected_blocks(message, collector)
|
|
208
|
+
return unless collector.server_blocks?
|
|
209
|
+
|
|
210
|
+
raw = RubyLLM::BedrockInvoke::RawContent.new(collector.content_blocks, text: message.content)
|
|
211
|
+
message.instance_variable_set(:@content, raw)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Providers
|
|
5
|
+
# Anthropic Claude on AWS Bedrock via the InvokeModel API (RubyLLM 2.0).
|
|
6
|
+
#
|
|
7
|
+
# Where/auth only — the wire format lives in
|
|
8
|
+
# RubyLLM::BedrockInvoke::Protocol. Credentials come from the standard
|
|
9
|
+
# AWS chain (env vars, shared config, IRSA/Pod Identity, IMDS) unless a
|
|
10
|
+
# credential provider is configured explicitly.
|
|
11
|
+
class BedrockInvoke < Provider
|
|
12
|
+
include RubyLLM::BedrockInvoke::Signing
|
|
13
|
+
|
|
14
|
+
protocol :anthropic, RubyLLM::BedrockInvoke::Protocol
|
|
15
|
+
|
|
16
|
+
def api_base
|
|
17
|
+
config_value(:bedrock_invoke_api_base) ||
|
|
18
|
+
"https://bedrock-runtime.#{bedrock_invoke_region}.amazonaws.com"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def headers
|
|
22
|
+
{}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ensure_configured!
|
|
26
|
+
return if self.class.configured?(@config)
|
|
27
|
+
|
|
28
|
+
raise ConfigurationError,
|
|
29
|
+
'No AWS region configured for bedrock_invoke. Set config.bedrock_invoke_region ' \
|
|
30
|
+
'or the AWS_REGION environment variable.'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def parse_error(response)
|
|
34
|
+
body = response.body
|
|
35
|
+
body = try_parse_json(body) if respond_to?(:try_parse_json, true)
|
|
36
|
+
return if body.nil? || (body.respond_to?(:empty?) && body.empty?)
|
|
37
|
+
|
|
38
|
+
return body if body.is_a?(String)
|
|
39
|
+
|
|
40
|
+
body['message'] || body['Message'] || body['error'] || body['__type'] || super
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def list_models
|
|
44
|
+
[]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
def capabilities
|
|
49
|
+
RubyLLM::BedrockInvoke::Capabilities
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def configuration_options
|
|
53
|
+
RubyLLM::BedrockInvoke::CONFIGURATION_OPTIONS
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def configuration_requirements
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def configured?(config)
|
|
61
|
+
region = (config.bedrock_invoke_region if config.respond_to?(:bedrock_invoke_region))
|
|
62
|
+
!(region || ENV['AWS_REGION'] || ENV.fetch('AWS_DEFAULT_REGION', nil)).nil?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def assume_models_exist?
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module BedrockInvoke
|
|
5
|
+
# Raw Anthropic content blocks that must be replayed verbatim on
|
|
6
|
+
# subsequent requests (server_tool_use / tool_search_tool_result and their
|
|
7
|
+
# siblings), while still displaying as the assistant's text.
|
|
8
|
+
#
|
|
9
|
+
# RubyLLM renders RubyLLM::Content::Raw message content into the payload
|
|
10
|
+
# unchanged, which is exactly what the tool search tool requires: its
|
|
11
|
+
# server-side blocks are passed back untouched and expanded by the API.
|
|
12
|
+
class RawContent < RubyLLM::Content::Raw
|
|
13
|
+
def initialize(value, text: nil)
|
|
14
|
+
super(value)
|
|
15
|
+
@text = text
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
@text.to_s
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aws-sdk-core'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module BedrockInvoke
|
|
8
|
+
# SigV4 request signing backed by the standard AWS credential chain.
|
|
9
|
+
#
|
|
10
|
+
# Credentials resolve exactly like any AWS SDK client: environment
|
|
11
|
+
# variables, shared config/credentials files, SSO, ECS/EKS container
|
|
12
|
+
# credentials (Pod Identity), IRSA web-identity tokens, and IMDS. A
|
|
13
|
+
# token projected into a pod therefore "just works" with no RubyLLM
|
|
14
|
+
# configuration beyond the region.
|
|
15
|
+
#
|
|
16
|
+
# Mixed into objects that expose +@config+ (the RubyLLM configuration)
|
|
17
|
+
# and +api_base+.
|
|
18
|
+
module Signing
|
|
19
|
+
SERVICE = 'bedrock'
|
|
20
|
+
|
|
21
|
+
# The default chain is resolved once per process per region and shared:
|
|
22
|
+
# the returned credentials object self-refreshes (the AWS SDK norm),
|
|
23
|
+
# and re-resolving per Chat would mean an STS/IMDS network round trip
|
|
24
|
+
# for every RubyLLM.chat under IRSA or instance profiles.
|
|
25
|
+
DEFAULT_CHAIN_MUTEX = Mutex.new
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def cached_default_chain_credentials(region)
|
|
29
|
+
DEFAULT_CHAIN_MUTEX.synchronize do
|
|
30
|
+
@default_chain_cache ||= {}
|
|
31
|
+
@default_chain_cache[region] ||= ::Aws::STS::Client.new(region: region).config.credentials
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# For tests and credential rotation.
|
|
36
|
+
def reset_default_chain_cache!
|
|
37
|
+
DEFAULT_CHAIN_MUTEX.synchronize { @default_chain_cache = {} }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def bedrock_invoke_region
|
|
42
|
+
region = presence(config_value(:bedrock_invoke_region)) ||
|
|
43
|
+
presence(ENV.fetch('AWS_REGION', nil)) ||
|
|
44
|
+
presence(ENV.fetch('AWS_DEFAULT_REGION', nil))
|
|
45
|
+
return region if region
|
|
46
|
+
|
|
47
|
+
raise RubyLLM::ConfigurationError,
|
|
48
|
+
'No AWS region configured for bedrock_invoke. Set config.bedrock_invoke_region ' \
|
|
49
|
+
'or the AWS_REGION environment variable.'
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def sigv4_signer
|
|
53
|
+
@sigv4_signer ||= ::Aws::Sigv4::Signer.new(
|
|
54
|
+
service: SERVICE,
|
|
55
|
+
region: bedrock_invoke_region,
|
|
56
|
+
credentials_provider: bedrock_invoke_credentials
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns headers (authorization, x-amz-date, x-amz-content-sha256 and,
|
|
61
|
+
# for temporary credentials, x-amz-security-token) for one request.
|
|
62
|
+
# +path+ is relative to +api_base+; +body+ must be the exact bytes sent.
|
|
63
|
+
# URI.join mirrors Faraday's absolute-path semantics, so a custom
|
|
64
|
+
# api_base carrying a path component cannot desynchronize the signature
|
|
65
|
+
# from the transmitted URL.
|
|
66
|
+
def sign_headers(method, path, body)
|
|
67
|
+
signature = sigv4_signer.sign_request(
|
|
68
|
+
http_method: method,
|
|
69
|
+
url: URI.join(api_base, path).to_s,
|
|
70
|
+
body: body
|
|
71
|
+
)
|
|
72
|
+
signature.headers
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def bedrock_invoke_credentials
|
|
76
|
+
explicit = config_value(:bedrock_invoke_credential_provider)
|
|
77
|
+
return explicit if explicit
|
|
78
|
+
|
|
79
|
+
credentials = Signing.cached_default_chain_credentials(bedrock_invoke_region)
|
|
80
|
+
return credentials if credentials
|
|
81
|
+
|
|
82
|
+
raise RubyLLM::ConfigurationError,
|
|
83
|
+
'No AWS credentials found for bedrock_invoke. Provide credentials via the standard ' \
|
|
84
|
+
'AWS chain (env vars, shared config, IRSA/Pod Identity, instance profile) or set ' \
|
|
85
|
+
'config.bedrock_invoke_credential_provider.'
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def config_value(key)
|
|
91
|
+
return nil unless @config.respond_to?(key)
|
|
92
|
+
|
|
93
|
+
@config.public_send(key)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# On <= 1.15 the config accessors are plain attr_accessors, so empty
|
|
97
|
+
# strings are not coerced to nil the way 1.16+/2.0 writers do.
|
|
98
|
+
def presence(value)
|
|
99
|
+
return nil if value.nil?
|
|
100
|
+
return nil if value.respond_to?(:empty?) && value.to_s.strip.empty?
|
|
101
|
+
|
|
102
|
+
value
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module BedrockInvoke
|
|
5
|
+
# Per-attempt streaming state: accumulator, block collector, and
|
|
6
|
+
# event-stream decoder.
|
|
7
|
+
#
|
|
8
|
+
# RubyLLM's Faraday connection installs retry middleware that re-issues
|
|
9
|
+
# POSTs on retryable failures (throttling raised mid-stream, connection
|
|
10
|
+
# drops). The on_data callback closes over this holder; resetting it at
|
|
11
|
+
# the first chunk of each physical attempt prevents a retried stream from
|
|
12
|
+
# appending to a half-filled accumulator or feeding a decoder stuck on a
|
|
13
|
+
# partial frame. Detection: Faraday reports cumulative received bytes,
|
|
14
|
+
# which equal the chunk size exactly when an attempt starts fresh.
|
|
15
|
+
class StreamState
|
|
16
|
+
attr_reader :accumulator, :collector, :decoder
|
|
17
|
+
|
|
18
|
+
def initialize(event_stream_owner)
|
|
19
|
+
@owner = event_stream_owner
|
|
20
|
+
reset!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reset!
|
|
24
|
+
@accumulator = RubyLLM::StreamAccumulator.new
|
|
25
|
+
@collector = BlockCollector.new
|
|
26
|
+
@decoder = @owner.event_stream_decoder
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def reset_if_new_attempt!(chunk, received_bytes)
|
|
30
|
+
reset! if received_bytes && received_bytes == chunk.bytesize
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|