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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +8 -3
- 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/object.rb +8 -0
- data/lib/llm/provider/transport/http/execution.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/version.rb +1 -1
- data/lib/llm.rb +8 -0
- metadata +13 -1
data/data/deepseek.json
CHANGED
|
@@ -29,13 +29,13 @@
|
|
|
29
29
|
},
|
|
30
30
|
"open_weights": true,
|
|
31
31
|
"cost": {
|
|
32
|
-
"input": 0.
|
|
33
|
-
"output": 0.
|
|
32
|
+
"input": 0.14,
|
|
33
|
+
"output": 0.28,
|
|
34
34
|
"cache_read": 0.028
|
|
35
35
|
},
|
|
36
36
|
"limit": {
|
|
37
|
-
"context":
|
|
38
|
-
"output":
|
|
37
|
+
"context": 1000000,
|
|
38
|
+
"output": 384000
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
41
|
"deepseek-reasoner": {
|
|
@@ -62,13 +62,13 @@
|
|
|
62
62
|
},
|
|
63
63
|
"open_weights": true,
|
|
64
64
|
"cost": {
|
|
65
|
-
"input": 0.
|
|
66
|
-
"output": 0.
|
|
65
|
+
"input": 0.14,
|
|
66
|
+
"output": 0.28,
|
|
67
67
|
"cache_read": 0.028
|
|
68
68
|
},
|
|
69
69
|
"limit": {
|
|
70
|
-
"context":
|
|
71
|
-
"output":
|
|
70
|
+
"context": 1000000,
|
|
71
|
+
"output": 384000
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
74
|
"deepseek-v4-flash": {
|
data/data/openai.json
CHANGED
|
@@ -230,8 +230,8 @@
|
|
|
230
230
|
},
|
|
231
231
|
"limit": {
|
|
232
232
|
"context": 1050000,
|
|
233
|
-
"input":
|
|
234
|
-
"output":
|
|
233
|
+
"input": 922000,
|
|
234
|
+
"output": 128000
|
|
235
235
|
},
|
|
236
236
|
"experimental": {
|
|
237
237
|
"modes": {
|
|
@@ -1554,6 +1554,43 @@
|
|
|
1554
1554
|
"output": 0
|
|
1555
1555
|
}
|
|
1556
1556
|
},
|
|
1557
|
+
"gpt-5.5-pro": {
|
|
1558
|
+
"id": "gpt-5.5-pro",
|
|
1559
|
+
"name": "GPT-5.5 Pro",
|
|
1560
|
+
"family": "gpt-pro",
|
|
1561
|
+
"attachment": true,
|
|
1562
|
+
"reasoning": true,
|
|
1563
|
+
"tool_call": true,
|
|
1564
|
+
"structured_output": true,
|
|
1565
|
+
"temperature": false,
|
|
1566
|
+
"knowledge": "2025-12-01",
|
|
1567
|
+
"release_date": "2026-04-23",
|
|
1568
|
+
"last_updated": "2026-04-23",
|
|
1569
|
+
"modalities": {
|
|
1570
|
+
"input": [
|
|
1571
|
+
"text",
|
|
1572
|
+
"image",
|
|
1573
|
+
"pdf"
|
|
1574
|
+
],
|
|
1575
|
+
"output": [
|
|
1576
|
+
"text"
|
|
1577
|
+
]
|
|
1578
|
+
},
|
|
1579
|
+
"open_weights": false,
|
|
1580
|
+
"cost": {
|
|
1581
|
+
"input": 30,
|
|
1582
|
+
"output": 180,
|
|
1583
|
+
"context_over_200k": {
|
|
1584
|
+
"input": 60,
|
|
1585
|
+
"output": 270
|
|
1586
|
+
}
|
|
1587
|
+
},
|
|
1588
|
+
"limit": {
|
|
1589
|
+
"context": 1050000,
|
|
1590
|
+
"input": 922000,
|
|
1591
|
+
"output": 128000
|
|
1592
|
+
}
|
|
1593
|
+
},
|
|
1557
1594
|
"gpt-4.1": {
|
|
1558
1595
|
"id": "gpt-4.1",
|
|
1559
1596
|
"name": "GPT-4.1",
|
data/data/xai.json
CHANGED
|
@@ -68,6 +68,41 @@
|
|
|
68
68
|
"output": 4096
|
|
69
69
|
}
|
|
70
70
|
},
|
|
71
|
+
"grok-4.3": {
|
|
72
|
+
"id": "grok-4.3",
|
|
73
|
+
"name": "Grok 4.3",
|
|
74
|
+
"family": "grok",
|
|
75
|
+
"attachment": true,
|
|
76
|
+
"reasoning": true,
|
|
77
|
+
"tool_call": true,
|
|
78
|
+
"temperature": true,
|
|
79
|
+
"release_date": "2026-05-01",
|
|
80
|
+
"last_updated": "2026-05-01",
|
|
81
|
+
"modalities": {
|
|
82
|
+
"input": [
|
|
83
|
+
"text",
|
|
84
|
+
"image"
|
|
85
|
+
],
|
|
86
|
+
"output": [
|
|
87
|
+
"text"
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
"open_weights": false,
|
|
91
|
+
"cost": {
|
|
92
|
+
"input": 1.25,
|
|
93
|
+
"output": 2.5,
|
|
94
|
+
"cache_read": 0.2,
|
|
95
|
+
"context_over_200k": {
|
|
96
|
+
"input": 2.5,
|
|
97
|
+
"output": 5,
|
|
98
|
+
"cache_read": 0.4
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"limit": {
|
|
102
|
+
"context": 1000000,
|
|
103
|
+
"output": 30000
|
|
104
|
+
}
|
|
105
|
+
},
|
|
71
106
|
"grok-3-mini-fast": {
|
|
72
107
|
"id": "grok-3-mini-fast",
|
|
73
108
|
"name": "Grok 3 Mini Fast",
|
data/data/zai.json
CHANGED
data/lib/llm/object.rb
CHANGED
|
@@ -60,6 +60,14 @@ class LLM::Object < BasicObject
|
|
|
60
60
|
@h.each(&)
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
##
|
|
64
|
+
# In-place transform of values with a block.
|
|
65
|
+
# @yieldparam [Object] v
|
|
66
|
+
# @return [Hash]
|
|
67
|
+
def transform_values!(&)
|
|
68
|
+
@h.transform_values!(&)
|
|
69
|
+
end
|
|
70
|
+
|
|
63
71
|
##
|
|
64
72
|
# @param [Symbol, #to_sym] k
|
|
65
73
|
# @return [Object]
|
|
@@ -92,7 +92,7 @@ module LLM::Provider::Transport
|
|
|
92
92
|
if stream
|
|
93
93
|
http.request(request) do |res|
|
|
94
94
|
if Net::HTTPSuccess === res
|
|
95
|
-
parser =
|
|
95
|
+
parser = stream_decoder.new(stream_parser.new(stream))
|
|
96
96
|
res.read_body(parser)
|
|
97
97
|
body = parser.body
|
|
98
98
|
res.body = (Hash === body || Array === body) ? LLM::Object.from(body) : body
|
data/lib/llm/provider.rb
CHANGED
|
@@ -399,6 +399,13 @@ class LLM::Provider
|
|
|
399
399
|
raise NotImplementedError
|
|
400
400
|
end
|
|
401
401
|
|
|
402
|
+
##
|
|
403
|
+
# @return [Class]
|
|
404
|
+
# Returns the class responsible for decoding streamed response bodies
|
|
405
|
+
def stream_decoder
|
|
406
|
+
LLM::Provider::Transport::HTTP::StreamDecoder
|
|
407
|
+
end
|
|
408
|
+
|
|
402
409
|
##
|
|
403
410
|
# Resolves tools to their function representations
|
|
404
411
|
# @param [Array<LLM::Function, LLM::Tool>] tools
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Bedrock
|
|
4
|
+
##
|
|
5
|
+
# Handles Bedrock API error responses.
|
|
6
|
+
#
|
|
7
|
+
# Bedrock errors come as JSON with:
|
|
8
|
+
# { "message" => "...", "__type" => "..." }
|
|
9
|
+
# or as standard HTTP status codes.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
class ErrorHandler
|
|
13
|
+
##
|
|
14
|
+
# @return [Net::HTTPResponse]
|
|
15
|
+
attr_reader :res
|
|
16
|
+
|
|
17
|
+
##
|
|
18
|
+
# @return [Object, nil]
|
|
19
|
+
attr_reader :span
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# @param [LLM::Tracer] tracer
|
|
23
|
+
# @param [Object, nil] span
|
|
24
|
+
# @param [Net::HTTPResponse] res
|
|
25
|
+
# @return [LLM::Bedrock::ErrorHandler]
|
|
26
|
+
def initialize(tracer, span, res)
|
|
27
|
+
@tracer = tracer
|
|
28
|
+
@span = span
|
|
29
|
+
@res = res
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# @raise [LLM::Error]
|
|
34
|
+
def raise_error!
|
|
35
|
+
ex = error
|
|
36
|
+
@tracer.on_request_error(ex:, span:)
|
|
37
|
+
ensure
|
|
38
|
+
raise(ex) if ex
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# @return [LLM::Error]
|
|
45
|
+
def error
|
|
46
|
+
message = extract_message
|
|
47
|
+
case res
|
|
48
|
+
when Net::HTTPServerError
|
|
49
|
+
LLM::ServerError.new(message).tap { _1.response = res }
|
|
50
|
+
when Net::HTTPUnauthorized
|
|
51
|
+
LLM::UnauthorizedError.new(message).tap { _1.response = res }
|
|
52
|
+
when Net::HTTPForbidden
|
|
53
|
+
LLM::UnauthorizedError.new(message).tap { _1.response = res }
|
|
54
|
+
when Net::HTTPTooManyRequests
|
|
55
|
+
LLM::RateLimitError.new(message).tap { _1.response = res }
|
|
56
|
+
when Net::HTTPNotFound
|
|
57
|
+
LLM::Error.new("Bedrock model not found: #{message}").tap { _1.response = res }
|
|
58
|
+
else
|
|
59
|
+
LLM::Error.new(message).tap { _1.response = res }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
##
|
|
64
|
+
# @return [String]
|
|
65
|
+
def extract_message
|
|
66
|
+
body = parse_body
|
|
67
|
+
body["message"] || body["Message"] || body["__type"] || "Unexpected error"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
##
|
|
71
|
+
# @return [Hash]
|
|
72
|
+
def parse_body
|
|
73
|
+
return {} if res.body.nil? || res.body.empty?
|
|
74
|
+
parsed = LLM.json.load(res.body.dup.force_encoding(Encoding::UTF_8).scrub)
|
|
75
|
+
Hash === parsed ? parsed : {}
|
|
76
|
+
rescue *LLM.json.parser_error
|
|
77
|
+
{}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Bedrock
|
|
4
|
+
##
|
|
5
|
+
# The {LLM::Bedrock::Models} class provides a model object for
|
|
6
|
+
# interacting with [AWS Bedrock's ListFoundationModels API](
|
|
7
|
+
# https://docs.aws.amazon.com/bedrock/latest/APIReference/API_ListFoundationModels.html).
|
|
8
|
+
#
|
|
9
|
+
# Unlike the Converse API (which lives on `bedrock-runtime.<region>.amazonaws.com`),
|
|
10
|
+
# the models endpoint lives on the control plane at
|
|
11
|
+
# `bedrock.<region>.amazonaws.com`. This class manages its own HTTP
|
|
12
|
+
# connection since the provider's transport is pinned to the runtime host.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# llm = LLM.bedrock(
|
|
16
|
+
# access_key_id: ENV["AWS_ACCESS_KEY_ID"],
|
|
17
|
+
# secret_access_key: ENV["AWS_SECRET_ACCESS_KEY"],
|
|
18
|
+
# region: "us-east-1"
|
|
19
|
+
# )
|
|
20
|
+
# llm.models.all.each { |m| puts m.id }
|
|
21
|
+
class Models
|
|
22
|
+
##
|
|
23
|
+
# @param [LLM::Bedrock] provider
|
|
24
|
+
# @return [LLM::Bedrock::Models]
|
|
25
|
+
def initialize(provider)
|
|
26
|
+
@provider = provider
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
##
|
|
30
|
+
# List all foundation models available in the configured region.
|
|
31
|
+
#
|
|
32
|
+
# @note
|
|
33
|
+
# This calls AWS Bedrock's ListFoundationModels API which returns
|
|
34
|
+
# all models available in the region, not just the ones the
|
|
35
|
+
# current account is subscribed to.
|
|
36
|
+
#
|
|
37
|
+
# @param [Hash] params Optional query parameters
|
|
38
|
+
# (e.g. `byProvider: "Anthropic"`, `byInferenceType: "ON_DEMAND"`)
|
|
39
|
+
# @return [LLM::Response]
|
|
40
|
+
def all(**params)
|
|
41
|
+
host = credentials.host
|
|
42
|
+
handle_response http(host).request(build_request(host, params))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
##
|
|
48
|
+
# @param [String] host
|
|
49
|
+
# @return [Net::HTTP]
|
|
50
|
+
def http(host)
|
|
51
|
+
http = Net::HTTP.new(host, 443)
|
|
52
|
+
http.use_ssl = true
|
|
53
|
+
http.read_timeout = timeout
|
|
54
|
+
http
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
##
|
|
58
|
+
# @param [String] host
|
|
59
|
+
# @param [Hash] params
|
|
60
|
+
# @return [Net::HTTP::Get]
|
|
61
|
+
def build_request(host, params)
|
|
62
|
+
path = "/foundation-models"
|
|
63
|
+
query = URI.encode_www_form(params) unless params.empty?
|
|
64
|
+
path = "#{path}?#{query}" if query && !query.empty?
|
|
65
|
+
body = ""
|
|
66
|
+
req = Net::HTTP::Get.new(path, {"Content-Type" => "application/json", "Accept" => "application/json"})
|
|
67
|
+
req.tap { sign!(req, body, host, query) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
##
|
|
71
|
+
# @param [Net::HTTPResponse] res
|
|
72
|
+
# @return [LLM::Response]
|
|
73
|
+
# @raise [LLM::Error]
|
|
74
|
+
def handle_response(res)
|
|
75
|
+
case res
|
|
76
|
+
when Net::HTTPSuccess
|
|
77
|
+
res.body = LLM::Object.from(LLM.json.load(res.body || "{}"))
|
|
78
|
+
LLM::Bedrock::ResponseAdapter.adapt(res, type: :models)
|
|
79
|
+
else
|
|
80
|
+
body = +""
|
|
81
|
+
res.read_body { body << _1 } if res.body.nil?
|
|
82
|
+
LLM::Bedrock::ErrorHandler.new(tracer, nil, res).raise_error!
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
##
|
|
87
|
+
# @param [Net::HTTPRequest] req
|
|
88
|
+
# @param [String] body
|
|
89
|
+
# @param [String] host
|
|
90
|
+
# @param [String, nil] query
|
|
91
|
+
# @return [Net::HTTPRequest]
|
|
92
|
+
def sign!(req, body, host = credentials.host, query = nil)
|
|
93
|
+
creds = credentials.tap { _1.host = host }
|
|
94
|
+
Signature.new(credentials: creds, method: "GET", path: "/foundation-models", query:, body:).sign!(req)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
##
|
|
98
|
+
# @return [LLM::Object]
|
|
99
|
+
def credentials
|
|
100
|
+
LLM::Object.from(@provider.send(:credentials).to_h).tap do
|
|
101
|
+
_1.host = "bedrock.#{_1.aws_region}.amazonaws.com"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
[:timeout, :tracer].each do |m|
|
|
106
|
+
define_method(m) { @provider.send(m) }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -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
|