little_ghost 0.5.0 → 0.7.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/docs/guides/code_mode.md +6 -3
  4. data/docs/guides/integrations.md +4 -4
  5. data/docs/guides/models_and_providers.md +91 -0
  6. data/docs/guides/production.md +114 -7
  7. data/docs/guides/tools.md +26 -0
  8. data/lib/little_ghost/agent.rb +5 -5
  9. data/lib/little_ghost/assembly.rb +9 -11
  10. data/lib/little_ghost/assembly_execution.rb +8 -17
  11. data/lib/little_ghost/code_mode/broker.rb +4 -0
  12. data/lib/little_ghost/code_mode/javascript/session.rb +23 -24
  13. data/lib/little_ghost/code_mode/ruby/host.rb +50 -10
  14. data/lib/little_ghost/code_mode/ruby/session.rb +22 -18
  15. data/lib/little_ghost/code_mode/ruby_engine.rb +1 -0
  16. data/lib/little_ghost/configuration.rb +101 -9
  17. data/lib/little_ghost/embeddings/request.rb +103 -0
  18. data/lib/little_ghost/embeddings/response.rb +55 -0
  19. data/lib/little_ghost/errors.rb +2 -0
  20. data/lib/little_ghost/execution.rb +31 -25
  21. data/lib/little_ghost/execution_state.rb +3 -3
  22. data/lib/little_ghost/graph.rb +6 -3
  23. data/lib/little_ghost/instrumentation.rb +2 -1
  24. data/lib/little_ghost/mcp/toolset.rb +1 -1
  25. data/lib/little_ghost/model.rb +20 -0
  26. data/lib/little_ghost/model_operations.rb +210 -0
  27. data/lib/little_ghost/network/certificate_authority.rb +6 -0
  28. data/lib/little_ghost/network/envoy_gateway.rb +11 -2
  29. data/lib/little_ghost/providers/base.rb +16 -1
  30. data/lib/little_ghost/providers/bedrock/aws_protocol.rb +1 -1
  31. data/lib/little_ghost/providers/bedrock/http_client.rb +28 -7
  32. data/lib/little_ghost/providers/bedrock.rb +93 -8
  33. data/lib/little_ghost/providers/openai.rb +80 -4
  34. data/lib/little_ghost/run.rb +9 -15
  35. data/lib/little_ghost/run_context.rb +2 -1
  36. data/lib/little_ghost/runtime/hooks/artifacts.rb +8 -0
  37. data/lib/little_ghost/runtime.rb +35 -3
  38. data/lib/little_ghost/sandbox/process_runner.rb +1 -1
  39. data/lib/little_ghost/sandboxes/unrestricted.rb +1 -2
  40. data/lib/little_ghost/session_stores/filesystem.rb +29 -13
  41. data/lib/little_ghost/subagents/manager.rb +170 -70
  42. data/lib/little_ghost/support/cancellation_token.rb +2 -2
  43. data/lib/little_ghost/support/executor.rb +84 -15
  44. data/lib/little_ghost/support/http_client.rb +19 -5
  45. data/lib/little_ghost/support/interruptible_stream.rb +4 -2
  46. data/lib/little_ghost/support/pooled_thread_runner.rb +126 -0
  47. data/lib/little_ghost/support/serialized_dispatcher.rb +84 -0
  48. data/lib/little_ghost/support/task.rb +126 -0
  49. data/lib/little_ghost/support/task_runner.rb +74 -0
  50. data/lib/little_ghost/support.rb +4 -0
  51. data/lib/little_ghost/tracing/open_telemetry.rb +6 -4
  52. data/lib/little_ghost/version.rb +1 -1
  53. data/lib/little_ghost/workflow.rb +3 -3
  54. data/lib/little_ghost.rb +42 -0
  55. metadata +27 -6
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LittleGhost
4
- # Runs one dormant Run in a supervised worker while the caller remains free to
5
- # serve health checks, deliver interjections, or coordinate process shutdown.
4
+ # Runs one dormant Run in the background while the caller remains free to
5
+ # serve health checks, deliver interjections, or coordinate shutdown.
6
6
  #
7
7
  # execution = agent.start_execution(message: "Investigate transfer 481") do |event|
8
8
  # event_buffer << event
@@ -12,21 +12,24 @@ module LittleGhost
12
12
  # execution.wait(deadline: Time.now + 30)
13
13
  # execution.run.completed? # => true
