little_ghost 0.1.0 → 0.2.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +50 -47
  3. data/docs/guides/{Core Concepts.md → core_concepts.md} +40 -20
  4. data/docs/guides/getting_started.md +164 -0
  5. data/lib/little_ghost/ag_ui/adapter.rb +1 -1
  6. data/lib/little_ghost/agent/context_management.rb +1 -4
  7. data/lib/little_ghost/agent.rb +94 -26
  8. data/lib/little_ghost/agent_builder.rb +1 -1
  9. data/lib/little_ghost/configuration.rb +210 -29
  10. data/lib/little_ghost/data/model_catalog.json +19178 -0
  11. data/lib/little_ghost/errors.rb +6 -0
  12. data/lib/little_ghost/invocation.rb +2 -17
  13. data/lib/little_ghost/model.rb +53 -33
  14. data/lib/little_ghost/model_capabilities.rb +6 -5
  15. data/lib/little_ghost/model_resolver.rb +316 -0
  16. data/lib/little_ghost/models/catalog/models_dev_source.rb +62 -0
  17. data/lib/little_ghost/models/catalog/source.rb +30 -0
  18. data/lib/little_ghost/models/catalog.rb +154 -0
  19. data/lib/little_ghost/models/catalog_snapshot.rb +44 -0
  20. data/lib/little_ghost/models/configuration.rb +43 -0
  21. data/lib/little_ghost/models/details.rb +48 -0
  22. data/lib/little_ghost/models/target.rb +29 -0
  23. data/lib/little_ghost/provider_registry.rb +75 -0
  24. data/lib/little_ghost/providers/anthropic/catalog_source.rb +31 -0
  25. data/lib/little_ghost/providers/anthropic.rb +222 -0
  26. data/lib/little_ghost/providers/base.rb +46 -0
  27. data/lib/little_ghost/providers/bedrock/aws_protocol.rb +132 -0
  28. data/lib/little_ghost/providers/bedrock/catalog_source.rb +200 -0
  29. data/lib/little_ghost/providers/bedrock/credential_resolver.rb +123 -0
  30. data/lib/little_ghost/providers/bedrock/http_client.rb +78 -0
  31. data/lib/little_ghost/providers/bedrock.rb +23 -15
  32. data/lib/little_ghost/providers/configuration.rb +79 -0
  33. data/lib/little_ghost/providers/gemini/catalog_source.rb +35 -0
  34. data/lib/little_ghost/providers/gemini.rb +204 -0
  35. data/lib/little_ghost/providers/open_router/catalog_source.rb +42 -0
  36. data/lib/little_ghost/providers/open_router.rb +6 -2
  37. data/lib/little_ghost/providers/openai_compatible.rb +19 -23
  38. data/lib/little_ghost/providers/vertex_ai/credential_resolver.rb +90 -0
  39. data/lib/little_ghost/providers/vertex_ai.rb +38 -0
  40. data/lib/little_ghost/run.rb +7 -7
  41. data/lib/little_ghost/runtime.rb +10 -7
  42. data/lib/little_ghost/sandbox.rb +5 -5
  43. data/lib/little_ghost/session_store.rb +3 -3
  44. data/lib/little_ghost/structured_output.rb +2 -8
  45. data/lib/little_ghost/support/http_client.rb +186 -0
  46. data/lib/little_ghost/{providers → support}/sse_parser.rb +1 -1
  47. data/lib/little_ghost/tool.rb +5 -1
  48. data/lib/little_ghost/tool_registry.rb +2 -3
  49. data/lib/little_ghost/version.rb +1 -1
  50. data/lib/little_ghost/workflow.rb +1 -1
  51. data/lib/little_ghost.rb +21 -15
  52. metadata +28 -7
  53. data/docs/guides/Getting Started.md +0 -187
  54. data/lib/little_ghost/default_model_registry.rb +0 -71
  55. data/lib/little_ghost/model_registry.rb +0 -173
  56. data/lib/little_ghost/providers/http_transport.rb +0 -149
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ module Providers
5
+ # Reports a bounded HTTP or network failure from a provider connection.
6
+ class HTTPError < ProviderError
7
+ # HTTP status, when a response was received, and the bounded response body.
8
+ attr_reader :status, :body
9
+
10
+ # Captures a provider failure without retaining an unbounded response.
11
+ def initialize(message, status: nil, body: nil)
12
+ @status = status
13
+ @body = body
14
+ super(message)
15
+ end
16
+
17
+ # Whether retrying the same provider request may succeed.
18
+ def retryable?
19
+ status.nil? || status == 408 || status == 409 || status == 429 || status >= 500
20
+ end
21
+ end
22
+
23
+ # Shared provider contract. Provider adapters implement #stream and may
24
+ # override capability-sensitive request preparation.
25
+ class Base
26
+ # Returns the trusted per-profile request options this adapter accepts.
27
+ # Connection options remain authoritative and are configured separately.
28
+ def self.request_options = [].freeze
29
+
30
+ # Streams normalized events for +request+.
31
+ def stream(_request)
32
+ raise AbstractMethodError, "#{self.class} must implement #stream"
33
+ end
34
+
35
+ # Applies provider-specific capability constraints before streaming.
36
+ def prepare_request(request, capabilities:)
37
+ request
38
+ end
39
+
40
+ # Returns provider capabilities derived from normalized metadata.
41
+ def capabilities(metadata: {})
42
+ ModelCapabilities.unknown
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "openssl"
5
+ require "time"
6
+ require "zlib"
7
+
8
+ module LittleGhost
9
+ module Providers
10
+ class Bedrock < Base
11
+ # Signs AWS HTTP requests with Signature Version 4.
12
+ class AwsSigV4 # :nodoc:
13
+ def initialize(service:, region:, credentials:, clock: -> { Time.now.utc })
14
+ @service = service
15
+ @region = region
16
+ @credentials = credentials
17
+ @clock = clock
18
+ end
19
+
20
+ def headers(method:, uri:, headers:, body:)
21
+ time = @clock.call.utc
22
+ timestamp = time.strftime("%Y%m%dT%H%M%SZ")
23
+ date = time.strftime("%Y%m%d")
24
+ normalized = headers.to_h.transform_keys { |key| key.to_s.downcase }
25
+ .merge("host" => host_header(uri), "x-amz-date" => timestamp)
26
+ normalized["x-amz-security-token"] = @credentials.session_token if @credentials.session_token
27
+ canonical_headers = normalized.sort.map { |key, value| "#{key}:#{value.to_s.strip.gsub(/\s+/, " ")}\n" }.join
28
+ signed_headers = normalized.keys.sort.join(";")
29
+ canonical_request = [method.to_s.upcase, canonical_path(uri), canonical_query(uri), canonical_headers,
30
+ signed_headers, Digest::SHA256.hexdigest(body)].join("\n")
31
+ scope = "#{date}/#{@region}/#{@service}/aws4_request"
32
+ string_to_sign = ["AWS4-HMAC-SHA256", timestamp, scope, Digest::SHA256.hexdigest(canonical_request)].join("\n")
33
+ signature = OpenSSL::HMAC.hexdigest("SHA256", signing_key(date), string_to_sign)
34
+ normalized.merge("authorization" => "AWS4-HMAC-SHA256 Credential=#{@credentials.access_key_id}/#{scope}, SignedHeaders=#{signed_headers}, Signature=#{signature}")
35
+ end
36
+
37
+ private
38
+
39
+ def signing_key(date)
40
+ date_key = OpenSSL::HMAC.digest("SHA256", "AWS4#{@credentials.secret_access_key}", date)
41
+ region_key = OpenSSL::HMAC.digest("SHA256", date_key, @region)
42
+ service_key = OpenSSL::HMAC.digest("SHA256", region_key, @service)
43
+ OpenSSL::HMAC.digest("SHA256", service_key, "aws4_request")
44
+ end
45
+
46
+ def canonical_path(uri)
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("/")
49
+ end
50
+
51
+ def canonical_query(uri)
52
+ URI.decode_www_form(uri.query.to_s).sort.map { |key, value| "#{escape(key)}=#{escape(value)}" }.join("&")
53
+ end
54
+
55
+ def escape(value) = URI.encode_www_form_component(value).gsub("+", "%20")
56
+ def host_header(uri) = (uri.default_port == uri.port) ? uri.host : "#{uri.host}:#{uri.port}"
57
+ end
58
+
59
+ # Incrementally decodes the AWS EventStream binary framing protocol.
60
+ class EventStreamDecoder # :nodoc:
61
+ DEFAULT_MAX_FRAME_BYTES = 16 * 1024 * 1024
62
+ DEFAULT_MAX_HEADERS_BYTES = 128 * 1024
63
+
64
+ def initialize(max_frame_bytes: DEFAULT_MAX_FRAME_BYTES, max_headers_bytes: DEFAULT_MAX_HEADERS_BYTES)
65
+ @buffer = String.new(encoding: Encoding::BINARY)
66
+ @max_frame_bytes = Integer(max_frame_bytes)
67
+ @max_headers_bytes = Integer(max_headers_bytes)
68
+ end
69
+
70
+ def <<(chunk)
71
+ @buffer << chunk.b
72
+ events = []
73
+ loop do
74
+ break if @buffer.bytesize < 12
75
+
76
+ total_length, headers_length, prelude_crc = @buffer.unpack("NNN")
77
+ raise ProtocolError, "AWS EventStream frame length is invalid" if total_length < 16 || headers_length > total_length - 16
78
+ raise ProtocolError, "AWS EventStream frame exceeds the configured limit" if total_length > @max_frame_bytes
79
+ raise ProtocolError, "AWS EventStream headers exceed the configured limit" if headers_length > @max_headers_bytes
80
+ break if @buffer.bytesize < total_length
81
+
82
+ frame = @buffer.slice!(0, total_length)
83
+ raise ProtocolError, "AWS EventStream prelude checksum is invalid" unless Zlib.crc32(frame.byteslice(0, 8)) == prelude_crc
84
+ expected_crc = frame.byteslice(total_length - 4, 4).unpack1("N")
85
+ raise ProtocolError, "AWS EventStream message checksum is invalid" unless Zlib.crc32(frame.byteslice(0, total_length - 4)) == expected_crc
86
+
87
+ headers = decode_headers(frame.byteslice(12, headers_length))
88
+ payload = frame.byteslice(12 + headers_length, total_length - headers_length - 16)
89
+ events << [headers, payload]
90
+ end
91
+ events
92
+ end
93
+
94
+ def finish
95
+ raise ProtocolError, "AWS EventStream ended with an incomplete frame" unless @buffer.empty?
96
+ end
97
+
98
+ private
99
+
100
+ def decode_headers(bytes)
101
+ offset = 0
102
+ headers = {}
103
+ while offset < bytes.bytesize
104
+ name_length = bytes.getbyte(offset)
105
+ offset += 1
106
+ name = bytes.byteslice(offset, name_length)
107
+ offset += name_length
108
+ type = bytes.getbyte(offset)
109
+ offset += 1
110
+ case type
111
+ when 0 then value = true
112
+ when 1 then value = false
113
+ when 2 then value = bytes.getbyte(offset).then {
114
+ offset += 1
115
+ _1
116
+ }
117
+ when 6, 7
118
+ length = bytes.byteslice(offset, 2).unpack1("n")
119
+ offset += 2
120
+ value = bytes.byteslice(offset, length)
121
+ offset += length
122
+ else
123
+ raise ProtocolError, "Unsupported AWS EventStream header type #{type}"
124
+ end
125
+ headers[name] = value
126
+ end
127
+ headers
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ module Providers
5
+ class Bedrock < Base
6
+ # Enriches Bedrock availability and on-demand pricing using bounded,
7
+ # SigV4-signed AWS APIs.
8
+ class CatalogSource < Models::Catalog::Source
9
+ PRICING_ENDPOINT = URI("https://api.pricing.us-east-1.amazonaws.com") # :nodoc:
10
+ PRICING_REGION = "us-east-1" # :nodoc:
11
+ MAX_PRICING_PAGES = 10 # :nodoc:
12
+ MAX_RESULTS = 100 # :nodoc:
13
+ FOUNDATION_MODEL_OFFER = "AmazonBedrockFoundationModels" # :nodoc:
14
+ BEDROCK_OFFER = "AmazonBedrock" # :nodoc:
15
+ ATTRIBUTE_MERGE_STRATEGIES = {input_modalities: :union}.freeze # :nodoc:
16
+
17
+ # Creates a source for one Bedrock provider connection and AWS region.
18
+ def initialize(provider:, region:, credential_resolver: nil, clock: -> { Time.now.utc }, http_client: nil)
19
+ super(name: "bedrock")
20
+ @provider = provider
21
+ @region = region
22
+ @credential_resolver = credential_resolver || CredentialResolver.new
23
+ @clock = clock
24
+ @http_client = http_client || Support::HTTPClient.new(
25
+ open_timeout: 5,
26
+ read_timeout: 30,
27
+ max_response_bytes: 25 * 1024 * 1024
28
+ )
29
+ end
30
+
31
+ # AWS reports a coarse subset of Converse input capabilities, so live
32
+ # modalities augment richer facts supplied by another catalog source.
33
+ def attribute_merge_strategies = ATTRIBUTE_MERGE_STRATEGIES
34
+
35
+ def refresh(target: nil)
36
+ credentials = @credential_resolver.call
37
+ models = foundation_models(credentials, target:)
38
+ products = (target && models.one?) ? pricing_products(credentials, models.first) : []
39
+ records(models, products, target:)
40
+ rescue JSON::ParserError, KeyError, TypeError, ArgumentError => error
41
+ raise ProviderError, "Bedrock returned an invalid catalog: #{error.message}"
42
+ end
43
+
44
+ private
45
+
46
+ def foundation_models(credentials, target:)
47
+ uri = URI("https://bedrock.#{@region}.amazonaws.com/foundation-models")
48
+ values = JSON.parse(request(uri:, service: "bedrock", region: @region, credentials:))
49
+ .fetch("modelSummaries")
50
+ model_id = base_model_id(target.model_id) if target
51
+ values.select! { |value| value["modelId"] == model_id } if model_id
52
+ values
53
+ end
54
+
55
+ def pricing_products(credentials, model)
56
+ products = pricing_products_for(
57
+ credentials,
58
+ service_code: FOUNDATION_MODEL_OFFER,
59
+ filters: pricing_filters("servicename", "#{model.fetch("modelName")} (Amazon Bedrock Edition)")
60
+ )
61
+ return products unless products.empty?
62
+
63
+ pricing_products_for(
64
+ credentials,
65
+ service_code: BEDROCK_OFFER,
66
+ filters: pricing_filters("model", model.fetch("modelName"))
67
+ )
68
+ end
69
+
70
+ def pricing_products_for(credentials, service_code:, filters:)
71
+ products = []
72
+ next_token = nil
73
+ seen_tokens = {}
74
+
75
+ MAX_PRICING_PAGES.times do
76
+ body = pricing_request(service_code:, filters:, next_token:)
77
+ uri = URI.join(PRICING_ENDPOINT.to_s, "/")
78
+ response = JSON.parse(request(
79
+ uri:,
80
+ method: :post,
81
+ service: "pricing",
82
+ region: PRICING_REGION,
83
+ credentials:,
84
+ headers: {
85
+ "content-type" => "application/x-amz-json-1.1",
86
+ "x-amz-target" => "AWSPriceListService.GetProducts"
87
+ },
88
+ body:
89
+ ))
90
+ products.concat(response.fetch("PriceList").map { |value| JSON.parse(value) })
91
+ next_token = response["NextToken"]
92
+ break if next_token.to_s.empty?
93
+ raise ProviderError, "AWS pricing pagination repeated a token" if seen_tokens[next_token]
94
+
95
+ seen_tokens[next_token] = true
96
+ end
97
+ raise ProviderError, "AWS pricing exceeded #{MAX_PRICING_PAGES} pages" unless next_token.to_s.empty?
98
+
99
+ products
100
+ rescue HTTPError => error
101
+ raise unless error.status == 400 && error.body.to_s.include?("InvalidParameter")
102
+
103
+ []
104
+ end
105
+
106
+ def pricing_filters(field, value)
107
+ [
108
+ {"Type" => "TERM_MATCH", "Field" => "regionCode", "Value" => @region},
109
+ {"Type" => "TERM_MATCH", "Field" => field, "Value" => value}
110
+ ]
111
+ end
112
+
113
+ def pricing_request(service_code:, filters:, next_token:)
114
+ request = {
115
+ "ServiceCode" => service_code,
116
+ "FormatVersion" => "aws_v1",
117
+ "MaxResults" => MAX_RESULTS,
118
+ "Filters" => filters
119
+ }
120
+ request["NextToken"] = next_token if next_token
121
+ JSON.generate(request)
122
+ end
123
+
124
+ def request(uri:, service:, region:, credentials:, method: :get, headers: {}, body: "")
125
+ signed_headers = AwsSigV4.new(service:, region:, credentials:, clock: @clock)
126
+ .headers(method:, uri:, headers:, body:)
127
+ @http_client.request(uri:, method:, headers: signed_headers, body: (body unless body.empty?))
128
+ end
129
+
130
+ def records(models, products, target:)
131
+ models.to_h do |model|
132
+ model_id = model.fetch("modelId")
133
+ selected_id = target&.model_id || model_id
134
+ pricing = normalized_prices(products, selected_id)
135
+ attributes = {
136
+ available: true,
137
+ input_modalities: model["inputModalities"]&.map(&:downcase),
138
+ output_modalities: model["outputModalities"]&.map(&:downcase),
139
+ pricing:
140
+ }.compact
141
+ provenance = attributes.keys.to_h { |key| [key, (key == :pricing) ? "aws_pricing" : name] }
142
+ ["#{@provider}:#{selected_id}", attributes.merge(provenance:)]
143
+ end
144
+ end
145
+
146
+ def normalized_prices(products, model_id)
147
+ global_pricing = model_id.to_s.match?(/\A(?:global|us|eu|apac)\./)
148
+ prices = products.each_with_object({}) do |product, result|
149
+ attributes = product.fetch("product").fetch("attributes")
150
+ kind = price_kind(attributes, global_pricing:)
151
+ next unless kind
152
+
153
+ dimensions = product.fetch("terms").fetch("OnDemand", {}).values.flat_map do |term|
154
+ term.fetch("priceDimensions", {}).values
155
+ end
156
+ result[kind] ||= normalized_price(dimensions.first) if dimensions.first
157
+ end
158
+ prices if prices.key?(:input) && prices.key?(:output)
159
+ end
160
+
161
+ def price_kind(attributes, global_pricing:)
162
+ value = "#{attributes["usagetype"]} #{attributes["inferenceType"]}".downcase
163
+ return if value.match?(/batch|priority|flex|reserved|tpm/) || !value.include?("token")
164
+
165
+ global = value.match?(/global|cross-region/)
166
+ return if global_pricing != global
167
+
168
+ if value.include?("cache") && value.include?("read")
169
+ :cache_read
170
+ elsif value.include?("cache") && value.include?("write")
171
+ :cache_write
172
+ elsif value.match?(/(?:\b|_)output(?:token| tokens)/)
173
+ :output
174
+ elsif value.match?(/(?:\b|_)input(?:token| tokens)/) && !value.match?(/image|audio|video/)
175
+ :input
176
+ end
177
+ end
178
+
179
+ def normalized_price(dimension)
180
+ amount = Float(dimension.fetch("pricePerUnit").fetch("USD"))
181
+ unit = dimension.fetch("unit").downcase
182
+ description = dimension["description"].to_s.downcase
183
+ if unit.match?(/1m|million/) || description.match?(/(?:1,000,000|million)\s+tokens/)
184
+ amount
185
+ elsif unit.match?(/1k|thousand/) || description.match?(/(?:1,000|thousand)\s+tokens/)
186
+ amount * 1_000
187
+ elsif unit.include?("token")
188
+ amount * 1_000_000
189
+ else
190
+ raise ArgumentError, "unsupported Bedrock pricing unit #{dimension.fetch("unit")}"
191
+ end
192
+ end
193
+
194
+ def base_model_id(model_id)
195
+ model_id.to_s.sub(/\A(?:global|us|eu|apac)\./, "")
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "uri"
6
+
7
+ module LittleGhost
8
+ module Providers
9
+ class Bedrock < Base
10
+ Credentials = Data.define(:access_key_id, :secret_access_key, :session_token, :expiration) do # :nodoc:
11
+ def initialize(access_key_id:, secret_access_key:, session_token: nil, expiration: nil)
12
+ raise CredentialError, "AWS access key ID and secret access key are required" if access_key_id.to_s.empty? || secret_access_key.to_s.empty?
13
+
14
+ super(access_key_id: access_key_id.to_s, secret_access_key: secret_access_key.to_s,
15
+ session_token: session_token&.to_s, expiration: expiration && Time.parse(expiration.to_s))
16
+ end
17
+ end
18
+
19
+ # Resolves common AWS credentials without depending on an AWS SDK.
20
+ class CredentialResolver
21
+ # Uses +environment+ and the selected shared-credentials +profile+.
22
+ def initialize(environment: ENV, profile: nil, credentials_file: nil)
23
+ @environment = environment
24
+ @profile = profile || environment["AWS_PROFILE"] || "default"
25
+ @credentials_file = credentials_file || environment["AWS_SHARED_CREDENTIALS_FILE"] || File.expand_path("~/.aws/credentials")
26
+ end
27
+
28
+ # Returns the first complete credential set from the environment,
29
+ # shared credentials, container metadata, or IMDSv2.
30
+ def call
31
+ from_environment || from_profile || from_container || from_instance_metadata ||
32
+ raise(CredentialError, "No AWS credentials were found in the environment, selected shared profile, container credentials, or IMDSv2")
33
+ end
34
+
35
+ # Returns the configured AWS region, when one can be resolved.
36
+ def region
37
+ @environment["AWS_REGION"] || @environment["AWS_DEFAULT_REGION"] || profile_values(config_file)["region"]
38
+ end
39
+
40
+ private
41
+
42
+ def from_environment
43
+ key = @environment["AWS_ACCESS_KEY_ID"]
44
+ secret = @environment["AWS_SECRET_ACCESS_KEY"]
45
+ return unless key && secret
46
+
47
+ Credentials.new(access_key_id: key, secret_access_key: secret, session_token: @environment["AWS_SESSION_TOKEN"])
48
+ end
49
+
50
+ def from_profile
51
+ values = profile_values(@credentials_file)
52
+ return unless values["aws_access_key_id"] && values["aws_secret_access_key"]
53
+
54
+ Credentials.new(access_key_id: values["aws_access_key_id"], secret_access_key: values["aws_secret_access_key"],
55
+ session_token: values["aws_session_token"])
56
+ end
57
+
58
+ def from_container
59
+ relative = @environment["AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"]
60
+ full = @environment["AWS_CONTAINER_CREDENTIALS_FULL_URI"]
61
+ return unless relative || full
62
+
63
+ uri = relative ? URI("http://169.254.170.2#{relative}") : URI(full)
64
+ allowed = uri.host == "169.254.170.2" || %w[127.0.0.1 ::1 localhost].include?(uri.host)
65
+ raise CredentialError, "AWS container credential endpoint is not an allowed local address" unless allowed && uri.scheme == "http"
66
+
67
+ headers = {}
68
+ token = @environment["AWS_CONTAINER_AUTHORIZATION_TOKEN"]
69
+ token_file = @environment["AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE"]
70
+ token ||= File.read(token_file).strip if token_file && File.file?(token_file)
71
+ headers["Authorization"] = token if token
72
+ credential_json(http_client.request(uri:, headers:, allow_insecure_http: true))
73
+ end
74
+
75
+ def from_instance_metadata
76
+ return if @environment["AWS_EC2_METADATA_DISABLED"].to_s.casecmp?("true")
77
+
78
+ base = URI("http://169.254.169.254/latest/")
79
+ token = http_client.request(uri: URI.join(base, "api/token"), method: :put,
80
+ headers: {"X-aws-ec2-metadata-token-ttl-seconds" => "21600"}, allow_insecure_http: true)
81
+ headers = {"X-aws-ec2-metadata-token" => token}
82
+ role = http_client.request(uri: URI.join(base, "meta-data/iam/security-credentials/"), headers:,
83
+ allow_insecure_http: true).strip
84
+ return if role.empty?
85
+
86
+ credential_json(http_client.request(
87
+ uri: URI.join(base, "meta-data/iam/security-credentials/#{URI.encode_www_form_component(role)}"),
88
+ headers:,
89
+ allow_insecure_http: true
90
+ ))
91
+ rescue HTTPError
92
+ nil
93
+ end
94
+
95
+ def credential_json(body)
96
+ value = JSON.parse(body)
97
+ Credentials.new(access_key_id: value.fetch("AccessKeyId"), secret_access_key: value.fetch("SecretAccessKey"),
98
+ session_token: value["Token"], expiration: value["Expiration"])
99
+ rescue JSON::ParserError, KeyError => error
100
+ raise CredentialError, "AWS credential endpoint returned an invalid response: #{error.message}"
101
+ end
102
+
103
+ def profile_values(path)
104
+ return {} unless path && File.file?(path)
105
+
106
+ section = nil
107
+ File.foreach(path).each_with_object({}) do |line, result|
108
+ stripped = line.strip
109
+ if (match = stripped.match(/\A\[([^\]]+)\]\z/))
110
+ section = match[1].sub(/\Aprofile\s+/, "")
111
+ elsif section == @profile && (match = stripped.match(/\A([^#;=]+?)\s*=\s*(.*?)\s*\z/))
112
+ result[match[1].strip] = match[2]
113
+ end
114
+ end
115
+ end
116
+
117
+ def config_file = @environment["AWS_CONFIG_FILE"] || File.expand_path("~/.aws/config")
118
+
119
+ def http_client = Support::HTTPClient.new(open_timeout: 1, read_timeout: 1, max_response_bytes: 1024 * 1024)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module LittleGhost
7
+ module Providers
8
+ class Bedrock < Base
9
+ # Stdlib Bedrock Runtime client with the small ConverseStream surface used by
10
+ # the adapter.
11
+ class HTTPClient # :nodoc:
12
+ Response = Data.define(:stream)
13
+ DEFAULT_MAX_RESPONSE_BYTES = 256 * 1024 * 1024
14
+
15
+ def initialize(region:, credentials: nil, credential_resolver: nil, endpoint: nil,
16
+ open_timeout: 10, read_timeout: 120, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
17
+ max_frame_bytes: EventStreamDecoder::DEFAULT_MAX_FRAME_BYTES, clock: -> { Time.now.utc })
18
+ @region = region.to_s
19
+ raise ConfigurationError, "Bedrock region is required" if @region.empty?
20
+
21
+ @credential_resolver = credential_resolver || -> { credentials || CredentialResolver.new.call }
22
+ @endpoint = URI(endpoint || "https://bedrock-runtime.#{@region}.amazonaws.com")
23
+ raise ConfigurationError, "Bedrock endpoint must use HTTPS" unless @endpoint.scheme == "https"
24
+
25
+ @max_frame_bytes = Integer(max_frame_bytes)
26
+ @clock = clock
27
+ @http_client = Support::HTTPClient.new(open_timeout:, read_timeout:, max_response_bytes:)
28
+ end
29
+
30
+ def converse_stream(**parameters)
31
+ model_id = parameters.delete(:model_id)
32
+ body = JSON.generate(camelize(parameters))
33
+ uri = URI.join(@endpoint.to_s, "/model/#{escape_path(model_id)}/converse-stream")
34
+ credentials = @credential_resolver.call
35
+ signer = AwsSigV4.new(service: "bedrock", region: @region, credentials:, clock: @clock)
36
+ headers = signer.headers(method: :post, uri:, headers: {"content-type" => "application/json"}, body:)
37
+ stream = Enumerator.new do |events|
38
+ decoder = EventStreamDecoder.new(max_frame_bytes: @max_frame_bytes)
39
+ @http_client.each_chunk(uri:, method: :post, headers:, body:, label: "Bedrock request") do |chunk|
40
+ decoder.<<(chunk).each { |event_headers, payload| events << event(event_headers, payload) }
41
+ end
42
+ decoder.finish
43
+ end
44
+ Response.new(stream:)
45
+ end
46
+
47
+ private
48
+
49
+ def event(headers, payload)
50
+ value = payload.empty? ? {} : JSON.parse(payload)
51
+ type = headers[":event-type"] || headers[":exception-type"]
52
+ {type.to_s.gsub(/([a-z])([A-Z])/, "\\1_\\2").downcase.to_sym => symbolize(value)}
53
+ rescue JSON::ParserError => error
54
+ raise ProtocolError, "Bedrock returned invalid event JSON: #{error.message}"
55
+ end
56
+
57
+ def camelize(value)
58
+ case value
59
+ 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) }
62
+ else value
63
+ end
64
+ end
65
+
66
+ def symbolize(value)
67
+ 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) }
70
+ else value
71
+ end
72
+ end
73
+
74
+ def escape_path(value) = URI.encode_www_form_component(value.to_s).gsub("+", "%20")
75
+ end
76
+ end
77
+ end
78
+ end
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "bedrock/credential_resolver"
5
+ require_relative "bedrock/aws_protocol"
6
+ require_relative "bedrock/http_client"
7
+ require_relative "bedrock/catalog_source"
4
8
 
