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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +151 -1
  3. data/README.md +45 -25
  4. data/data/bedrock.json +2948 -0
  5. data/data/deepseek.json +8 -8
  6. data/data/openai.json +39 -2
  7. data/data/xai.json +35 -0
  8. data/data/zai.json +1 -1
  9. data/lib/llm/active_record/acts_as_agent.rb +2 -6
  10. data/lib/llm/active_record/acts_as_llm.rb +4 -82
  11. data/lib/llm/active_record.rb +80 -2
  12. data/lib/llm/agent.rb +9 -4
  13. data/lib/llm/error.rb +4 -0
  14. data/lib/llm/function/array.rb +7 -3
  15. data/lib/llm/function/fiber_group.rb +9 -3
  16. data/lib/llm/function/fork/job.rb +67 -0
  17. data/lib/llm/function/fork/task.rb +76 -0
  18. data/lib/llm/function/fork.rb +8 -0
  19. data/lib/llm/function/fork_group.rb +36 -0
  20. data/lib/llm/function/ractor/task.rb +13 -3
  21. data/lib/llm/function/task.rb +10 -2
  22. data/lib/llm/function.rb +24 -11
  23. data/lib/llm/mcp/command.rb +1 -1
  24. data/lib/llm/mcp/transport/http.rb +2 -2
  25. data/lib/llm/mcp.rb +7 -4
  26. data/lib/llm/object/kernel.rb +8 -2
  27. data/lib/llm/object.rb +75 -21
  28. data/lib/llm/{mcp/pipe.rb → pipe.rb} +9 -8
  29. data/lib/llm/provider/transport/http/execution.rb +1 -1
  30. data/lib/llm/provider/transport/http.rb +1 -1
  31. data/lib/llm/provider.rb +7 -0
  32. data/lib/llm/providers/bedrock/error_handler.rb +80 -0
  33. data/lib/llm/providers/bedrock/models.rb +109 -0
  34. data/lib/llm/providers/bedrock/request_adapter/completion.rb +153 -0
  35. data/lib/llm/providers/bedrock/request_adapter.rb +95 -0
  36. data/lib/llm/providers/bedrock/response_adapter/completion.rb +143 -0
  37. data/lib/llm/providers/bedrock/response_adapter/models.rb +34 -0
  38. data/lib/llm/providers/bedrock/response_adapter.rb +40 -0
  39. data/lib/llm/providers/bedrock/signature.rb +166 -0
  40. data/lib/llm/providers/bedrock/stream_decoder.rb +140 -0
  41. data/lib/llm/providers/bedrock/stream_parser.rb +201 -0
  42. data/lib/llm/providers/bedrock.rb +272 -0
  43. data/lib/llm/stream/queue.rb +1 -1
  44. data/lib/llm/version.rb +1 -1
  45. data/lib/llm.rb +27 -1
  46. data/llm.gemspec +2 -1
  47. 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