14
14
  #
15
- # An execution owns its worker. The worker receives a snapshot of the caller's
16
- # request-scoped ExecutionState. The Run continues to own its workspace,
15
+ # The Runtime selects a scheduled fiber or worker thread for the execution.
16
+ # LittleGhost copies the caller's ExecutionState, but not other application
17
+ # fiber-local or thread-local values. The Run continues to own its workspace,
17
18
  # sandbox, session, entrypoint, and registered resources. +close+ requests
18
- # cooperative cancellation and waits for both the worker and in-flight
19
+ # cooperative cancellation and waits for the execution and any in-flight
19
20
  # interjection calls.
20
21
  class Execution
21
22
  # The supervised Run.
22
23
  attr_reader :run
23
24
 
24
25
  class << self
25
- # Starts +run+ immediately and returns its supervising Execution.
26
+ # Starts +run+ immediately and returns its supervising Execution. If the
27
+ # work cannot start, this method closes +run+ before raising.
26
28
  #
27
- # The optional block receives each StreamEvent on the worker thread. It
28
- # must be safe to call from that thread and should not retain sensitive
29
- # event content longer than the application requires.
29
+ # The optional block receives each StreamEvent from the fiber or thread
30
+ # running the Execution. It must not depend on a particular thread and
31
+ # should not pause the scheduler or retain sensitive event content longer
32
+ # than the application requires.
30
33
  def start(run, &event_consumer)
31
34
  new(run, event_consumer:).send(:start)
32
35
  end
@@ -41,12 +44,11 @@ module LittleGhost
41
44
  @run = run
42
45
  @event_consumer = event_consumer
43
46
  @state = :pending
44
- @error = nil
45
47
  @mutex = Mutex.new
46
48
  @condition = ConditionVariable.new
47
49
  @active_interjections = 0
48
50
  @closing = false
49
- @execution_state = ExecutionState.capture
51
+ @task_runner = run.runtime.task_runner
50
52
  end
51
53
 
52
54
  # Returns +:pending+, +:running+, or +:finished+.
@@ -54,17 +56,17 @@ module LittleGhost
54
56
  @mutex.synchronize { @state }
55
57
  end
56
58
 
57
- # Returns an event-delivery or cleanup exception raised by the worker.
59
+ # Returns an event-delivery or cleanup exception raised by the Execution.
58
60
  def error
59
- @mutex.synchronize { @error }
61
+ @mutex.synchronize { @worker }&.error
60
62
  end
61
63
 
62
- # Indicates that the worker or an interjection call is still active.
64
+ # Indicates that the Execution or an interjection call is still active.
63
65
  def active?
64
66
  @mutex.synchronize { @state != :finished || @active_interjections.positive? }
65
67
  end
66
68
 
67
- # Indicates that the worker and all interjection calls have finished.
69
+ # Indicates that the Execution and all interjection calls have finished.
68
70
  def finished?
69
71
  !active?
70
72
  end
@@ -97,15 +99,15 @@ module LittleGhost
97
99
  self
98
100
  end
99
101
 
100
- # Waits for the worker and in-flight interjections, then returns the Run.
102
+ # Waits for the Execution and in-flight interjections, then returns the Run.
101
103
  #
102
104
  # +deadline+ is an absolute Time. Reaching it raises DeadlineExceededError
103
105
  # without cancelling the run. An event-delivery or cleanup failure raised by
104
- # the worker is re-raised after all supervised work finishes.
106
+ # the Execution is re-raised after all supervised work finishes.
105
107
  def wait(deadline: nil)
106
108
  worker = wait_until_finished(deadline:)
107
- worker.join
108
- caught = error
109
+ worker.wait
110
+ caught = worker.error
109
111
  raise caught if caught
110
112
 
111
113
  run
@@ -126,19 +128,23 @@ module LittleGhost
126
128
  raise Error, "execution has already started" unless @state == :pending
127
129
 
128
130
  @state = :running
129
- @worker = Thread.new { execute }
130
131
  end
132
+ worker = @task_runner.spawn { execute }
133
+ @mutex.synchronize { @worker = worker }
131
134
  self
135
+ rescue
136
+ @mutex.synchronize do
137
+ @state = :finished
138
+ @condition.broadcast
139
+ end
140
+ run.close
141
+ raise
132
142
  end
133
143
 
134
144
  private
135
145
 
136
146
  def execute