5
9
  module LittleGhost
6
10
  # Provider adapters translate model APIs into LittleGhost's shared streaming
7
- # request and response types. Agents select them through a ModelRegistry rather
11
+ # request and response types. Agents select them through model configuration rather
8
12
  # than depending on a provider class directly.
9
13
  module Providers
10
14
  # Bedrock lets LittleGhost agents use models available through Amazon Bedrock
@@ -16,14 +20,18 @@ module LittleGhost
16
20
  # region: ENV.fetch("AWS_REGION")
17
21
  # )
18
22
  #
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.
23
+ # The default client uses LittleGhost's standard-library SigV4 and AWS
24
+ # EventStream implementations. Applications may inject +client+ instead.
22
25
  #
23
26
  # Transient service and stream failures retry with exponential backoff. Each
24
27
  # retry emits +:model_retry+ and reports whether partial text was already
25
28
  # emitted, allowing stream consumers to handle repeated output deliberately.
26
- class Bedrock
29
+ class Bedrock < Base
30
+ # Request policy supported by Bedrock retries and its built-in HTTP client.
31
+ def self.request_options
32
+ %i[max_response_bytes max_retries open_timeout read_timeout].freeze
33
+ end
34
+
27
35
  INITIAL_RETRY_DELAY = 1 # :nodoc:
