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.
Files changed (82) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +122 -0
  4. data/docs/guides/Core Concepts.md +203 -0
  5. data/docs/guides/Getting Started.md +187 -0
  6. data/lib/little_ghost/ag_ui/adapter.rb +194 -0
  7. data/lib/little_ghost/ag_ui.rb +5 -0
  8. data/lib/little_ghost/agent/context_management.rb +285 -0
  9. data/lib/little_ghost/agent/delegation.rb +128 -0
  10. data/lib/little_ghost/agent/skills.rb +96 -0
  11. data/lib/little_ghost/agent/tool_loop.rb +239 -0
  12. data/lib/little_ghost/agent.rb +2111 -0
  13. data/lib/little_ghost/agent_builder.rb +191 -0
  14. data/lib/little_ghost/agent_interruptions.rb +197 -0
  15. data/lib/little_ghost/configuration.rb +337 -0
  16. data/lib/little_ghost/content.rb +324 -0
  17. data/lib/little_ghost/default_model_registry.rb +71 -0
  18. data/lib/little_ghost/errors.rb +48 -0
  19. data/lib/little_ghost/events.rb +264 -0
  20. data/lib/little_ghost/execution_state.rb +58 -0
  21. data/lib/little_ghost/instrumentation.rb +475 -0
  22. data/lib/little_ghost/invocation.rb +285 -0
  23. data/lib/little_ghost/lookup.rb +37 -0
  24. data/lib/little_ghost/mcp/client.rb +396 -0
  25. data/lib/little_ghost/mcp.rb +5 -0
  26. data/lib/little_ghost/message.rb +75 -0
  27. data/lib/little_ghost/model.rb +88 -0
  28. data/lib/little_ghost/model_capabilities.rb +126 -0
  29. data/lib/little_ghost/model_registry.rb +173 -0
  30. data/lib/little_ghost/model_request.rb +107 -0
  31. data/lib/little_ghost/model_response.rb +48 -0
  32. data/lib/little_ghost/path_set.rb +32 -0
  33. data/lib/little_ghost/prompt_resolver.rb +251 -0
  34. data/lib/little_ghost/providers/bedrock.rb +506 -0
  35. data/lib/little_ghost/providers/http_transport.rb +149 -0
  36. data/lib/little_ghost/providers/open_router.rb +171 -0
  37. data/lib/little_ghost/providers/openai.rb +27 -0
  38. data/lib/little_ghost/providers/openai_compatible.rb +745 -0
  39. data/lib/little_ghost/providers/sse_parser.rb +35 -0
  40. data/lib/little_ghost/run.rb +607 -0
  41. data/lib/little_ghost/run_context.rb +129 -0
  42. data/lib/little_ghost/run_result.rb +111 -0
  43. data/lib/little_ghost/runtime/hook.rb +31 -0
  44. data/lib/little_ghost/runtime.rb +392 -0
  45. data/lib/little_ghost/sandbox.rb +138 -0
  46. data/lib/little_ghost/session.rb +229 -0
  47. data/lib/little_ghost/session_store.rb +96 -0
  48. data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
  49. data/lib/little_ghost/session_stores/memory.rb +86 -0
  50. data/lib/little_ghost/skills/catalog.rb +283 -0
  51. data/lib/little_ghost/skills/skill.rb +60 -0
  52. data/lib/little_ghost/skills.rb +4 -0
  53. data/lib/little_ghost/stream_event.rb +49 -0
  54. data/lib/little_ghost/structured_output.rb +126 -0
  55. data/lib/little_ghost/subagents/agent_path.rb +63 -0
  56. data/lib/little_ghost/subagents/definition.rb +42 -0
  57. data/lib/little_ghost/subagents/manager.rb +1615 -0
  58. data/lib/little_ghost/support/callbacks.rb +151 -0
  59. data/lib/little_ghost/support/cancellation_token.rb +86 -0
  60. data/lib/little_ghost/support/class_attributes.rb +40 -0
  61. data/lib/little_ghost/support/content_capture.rb +150 -0
  62. data/lib/little_ghost/support/executor.rb +75 -0
  63. data/lib/little_ghost/support/interruptible_stream.rb +103 -0
  64. data/lib/little_ghost/support/loader.rb +263 -0
  65. data/lib/little_ghost/support/output_truncation.rb +71 -0
  66. data/lib/little_ghost/support/redactor.rb +66 -0
  67. data/lib/little_ghost/support.rb +34 -0
  68. data/lib/little_ghost/tool.rb +448 -0
  69. data/lib/little_ghost/tool_execution.rb +59 -0
  70. data/lib/little_ghost/tool_registry.rb +156 -0
  71. data/lib/little_ghost/tools/filesystem.rb +119 -0
  72. data/lib/little_ghost/tools/shell.rb +45 -0
  73. data/lib/little_ghost/tools/write_todos.rb +91 -0
  74. data/lib/little_ghost/tools.rb +6 -0
  75. data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
  76. data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
  77. data/lib/little_ghost/usage.rb +47 -0
  78. data/lib/little_ghost/version.rb +6 -0
  79. data/lib/little_ghost/workflow.rb +351 -0
  80. data/lib/little_ghost/workspace.rb +31 -0
  81. data/lib/little_ghost.rb +120 -0
  82. 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