137
- ExecutionState.with(@execution_state) do
138
- run.each { |event| @event_consumer&.call(event) }
139
- end
140
- rescue => caught
141
- @mutex.synchronize { @error = caught }
147
+ run.each { |event| @event_consumer&.call(event) }
142
148
  ensure
143
149
  @mutex.synchronize do
144
150
  @state = :finished
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LittleGhost
4
- # ExecutionState carries request-scoped values across fibers and the worker
5
- # threads LittleGhost creates. It keeps event and instrumentation context from
6
- # leaking between concurrent runs.
4
+ # ExecutionState carries request-scoped values across scheduled fibers and
5
+ # worker threads. It keeps event and instrumentation context from leaking
6
+ # between concurrent runs.
7
7
  #
8
8
  # Framework extensions may use #capture and #with to preserve event and
9
9
  # instrumentation context. Captured hashes are immutable; values within them
@@ -808,9 +808,12 @@ module LittleGhost
808
808
  deadline:, settings:, template_locals:, template_paths:, parent_operation_id:, events:)
809
809
  token = cancellation_token.child
810
810
  queue = SizedQueue.new(1_000)
811
- worker = Thread.new do
811
+ worker = task_runner.spawn do
812
812
  completed = []
813
- results = Support::Executor.new(max_concurrency: fork.max_concurrency).map(
813
+ results = Support::Executor.new(
814
+ max_concurrency: fork.max_concurrency,
815
+ runner: task_runner
816
+ ).map(
814
817
  fork.to,
815
818
  cancellation_token: token,
816
819
  on_result: ->(_index, result) { completed << result }
@@ -840,7 +843,7 @@ module LittleGhost
840
843
  end
841
844
  ensure
842
845
  token&.cancel
843
- worker&.join
846
+ worker&.wait
844
847
  end
845
848
 
846
849
  def run_graph_branch(start:, source:, source_step_id:, join:, nodes:, edges:,
@@ -33,7 +33,8 @@ module LittleGhost
33
33
  # Override the callbacks a backend supports. +start+ and +finish+ receive the
34
34
  # same operation name and correlated attributes. +emit+ receives point
35
35
  # events. Implementations may return a propagation carrier from
36
- # #trace_context. Callbacks should be thread-safe.
36
+ # #trace_context. Calls may overlap on threads or fibers, so implementations
37
+ # must protect shared mutable state without relying on thread identity.
37
38
  class Subscriber
38
39
  # Called when a lifecycle operation starts.
39
40
  def start(_name, _attributes) = nil
@@ -111,7 +111,7 @@ module LittleGhost
111
111
  # Generates Tool classes for an Agent's current binding.
112
112
  def tools(binding)
113
113
  options = resolved_connection(binding)
114
- context = binding.run.context if binding.run&.respond_to?(:context)
114
+ context = binding.run&.context
115
115
  mapper = tool_mapping_value
116
116
  result_mapper = result_mapping_value
117
117
  options = connection_with_wrapped_signer(options)
@@ -76,6 +76,26 @@ module LittleGhost
76
76
  provider.stream(configured_request, &block)
77
77
  end
78
78
 
79
+ # Applies profile settings and executes an Embeddings::Request.
80
+ #
81
+ # Returns an Embeddings::Response with one vector per input. A provider that
82
+ # returns another shape raises ProtocolError.
83
+ def embed(request)
84
+ configured = Embeddings::Request.new(
85
+ inputs: request.inputs,
86
+ settings: settings.merge(request.settings),
87
+ limits: request.limits,
88
+ cancellation_token: request.cancellation_token,
89
+ deadline: request.deadline
90
+ )
91
+ response = provider.embed(configured)
92
+ unless response.is_a?(Embeddings::Response) && response.vectors.length == configured.inputs.length
93
+ raise ProtocolError, "Embedding provider returned an unexpected vector count"
94
+ end
95
+
96
+ response
97
+ end
98
+
79
99
  # Uses advertised provider capabilities.
80
100
  def capabilities
81
101
  @capabilities ||= provider.capabilities(metadata: details.attributes)
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LittleGhost
6
+ # Executes bounded model operations without creating an Agent or Run.
7
+ class ModelOperations # :nodoc:
8
+ MAX_STRUCTURED_RESULT_BYTES = 1_000_000
9
+ MAX_STRUCTURED_RESULT_DEPTH = 64
10
+ MAX_STRUCTURED_RESULT_NODES = 100_000
11
+ MAX_STRUCTURED_RESULT_REPAIR_ATTEMPTS = 3
12
+
13
+ def initialize(model_resolver:)
14
+ @model_resolver = model_resolver
15
+ end
16
+
17
+ def generate(model:, messages:, result_schema: nil, settings: {}, structured_result_repair_attempts: 1, cancellation_token: Support::CancellationToken.new, deadline: nil)
18
+ resolved = @model_resolver.resolve(model)
19
+ schema = normalize_schema(result_schema)
20
+ repair_attempts = normalize_repair_attempts(structured_result_repair_attempts) if schema
21
+ strategy = StructuredOutput.resolve(schema, model: resolved, ordinary_tools: []) if schema
22
+ handle = Instrumentation.start(:generation, model_provider: resolved.target.provider, model_id: resolved.model_id, model_role: resolved.role, structured: !schema.nil?)
23
+ usage = Usage.new
24
+ conversation = messages.map { |message| Message.coerce(message) }
25
+ response = complete(resolved, messages: conversation, settings:, schema:, strategy:, repair: false, cancellation_token:, deadline:)
26
+ usage += response.usage
27
+ output, errors = schema ? parse_structured_response(response.message, schema, strategy) : [response.message.text, []]
28
+ conversation << (schema ? redact_structured_response(response.message, schema, strategy) : response.message)
29
+ repairs_remaining = repair_attempts
30
+ while schema && !errors.empty? && repairs_remaining.positive?
31
+ conversation << structured_repair_message(response.message, strategy, errors:, repairs_remaining:)
32
+ response = complete(resolved, messages: conversation, settings:, schema:, strategy:, repair: true, cancellation_token:, deadline:)
33
+ usage += response.usage
34
+ output, errors = parse_structured_response(response.message, schema, strategy)
35
+ conversation << redact_structured_response(response.message, schema, strategy)
36
+ repairs_remaining -= 1
37
+ end
38
+ unless errors.empty?
39
+ raise StructuredResultError.new(
40
+ "The model did not return a valid structured result after its repair attempts",
41
+ schema_name: schema.fetch(:name), validation_errors: errors
42
+ )
43
+ end
44
+ handle.finish(outcome: :success, **usage_attributes(usage))
45
+ structured_result = StructuredResult.new(schema_name: schema.fetch(:name), value: output) if schema
46
+ final_message = schema ? conversation.last : response.message
47
+ RunResult.new(
48
+ message: final_message,
49
+ stop_reason: schema ? :structured_result : response.stop_reason,
50
+ usage:,
51
+ messages: conversation.freeze,
52
+ state: DataMap.new,
53
+ structured_result:,
54
+ steps: []
55
+ )
56
+ rescue => error
57
+ handle&.finish(outcome: :error, error_type: error.class.name) if handle&.active?
58
+ raise
59
+ end
60
+
61
+ def embed(model:, inputs:, settings: {}, limits: {}, cancellation_token: Support::CancellationToken.new, deadline: nil)
62
+ request = Embeddings::Request.new(inputs:, settings:, limits:, cancellation_token:, deadline:)
63
+ resolved = @model_resolver.resolve(model)
64
+ handle = Instrumentation.start(:embedding, model_provider: resolved.target.provider, model_id: resolved.model_id, model_role: resolved.role, input_count: request.inputs.length)
65
+ response = resolved.embed(request)
66
+ metadata = response.metadata.merge(provider: resolved.target.provider, model: resolved.model_id, model_role: resolved.role, input_count: request.inputs.length).compact
67
+ result = Embeddings::Response.new(vectors: response.vectors, usage: response.usage, metadata:)
68
+ handle.finish(outcome: :success, dimensions: result.dimensions, **usage_attributes(result.usage))
69
+ result
70
+ rescue => error
71
+ handle&.finish(outcome: :error, error_type: error.class.name) if handle&.active?
72
+ raise
73
+ end
74
+
75
+ private
76
+
77
+ def complete(model, messages:, settings:, schema:, strategy:, repair:, cancellation_token:, deadline:)
78
+ request = ModelRequest.new(
79
+ messages:, settings:,
80
+ tools: strategy ? strategy.tools([]) : [],
81
+ output_schema: strategy&.output_schema,
82
+ tool_choice: direct_generation_tool_choice(strategy, repair:),
83
+ required_capabilities: strategy ? strategy.required_capabilities : [],
84
+ cancellation_token:, deadline:
85
+ )
86
+ response = nil
87
+ model.stream(request) do |event|
88
+ response = event.data[:response] if event.type == :message_stop
89
+ end
90
+ response || raise(ProtocolError, "Provider stream ended without a response")
91
+ end
92
+
93
+ def normalize_schema(value)
94
+ return unless value
95
+ raise ArgumentError, "result_schema must be a mapping" unless value.respond_to?(:to_h)
96
+
97
+ schema = value.to_h.transform_keys(&:to_sym)
98
+ name = schema.fetch(:name).to_s
99
+ json_schema = schema.fetch(:schema)
100
+ Class.new(Agent).result_schema(
101
+ json_schema,
102
+ name:,
103
+ description: schema[:description],
104
+ strategy: :auto
105
+ ).except(:strategy).freeze
106
+ end
107
+
108
+ def parse_structured_response(message, schema, strategy)
109
+ return parse_structured(message.text, schema) if strategy.provider?
110
+
111
+ tool_uses = message.content.grep(Content::ToolUse)
112
+ result_tool_uses = tool_uses.select { |tool_use| tool_use.name == strategy.schema_name }
113
+ return [nil, ["The structured result tool was not called"]] if result_tool_uses.empty?
114
+ return [nil, ["The model called the structured result tool more than once"]] if result_tool_uses.length > 1
115
+ return [nil, ["The structured result tool must be the only tool call in its response"]] if tool_uses.length > 1
116
+
117
+ validate_structured_value(result_tool_uses.first.input, schema)
118
+ end
119
+
120
+ def parse_structured(text, schema)
121
+ raise StructuredResultError.new("Structured result exceeds the maximum serialized size", schema_name: schema.fetch(:name)) if text.bytesize > MAX_STRUCTURED_RESULT_BYTES
122
+ value = JSON.parse(text)
123
+ validate_structured_value(value, schema)
124
+ rescue JSON::ParserError
125
+ [nil, ["Structured result is not valid JSON"]]
126
+ rescue StructuredResultError => error
127
+ [nil, [error.message]]
128
+ end
129
+
130
+ def validate_structured_value(value, schema)
131
+ validate_complexity!(value, schema.fetch(:name))
132
+ errors = Tool::SchemaValidator.new(schema.fetch(:schema)).validate(value)
133
+ [value, errors]
134
+ rescue StructuredResultError => error
135
+ [nil, [error.message]]
136
+ end
137
+
138
+ def structured_repair_message(message, strategy, errors:, repairs_remaining:)
139
+ tool_uses = message.content.grep(Content::ToolUse)
140
+ feedback = "The structured result did not match the required schema: #{errors.join("; ")}. Submit it again using the required schema."
141
+ if strategy.tool? && !tool_uses.empty?
142
+ return Message.new(
143
+ role: :tool,
144
+ content: tool_uses.map do |tool_use|
145
+ Content::ToolResult.new(
146
+ tool_use_id: tool_use.id,
147
+ content: feedback,
148
+ status: :error
149
+ )
150
+ end
151
+ )
152
+ end
153
+
154
+ requirement = strategy.tool? ? "Call #{strategy.schema_name} exactly once as your only tool call." : "Return only JSON matching the configured output schema."
155
+ attempts_description = (repairs_remaining == 1) ? "one repair attempt" : "#{repairs_remaining} repair attempts"
156
+ Message.new(role: :user, content: "#{requirement} You have #{attempts_description} remaining. #{feedback}")
157
+ end
158
+
159
+ def direct_generation_tool_choice(strategy, repair:)
160
+ return unless strategy
161
+ return {name: strategy.schema_name}.freeze if strategy.tool?
162
+
163
+ strategy.tool_choice(repair:)
164
+ end
165
+
166
+ def normalize_repair_attempts(value)
167
+ return value if value.is_a?(Integer) && value.between?(0, MAX_STRUCTURED_RESULT_REPAIR_ATTEMPTS)
168
+
169
+ raise ArgumentError, "structured_result_repair_attempts must be an integer from zero through #{MAX_STRUCTURED_RESULT_REPAIR_ATTEMPTS}"
170
+ end
171
+
172
+ def validate_complexity!(value, schema_name)
173
+ nodes = 0
174
+ stack = [[value, 1]]
175
+ until stack.empty?
176
+ child, depth = stack.pop
177
+ nodes += 1
178
+ raise StructuredResultError.new("Structured result exceeds the maximum nesting depth", schema_name:) if depth > MAX_STRUCTURED_RESULT_DEPTH
179
+ raise StructuredResultError.new("Structured result exceeds the maximum complexity", schema_name:) if nodes > MAX_STRUCTURED_RESULT_NODES
180
+ child.each { |key, nested| stack << [key, depth + 1] << [nested, depth + 1] } if child.is_a?(Hash)
181
+ child.each { |nested| stack << [nested, depth + 1] } if child.is_a?(Array)
182
+ end
183
+ end
184
+
185
+ def redact_structured_message(message, schema)
186
+ Message.new(
187
+ role: message.role,
188
+ content: "[Structured result #{schema.fetch(:name)} redacted]",
189
+ metadata: message.metadata
190
+ )
191
+ end
192
+
193
+ def redact_structured_response(message, schema, strategy)
194
+ tool_uses = message.content.grep(Content::ToolUse)
195
+ return redact_structured_message(message, schema) unless strategy.tool? && !tool_uses.empty?
196
+
197
+ Message.new(
198
+ role: message.role,
199
+ content: tool_uses.map do |tool_use|
200
+ Content::ToolUse.new(id: tool_use.id, name: tool_use.name, input: {})
201
+ end,
202
+ metadata: message.metadata
203
+ )
204
+ end
205
+
206
+ def usage_attributes(usage)
207
+ usage.to_h.except(:total_tokens)
208
+ end
209
+ end
210
+ end
@@ -10,6 +10,12 @@ module LittleGhost
10
10
  module_function
11
11
 
12
12
  def create(root:, hosts:, trust_root: File.join(root, "trust"))
13
+ LittleGhost.offload_blocking do
14
+ create_material(root:, hosts:, trust_root:)
15
+ end
16
+ end
17
+
18
+ def create_material(root:, hosts:, trust_root:)
13
19
  Dir.mkdir(trust_root, 0o700) unless Dir.exist?(trust_root)
14
20
  ca_key = OpenSSL::PKey::RSA.new(3072)
15
21
  ca_certificate = certificate(
@@ -307,13 +307,22 @@ module LittleGhost
307
307
 
308
308
  Process.kill("TERM", -@envoy_pid)
309
309
  deadline = monotonic_time + 2
310
+ finished = nil
310
311
  until monotonic_time >= deadline
311
312
  finished = Process.waitpid(@envoy_pid, Process::WNOHANG)
312
313
  break if finished
313
314
  sleep(0.01)
314
315
  end
315
- Process.kill("KILL", -@envoy_pid)
316
- Process.waitpid(@envoy_pid)
316
+ unless finished
317
+ Process.kill("KILL", -@envoy_pid)
318
+ deadline = monotonic_time + 2
319
+ until Process.waitpid(@envoy_pid, Process::WNOHANG)
320
+ if monotonic_time >= deadline
321
+ raise CleanupError, "Envoy did not exit after it was killed"
322
+ end
323
+ sleep(0.01)
324
+ end
325
+ end
317
326
  rescue Errno::ESRCH, Errno::ECHILD
318
327
  nil
319
328
  ensure
@@ -21,7 +21,12 @@ module LittleGhost
21
21
  end
22
22
 
23
23
  # Shared provider contract. Provider adapters implement #stream and may
24
- # override capability-sensitive request preparation.
24
+ # implement #embed or override capability-sensitive request preparation.
25
+ #
26
+ # LittleGhost may call one provider instance concurrently. An embedding
27
+ # adapter accepts Embeddings::Request, observes its cancellation and
28
+ # deadline, and returns Embeddings::Response. Provider failures should use a
29
+ # content-safe ProviderError subclass.
25
30
  class Base
26
31
  # Returns the trusted per-profile request options this adapter accepts.
27
32
  # Connection options remain authoritative and are configured separately.
@@ -32,6 +37,16 @@ module LittleGhost
32
37
  raise AbstractMethodError, "#{self.class} must implement #stream"
33
38
  end
34
39
 
40
+ # Executes +request+ when the adapter supports text embeddings.
41
+ #
42
+ # The default implementation raises UnsupportedModelOperationError.
43
+ #
44
+ # :call-seq:
45
+ # embed(request) -> Embeddings::Response
46
+ def embed(_request)
47
+ raise UnsupportedModelOperationError, "#{self.class} does not support embeddings"
48
+ end
49
+
35
50
  # Applies provider-specific capability constraints before streaming.
36
51
  def prepare_request(request, capabilities:)
37
52
  request
@@ -45,7 +45,7 @@ module LittleGhost
45
45
 
46
46
  def canonical_path(uri)
47
47
  path = uri.path.empty? ? "/" : uri.path
48
- path.split("/", -1).map { |part| URI.encode_www_form_component(URI.decode_www_form_component(part)).gsub("+", "%20") }.join("/")
48
+ path.split("/", -1).map { |part| URI.encode_www_form_component(part).gsub("+", "%20") }.join("/")
49
49
  end
50
50
 
51
51
  def canonical_query(uri)
@@ -10,6 +10,7 @@ module LittleGhost
10
10
  # the adapter.
11
11
  class HTTPClient # :nodoc:
12
12
  Response = Data.define(:stream)
13
+ InvokeResponse = Data.define(:body)
13
14
  DEFAULT_MAX_RESPONSE_BYTES = 256 * 1024 * 1024
14
15
 
15
16
  def initialize(region:, credentials: nil, credential_resolver: nil, endpoint: nil,
@@ -44,33 +45,53 @@ module LittleGhost
44
45
  Response.new(stream:)
45
46
  end
46
47
 
48
+ def invoke_model(model_id:, body:, max_response_bytes:, cancellation_token: nil, deadline: nil)
49
+ payload = JSON.generate(camelize(body))
50
+ uri = URI.join(@endpoint.to_s, "/model/#{escape_path(model_id)}/invoke")
51
+ credentials = @credential_resolver.call
52
+ signer = AwsSigV4.new(service: "bedrock", region: @region, credentials:, clock: @clock)
53
+ headers = signer.headers(method: :post, uri:, headers: {"content-type" => "application/json", "accept" => "application/json"}, body: payload)
54
+ response = @http_client.request(
55
+ uri:, method: :post, headers:, body: payload,
56
+ cancellation_token:, deadline:, label: "Bedrock request", max_response_bytes:
57
+ )
58
+ InvokeResponse.new(body: response)
59
+ end
60
+
47
61
  private
48
62
 
49
63
  def event(headers, payload)
50
64
  value = payload.empty? ? {} : JSON.parse(payload)
51
65
  type = headers[":event-type"] || headers[":exception-type"]
52
- {type.to_s.gsub(/([a-z])([A-Z])/, "\\1_\\2").downcase.to_sym => symbolize(value)}
66
+ {underscore(type).to_sym => normalize_event_payload(value)}
53
67
  rescue JSON::ParserError => error
54
68
  raise ProtocolError, "Bedrock returned invalid event JSON: #{error.message}"
55
69
  end
56
70
 
57
- def camelize(value)
71
+ def camelize(value, json_schema: false, input_schema: false)
58
72
  case value
59
73
  when Hash
60
- value.to_h { |key, child| [key.to_s.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }, camelize(child)] }
61
- when Array then value.map { |child| camelize(child) }
74
+ value.to_h do |key, child|
75
+ key = key.to_s
76
+ [json_schema ? key : camelize_key(key), camelize(child, json_schema: json_schema || (input_schema && key == "json"), input_schema: key == "input_schema")]
77
+ end
78
+ when Array then value.map { |child| camelize(child, json_schema:, input_schema: false) }
62
79
  else value
63
80
  end
64
81
  end
65
82
 
66
- def symbolize(value)
83
+ def camelize_key(key) = key.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
84
+
85
+ def normalize_event_payload(value)
67
86
  case value
68
- when Hash then value.to_h { |key, child| [key.to_sym, symbolize(child)] }
69
- when Array then value.map { |child| symbolize(child) }
87
+ when Hash then value.to_h { |key, child| [underscore(key).to_sym, normalize_event_payload(child)] }
88
+ when Array then value.map { |child| normalize_event_payload(child) }
70
89
  else value
71
90
  end
72
91
  end
73
92
 
93
+ def underscore(value) = value.to_s.gsub(/([a-z0-9])([A-Z])/, "\\1_\\2").downcase
94
+
74
95
  def escape_path(value) = URI.encode_www_form_component(value.to_s).gsub("+", "%20")
75
96
  end
76
97
  end