llm.rb 7.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +151 -1
- data/README.md +45 -25
- data/data/bedrock.json +2948 -0
- data/data/deepseek.json +8 -8
- data/data/openai.json +39 -2
- data/data/xai.json +35 -0
- data/data/zai.json +1 -1
- data/lib/llm/active_record/acts_as_agent.rb +2 -6
- data/lib/llm/active_record/acts_as_llm.rb +4 -82
- data/lib/llm/active_record.rb +80 -2
- data/lib/llm/agent.rb +9 -4
- data/lib/llm/error.rb +4 -0
- data/lib/llm/function/array.rb +7 -3
- data/lib/llm/function/fiber_group.rb +9 -3
- data/lib/llm/function/fork/job.rb +67 -0
- data/lib/llm/function/fork/task.rb +76 -0
- data/lib/llm/function/fork.rb +8 -0
- data/lib/llm/function/fork_group.rb +36 -0
- data/lib/llm/function/ractor/task.rb +13 -3
- data/lib/llm/function/task.rb +10 -2
- data/lib/llm/function.rb +24 -11
- data/lib/llm/mcp/command.rb +1 -1
- data/lib/llm/mcp/transport/http.rb +2 -2
- data/lib/llm/mcp.rb +7 -4
- data/lib/llm/object/kernel.rb +8 -2
- data/lib/llm/object.rb +75 -21
- data/lib/llm/{mcp/pipe.rb → pipe.rb} +9 -8
- data/lib/llm/provider/transport/http/execution.rb +1 -1
- data/lib/llm/provider/transport/http.rb +1 -1
- data/lib/llm/provider.rb +7 -0
- data/lib/llm/providers/bedrock/error_handler.rb +80 -0
- data/lib/llm/providers/bedrock/models.rb +109 -0
- data/lib/llm/providers/bedrock/request_adapter/completion.rb +153 -0
- data/lib/llm/providers/bedrock/request_adapter.rb +95 -0
- data/lib/llm/providers/bedrock/response_adapter/completion.rb +143 -0
- data/lib/llm/providers/bedrock/response_adapter/models.rb +34 -0
- data/lib/llm/providers/bedrock/response_adapter.rb +40 -0
- data/lib/llm/providers/bedrock/signature.rb +166 -0
- data/lib/llm/providers/bedrock/stream_decoder.rb +140 -0
- data/lib/llm/providers/bedrock/stream_parser.rb +201 -0
- data/lib/llm/providers/bedrock.rb +272 -0
- data/lib/llm/stream/queue.rb +1 -1
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +27 -1
- data/llm.gemspec +2 -1
- metadata +33 -3
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM::Bedrock::RequestAdapter
|
|
4
|
+
##
|
|
5
|
+
# Adapts a single message to Bedrock Converse content blocks.
|
|
6
|
+
#
|
|
7
|
+
# Bedrock Converse content blocks include:
|
|
8
|
+
# - {text: "..."}
|
|
9
|
+
# - {image: {format: "png", source: {bytes: "..."}}}
|
|
10
|
+
# - {document: {format: "pdf", name: "...", source: {bytes: "..."}}}
|
|
11
|
+
# - {toolUse: {toolUseId: "...", name: "...", input: {...}}}
|
|
12
|
+
# - {toolResult: {toolUseId: "...", content: [{text: "..."}]}}
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
15
|
+
class Completion
|
|
16
|
+
##
|
|
17
|
+
# @param [LLM::Message, Hash] message
|
|
18
|
+
def initialize(message)
|
|
19
|
+
@message = message
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
##
|
|
23
|
+
# Adapts the message for the Bedrock Converse API
|
|
24
|
+
# @return [Hash, nil]
|
|
25
|
+
def adapt
|
|
26
|
+
catch(:abort) do
|
|
27
|
+
if Hash === message
|
|
28
|
+
{role: message[:role], content: adapt_content(message[:content])}
|
|
29
|
+
else
|
|
30
|
+
adapt_message
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def adapt_message
|
|
38
|
+
if message.tool_call?
|
|
39
|
+
blocks = [*adapt_tool_calls]
|
|
40
|
+
blocks.unshift(*adapt_content(content)) unless String === content && content.empty?
|
|
41
|
+
{role: "assistant", content: blocks}
|
|
42
|
+
else
|
|
43
|
+
{role: message.role, content: adapt_content(content)}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def adapt_tool_calls
|
|
48
|
+
message.extra[:tool_calls].filter_map do |tool|
|
|
49
|
+
next unless tool[:id] && tool[:name]
|
|
50
|
+
{
|
|
51
|
+
toolUse: {
|
|
52
|
+
toolUseId: tool[:id],
|
|
53
|
+
name: tool[:name],
|
|
54
|
+
input: parse_tool_input(tool[:arguments])
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
##
|
|
61
|
+
# @param [String, Array, LLM::Object, LLM::Function::Return] content
|
|
62
|
+
# @return [Array<Hash>, nil]
|
|
63
|
+
def adapt_content(content)
|
|
64
|
+
case content
|
|
65
|
+
when Hash
|
|
66
|
+
content.empty? ? throw(:abort, nil) : [content]
|
|
67
|
+
when Array
|
|
68
|
+
content.empty? ? throw(:abort, nil) : content.flat_map { adapt_content(_1) }
|
|
69
|
+
when LLM::Object
|
|
70
|
+
adapt_object(content)
|
|
71
|
+
when String
|
|
72
|
+
[{text: content}]
|
|
73
|
+
when LLM::Response
|
|
74
|
+
adapt_remote_file(content)
|
|
75
|
+
when LLM::Message
|
|
76
|
+
adapt_content(content.content)
|
|
77
|
+
when LLM::Function::Return
|
|
78
|
+
[{toolResult: {toolUseId: content.id, content: [{text: LLM.json.dump(content.value)}]}}]
|
|
79
|
+
else
|
|
80
|
+
prompt_error!(content)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def adapt_object(object)
|
|
85
|
+
case object.kind
|
|
86
|
+
when :image_url
|
|
87
|
+
[{image: {format: detect_format(object.value.to_s),
|
|
88
|
+
source: {url: object.value.to_s}}}]
|
|
89
|
+
when :local_file
|
|
90
|
+
adapt_local_file(object.value)
|
|
91
|
+
when :remote_file
|
|
92
|
+
adapt_remote_file(object.value)
|
|
93
|
+
else
|
|
94
|
+
prompt_error!(object)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def adapt_local_file(file)
|
|
99
|
+
if file.image?
|
|
100
|
+
[{image: {format: file.format,
|
|
101
|
+
source: {bytes: file.to_b64}}}]
|
|
102
|
+
elsif file.pdf?
|
|
103
|
+
name = sanitize_name(file.basename)
|
|
104
|
+
[{document: {format: "pdf", name:,
|
|
105
|
+
source: {bytes: file.to_b64}}}]
|
|
106
|
+
else
|
|
107
|
+
raise LLM::PromptError,
|
|
108
|
+
"The #{file.class} is not an image or PDF, " \
|
|
109
|
+
"and not supported by the Bedrock API"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def adapt_remote_file(file)
|
|
114
|
+
prompt_error!(file) unless file.file?
|
|
115
|
+
[{file.file_type => {source: {file_id: file.id}}}]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def detect_format(url)
|
|
119
|
+
case url
|
|
120
|
+
when /\.png/i then "png"
|
|
121
|
+
when /\.jpe?g/i then "jpeg"
|
|
122
|
+
when /\.gif/i then "gif"
|
|
123
|
+
when /\.webp/i then "webp"
|
|
124
|
+
else "png"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def sanitize_name(name)
|
|
129
|
+
name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def parse_tool_input(input)
|
|
133
|
+
case input
|
|
134
|
+
when Hash then input
|
|
135
|
+
when String
|
|
136
|
+
parsed = LLM.json.load(input)
|
|
137
|
+
Hash === parsed ? parsed : {}
|
|
138
|
+
when nil then {}
|
|
139
|
+
else input.respond_to?(:to_h) ? input.to_h : {}
|
|
140
|
+
end
|
|
141
|
+
rescue *LLM.json.parser_error
|
|
142
|
+
{}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def prompt_error!(content)
|
|
146
|
+
raise LLM::PromptError,
|
|
147
|
+
"#{content.class} is not supported by the Bedrock API"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def message = @message
|
|
151
|
+
def content = message.content
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Bedrock
|
|
4
|
+
##
|
|
5
|
+
# Adapts llm.rb internal message format to Bedrock Converse API format.
|
|
6
|
+
#
|
|
7
|
+
# Bedrock Converse uses:
|
|
8
|
+
# - system: [{text: "..."}] (top-level, separate from messages)
|
|
9
|
+
# - messages: [{role: "user"|"assistant", content: [{...}, ...]}]
|
|
10
|
+
# - Content blocks: text, image, document, toolUse, toolResult
|
|
11
|
+
# - toolConfig: {tools: [{toolSpec: {name:, description:, inputSchema: {json: ...}}}]}
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
module RequestAdapter
|
|
15
|
+
##
|
|
16
|
+
# @param [Array<LLM::Message>] messages
|
|
17
|
+
# @return [Hash]
|
|
18
|
+
def adapt(messages, mode: nil)
|
|
19
|
+
payload = {messages: [], system: []}
|
|
20
|
+
messages.each do |message|
|
|
21
|
+
adapted = Completion.new(message).adapt
|
|
22
|
+
next if adapted.nil?
|
|
23
|
+
if system?(message)
|
|
24
|
+
payload[:system].concat Array(adapted[:content])
|
|
25
|
+
else
|
|
26
|
+
payload[:messages] << adapted
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
payload.delete(:system) if payload[:system].empty?
|
|
30
|
+
payload
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
##
|
|
36
|
+
# @param [Hash] params
|
|
37
|
+
# @return [Hash]
|
|
38
|
+
def adapt_schema(params)
|
|
39
|
+
return {} unless params&.key?(:schema)
|
|
40
|
+
schema = params.delete(:schema)
|
|
41
|
+
schema = schema.respond_to?(:object) ? schema.object : schema
|
|
42
|
+
cleaned = schema.respond_to?(:to_h) ? schema.to_h : schema
|
|
43
|
+
[:strict, "strict", :$schema, "$schema"].each { cleaned.delete(_1) }
|
|
44
|
+
{
|
|
45
|
+
outputConfig: {
|
|
46
|
+
textFormat: {
|
|
47
|
+
type: "json_schema",
|
|
48
|
+
structure: {
|
|
49
|
+
jsonSchema: {
|
|
50
|
+
name: "response",
|
|
51
|
+
schema: LLM.json.dump(cleaned)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
# @param [Array<LLM::Function>] tools
|
|
61
|
+
# @return [Hash]
|
|
62
|
+
def adapt_tools(tools)
|
|
63
|
+
return {} unless tools&.any?
|
|
64
|
+
{toolConfig: {tools: tools.map { |t| adapt_tool(t) }}}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# @param [LLM::Function] tool
|
|
69
|
+
# @return [Hash]
|
|
70
|
+
def adapt_tool(tool)
|
|
71
|
+
function = tool.respond_to?(:function) ? tool.function : tool
|
|
72
|
+
{
|
|
73
|
+
toolSpec: {
|
|
74
|
+
name: function.name,
|
|
75
|
+
description: function.description,
|
|
76
|
+
inputSchema: {
|
|
77
|
+
json: function.params || default_input_schema
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def default_input_schema
|
|
84
|
+
{"type" => "object", "properties" => {}, "required" => []}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def system?(message)
|
|
88
|
+
if message.respond_to?(:system?)
|
|
89
|
+
message.system?
|
|
90
|
+
else
|
|
91
|
+
Hash === message && message[:role].to_s == "system"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -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
|