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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 911a2cdfef791e621f685c7560906cd38bb3c6362303d7a488799617c0a66d65
4
+ data.tar.gz: 76efc4ffcf2932e7baf423a9498044f9e47eccc9bb478bd56f75adb4f3c1f95f
5
+ SHA512:
6
+ metadata.gz: b5c1b976369f15fc99ad75f1b6661fda71ec07ceafc2db7595af8d8ad159525c152dd5b9f22187c43b389813b8d14e5b9346e4f8fb21cd818a7f61013635ffe0
7
+ data.tar.gz: 0c03c773fb9f8ea9505a90734e677db5d3803c6c5aeeabe6e67574d0dc0dc6862f4c6f7fac90b6e27fb80d8bdfd4e0e82cc7c5f2b10886ce658b6b07bbf3f10b
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Petersen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,194 @@
1
+ # ruby_llm-bedrock_invoke
2
+
3
+ A [RubyLLM](https://github.com/crmne/ruby_llm) provider for **Anthropic Claude via the AWS Bedrock InvokeModel API**, registered as `:bedrock_invoke`. Works with released RubyLLM **1.13+** and the unreleased **2.0** architecture from one gem.
4
+
5
+ ## Why this exists
6
+
7
+ RubyLLM's built-in `:bedrock` provider speaks AWS's unified [Converse API](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html). Converse is great for portability, but it is a translation layer — and Anthropic features that don't fit its schema simply don't exist there. As of mid-2026, if you need Claude-specific capabilities *and* your data must stay inside the AWS boundary, the feature matrix looks like this:
8
+
9
+ | Capability | Bedrock Converse | Bedrock Mantle (`/anthropic/v1/messages`) | **Bedrock InvokeModel** | First-party Anthropic API |
10
+ |---|:---:|:---:|:---:|:---:|
11
+ | Tool search / deferred tool loading | ❌ impossible | ❌ not offered | ✅ beta | ✅ GA |
12
+ | Structured outputs (`output_config`) | ✅ | ❌ rejected (400) | ✅ GA | ✅ |
13
+ | Prompt caching (`cache_control`) | ✅ (`cachePoint`) | ✅ | ✅ GA | ✅ |
14
+ | Streaming | ✅ | ✅ | ✅ | ✅ |
15
+ | Data stays inside AWS | ✅ | ✅ | ✅ | ❌ |
16
+
17
+ InvokeModel is the only surface with the full set. It takes the native Anthropic Messages body verbatim (plus `anthropic_version`), so this gem inherits RubyLLM's entire Anthropic wire format and swaps only the transport: SigV4 signing against `bedrock-runtime` and AWS event-stream decoding.
18
+
19
+ ### The problem deferred tool loading solves
20
+
21
+ Agents with large tool catalogs (many MCP servers, dozens of tools) face an ugly trade-off:
22
+
23
+ - **Load every tool eagerly** → tool schemas consume thousands of prompt tokens on every request.
24
+ - **Load tools dynamically** (intent routing, per-request tool selection) → the tools array is part of the prompt-cache prefix, so *any* change to it invalidates the cache for the whole conversation.
25
+
26
+ Anthropic's [tool search tool](https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool) is the first-party fix: mark long-tail tools `defer_loading: true` and they are **excluded from the context and the cache prefix** until the model discovers them via a server-side search tool. Discovery happens *inline in the message stream* — the prefix never changes, so the cache survives.
27
+
28
+ This is measurable. `bin/cache_smoke` runs both strategies live against Bedrock with a ~9k-token cached system prefix:
29
+
30
+ ```
31
+ RUN A — deferred tools + tool search RUN B — inject tool def mid-conversation
32
+ response cache READ WRITE response cache READ WRITE
33
+ R1 search + call 9064 9064 R1 tool call 0 8851
34
+ R2 final answer 9064 0 R2 final answer 8851 0
35
+ R3 tool call (turn 2) 9064 0 R3 tool call (def added!) 0 8936 <- cache busted
36
+ R4 final answer 9064 0 R4 final answer 8936 0
37
+ ```
38
+
39
+ Run A discovers and calls a hidden tool with the cache intact on every subsequent request. Run B "loads" the same tool by adding its definition — and pays a full cache re-write. That contrast is the reason this gem targets InvokeModel: on Bedrock, tool search exists nowhere else.
40
+
41
+ ## Installation
42
+
43
+ ```ruby
44
+ gem 'ruby_llm-bedrock_invoke'
45
+ ```
46
+
47
+ Dependencies: `ruby_llm` (>= 1.13, < 3), `aws-sdk-core` (credential chain + SigV4), `aws-eventstream` (streaming).
48
+
49
+ ## Quick start
50
+
51
+ ```ruby
52
+ require 'ruby_llm-bedrock_invoke'
53
+
54
+ RubyLLM.configure do |config|
55
+ config.bedrock_invoke_region = 'us-east-1' # or rely on AWS_REGION
56
+ # No credential config needed: the standard AWS chain is used automatically.
57
+ end
58
+
59
+ chat = RubyLLM.chat(
60
+ model: 'global.anthropic.claude-haiku-4-5-20251001-v1:0',
61
+ provider: :bedrock_invoke,
62
+ assume_model_exists: true # Bedrock ids/ARNs aren't in RubyLLM's registry
63
+ )
64
+
65
+ chat.ask 'Hello from inside the AWS boundary!'
66
+ ```
67
+
68
+ Everything RubyLLM documents for chat works unchanged: streaming (`chat.ask('...') { |chunk| print chunk.content }`), tools, `with_temperature`, `with_params`.
69
+
70
+ ### Authentication: why the default chain
71
+
72
+ Credentials resolve exactly like any AWS SDK client — env vars, shared config/SSO, ECS/EKS **Pod Identity**, **IRSA** web-identity tokens, IMDS — resolved once per process and cached (the credentials object self-refreshes). A token projected into a pod just works with zero RubyLLM configuration. This is deliberately different from core's `:bedrock` provider, which requires static keys or a hand-built credential provider object. To override: `config.bedrock_invoke_credential_provider = anything_responding_to_credentials`.
73
+
74
+ ## Tool search / deferred tool loading
75
+
76
+ Mark long-tail tools deferred; keep your 3–5 hottest tools eager. When at least one tool is deferred, the provider automatically flags those definitions `defer_loading: true`, injects the `tool_search_tool_regex` server tool, and opts into the `tool-search-tool-2025-10-19` beta (in the request body — Bedrock has no beta HTTP header).
77
+
78
+ ```ruby
79
+ class DatabaseQuery < RubyLLM::Tool
80
+ include RubyLLM::BedrockInvoke::Deferred # class-level: always deferred
81
+ description 'Run a read-only SQL query against the warehouse'
82
+ # ...
83
+ end
84
+
85
+ chat.with_tools(HotTool, DatabaseQuery)
86
+ ```
87
+
88
+ For tool instances you don't control (e.g. built by `ruby_llm-mcp`), defer per-instance:
89
+
90
+ ```ruby
91
+ chat.with_tools(hot_tool, *mcp_tools.map { |t| RubyLLM::BedrockInvoke.defer(t) })
92
+ ```
93
+
94
+ **You must tell the model that hidden tools exist.** In live testing, models look at their visible tool list, conclude the capability is missing, and never search. One paragraph in your (cached) system prompt fixes it:
95
+
96
+ ```ruby
97
+ chat.with_instructions <<~PROMPT
98
+ Your visible tool list is only a subset of the tools available. Before saying
99
+ a capability is missing, use tool_search_tool_regex to search the full catalog
100
+ by keyword. Discovered tools can then be called normally.
101
+ PROMPT
102
+ ```
103
+
104
+ ### What happens at runtime
105
+
106
+ 1. The model calls the search tool server-side (`server_tool_use` block) with a regex.
107
+ 2. The API returns a `tool_search_tool_result` containing `tool_reference` blocks; matching deferred tools are expanded into context **inline in the message stream** — the cached prefix is untouched.
108
+ 3. The model calls the discovered tool like any other; RubyLLM's tool loop executes it normally.
109
+
110
+ Responses that used tool search carry the full server content blocks, which this gem replays verbatim on subsequent requests (required by the API). Access them on either RubyLLM generation with:
111
+
112
+ ```ruby
113
+ blocks = RubyLLM::BedrockInvoke.raw_blocks(message) # nil for ordinary messages
114
+ ```
115
+
116
+ If you persist conversations (Rails `acts_as_chat`), these blocks are process-local — persist and restore them yourself if tool-search turns must survive a reload.
117
+
118
+ ## Prompt caching
119
+
120
+ Caching is GA and on by default on InvokeModel — you only place breakpoints. The prefix is cached in order `tools → system → messages`, so one `cache_control` breakpoint at the end of your system prompt covers the tool schemas and instructions:
121
+
122
+ ```ruby
123
+ chat = RubyLLM.chat(model: MODEL, provider: :bedrock_invoke, assume_model_exists: true)
124
+ chat.with_params(system: [ # with_provider_options on RubyLLM 2.0
125
+ { type: 'text', text: BIG_SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }
126
+ ])
127
+ ```
128
+
129
+ Note that `with_params(system:)` **replaces** the rendered system prompt: if you also use `with_instructions` (e.g. for the tool-search guidance above), fold that text into the blocks array rather than setting both. On the unreleased RubyLLM 2.0 you can use the native `chat.with_caching` API instead.
130
+
131
+ Verify it's working by reading usage off the response:
132
+
133
+ ```ruby
134
+ response = chat.ask 'First question'
135
+ response.cache_creation_tokens # > 0: the prefix was written (2.0: response.tokens.cache_creation)
136
+ response.cached_tokens # subsequent asks: > 0 = cache hit (2.0: response.tokens.cached)
137
+ ```
138
+
139
+ Why this matters with deferred tools: deferred definitions are excluded from the cached prefix (a deferred tool cannot even carry `cache_control` — the API 400s), so the catalog can grow or shrink *between conversations* and discovery can happen *within* one, all without invalidating the cache. Mind the per-model minimum cacheable size (4,096 tokens for Haiku 4.5-class models) — below it you'll see neither reads nor writes.
140
+
141
+ ## Structured output, streaming, thinking
142
+
143
+ ```ruby
144
+ # Structured output — GA on InvokeModel, no beta flag:
145
+ chat.with_schema(
146
+ type: 'object',
147
+ properties: { answer: { type: 'string' } },
148
+ required: ['answer'],
149
+ additionalProperties: false
150
+ ).ask('...')
151
+
152
+ # Streaming — InvokeModelWithResponseStream (AWS event-stream framing, decoded for you):
153
+ chat.ask('Tell me a story') { |chunk| print chunk.content }
154
+
155
+ # Extended thinking — budget-based only (Bedrock has no adaptive/effort API):
156
+ chat.with_thinking(budget: 8_000).ask('Think hard about this')
157
+ ```
158
+
159
+ ## Configuration
160
+
161
+ | Key | Meaning | Default |
162
+ |---|---|---|
163
+ | `bedrock_invoke_region` | AWS region | `AWS_REGION` / `AWS_DEFAULT_REGION` |
164
+ | `bedrock_invoke_api_base` | Endpoint override (host only, no path) | `https://bedrock-runtime.{region}.amazonaws.com` |
165
+ | `bedrock_invoke_credential_provider` | Any object responding to `#credentials` | AWS default chain, cached per region |
166
+
167
+ ## Notes and limits
168
+
169
+ - **Model coverage**: Claude Sonnet 5 is not served through the InvokeModel surface; Fable 5, Opus 4.8/4.7, Haiku 4.5 and earlier are. Model IDs are not registry-validated, so ARNs, inference profiles, and `global.`/regional prefixes all work — but resolved models carry generic capability metadata: no pricing, and `max_tokens` defaults to 4096 (raise it with `chat.with_params(max_tokens: 32_000)`).
170
+ - Tool search on Bedrock is a **beta** and only offers the regex variant (`tool_search_tool_regex`); no BM25 variant, unlike the first-party API.
171
+ - **Betas ride in the body**: Bedrock InvokeModel has no `anthropic-beta` HTTP header. Opt into additional betas via `chat.with_params(anthropic_beta: [...])` — the tool-search flag is re-applied automatically if your params replace the array.
172
+ - **Keep deferred tools attached** for the lifetime of a conversation that used tool search: past turns replay `tool_reference` blocks that must still resolve to sent tool definitions.
173
+ - On a retried stream (throttling mid-stream), your streaming block may see chunks from the abandoned attempt; the final returned message contains only the successful attempt.
174
+ - Embeddings, image generation, Batches, and the Files API are out of scope (use the stock `:bedrock` provider or first-party Anthropic).
175
+
176
+ ## Development
177
+
178
+ ```bash
179
+ bundle exec rspec # newest 1.x
180
+ BUNDLE_GEMFILE=gemfiles/ruby_llm_1_13.gemfile bundle exec rspec # oldest supported 1.x
181
+ BUNDLE_GEMFILE=gemfiles/ruby_llm_2_0.gemfile bundle exec rspec # 2.0: upstream main (needs Ruby >= 3.4;
182
+ # RUBY_LLM_PATH=... for a local checkout)
183
+
184
+ bundle exec bin/smoke # live AWS: completion, streaming, schema, tool search
185
+ bundle exec bin/cache_smoke # live AWS: the cache-preservation proof shown above
186
+ ```
187
+
188
+ ## Why a plugin (and the road upstream)
189
+
190
+ Upstream RubyLLM removed its original InvokeModel chat path in 1.12.0 in favor of Converse, and the maintainer closed the community tool-search PR ([crmne/ruby_llm#745](https://github.com/crmne/ruby_llm/pull/745)) asking for a provider-agnostic design first — [explicitly noting](https://github.com/crmne/ruby_llm/pull/745#issuecomment-4879003367) that *"Bedrock exposes Claude's version through InvokeModel, though not Converse."* The open feature request is [crmne/ruby_llm#748](https://github.com/crmne/ruby_llm/issues/748). This gem is the working Bedrock-side reference implementation for that design conversation: `#745`'s `defer:`/`deferred` user-facing API plus this provider underneath would cover both the first-party and Bedrock dialects.
191
+
192
+ ## License
193
+
194
+ MIT
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RubyLLM
6
+ module BedrockInvoke
7
+ # Reassembles Anthropic content blocks from streaming events.
8
+ #
9
+ # Two jobs:
10
+ # 1. Rebuild the assistant's full ordered content-block array so a
11
+ # response containing tool-search blocks can be replayed verbatim
12
+ # (see RawContent).
13
+ # 2. Identify events belonging to server-managed blocks
14
+ # (server_tool_use / tool_search_tool_result) so they can be kept away
15
+ # from RubyLLM's tool-call accumulator — its input_json_delta handling
16
+ # keys nil-id fragments to the latest client tool call, which a
17
+ # server_tool_use block would silently corrupt.
18
+ class BlockCollector
19
+ def initialize
20
+ @blocks = {}
21
+ @json_buffers = {}
22
+ @server_indexes = {}
23
+ @saw_server_block = false
24
+ end
25
+
26
+ def observe(event)
27
+ case event['type']
28
+ when 'content_block_start' then start_block(event)
29
+ when 'content_block_delta' then apply_delta(event)
30
+ when 'content_block_stop' then finish_block(event)
31
+ end
32
+ end
33
+
34
+ # True for content-block events that belong to a server-managed block.
35
+ def suppress?(event)
36
+ index = event['index']
37
+ return false if index.nil?
38
+
39
+ @server_indexes.key?(index) && event['type'].to_s.start_with?('content_block')
40
+ end
41
+
42
+ def server_blocks?
43
+ @saw_server_block
44
+ end
45
+
46
+ def content_blocks
47
+ @blocks.keys.sort.map { |index| @blocks[index] }
48
+ end
49
+
50
+ private
51
+
52
+ def start_block(event)
53
+ index = event['index']
54
+ block = event['content_block'] || {}
55
+ @blocks[index] = block
56
+
57
+ if ToolSearch.server_block?(block)
58
+ @saw_server_block = true
59
+ @server_indexes[index] = true
60
+ end
61
+
62
+ @json_buffers[index] = +'' if %w[tool_use server_tool_use].include?(block['type'])
63
+ end
64
+
65
+ def apply_delta(event)
66
+ block = @blocks[event['index']]
67
+ delta = event['delta'] || {}
68
+ return if block.nil?
69
+
70
+ case delta['type']
71
+ when 'text_delta'
72
+ append_string(block, 'text', delta['text'])
73
+ when 'input_json_delta'
74
+ buffer = @json_buffers[event['index']]
75
+ buffer << delta['partial_json'].to_s if buffer
76
+ when 'thinking_delta'
77
+ append_string(block, 'thinking', delta['thinking'])
78
+ when 'signature_delta'
79
+ block['signature'] = delta['signature']
80
+ when 'citations_delta'
81
+ (block['citations'] ||= []) << delta['citation']
82
+ end
83
+ end
84
+
85
+ # In-place append (amortized O(n) over a stream, unlike interpolation).
86
+ def append_string(block, key, piece)
87
+ return if piece.nil?
88
+
89
+ existing = block[key]
90
+ block[key] = existing = +'' unless existing.is_a?(String) && !existing.frozen?
91
+ existing << piece
92
+ end
93
+
94
+ def finish_block(event)
95
+ index = event['index']
96
+ buffer = @json_buffers.delete(index)
97
+ return if buffer.nil? || buffer.empty?
98
+
99
+ block = @blocks[index]
100
+ return if block.nil?
101
+
102
+ begin
103
+ block['input'] = JSON.parse(buffer)
104
+ rescue JSON::ParserError
105
+ RubyLLM.logger.debug { "bedrock_invoke: unparseable tool input for block #{index}" }
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module BedrockInvoke
5
+ # Delegates capability lookups to the Anthropic capabilities module after
6
+ # normalizing Bedrock model ids (region prefixes, vendor prefix, version
7
+ # suffixes) into first-party Anthropic ids, so context windows, pricing
8
+ # and feature flags resolve correctly for ids like
9
+ # `us.anthropic.claude-sonnet-4-5-20250929-v1:0`.
10
+ module Capabilities
11
+ module_function
12
+
13
+ def normalize_model_id(model_id)
14
+ model_id.to_s
15
+ .sub(/\A(?:us|eu|apac|global)\./, '')
16
+ .sub(/\Aanthropic\./, '')
17
+ .sub(/-v\d+(?::\d+)?\z/, '')
18
+ end
19
+
20
+ def anthropic_capabilities
21
+ RubyLLM::Providers::Anthropic::Capabilities
22
+ end
23
+
24
+ def method_missing(name, *args, &)
25
+ target = anthropic_capabilities
26
+ return super unless target.respond_to?(name)
27
+
28
+ args = args.dup
29
+ args[0] = normalize_model_id(args[0]) if args[0].is_a?(String)
30
+ target.public_send(name, *args, &)
31
+ end
32
+
33
+ def respond_to_missing?(name, include_private = false)
34
+ anthropic_capabilities.respond_to?(name) || super
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module BedrockInvoke
5
+ # Marks a tool for deferred loading (Anthropic tool search).
6
+ #
7
+ # class DatabaseQuery < RubyLLM::Tool
8
+ # include RubyLLM::BedrockInvoke::Deferred
9
+ # ...
10
+ # end
11
+ #
12
+ # or per-instance (e.g. for tools built by ruby_llm-mcp):
13
+ #
14
+ # chat.with_tool(RubyLLM::BedrockInvoke.defer(tool))
15
+ #
16
+ # Any tool object responding truthily to +defer_loading?+ is treated as
17
+ # deferred — including tools that define the method themselves.
18
+ module Deferred
19
+ def defer_loading?
20
+ true
21
+ end
22
+ end
23
+
24
+ def self.defer(tool)
25
+ if tool.is_a?(Class)
26
+ # Chat#with_tools instantiates classes, so extending the class object
27
+ # would be silently lost. Fail loudly instead.
28
+ raise ArgumentError,
29
+ 'Pass a tool instance to RubyLLM::BedrockInvoke.defer (e.g. defer(MyTool.new)), ' \
30
+ 'or include RubyLLM::BedrockInvoke::Deferred in the tool class.'
31
+ end
32
+
33
+ tool.extend(Deferred) unless ToolSearch.deferred?(tool)
34
+ tool
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+
6
+ module RubyLLM
7
+ module BedrockInvoke
8
+ # Decodes AWS event-stream framing (application/vnd.amazon.eventstream)
9
+ # from InvokeModelWithResponseStream into plain Anthropic streaming events.
10
+ #
11
+ # Each `chunk` event carries a PayloadPart whose base64 `bytes` field
12
+ # decodes to one standard Anthropic SSE event (message_start,
13
+ # content_block_delta, ...). Failures surface three ways, all handled
14
+ # before the payload is trusted: frames with :message-type 'exception'
15
+ # (typed via :exception-type, payload may be empty), frames with
16
+ # :message-type 'error' (:error-code/:error-message headers, empty
17
+ # payload), and exception-suffixed keys inside chunk payloads.
18
+ module EventStream
19
+ EXCEPTION_STATUS = {
20
+ 'throttlingException' => 429,
21
+ 'validationException' => 400,
22
+ 'modelTimeoutException' => 408,
23
+ 'modelStreamErrorException' => 424,
24
+ 'serviceUnavailableException' => 503,
25
+ 'internalServerException' => 500
26
+ }.freeze
27
+
28
+ def event_stream_decoder
29
+ require 'aws-eventstream'
30
+ ::Aws::EventStream::Decoder.new
31
+ end
32
+
33
+ # Yields one decoded Anthropic event Hash per event in the raw chunk.
34
+ # Raises through ErrorMiddleware when the stream carries an exception event.
35
+ def each_event_stream_event(decoder, raw_chunk, &)
36
+ message, eof = decoder.decode_chunk(raw_chunk)
37
+ process_event_stream_message(message, &) if message
38
+ until eof
39
+ message, eof = decoder.decode_chunk
40
+ process_event_stream_message(message, &) if message
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def process_event_stream_message(message)
47
+ payload = parse_event_stream_payload(message)
48
+ raise_frame_errors!(message, payload)
49
+ return if payload.nil?
50
+
51
+ event = unwrap_payload_bytes(payload)
52
+ return if event.nil?
53
+
54
+ raise_embedded_exception!(event)
55
+ yield event
56
+ end
57
+
58
+ # Exception frames may carry empty payloads; error frames carry only
59
+ # :error-code/:error-message headers. Both must raise before the
60
+ # payload-nil early return or the stream would complete as success.
61
+ def raise_frame_errors!(message, payload)
62
+ message_type = event_stream_header(message, ':message-type')
63
+ exception_type = event_stream_header(message, ':exception-type')
64
+
65
+ if exception_type || message_type == 'exception'
66
+ raise_event_stream_error(exception_type || 'exception', payload)
67
+ elsif message_type == 'error'
68
+ code = event_stream_header(message, ':error-code')
69
+ raise_event_stream_error(code || 'error', event_stream_header(message, ':error-message') || code.to_s)
70
+ end
71
+ end
72
+
73
+ def raise_embedded_exception!(event)
74
+ error_key = event.keys.find { |key| key.to_s.end_with?('Exception') }
75
+ raise_event_stream_error(error_key, event[error_key] || event) if error_key
76
+ end
77
+
78
+ def event_stream_header(message, name)
79
+ header = message.headers[name]
80
+ header&.value
81
+ end
82
+
83
+ def parse_event_stream_payload(message)
84
+ raw = message.payload.read
85
+ return nil if raw.nil? || raw.empty?
86
+
87
+ JSON.parse(raw)
88
+ rescue JSON::ParserError
89
+ RubyLLM.logger.debug { "bedrock_invoke: undecodable event payload: #{raw.inspect}" }
90
+ nil
91
+ end
92
+
93
+ def unwrap_payload_bytes(payload)
94
+ return payload unless payload.is_a?(Hash) && payload.key?('bytes')
95
+
96
+ JSON.parse(Base64.decode64(payload['bytes']))
97
+ rescue JSON::ParserError
98
+ RubyLLM.logger.debug { 'bedrock_invoke: undecodable bytes payload in stream event' }
99
+ nil
100
+ end
101
+
102
+ def raise_event_stream_error(type, detail)
103
+ status = EXCEPTION_STATUS.fetch(type.to_s, 500)
104
+ message = detail.is_a?(Hash) ? detail['message'] || detail['Message'] : detail&.to_s
105
+ message = type.to_s if message.nil? || message.empty?
106
+ response = Struct.new(:body, :status).new({ 'message' => message }, status)
107
+ RubyLLM::ErrorMiddleware.parse_error(provider: error_middleware_provider, response: response)
108
+ end
109
+
110
+ # The object ErrorMiddleware treats as the provider (self for 1.x
111
+ # providers; overridden by 2.0 protocols to hand back the provider).
112
+ def error_middleware_provider
113
+ self
114
+ end
115
+ end
116
+ end
117
+ end