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
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
|