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.
- checksums.yaml +4 -4
- data/README.md +50 -47
- data/docs/guides/{Core Concepts.md → core_concepts.md} +40 -20
- data/docs/guides/getting_started.md +164 -0
- data/lib/little_ghost/ag_ui/adapter.rb +1 -1
- data/lib/little_ghost/agent/context_management.rb +1 -4
- data/lib/little_ghost/agent.rb +94 -26
- data/lib/little_ghost/agent_builder.rb +1 -1
- data/lib/little_ghost/configuration.rb +210 -29
- data/lib/little_ghost/data/model_catalog.json +19178 -0
- data/lib/little_ghost/errors.rb +6 -0
- data/lib/little_ghost/invocation.rb +2 -17
- data/lib/little_ghost/model.rb +53 -33
- data/lib/little_ghost/model_capabilities.rb +6 -5
- data/lib/little_ghost/model_resolver.rb +316 -0
- data/lib/little_ghost/models/catalog/models_dev_source.rb +62 -0
- data/lib/little_ghost/models/catalog/source.rb +30 -0
- data/lib/little_ghost/models/catalog.rb +154 -0
- data/lib/little_ghost/models/catalog_snapshot.rb +44 -0
- data/lib/little_ghost/models/configuration.rb +43 -0
- data/lib/little_ghost/models/details.rb +48 -0
- data/lib/little_ghost/models/target.rb +29 -0
- data/lib/little_ghost/provider_registry.rb +75 -0
- data/lib/little_ghost/providers/anthropic/catalog_source.rb +31 -0
- data/lib/little_ghost/providers/anthropic.rb +222 -0
- data/lib/little_ghost/providers/base.rb +46 -0
- data/lib/little_ghost/providers/bedrock/aws_protocol.rb +132 -0
- data/lib/little_ghost/providers/bedrock/catalog_source.rb +200 -0
- data/lib/little_ghost/providers/bedrock/credential_resolver.rb +123 -0
- data/lib/little_ghost/providers/bedrock/http_client.rb +78 -0
- data/lib/little_ghost/providers/bedrock.rb +23 -15
- data/lib/little_ghost/providers/configuration.rb +79 -0
- data/lib/little_ghost/providers/gemini/catalog_source.rb +35 -0
- data/lib/little_ghost/providers/gemini.rb +204 -0
- data/lib/little_ghost/providers/open_router/catalog_source.rb +42 -0
- data/lib/little_ghost/providers/open_router.rb +6 -2
- data/lib/little_ghost/providers/openai_compatible.rb +19 -23
- data/lib/little_ghost/providers/vertex_ai/credential_resolver.rb +90 -0
- data/lib/little_ghost/providers/vertex_ai.rb +38 -0
- data/lib/little_ghost/run.rb +7 -7
- data/lib/little_ghost/runtime.rb +10 -7
- data/lib/little_ghost/sandbox.rb +5 -5
- data/lib/little_ghost/session_store.rb +3 -3
- data/lib/little_ghost/structured_output.rb +2 -8
- data/lib/little_ghost/support/http_client.rb +186 -0
- data/lib/little_ghost/{providers → support}/sse_parser.rb +1 -1
- data/lib/little_ghost/tool.rb +5 -1
- data/lib/little_ghost/tool_registry.rb +2 -3
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workflow.rb +1 -1
- data/lib/little_ghost.rb +21 -15
- metadata +28 -7
- data/docs/guides/Getting Started.md +0 -187
- data/lib/little_ghost/default_model_registry.rb +0 -71
- data/lib/little_ghost/model_registry.rb +0 -173
- 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
|
|
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
|
|
20
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
-
|
|
305
|
-
deep_stringify(value)
|
|
313
|
+
deep_stringify(event.to_h)
|
|
306
314
|
end
|
|
307
315
|
|
|
308
316
|
def deep_stringify(value)
|