little_ghost 0.1.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 +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/docs/guides/Core Concepts.md +203 -0
- data/docs/guides/Getting Started.md +187 -0
- data/lib/little_ghost/ag_ui/adapter.rb +194 -0
- data/lib/little_ghost/ag_ui.rb +5 -0
- data/lib/little_ghost/agent/context_management.rb +285 -0
- data/lib/little_ghost/agent/delegation.rb +128 -0
- data/lib/little_ghost/agent/skills.rb +96 -0
- data/lib/little_ghost/agent/tool_loop.rb +239 -0
- data/lib/little_ghost/agent.rb +2111 -0
- data/lib/little_ghost/agent_builder.rb +191 -0
- data/lib/little_ghost/agent_interruptions.rb +197 -0
- data/lib/little_ghost/configuration.rb +337 -0
- data/lib/little_ghost/content.rb +324 -0
- data/lib/little_ghost/default_model_registry.rb +71 -0
- data/lib/little_ghost/errors.rb +48 -0
- data/lib/little_ghost/events.rb +264 -0
- data/lib/little_ghost/execution_state.rb +58 -0
- data/lib/little_ghost/instrumentation.rb +475 -0
- data/lib/little_ghost/invocation.rb +285 -0
- data/lib/little_ghost/lookup.rb +37 -0
- data/lib/little_ghost/mcp/client.rb +396 -0
- data/lib/little_ghost/mcp.rb +5 -0
- data/lib/little_ghost/message.rb +75 -0
- data/lib/little_ghost/model.rb +88 -0
- data/lib/little_ghost/model_capabilities.rb +126 -0
- data/lib/little_ghost/model_registry.rb +173 -0
- data/lib/little_ghost/model_request.rb +107 -0
- data/lib/little_ghost/model_response.rb +48 -0
- data/lib/little_ghost/path_set.rb +32 -0
- data/lib/little_ghost/prompt_resolver.rb +251 -0
- data/lib/little_ghost/providers/bedrock.rb +506 -0
- data/lib/little_ghost/providers/http_transport.rb +149 -0
- data/lib/little_ghost/providers/open_router.rb +171 -0
- data/lib/little_ghost/providers/openai.rb +27 -0
- data/lib/little_ghost/providers/openai_compatible.rb +745 -0
- data/lib/little_ghost/providers/sse_parser.rb +35 -0
- data/lib/little_ghost/run.rb +607 -0
- data/lib/little_ghost/run_context.rb +129 -0
- data/lib/little_ghost/run_result.rb +111 -0
- data/lib/little_ghost/runtime/hook.rb +31 -0
- data/lib/little_ghost/runtime.rb +392 -0
- data/lib/little_ghost/sandbox.rb +138 -0
- data/lib/little_ghost/session.rb +229 -0
- data/lib/little_ghost/session_store.rb +96 -0
- data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
- data/lib/little_ghost/session_stores/memory.rb +86 -0
- data/lib/little_ghost/skills/catalog.rb +283 -0
- data/lib/little_ghost/skills/skill.rb +60 -0
- data/lib/little_ghost/skills.rb +4 -0
- data/lib/little_ghost/stream_event.rb +49 -0
- data/lib/little_ghost/structured_output.rb +126 -0
- data/lib/little_ghost/subagents/agent_path.rb +63 -0
- data/lib/little_ghost/subagents/definition.rb +42 -0
- data/lib/little_ghost/subagents/manager.rb +1615 -0
- data/lib/little_ghost/support/callbacks.rb +151 -0
- data/lib/little_ghost/support/cancellation_token.rb +86 -0
- data/lib/little_ghost/support/class_attributes.rb +40 -0
- data/lib/little_ghost/support/content_capture.rb +150 -0
- data/lib/little_ghost/support/executor.rb +75 -0
- data/lib/little_ghost/support/interruptible_stream.rb +103 -0
- data/lib/little_ghost/support/loader.rb +263 -0
- data/lib/little_ghost/support/output_truncation.rb +71 -0
- data/lib/little_ghost/support/redactor.rb +66 -0
- data/lib/little_ghost/support.rb +34 -0
- data/lib/little_ghost/tool.rb +448 -0
- data/lib/little_ghost/tool_execution.rb +59 -0
- data/lib/little_ghost/tool_registry.rb +156 -0
- data/lib/little_ghost/tools/filesystem.rb +119 -0
- data/lib/little_ghost/tools/shell.rb +45 -0
- data/lib/little_ghost/tools/write_todos.rb +91 -0
- data/lib/little_ghost/tools.rb +6 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
- data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
- data/lib/little_ghost/usage.rb +47 -0
- data/lib/little_ghost/version.rb +6 -0
- data/lib/little_ghost/workflow.rb +351 -0
- data/lib/little_ghost/workspace.rb +31 -0
- data/lib/little_ghost.rb +120 -0
- metadata +225 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LittleGhost
|
|
6
|
+
# Provider adapters translate model APIs into LittleGhost's shared streaming
|
|
7
|
+
# request and response types. Agents select them through a ModelRegistry rather
|
|
8
|
+
# than depending on a provider class directly.
|
|
9
|
+
module Providers
|
|
10
|
+
# Bedrock lets LittleGhost agents use models available through Amazon Bedrock
|
|
11
|
+
# Converse. Its output follows the same streaming events as every other
|
|
12
|
+
# LittleGhost provider.
|
|
13
|
+
#
|
|
14
|
+
# provider = LittleGhost::Providers::Bedrock.new(
|
|
15
|
+
# model: ENV.fetch("BEDROCK_MODEL_ID"),
|
|
16
|
+
# region: ENV.fetch("AWS_REGION")
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# The default client requires the optional +aws-sdk-bedrockruntime+ gem and
|
|
20
|
+
# uses the AWS SDK credential chain. Applications may inject +client+
|
|
21
|
+
# instead.
|
|
22
|
+
#
|
|
23
|
+
# Transient service and stream failures retry with exponential backoff. Each
|
|
24
|
+
# retry emits +:model_retry+ and reports whether partial text was already
|
|
25
|
+
# emitted, allowing stream consumers to handle repeated output deliberately.
|
|
26
|
+
class Bedrock
|
|
27
|
+
INITIAL_RETRY_DELAY = 1 # :nodoc:
|
|
28
|
+
MAX_RETRY_DELAY = 16 # :nodoc:
|
|
29
|
+
TRANSIENT_STREAM_ERRORS = %w[
|
|
30
|
+
internal_server_exception model_stream_error_exception service_unavailable_exception throttling_exception
|
|
31
|
+
].freeze # :nodoc:
|
|
32
|
+
CONTEXT_OVERFLOW_MARKERS = [
|
|
33
|
+
"context window", "maximum context length", "max context length",
|
|
34
|
+
"input is too long", "too many input tokens"
|
|
35
|
+
].freeze # :nodoc:
|
|
36
|
+
|
|
37
|
+
# Represents an error event returned inside a Bedrock stream.
|
|
38
|
+
class StreamError < ProviderError
|
|
39
|
+
# Normalized Bedrock event type used to decide whether a retry is safe.
|
|
40
|
+
attr_reader :event_type
|
|
41
|
+
|
|
42
|
+
# Creates a stream error for +event_type+.
|
|
43
|
+
def initialize(message, event_type:)
|
|
44
|
+
@event_type = event_type.to_s
|
|
45
|
+
super(message)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Indicates whether LittleGhost may retry this Bedrock event.
|
|
49
|
+
def retryable? = TRANSIENT_STREAM_ERRORS.include?(event_type)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Bedrock model identifier used for requests.
|
|
53
|
+
attr_reader :model
|
|
54
|
+
|
|
55
|
+
# Configures Bedrock for +model+.
|
|
56
|
+
#
|
|
57
|
+
# +region+ and remaining +client_options+ configure the default AWS client.
|
|
58
|
+
# +max_retries+, +sleeper+, and +on_retry+ control retry behavior. Injecting
|
|
59
|
+
# +client+ bypasses creation of the optional SDK client.
|
|
60
|
+
def initialize(model:, region: nil, client: nil, max_retries: 2, sleeper: nil,
|
|
61
|
+
on_retry: ->(*) {}, **client_options)
|
|
62
|
+
@model = model
|
|
63
|
+
@client = client || build_client(region:, **client_options)
|
|
64
|
+
@max_retries = Integer(max_retries)
|
|
65
|
+
@sleeper = sleeper
|
|
66
|
+
@on_retry = on_retry
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Streams LittleGhost StreamEvent objects for +request+.
|
|
70
|
+
#
|
|
71
|
+
# Without a block, returns an Enumerator. Context-window failures normalize
|
|
72
|
+
# to ContextWindowOverflowError and malformed tool calls normalize to
|
|
73
|
+
# MalformedToolCallError.
|
|
74
|
+
def stream(request)
|
|
75
|
+
return enum_for(__method__, request) unless block_given?
|
|
76
|
+
|
|
77
|
+
attempts = 0
|
|
78
|
+
|
|
79
|
+
begin
|
|
80
|
+
partial_text = false
|
|
81
|
+
request.cancellation_token.raise_if_cancelled!
|
|
82
|
+
normalizer = StreamNormalizer.new(model:)
|
|
83
|
+
stream = Support::InterruptibleStream.new(
|
|
84
|
+
cancellation_token: request.cancellation_token,
|
|
85
|
+
deadline: request.deadline
|
|
86
|
+
) do |emit|
|
|
87
|
+
response = @client.converse_stream(**request_parameters(request))
|
|
88
|
+
response.stream.each { |event| emit.call(event) }
|
|
89
|
+
end
|
|
90
|
+
stream.each do |event|
|
|
91
|
+
normalizer.consume(event_hash(event)).each do |normalized|
|
|
92
|
+
partial_text ||= normalized.type == :text_delta && !normalized.data[:text].to_s.empty?
|
|
93
|
+
yield normalized
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
normalizer.finish.each do |event|
|
|
97
|
+
partial_text ||= event.type == :text_delta && !event.data[:text].to_s.empty?
|
|
98
|
+
yield event
|
|
99
|
+
end
|
|
100
|
+
rescue CancelledError, DeadlineExceededError, CleanupError
|
|
101
|
+
raise
|
|
102
|
+
rescue => error
|
|
103
|
+
raise if error.is_a?(Error) && !error.is_a?(StreamError)
|
|
104
|
+
|
|
105
|
+
if context_window_overflow?(error)
|
|
106
|
+
raise ContextWindowOverflowError, "The model context window was exceeded"
|
|
107
|
+
end
|
|
108
|
+
raise provider_error(error) if !retryable?(error) || attempts >= @max_retries
|
|
109
|
+
|
|
110
|
+
attempts += 1
|
|
111
|
+
request.cancellation_token.raise_if_cancelled!
|
|
112
|
+
delay = capped_retry_delay(request, retry_delay(attempts))
|
|
113
|
+
@on_retry.call(attempts, error, delay)
|
|
114
|
+
wait_before_retry(request, delay)
|
|
115
|
+
yield StreamEvent.build(
|
|
116
|
+
:model_retry,
|
|
117
|
+
attempt: attempts,
|
|
118
|
+
delay:,
|
|
119
|
+
error_class: error.class.name,
|
|
120
|
+
error_code: (error.event_type if error.is_a?(StreamError)),
|
|
121
|
+
partial_text:
|
|
122
|
+
)
|
|
123
|
+
retry
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Reads capabilities from Bedrock +supported_parameters+ metadata. Missing
|
|
128
|
+
# metadata produces ModelCapabilities.unknown.
|
|
129
|
+
def capabilities(metadata: {})
|
|
130
|
+
parameters = metadata[:supported_parameters] || metadata["supported_parameters"]
|
|
131
|
+
return ModelCapabilities.unknown unless parameters.is_a?(Array)
|
|
132
|
+
|
|
133
|
+
values = parameters.map(&:to_s)
|
|
134
|
+
ModelCapabilities.new(
|
|
135
|
+
native_structured_output: values.include?("structured_outputs"),
|
|
136
|
+
tools: values.include?("tools"),
|
|
137
|
+
tool_choice: values.include?("tool_choice"),
|
|
138
|
+
supported_parameters: values
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def build_client(region:, **options)
|
|
145
|
+
require "aws-sdk-bedrockruntime"
|
|
146
|
+
Aws::BedrockRuntime::Client.new(**options, **({region:} if region))
|
|
147
|
+
rescue LoadError
|
|
148
|
+
raise ConfigurationError,
|
|
149
|
+
"Bedrock requires the optional aws-sdk-bedrockruntime gem; add it to your application's Gemfile"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def request_parameters(request)
|
|
153
|
+
reasoning_effort = request.settings[:reasoning_effort] || request.settings["reasoning_effort"]
|
|
154
|
+
if reasoning_effort && reasoning_effort.to_s != "none"
|
|
155
|
+
raise ConfigurationError, "Bedrock does not support reasoning_effort"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
system, messages = request.messages.partition { |message| message.role == :system }
|
|
159
|
+
parameters = {
|
|
160
|
+
model_id: model,
|
|
161
|
+
messages: messages.filter_map { |message| bedrock_message(message) }
|
|
162
|
+
}
|
|
163
|
+
parameters[:system] = system.flat_map { |message| message.content.grep(Content::Text).map { |block| {text: block.text} } }
|
|
164
|
+
unless request.tools.empty?
|
|
165
|
+
parameters[:tool_config] = {tools: request.tools.map { |tool| bedrock_tool(tool) }}
|
|
166
|
+
parameters[:tool_config][:tool_choice] = bedrock_tool_choice(request.tool_choice) if request.tool_choice
|
|
167
|
+
end
|
|
168
|
+
parameters[:output_config] = bedrock_output_config(request.output_schema) if request.output_schema
|
|
169
|
+
|
|
170
|
+
inference = extract_settings(request.settings, %i[max_tokens temperature top_p stop_sequences])
|
|
171
|
+
parameters[:inference_config] = inference unless inference.empty?
|
|
172
|
+
additional = request.settings[:additional_model_request_fields] || request.settings["additional_model_request_fields"]
|
|
173
|
+
parameters[:additional_model_request_fields] = additional if additional
|
|
174
|
+
parameters
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def bedrock_output_config(output_schema)
|
|
178
|
+
{
|
|
179
|
+
text_format: {
|
|
180
|
+
type: "json_schema",
|
|
181
|
+
structure: {
|
|
182
|
+
json_schema: {
|
|
183
|
+
schema: JSON.generate(bedrock_output_schema(output_schema.fetch(:schema))),
|
|
184
|
+
name: output_schema.fetch(:name),
|
|
185
|
+
description: output_schema[:description]
|
|
186
|
+
}.compact
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def bedrock_output_schema(value)
|
|
193
|
+
case value
|
|
194
|
+
when Hash
|
|
195
|
+
value.each_with_object({}) do |(key, child), result|
|
|
196
|
+
key = key.to_s
|
|
197
|
+
next if %w[minimum maximum minLength maxLength pattern maxItems].include?(key)
|
|
198
|
+
next if key == "minItems" && ![0, 1].include?(child)
|
|
199
|
+
|
|
200
|
+
result[key] = bedrock_output_schema(child)
|
|
201
|
+
end
|
|
202
|
+
when Array
|
|
203
|
+
value.map { |child| bedrock_output_schema(child) }
|
|
204
|
+
else
|
|
205
|
+
value
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def bedrock_message(message)
|
|
210
|
+
role = (message.role == :assistant) ? "assistant" : "user"
|
|
211
|
+
content = message.content.filter_map { |block| bedrock_content(block) }
|
|
212
|
+
{role:, content:} unless content.empty?
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def bedrock_content(block)
|
|
216
|
+
case block
|
|
217
|
+
when Content::Text
|
|
218
|
+
{text: block.text}
|
|
219
|
+
when Content::Reasoning
|
|
220
|
+
if block.redacted_content
|
|
221
|
+
{reasoning_content: {redacted_content: block.redacted_content}}
|
|
222
|
+
elsif !block.text.empty?
|
|
223
|
+
reasoning_text = {text: block.text}
|
|
224
|
+
reasoning_text[:signature] = block.signature unless block.signature.to_s.empty?
|
|
225
|
+
{reasoning_content: {reasoning_text:}}
|
|
226
|
+
end
|
|
227
|
+
when Content::Image
|
|
228
|
+
{image: {format: image_format(block.media_type), source: {bytes: block.data}}}
|
|
229
|
+
when Content::Document
|
|
230
|
+
{document: {format: document_format(block.media_type, block.name), name: block.name, source: {bytes: block.data}}}
|
|
231
|
+
when Content::ToolUse
|
|
232
|
+
{tool_use: {tool_use_id: block.id, name: block.name, input: block.input}}
|
|
233
|
+
when Content::ToolResult
|
|
234
|
+
{
|
|
235
|
+
tool_result: {
|
|
236
|
+
tool_use_id: block.tool_use_id,
|
|
237
|
+
content: Array(block.content).map { |content| {text: content.respond_to?(:text) ? content.text : content.to_s} },
|
|
238
|
+
status: block.status.to_s
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else
|
|
242
|
+
raise ConfigurationError, "Unsupported Bedrock content block: #{block.class}"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def bedrock_tool(tool)
|
|
247
|
+
definition = if tool.is_a?(Hash)
|
|
248
|
+
tool.transform_keys(&:to_sym)
|
|
249
|
+
else
|
|
250
|
+
{name: tool.public_send(:name), description: tool.public_send(:description), input_schema: tool.public_send(:input_schema)}
|
|
251
|
+
end
|
|
252
|
+
tool_spec = {
|
|
253
|
+
name: definition.fetch(:name),
|
|
254
|
+
description: definition[:description],
|
|
255
|
+
input_schema: {json: definition[:input_schema] || {}}
|
|
256
|
+
}
|
|
257
|
+
tool_spec[:strict] = definition[:strict] unless definition[:strict].nil?
|
|
258
|
+
{
|
|
259
|
+
tool_spec: {
|
|
260
|
+
**tool_spec
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def bedrock_tool_choice(choice)
|
|
266
|
+
return {any: {}} if choice == :required
|
|
267
|
+
|
|
268
|
+
{tool: {name: choice.fetch(:name).to_s}}
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def extract_settings(settings, keys)
|
|
272
|
+
keys.each_with_object({}) do |key, result|
|
|
273
|
+
value = settings[key] || settings[key.to_s]
|
|
274
|
+
result[key] = value unless value.nil?
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def image_format(media_type)
|
|
279
|
+
format = {"image/jpeg" => "jpeg", "image/png" => "png", "image/gif" => "gif", "image/webp" => "webp"}[media_type.to_s.downcase]
|
|
280
|
+
return format if format
|
|
281
|
+
|
|
282
|
+
raise ConfigurationError, "Unsupported Bedrock image media type: #{media_type}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def document_format(media_type, name)
|
|
286
|
+
format = {
|
|
287
|
+
"application/pdf" => "pdf",
|
|
288
|
+
"text/csv" => "csv",
|
|
289
|
+
"application/msword" => "doc",
|
|
290
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx",
|
|
291
|
+
"application/vnd.ms-excel" => "xls",
|
|
292
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx",
|
|
293
|
+
"text/html" => "html",
|
|
294
|
+
"text/plain" => "txt",
|
|
295
|
+
"text/markdown" => "md"
|
|
296
|
+
}[media_type.to_s.downcase]
|
|
297
|
+
format ||= File.extname(name.to_s).delete_prefix(".").downcase
|
|
298
|
+
return format if %w[pdf csv doc docx xls xlsx html txt md].include?(format)
|
|
299
|
+
|
|
300
|
+
raise ConfigurationError, "Unsupported Bedrock document media type: #{media_type}"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def event_hash(event)
|
|
304
|
+
value = event.respond_to?(:to_h) ? event.to_h : event
|
|
305
|
+
deep_stringify(value)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def deep_stringify(value)
|
|
309
|
+
case value
|
|
310
|
+
when Hash then value.to_h { |key, child| [key.to_s, deep_stringify(child)] }
|
|
311
|
+
when Array then value.map { |child| deep_stringify(child) }
|
|
312
|
+
else value
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def retryable?(error)
|
|
317
|
+
return error.retryable? if error.is_a?(StreamError)
|
|
318
|
+
|
|
319
|
+
name = error.class.name
|
|
320
|
+
name.match?(/Timeout|ServiceUnavailable|InternalServer|Connection|Networking/)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def context_window_overflow?(error)
|
|
324
|
+
message = error.message.to_s.downcase
|
|
325
|
+
CONTEXT_OVERFLOW_MARKERS.any? { |marker| message.include?(marker) }
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def retry_delay(attempt)
|
|
329
|
+
[INITIAL_RETRY_DELAY * (2**(attempt - 1)), MAX_RETRY_DELAY].min
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def capped_retry_delay(request, delay)
|
|
333
|
+
return delay unless request.deadline
|
|
334
|
+
|
|
335
|
+
remaining = request.deadline - Time.now
|
|
336
|
+
raise DeadlineExceededError, "The run deadline was reached" unless remaining.positive?
|
|
337
|
+
|
|
338
|
+
[delay, remaining].min
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def wait_before_retry(request, delay)
|
|
342
|
+
request.cancellation_token.raise_if_cancelled!
|
|
343
|
+
delay = capped_retry_delay(request, delay)
|
|
344
|
+
@sleeper ? @sleeper.call(delay) : request.cancellation_token.wait(delay)
|
|
345
|
+
request.cancellation_token.raise_if_cancelled!
|
|
346
|
+
raise DeadlineExceededError, "The run deadline was reached" if request.deadline && Time.now >= request.deadline
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def provider_error(error)
|
|
350
|
+
return error if error.is_a?(ProviderError)
|
|
351
|
+
|
|
352
|
+
ProviderError.new("Bedrock request failed: #{error.message}")
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
class StreamNormalizer # :nodoc:
|
|
356
|
+
def initialize(model:)
|
|
357
|
+
@model = model
|
|
358
|
+
@message_id = nil
|
|
359
|
+
@text = +""
|
|
360
|
+
@reasoning_blocks = {}
|
|
361
|
+
@tool_calls = {}
|
|
362
|
+
@usage = Usage.new
|
|
363
|
+
@stop_reason = nil
|
|
364
|
+
@finished = false
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def consume(event)
|
|
368
|
+
if event["event_type"]
|
|
369
|
+
type = event["event_type"].to_s
|
|
370
|
+
payload = event.except("event_type")
|
|
371
|
+
else
|
|
372
|
+
type, payload = event.first
|
|
373
|
+
end
|
|
374
|
+
case type
|
|
375
|
+
when "message_start"
|
|
376
|
+
@message_id = payload["role"]
|
|
377
|
+
[StreamEvent.build(:message_start, id: nil, model: @model)]
|
|
378
|
+
when "content_block_start"
|
|
379
|
+
content_start(payload)
|
|
380
|
+
when "content_block_delta"
|
|
381
|
+
content_delta(payload)
|
|
382
|
+
when "content_block_stop"
|
|
383
|
+
content_stop(payload)
|
|
384
|
+
when "message_stop"
|
|
385
|
+
@stop_reason = normalize_stop(payload["stop_reason"])
|
|
386
|
+
@terminal = true
|
|
387
|
+
[]
|
|
388
|
+
when "metadata"
|
|
389
|
+
metadata(payload)
|
|
390
|
+
when *TRANSIENT_STREAM_ERRORS, "validation_exception"
|
|
391
|
+
message = payload.is_a?(Hash) ? payload["message"].to_s : ""
|
|
392
|
+
message = "Bedrock returned #{type}" if message.empty?
|
|
393
|
+
raise StreamError.new(message, event_type: type)
|
|
394
|
+
else
|
|
395
|
+
[]
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def finish
|
|
400
|
+
return [] if @finished
|
|
401
|
+
raise ProtocolError, "Bedrock stream ended before message_stop" unless @terminal
|
|
402
|
+
|
|
403
|
+
@finished = true
|
|
404
|
+
blocks = []
|
|
405
|
+
@reasoning_blocks.sort.each do |_index, reasoning|
|
|
406
|
+
if !reasoning[:redacted_content].empty?
|
|
407
|
+
blocks << Content::Reasoning.new(redacted_content: reasoning[:redacted_content])
|
|
408
|
+
elsif !reasoning[:text].empty?
|
|
409
|
+
signature = reasoning[:signature]
|
|
410
|
+
blocks << Content::Reasoning.new(
|
|
411
|
+
text: reasoning[:text],
|
|
412
|
+
signature: signature.empty? ? nil : signature
|
|
413
|
+
)
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
blocks << Content::Text.new(text: @text) unless @text.empty?
|
|
417
|
+
@tool_calls.sort.each do |_index, tool|
|
|
418
|
+
input = tool[:arguments].empty? ? {} : JSON.parse(tool[:arguments])
|
|
419
|
+
blocks << Content::ToolUse.new(id: tool[:id], name: tool[:name], input:)
|
|
420
|
+
end
|
|
421
|
+
response = ModelResponse.new(
|
|
422
|
+
message: Message.new(role: :assistant, content: blocks),
|
|
423
|
+
stop_reason: @stop_reason || (@tool_calls.empty? ? :end_turn : :tool_use),
|
|
424
|
+
usage: @usage,
|
|
425
|
+
metadata: {model: @model}
|
|
426
|
+
)
|
|
427
|
+
[StreamEvent.build(:message_stop, response:)]
|
|
428
|
+
rescue JSON::ParserError, ArgumentError => error
|
|
429
|
+
raise MalformedToolCallError, "Bedrock returned an invalid tool call: #{error.message}"
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
private
|
|
433
|
+
|
|
434
|
+
def content_start(payload)
|
|
435
|
+
index = payload.fetch("content_block_index")
|
|
436
|
+
tool = payload.fetch("start", {})["tool_use"]
|
|
437
|
+
return [] unless tool
|
|
438
|
+
|
|
439
|
+
@tool_calls[index] = {id: tool["tool_use_id"], name: tool["name"], arguments: +""}
|
|
440
|
+
[StreamEvent.build(:tool_call_start, index:, id: tool["tool_use_id"], name: tool["name"])]
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def content_delta(payload)
|
|
444
|
+
index = payload.fetch("content_block_index")
|
|
445
|
+
delta = payload.fetch("delta")
|
|
446
|
+
if delta["text"]
|
|
447
|
+
@text << delta["text"]
|
|
448
|
+
[StreamEvent.build(:text_delta, text: delta["text"])]
|
|
449
|
+
elsif (reasoning_delta = delta["reasoning_content"])
|
|
450
|
+
reasoning = (@reasoning_blocks[index] ||= {
|
|
451
|
+
text: +"", signature: +"", redacted_content: String.new(encoding: Encoding::BINARY)
|
|
452
|
+
})
|
|
453
|
+
events = []
|
|
454
|
+
if (text = reasoning_delta["text"])
|
|
455
|
+
reasoning[:text] << text
|
|
456
|
+
events << StreamEvent.build(:reasoning_delta, text:)
|
|
457
|
+
end
|
|
458
|
+
reasoning[:signature] << reasoning_delta["signature"] if reasoning_delta["signature"]
|
|
459
|
+
reasoning[:redacted_content] << reasoning_delta["redacted_content"] if reasoning_delta["redacted_content"]
|
|
460
|
+
events
|
|
461
|
+
elsif delta["tool_use"]
|
|
462
|
+
arguments = delta.dig("tool_use", "input") || ""
|
|
463
|
+
@tool_calls.fetch(index)[:arguments] << arguments
|
|
464
|
+
[StreamEvent.build(:tool_call_delta, index:, arguments:)]
|
|
465
|
+
else
|
|
466
|
+
[]
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def content_stop(payload)
|
|
471
|
+
index = payload.fetch("content_block_index")
|
|
472
|
+
tool = @tool_calls[index]
|
|
473
|
+
return [] unless tool
|
|
474
|
+
|
|
475
|
+
input = tool[:arguments].empty? ? {} : JSON.parse(tool[:arguments])
|
|
476
|
+
use = Content::ToolUse.new(id: tool[:id], name: tool[:name], input:)
|
|
477
|
+
[StreamEvent.build(:tool_call_stop, index:, tool_use: use)]
|
|
478
|
+
rescue JSON::ParserError, ArgumentError => error
|
|
479
|
+
raise MalformedToolCallError, "Bedrock returned an invalid tool call: #{error.message}"
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def metadata(payload)
|
|
483
|
+
value = payload["usage"] || {}
|
|
484
|
+
cache_read = Integer(value["cache_read_input_tokens"] || 0)
|
|
485
|
+
cache_write = Integer(value["cache_write_input_tokens"] || 0)
|
|
486
|
+
@usage = Usage.new(
|
|
487
|
+
input_tokens: [Integer(value["input_tokens"] || 0) - cache_read - cache_write, 0].max,
|
|
488
|
+
output_tokens: value["output_tokens"],
|
|
489
|
+
cache_read_tokens: cache_read,
|
|
490
|
+
cache_write_tokens: cache_write
|
|
491
|
+
)
|
|
492
|
+
[StreamEvent.build(:usage, usage: @usage)]
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def normalize_stop(value)
|
|
496
|
+
case value
|
|
497
|
+
when "tool_use" then :tool_use
|
|
498
|
+
when "max_tokens" then :max_tokens
|
|
499
|
+
when "guardrail_intervened", "content_filtered" then :content_filter
|
|
500
|
+
else :end_turn
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module LittleGhost
|
|
8
|
+
module Providers
|
|
9
|
+
# Reports an HTTP or network failure from a provider transport.
|
|
10
|
+
class HTTPError < ProviderError
|
|
11
|
+
# HTTP status and bounded response body, when the server supplied them.
|
|
12
|
+
attr_reader :status, :body
|
|
13
|
+
|
|
14
|
+
# Creates an error with optional response details.
|
|
15
|
+
def initialize(message, status: nil, body: nil)
|
|
16
|
+
@status = status
|
|
17
|
+
@body = body
|
|
18
|
+
super(message)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Indicates whether a provider client may retry the request.
|
|
22
|
+
def retryable?
|
|
23
|
+
status.nil? || status == 408 || status == 409 || status == 429 || status >= 500
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# HTTPTransport gives provider clients a shared, bounded streaming HTTP layer.
|
|
28
|
+
# It applies cancellation, deadlines, timeouts, and response-size limits while
|
|
29
|
+
# yielding response chunks as they arrive.
|
|
30
|
+
#
|
|
31
|
+
# === Security and trust
|
|
32
|
+
#
|
|
33
|
+
# HTTPS is required by default. Enabling +allow_insecure_http+ can expose API
|
|
34
|
+
# keys and model content in transit; use it only with a trusted local
|
|
35
|
+
# development endpoint.
|
|
36
|
+
class HTTPTransport
|
|
37
|
+
# Default upper bound for a complete provider response (50 MiB).
|
|
38
|
+
DEFAULT_MAX_RESPONSE_BYTES = 50 * 1024 * 1024
|
|
39
|
+
DEFAULT_MAX_ERROR_BODY_BYTES = 4 * 1024 # :nodoc:
|
|
40
|
+
TRANSIENT_NETWORK_ERRORS = [
|
|
41
|
+
Net::OpenTimeout,
|
|
42
|
+
Net::ReadTimeout,
|
|
43
|
+
Net::WriteTimeout,
|
|
44
|
+
EOFError,
|
|
45
|
+
SocketError,
|
|
46
|
+
SystemCallError,
|
|
47
|
+
IOError,
|
|
48
|
+
OpenSSL::SSL::SSLError,
|
|
49
|
+
Net::ProtocolError,
|
|
50
|
+
Net::HTTPBadResponse,
|
|
51
|
+
Net::HTTPHeaderSyntaxError
|
|
52
|
+
].freeze # :nodoc:
|
|
53
|
+
|
|
54
|
+
# Configures +base_url+ with connection, read, and response
|
|
55
|
+
# size limits.
|
|
56
|
+
def initialize(
|
|
57
|
+
base_url:,
|
|
58
|
+
open_timeout:,
|
|
59
|
+
read_timeout:,
|
|
60
|
+
allow_insecure_http: false,
|
|
61
|
+
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
|
|
62
|
+
max_error_body_bytes: DEFAULT_MAX_ERROR_BODY_BYTES
|
|
63
|
+
)
|
|
64
|
+
@base_url = URI(base_url.end_with?("/") ? base_url : "#{base_url}/")
|
|
65
|
+
unless %w[http https].include?(@base_url.scheme) && @base_url.host
|
|
66
|
+
raise ConfigurationError, "Provider base_url must be an HTTP(S) URL"
|
|
67
|
+
end
|
|
68
|
+
if @base_url.scheme == "http" && !allow_insecure_http
|
|
69
|
+
raise ConfigurationError, "Provider base_url must use HTTPS unless allow_insecure_http is enabled"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
@open_timeout = open_timeout
|
|
73
|
+
@read_timeout = read_timeout
|
|
74
|
+
@max_response_bytes = positive_integer(max_response_bytes, :max_response_bytes)
|
|
75
|
+
@max_error_body_bytes = positive_integer(max_error_body_bytes, :max_error_body_bytes)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Posts +body+ and yields response chunks until completion.
|
|
79
|
+
#
|
|
80
|
+
# Cancellation and +deadline+ interrupt the request. Without a block, this
|
|
81
|
+
# method returns an Enumerator.
|
|
82
|
+
def stream(path:, headers:, body:, cancellation_token:, deadline: nil)
|
|
83
|
+
return enum_for(__method__, path:, headers:, body:, cancellation_token:, deadline:) unless block_given?
|
|
84
|
+
|
|
85
|
+
stream = Support::InterruptibleStream.new(cancellation_token:, deadline:) do |emit|
|
|
86
|
+
uri = URI.join(@base_url.to_s, path.sub(%r{\A/}, ""))
|
|
87
|
+
request = Net::HTTP::Post.new(uri)
|
|
88
|
+
headers.each { |name, value| request[name] = value }
|
|
89
|
+
request.body = body
|
|
90
|
+
|
|
91
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
92
|
+
http.use_ssl = uri.scheme == "https"
|
|
93
|
+
http.open_timeout = remaining_timeout(deadline, @open_timeout)
|
|
94
|
+
http.read_timeout = remaining_timeout(deadline, @read_timeout)
|
|
95
|
+
http.write_timeout = remaining_timeout(deadline, @read_timeout)
|
|
96
|
+
|
|
97
|
+
http.request(request) do |response|
|
|
98
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
99
|
+
response_body = read_limited(response, @max_error_body_bytes)
|
|
100
|
+
raise HTTPError.new(
|
|
101
|
+
"Provider request failed with HTTP #{response.code}",
|
|
102
|
+
status: response.code.to_i,
|
|
103
|
+
body: response_body
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
bytes_read = 0
|
|
108
|
+
response.read_body do |chunk|
|
|
109
|
+
bytes_read += chunk.bytesize
|
|
110
|
+
raise ProtocolError, "Provider response exceeded #{@max_response_bytes} bytes" if bytes_read > @max_response_bytes
|
|
111
|
+
|
|
112
|
+
emit.call(chunk)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
rescue *TRANSIENT_NETWORK_ERRORS => error
|
|
116
|
+
raise HTTPError, "Provider request failed (#{error.class})"
|
|
117
|
+
end
|
|
118
|
+
stream.each { |chunk| yield chunk }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def remaining_timeout(deadline, maximum)
|
|
124
|
+
return maximum unless deadline
|
|
125
|
+
|
|
126
|
+
remaining = deadline - Time.now
|
|
127
|
+
raise DeadlineExceededError, "The run deadline was reached" unless remaining.positive?
|
|
128
|
+
|
|
129
|
+
[remaining, maximum].min
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def read_limited(response, limit)
|
|
133
|
+
body = +""
|
|
134
|
+
response.read_body do |chunk|
|
|
135
|
+
remaining = limit - body.bytesize
|
|
136
|
+
body << chunk.byteslice(0, remaining) if remaining.positive?
|
|
137
|
+
end
|
|
138
|
+
body
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def positive_integer(value, name)
|
|
142
|
+
integer = Integer(value)
|
|
143
|
+
raise ArgumentError, "#{name} must be positive" unless integer.positive?
|
|
144
|
+
|
|
145
|
+
integer
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|