28
36
  MAX_RETRY_DELAY = 16 # :nodoc:
29
37
  TRANSIENT_STREAM_ERRORS = %w[
@@ -54,9 +62,9 @@ module LittleGhost
54
62
 
55
63
  # Configures Bedrock for +model+.
56
64
  #
57
- # +region+ and remaining +client_options+ configure the default AWS client.
65
+ # +region+ and remaining +client_options+ configure the built-in HTTP client.
58
66
  # +max_retries+, +sleeper+, and +on_retry+ control retry behavior. Injecting
59
- # +client+ bypasses creation of the optional SDK client.
67
+ # +client+ bypasses creation of the built-in HTTP client.
60
68
  def initialize(model:, region: nil, client: nil, max_retries: 2, sleeper: nil,
61
69
  on_retry: ->(*) {}, **client_options)
62
70
  @model = model
@@ -142,11 +150,10 @@ module LittleGhost
142
150
  private
143
151
 
144
152
  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"
153
+ resolver = options.delete(:credential_resolver)
154
+ resolver ||= CredentialResolver.new
155
+ region ||= resolver.region if resolver.is_a?(CredentialResolver)
156
+ HTTPClient.new(region:, credential_resolver: resolver, **options)
150
157
  end
151
158
 
152
159
  def request_parameters(request)
@@ -234,7 +241,9 @@ module LittleGhost
234
241
  {
235
242
  tool_result: {
236
243
  tool_use_id: block.tool_use_id,
237
- content: Array(block.content).map { |content| {text: content.respond_to?(:text) ? content.text : content.to_s} },
244
+ content: Array(block.content).map do |content|
245
+ {text: content.is_a?(Content::Text) ? content.text : content.to_s}
246
+ end,
238
247
  status: block.status.to_s
239
248
  }
240
249
  }
@@ -301,8 +310,7 @@ module LittleGhost
301
310
  end
302
311
 
303
312
  def event_hash(event)
304
- value = event.respond_to?(:to_h) ? event.to_h : event
305
- deep_stringify(value)
313
+ deep_stringify(event.to_h)
306
314
  end
307
315
 
308
316
  def deep_stringify(value)