llm.rb 8.0.0 → 8.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.
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Bedrock::ResponseAdapter
4
+ ##
5
+ # Adapts Bedrock Converse API completion responses.
6
+ #
7
+ # The Bedrock Converse response body looks like:
8
+ # {
9
+ # "output" => {"message" => {
10
+ # "role" => "assistant",
11
+ # "content" => [{"text" => "..."}, {"toolUse" => {...}}]
12
+ # }},
13
+ # "usage" => {"inputTokens" => N, "outputTokens" => N},
14
+ # "modelId" => "anthropic.claude-sonnet-4-20250514-v1:0",
15
+ # "stopReason" => "end_turn"
16
+ # }
17
+ module Completion
18
+ ##
19
+ # (see LLM::Contract::Completion#messages)
20
+ def messages
21
+ source = texts.empty? && tools.any? ? [{"text" => ""}] : texts
22
+ source.map.with_index do |choice, index|
23
+ extra = {
24
+ index:, response: self,
25
+ reasoning_content:,
26
+ tool_calls: adapt_tool_calls(tools),
27
+ original_tool_calls: tools
28
+ }
29
+ LLM::Message.new(role, choice["text"], extra)
30
+ end
31
+ end
32
+ alias_method :choices, :messages
33
+
34
+ ##
35
+ # Returns the Bedrock request id when present.
36
+ # @return [String, nil]
37
+ def id
38
+ res["x-amzn-requestid"] || res["x-amzn-request-id"]
39
+ end
40
+
41
+ ##
42
+ # (see LLM::Contract::Completion#input_tokens)
43
+ def input_tokens
44
+ body.usage&.inputTokens || 0
45
+ end
46
+
47
+ ##
48
+ # (see LLM::Contract::Completion#output_tokens)
49
+ def output_tokens
50
+ body.usage&.outputTokens || 0
51
+ end
52
+
53
+ ##
54
+ # (see LLM::Contract::Completion#reasoning_tokens)
55
+ def reasoning_tokens
56
+ 0
57
+ end
58
+
59
+ ##
60
+ # (see LLM::Contract::Completion#total_tokens)
61
+ def total_tokens
62
+ input_tokens + output_tokens
63
+ end
64
+
65
+ ##
66
+ # (see LLM::Contract::Completion#usage)
67
+ def usage
68
+ super
69
+ end
70
+
71
+ ##
72
+ # (see LLM::Contract::Completion#model)
73
+ def model
74
+ body.modelId
75
+ end
76
+
77
+ ##
78
+ # (see LLM::Contract::Completion#content)
79
+ def content
80
+ super
81
+ end
82
+
83
+ ##
84
+ # (see LLM::Contract::Completion#reasoning_content)
85
+ def reasoning_content
86
+ @reasoning_content ||= begin
87
+ text = parts.filter_map { _1.dig("reasoningContent", "text") }.join
88
+ text.empty? ? nil : text
89
+ end
90
+ end
91
+
92
+ ##
93
+ # (see LLM::Contract::Completion#content!)
94
+ def content!
95
+ super
96
+ end
97
+
98
+ private
99
+
100
+ def adapt_tool_calls(tools)
101
+ (tools || []).filter_map do |tool|
102
+ next unless tool["toolUse"]
103
+ {
104
+ id: tool["toolUse"]["toolUseId"],
105
+ name: tool["toolUse"]["name"],
106
+ arguments: parse_tool_input(tool["toolUse"]["input"])
107
+ }
108
+ end
109
+ end
110
+
111
+ def parse_tool_input(input)
112
+ case input
113
+ when Hash then input
114
+ when String
115
+ parsed = LLM.json.load(input)
116
+ Hash === parsed ? parsed : {}
117
+ when nil then {}
118
+ else input.respond_to?(:to_h) ? input.to_h : {}
119
+ end
120
+ rescue *LLM.json.parser_error
121
+ {}
122
+ end
123
+
124
+ def parts
125
+ raw = body.output&.message&.content || []
126
+ raw.is_a?(Array) ? raw : [raw].compact
127
+ end
128
+
129
+ def texts
130
+ @texts ||= parts.select { |b| b["text"] }
131
+ end
132
+
133
+ def tools
134
+ @tools ||= parts.select { |b| b["toolUse"] }
135
+ end
136
+
137
+ def role
138
+ body.output&.message&.role || "assistant"
139
+ end
140
+
141
+ include LLM::Contract::Completion
142
+ end
143
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Bedrock::ResponseAdapter
4
+ ##
5
+ # Adapts Bedrock ListFoundationModels API responses to llm.rb's
6
+ # normalized model collection format.
7
+ #
8
+ # The Bedrock ListFoundationModels response looks like:
9
+ # {
10
+ # "modelSummaries": [{
11
+ # "modelId": "anthropic.claude-sonnet-4-20250514-v1:0",
12
+ # "modelName": "Claude Sonnet 4",
13
+ # "providerName": "Anthropic",
14
+ # "inputModalities": ["TEXT", "IMAGE"],
15
+ # "outputModalities": ["TEXT"],
16
+ # ...
17
+ # }]
18
+ # }
19
+ module Models
20
+ include LLM::Model::Collection
21
+
22
+ private
23
+
24
+ def raw_models
25
+ (body.modelSummaries || []).map do |summary|
26
+ LLM::Object.from({
27
+ id: summary.modelId,
28
+ name: summary.modelName,
29
+ provider_name: summary.providerName
30
+ })
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Bedrock
4
+ ##
5
+ # Adapts Bedrock Converse API responses to llm.rb's Response format.
6
+ #
7
+ # Bedrock Converse returns:
8
+ # {
9
+ # output: {message: {role: "assistant", content: [{...}, ...]}},
10
+ # usage: {inputTokens: N, outputTokens: N},
11
+ # modelId: "anthropic.claude-...",
12
+ # stopReason: "end_turn" | "tool_use" | "max_tokens" | ...
13
+ # }
14
+ #
15
+ # @api private
16
+ module ResponseAdapter
17
+ module_function
18
+
19
+ ##
20
+ # @param [LLM::Response, Net::HTTPResponse] res
21
+ # @param [Symbol] type
22
+ # @return [LLM::Response]
23
+ def adapt(res, type:)
24
+ response = (LLM::Response === res) ? res : LLM::Response.new(res)
25
+ response.extend(select(type))
26
+ end
27
+
28
+ ##
29
+ # @api private
30
+ def select(type)
31
+ case type
32
+ when :completion then LLM::Bedrock::ResponseAdapter::Completion
33
+ when :models then LLM::Bedrock::ResponseAdapter::Models
34
+ else
35
+ raise ArgumentError,
36
+ "Unknown response adapter type: #{type.inspect}"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "openssl"
5
+
6
+ class LLM::Bedrock
7
+ ##
8
+ # Signs HTTP requests and headers with AWS Signature V4.
9
+ #
10
+ # Returns the signed headers as a Hash through #to_h, ready to merge
11
+ # into a Net::HTTPRequest or other HTTP client. Everything else is
12
+ # private.
13
+ #
14
+ # Uses only Ruby's stdlib (openssl, digest) with no external deps.
15
+ #
16
+ # @example
17
+ # signature = LLM::Bedrock::Signature.new(
18
+ # credentials: LLM::Object.from(
19
+ # access_key_id: ENV["AWS_ACCESS_KEY_ID"],
20
+ # secret_access_key: ENV["AWS_SECRET_ACCESS_KEY"],
21
+ # aws_region: "us-east-1",
22
+ # host: "bedrock-runtime.us-east-1.amazonaws.com",
23
+ # session_token: nil
24
+ # ),
25
+ # method: "POST",
26
+ # path: "/model/anthropic.claude-3/converse",
27
+ # body: '{"messages":[...]}'
28
+ # )
29
+ # signature.sign!(req)
30
+ #
31
+ # @api private
32
+ class Signature
33
+ SERVICE = "bedrock"
34
+
35
+ ##
36
+ # @param [LLM::Object] credentials AWS signing credentials and host
37
+ # @param [String] method HTTP method ("POST", "GET", etc.)
38
+ # @param [String] path Request path (e.g. "/model/.../converse")
39
+ # @param [String] body Raw request body
40
+ # @param [String, nil] query Canonical query string
41
+ def initialize(credentials:, method:, path:, body:, query: nil)
42
+ @credentials = credentials
43
+ @method = method
44
+ @path = path
45
+ @query = query
46
+ @body = body
47
+ end
48
+
49
+ ##
50
+ # Returns the signed headers as a plain Hash.
51
+ #
52
+ # Call this once per request and merge the result into your
53
+ # HTTP headers. Each call recomputes the signature with the
54
+ # current time, so call it immediately before sending.
55
+ #
56
+ # @return [Hash{String => String}]
57
+ def to_h
58
+ now = Time.now.utc
59
+ amz_date = now.strftime("%Y%m%dT%H%M%SZ")
60
+ date_stamp = now.strftime("%Y%m%d")
61
+ payload_hash = Digest::SHA256.hexdigest(@body)
62
+ headers = {
63
+ "X-Amz-Date" => amz_date,
64
+ "X-Amz-Content-Sha256" => payload_hash,
65
+ "Content-Type" => "application/json",
66
+ "Host" => @credentials.host
67
+ }
68
+ headers["X-Amz-Security-Token"] = @credentials.session_token if @credentials.session_token
69
+ signed_headers = build_signed_headers
70
+ canonical_headers = build_canonical_headers(headers, signed_headers)
71
+ canonical_uri = build_canonical_uri
72
+ canonical_query = build_canonical_query
73
+ canonical_request = build_canonical_request(
74
+ canonical_uri, canonical_query, canonical_headers, signed_headers, payload_hash
75
+ )
76
+ credential_scope = "#{date_stamp}/#{@credentials.aws_region}/#{SERVICE}/aws4_request"
77
+ string_to_sign = build_string_to_sign(
78
+ amz_date, credential_scope, canonical_request
79
+ )
80
+ signing_key = derive_signing_key(date_stamp)
81
+ signature = OpenSSL::HMAC.hexdigest(
82
+ "sha256", signing_key, string_to_sign
83
+ )
84
+ headers["Authorization"] =
85
+ "AWS4-HMAC-SHA256 " \
86
+ "Credential=#{@credentials.access_key_id}/#{credential_scope}, " \
87
+ "SignedHeaders=#{signed_headers}, Signature=#{signature}"
88
+ headers
89
+ end
90
+
91
+ ##
92
+ # @param [Net::HTTPRequest] req
93
+ # @return [Net::HTTPRequest]
94
+ def sign!(req)
95
+ to_h.each { |k, v| req[k] = v }
96
+ req
97
+ end
98
+
99
+ private
100
+
101
+ def build_signed_headers
102
+ %w[host x-amz-date x-amz-content-sha256].tap do |h|
103
+ h << "x-amz-security-token" if @credentials.session_token
104
+ h << "content-type"
105
+ end.sort.join(";")
106
+ end
107
+
108
+ def build_canonical_headers(headers, signed_headers)
109
+ headers = headers.transform_keys(&:downcase)
110
+ signed_headers.split(";").map do |key|
111
+ "#{key}:#{headers[key].to_s.strip}\n"
112
+ end.join
113
+ end
114
+
115
+ def build_canonical_uri
116
+ path = @path
117
+ return "/" if path.nil? || path.empty?
118
+ segments = path.split("/", -1).map { |s| uri_encode(s) }
119
+ canonical = segments.join("/")
120
+ canonical.start_with?("/") ? canonical : "/#{canonical}"
121
+ end
122
+
123
+ def build_canonical_query
124
+ return "" if @query.to_s.empty?
125
+ URI.decode_www_form(@query).sort.map do |key, value|
126
+ "#{uri_encode(key)}=#{uri_encode(value)}"
127
+ end.join("&")
128
+ end
129
+
130
+ def build_canonical_request(uri, query, canonical_headers,
131
+ signed_headers, payload_hash)
132
+ [
133
+ @method,
134
+ uri,
135
+ query,
136
+ canonical_headers,
137
+ signed_headers,
138
+ payload_hash
139
+ ].join("\n")
140
+ end
141
+
142
+ def build_string_to_sign(amz_date, credential_scope, canonical_request)
143
+ [
144
+ "AWS4-HMAC-SHA256",
145
+ amz_date,
146
+ credential_scope,
147
+ Digest::SHA256.hexdigest(canonical_request)
148
+ ].join("\n")
149
+ end
150
+
151
+ def derive_signing_key(date_stamp)
152
+ k_date = OpenSSL::HMAC.digest(
153
+ "sha256", "AWS4#{@credentials.secret_access_key}", date_stamp
154
+ )
155
+ k_region = OpenSSL::HMAC.digest("sha256", k_date, @credentials.aws_region)
156
+ k_service = OpenSSL::HMAC.digest("sha256", k_region, SERVICE)
157
+ OpenSSL::HMAC.digest("sha256", k_service, "aws4_request")
158
+ end
159
+
160
+ def uri_encode(str)
161
+ URI.encode_www_form_component(str.to_s)
162
+ .gsub("+", "%20")
163
+ .gsub("%7E", "~")
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ class LLM::Bedrock
6
+ ##
7
+ # Decodes AWS Event Stream binary frames.
8
+ #
9
+ # Bedrock Converse Stream uses the AWS Event Stream protocol,
10
+ # a binary framing format (not SSE). Each message has:
11
+ # - total length (4 bytes, big-endian)
12
+ # - headers length (4 bytes, big-endian)
13
+ # - prelude CRC (4 bytes)
14
+ # - headers (variable)
15
+ # - payload (variable, usually JSON)
16
+ # - message CRC (4 bytes)
17
+ #
18
+ # Implements #<< to match the interface expected by llm.rb's
19
+ # streaming transport, so it can replace the SSE-based
20
+ # StreamDecoder when streaming from Bedrock.
21
+ #
22
+ # @api private
23
+ class StreamDecoder
24
+ ##
25
+ # @return [LLM::Bedrock::StreamParser]
26
+ attr_reader :parser
27
+
28
+ ##
29
+ # @param [LLM::Bedrock::StreamParser] parser
30
+ def initialize(parser)
31
+ @buffer = +"".b
32
+ @parser = parser
33
+ end
34
+
35
+ ##
36
+ # Feeds a raw binary chunk into the decoder.
37
+ # Accumulates data until complete frames are available,
38
+ # then decodes them and passes the JSON payload to the parser.
39
+ #
40
+ # @param [String] chunk Raw binary data
41
+ # @return [void]
42
+ def <<(chunk)
43
+ @buffer << chunk
44
+ decode_frames
45
+ end
46
+
47
+ ##
48
+ # @return [Hash] The fully constructed response body
49
+ def body
50
+ parser.body
51
+ end
52
+
53
+ ##
54
+ # @return [void]
55
+ def free
56
+ @buffer.clear
57
+ parser.free if parser.respond_to?(:free)
58
+ end
59
+
60
+ private
61
+
62
+ def decode_frames
63
+ loop do
64
+ break if @buffer.bytesize < 12
65
+ total_length = @buffer[0, 4].unpack1("N")
66
+ break if @buffer.bytesize < total_length
67
+ # headers_length = @buffer[4, 4].unpack1("N")
68
+ # prelude_crc = @buffer[8, 4].unpack1("N")
69
+ headers = decode_headers
70
+ payload_start = 12 + headers[:length]
71
+ payload_length = total_length - payload_start - 4
72
+ payload = @buffer[payload_start, payload_length] if payload_length > 0
73
+ # message_crc from last 4 bytes, not needed for our purposes
74
+ json = payload ? LLM.json.load(payload) : {}
75
+ parser.parse!(json, event_type: headers[:event_type]) if json.is_a?(Hash)
76
+ @buffer = @buffer[total_length..] || +"".b
77
+ end
78
+ end
79
+
80
+ def decode_headers
81
+ headers_length = @buffer[4, 4].unpack1("N")
82
+ offset = 12
83
+ end_offset = offset + headers_length
84
+ result = {event_type: nil, length: headers_length}
85
+ while offset < end_offset
86
+ name_len = @buffer.getbyte(offset)
87
+ offset += 1
88
+ break if offset + name_len > end_offset
89
+ name = @buffer[offset, name_len]
90
+ offset += name_len
91
+ break if offset >= end_offset
92
+ value_type = @buffer.getbyte(offset)
93
+ offset += 1
94
+ value = case value_type
95
+ when 7 # string
96
+ str_len = @buffer[offset, 2].unpack1("n")
97
+ offset += 2
98
+ str = @buffer[offset, str_len]
99
+ offset += str_len
100
+ str
101
+ when 8 # binary
102
+ bin_len = @buffer[offset, 2].unpack1("n")
103
+ offset += 2
104
+ bin = @buffer[offset, bin_len]
105
+ offset += bin_len
106
+ bin
107
+ when 9 # boolean true
108
+ true
109
+ when 1 # boolean false
110
+ false
111
+ when 2 # byte
112
+ val = @buffer.getbyte(offset)
113
+ offset += 1
114
+ val
115
+ when 3 # int16
116
+ val = @buffer[offset, 2].unpack1("s>")
117
+ offset += 2
118
+ val
119
+ when 4 # int32
120
+ val = @buffer[offset, 4].unpack1("l>")
121
+ offset += 4
122
+ val
123
+ when 6 # byte array (as raw string)
124
+ bin_len = @buffer[offset, 2].unpack1("n")
125
+ offset += 2
126
+ bin = @buffer[offset, bin_len]
127
+ offset += bin_len
128
+ bin
129
+ else
130
+ # Unknown type, skip to end of headers
131
+ offset = end_offset
132
+ nil
133
+ end
134
+ result[:event_type] = value if name == ":event-type"
135
+ result[name] = value if name
136
+ end
137
+ result
138
+ end
139
+ end
140
+ end