ruby_llm 1.11.0 → 1.12.1

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +12 -0
  3. data/lib/ruby_llm/active_record/acts_as.rb +0 -2
  4. data/lib/ruby_llm/active_record/acts_as_legacy.rb +97 -27
  5. data/lib/ruby_llm/active_record/chat_methods.rb +73 -19
  6. data/lib/ruby_llm/agent.rb +326 -0
  7. data/lib/ruby_llm/aliases.json +47 -29
  8. data/lib/ruby_llm/chat.rb +27 -3
  9. data/lib/ruby_llm/configuration.rb +3 -0
  10. data/lib/ruby_llm/content.rb +6 -0
  11. data/lib/ruby_llm/models.json +19090 -5190
  12. data/lib/ruby_llm/models.rb +35 -6
  13. data/lib/ruby_llm/provider.rb +8 -0
  14. data/lib/ruby_llm/providers/azure/chat.rb +29 -0
  15. data/lib/ruby_llm/providers/azure/embeddings.rb +24 -0
  16. data/lib/ruby_llm/providers/azure/media.rb +45 -0
  17. data/lib/ruby_llm/providers/azure/models.rb +14 -0
  18. data/lib/ruby_llm/providers/azure.rb +56 -0
  19. data/lib/ruby_llm/providers/bedrock/auth.rb +122 -0
  20. data/lib/ruby_llm/providers/bedrock/chat.rb +296 -64
  21. data/lib/ruby_llm/providers/bedrock/media.rb +62 -33
  22. data/lib/ruby_llm/providers/bedrock/models.rb +88 -65
  23. data/lib/ruby_llm/providers/bedrock/streaming.rb +305 -8
  24. data/lib/ruby_llm/providers/bedrock.rb +61 -52
  25. data/lib/ruby_llm/version.rb +1 -1
  26. data/lib/ruby_llm.rb +4 -0
  27. data/lib/tasks/models.rake +10 -5
  28. data/lib/tasks/vcr.rake +32 -0
  29. metadata +17 -17
  30. data/lib/ruby_llm/providers/bedrock/capabilities.rb +0 -167
  31. data/lib/ruby_llm/providers/bedrock/signing.rb +0 -831
  32. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +0 -51
  33. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +0 -128
  34. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +0 -67
  35. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +0 -85
  36. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +0 -78
@@ -3,112 +3,344 @@
3
3
  module RubyLLM
4
4
  module Providers
5
5
  class Bedrock
6
- # Chat methods for the AWS Bedrock API implementation
6
+ # Chat methods for Bedrock Converse API.
7
7
  module Chat
8
8
  module_function
9
9
 
10
- def sync_response(connection, payload, additional_headers = {})
11
- signature = sign_request("#{connection.connection.url_prefix}#{completion_url}", payload:)
12
- response = connection.post completion_url, payload do |req|
13
- req.headers.merge! build_headers(signature.headers, streaming: block_given?)
14
- req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
15
- end
16
- Anthropic::Chat.parse_completion_response response
10
+ def completion_url
11
+ "/model/#{@model.id}/converse"
17
12
  end
18
13
 
19
- def format_message(msg, thinking: nil)
20
- thinking_enabled = thinking&.enabled?
14
+ def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists,Lint/UnusedMethodArgument
15
+ @model = model
16
+ @used_document_names = {}
17
+ system_messages, chat_messages = messages.partition { |msg| msg.role == :system }
21
18
 
22
- if msg.tool_call?
23
- format_tool_call_with_thinking(msg, thinking_enabled)
24
- elsif msg.tool_result?
25
- Anthropic::Tools.format_tool_result(msg)
26
- else
27
- format_basic_message_with_thinking(msg, thinking_enabled)
19
+ payload = {
20
+ messages: render_messages(chat_messages)
21
+ }
22
+
23
+ system_blocks = render_system(system_messages)
24
+ payload[:system] = system_blocks unless system_blocks.empty?
25
+
26
+ payload[:inferenceConfig] = render_inference_config(model, temperature)
27
+
28
+ tool_config = render_tool_config(tools)
29
+ if tool_config
30
+ payload[:toolConfig] = tool_config
31
+ payload[:tools] = tool_config[:tools] # Internal mirror for shared payload inspections in specs.
28
32
  end
33
+
34
+ additional_fields = render_additional_model_request_fields(thinking)
35
+ payload[:additionalModelRequestFields] = additional_fields if additional_fields
36
+
37
+ payload
29
38
  end
30
39
 
31
- private
40
+ def parse_completion_response(response)
41
+ data = response.body
42
+ return if data.nil? || data.empty?
32
43
 
33
- def completion_url
34
- "model/#{@model_id}/invoke"
44
+ content_blocks = data.dig('output', 'message', 'content') || []
45
+ usage = data['usage'] || {}
46
+ thinking_text, thinking_signature = parse_thinking(content_blocks)
47
+
48
+ Message.new(
49
+ role: :assistant,
50
+ content: parse_text_content(content_blocks),
51
+ thinking: Thinking.build(text: thinking_text, signature: thinking_signature),
52
+ tool_calls: parse_tool_calls(content_blocks),
53
+ input_tokens: usage['inputTokens'],
54
+ output_tokens: usage['outputTokens'],
55
+ cached_tokens: usage['cacheReadInputTokens'],
56
+ cache_creation_tokens: usage['cacheWriteInputTokens'],
57
+ thinking_tokens: usage['reasoningTokens'],
58
+ model_id: data['modelId'],
59
+ raw: response
60
+ )
35
61
  end
36
62
 
37
- def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil, thinking: nil) # rubocop:disable Lint/UnusedMethodArgument,Metrics/ParameterLists
38
- @model_id = model.id
63
+ def render_messages(messages)
64
+ rendered = []
65
+ tool_result_blocks = []
66
+
67
+ messages.each do |msg|
68
+ if msg.tool_result?
69
+ tool_result_blocks << render_tool_result_block(msg)
70
+ next
71
+ end
39
72
 
40
- system_messages, chat_messages = Anthropic::Chat.separate_messages(messages)
41
- system_content = Anthropic::Chat.build_system_content(system_messages)
73
+ unless tool_result_blocks.empty?
74
+ rendered << { role: 'user', content: tool_result_blocks }
75
+ tool_result_blocks = []
76
+ end
42
77
 
43
- build_base_payload(chat_messages, model, thinking).tap do |payload|
44
- Anthropic::Chat.add_optional_fields(payload, system_content:, tools:, temperature:)
78
+ message = render_non_tool_message(msg)
79
+ rendered << message if message
45
80
  end
81
+
82
+ rendered << { role: 'user', content: tool_result_blocks } unless tool_result_blocks.empty?
83
+ rendered
46
84
  end
47
85
 
48
- def build_base_payload(chat_messages, model, thinking)
49
- payload = {
50
- anthropic_version: 'bedrock-2023-05-31',
51
- messages: chat_messages.map { |msg| format_message(msg, thinking: thinking) },
52
- max_tokens: model.max_tokens || 4096
86
+ def render_non_tool_message(msg)
87
+ content = render_message_content(msg)
88
+ return nil if content.empty?
89
+
90
+ {
91
+ role: render_role(msg.role),
92
+ content: content
53
93
  }
94
+ end
54
95
 
55
- thinking_payload = Anthropic::Chat.build_thinking_payload(thinking)
56
- payload[:thinking] = thinking_payload if thinking_payload
96
+ def render_message_content(msg)
97
+ if msg.content.is_a?(RubyLLM::Content::Raw)
98
+ return render_raw_content(msg.content) if msg.role == :assistant
57
99
 
58
- payload
59
- end
100
+ return sanitize_non_assistant_raw_blocks(render_raw_content(msg.content))
101
+ end
102
+
103
+ blocks = []
60
104
 
61
- def format_basic_message_with_thinking(msg, thinking_enabled)
62
- content_blocks = []
105
+ thinking_block = render_thinking_block(msg.thinking)
106
+ blocks << thinking_block if msg.role == :assistant && thinking_block
63
107
 
64
- if msg.role == :assistant && thinking_enabled
65
- thinking_block = Anthropic::Chat.build_thinking_block(msg.thinking)
66
- content_blocks << thinking_block if thinking_block
108
+ text_and_media_blocks = Media.render_content(msg.content, used_document_names: @used_document_names)
109
+ blocks.concat(text_and_media_blocks) if text_and_media_blocks
110
+
111
+ if msg.tool_call?
112
+ msg.tool_calls.each_value do |tool_call|
113
+ blocks << {
114
+ toolUse: {
115
+ toolUseId: tool_call.id,
116
+ name: tool_call.name,
117
+ input: tool_call.arguments
118
+ }
119
+ }
120
+ end
67
121
  end
68
122
 
69
- append_formatted_content(content_blocks, msg.content)
123
+ blocks
124
+ end
70
125
 
126
+ def render_raw_content(content)
127
+ value = content.value
128
+ value.is_a?(Array) ? value : [value]
129
+ end
130
+
131
+ def sanitize_non_assistant_raw_blocks(blocks)
132
+ blocks.filter_map do |block|
133
+ next unless block.is_a?(Hash)
134
+ next if block.key?(:reasoningContent) || block.key?('reasoningContent')
135
+
136
+ block
137
+ end
138
+ end
139
+
140
+ def render_tool_result_block(msg)
71
141
  {
72
- role: Anthropic::Chat.convert_role(msg.role),
73
- content: content_blocks
142
+ toolResult: {
143
+ toolUseId: msg.tool_call_id,
144
+ content: render_tool_result_content(msg.content)
145
+ }
74
146
  }
75
147
  end
76
148
 
77
- def format_tool_call_with_thinking(msg, thinking_enabled)
78
- if msg.content.is_a?(RubyLLM::Content::Raw)
79
- content_blocks = msg.content.value
80
- content_blocks = [content_blocks] unless content_blocks.is_a?(Array)
81
- content_blocks = Anthropic::Chat.prepend_thinking_block(content_blocks, msg, thinking_enabled)
149
+ def render_tool_result_content(content)
150
+ return render_raw_tool_result_content(content.value) if content.is_a?(RubyLLM::Content::Raw)
82
151
 
83
- return { role: 'assistant', content: content_blocks }
152
+ if content.is_a?(Hash) || content.is_a?(Array)
153
+ [{ json: content }]
154
+ elsif content.is_a?(RubyLLM::Content)
155
+ blocks = []
156
+ blocks << { text: content.text } if content.text
157
+ content.attachments.each do |attachment|
158
+ blocks << { text: attachment.for_llm }
159
+ end
160
+ blocks
161
+ else
162
+ [{ text: content.to_s }]
84
163
  end
164
+ end
85
165
 
86
- content_blocks = Anthropic::Chat.prepend_thinking_block([], msg, thinking_enabled)
87
- content_blocks << Anthropic::Media.format_text(msg.content) unless msg.content.nil? || msg.content.empty?
166
+ def render_raw_tool_result_content(raw_value)
167
+ blocks = raw_value.is_a?(Array) ? raw_value : [raw_value]
88
168
 
89
- msg.tool_calls.each_value do |tool_call|
90
- content_blocks << {
91
- type: 'tool_use',
92
- id: tool_call.id,
93
- name: tool_call.name,
94
- input: tool_call.arguments
95
- }
169
+ normalized = blocks.filter_map do |block|
170
+ normalize_tool_result_block(block)
171
+ end
172
+
173
+ normalized.empty? ? [{ text: raw_value.to_s }] : normalized
174
+ end
175
+
176
+ def normalize_tool_result_block(block)
177
+ return nil unless block.is_a?(Hash)
178
+ return block if tool_result_content_block?(block)
179
+
180
+ nil
181
+ end
182
+
183
+ def tool_result_content_block?(block)
184
+ %w[text json document image].any? do |key|
185
+ block.key?(key) || block.key?(key.to_sym)
186
+ end
187
+ end
188
+
189
+ def render_role(role)
190
+ case role
191
+ when :assistant then 'assistant'
192
+ else 'user'
96
193
  end
194
+ end
195
+
196
+ def render_system(messages)
197
+ messages.flat_map { |msg| Media.render_content(msg.content, used_document_names: @used_document_names) }
198
+ end
199
+
200
+ def render_inference_config(_model, temperature)
201
+ config = {}
202
+ config[:temperature] = temperature unless temperature.nil?
203
+ config
204
+ end
205
+
206
+ def render_tool_config(tools)
207
+ return nil if tools.empty?
97
208
 
98
209
  {
99
- role: 'assistant',
100
- content: content_blocks
210
+ tools: tools.values.map { |tool| render_tool(tool) }
101
211
  }
102
212
  end
103
213
 
104
- def append_formatted_content(content_blocks, content)
105
- formatted_content = Media.format_content(content)
106
- if formatted_content.is_a?(Array)
107
- content_blocks.concat(formatted_content)
214
+ def render_tool(tool)
215
+ input_schema = tool.params_schema || RubyLLM::Tool::SchemaDefinition.from_parameters(tool.parameters)&.json_schema
216
+
217
+ tool_spec = {
218
+ toolSpec: {
219
+ name: tool.name,
220
+ description: tool.description,
221
+ inputSchema: {
222
+ json: input_schema || default_input_schema
223
+ }
224
+ }
225
+ }
226
+
227
+ return tool_spec if tool.provider_params.empty?
228
+
229
+ RubyLLM::Utils.deep_merge(tool_spec, tool.provider_params)
230
+ end
231
+
232
+ def render_additional_model_request_fields(thinking)
233
+ fields = {}
234
+
235
+ reasoning_fields = render_reasoning_fields(thinking)
236
+ fields = RubyLLM::Utils.deep_merge(fields, reasoning_fields) if reasoning_fields
237
+
238
+ fields.empty? ? nil : fields
239
+ end
240
+
241
+ def render_reasoning_fields(thinking)
242
+ return nil unless thinking&.enabled?
243
+
244
+ effort_config = effort_reasoning_config(thinking)
245
+ return effort_config if effort_config
246
+
247
+ budget_reasoning_config(thinking)
248
+ end
249
+
250
+ def effort_reasoning_config(thinking)
251
+ effort = thinking.respond_to?(:effort) ? thinking.effort : nil
252
+ effort = effort.to_s if effort
253
+ return nil if effort.nil? || effort.empty? || effort == 'none'
254
+
255
+ if reasoning_embedded?(@model)
256
+ { reasoning_config: { type: 'enabled', reasoning_effort: effort } }
108
257
  else
109
- content_blocks << formatted_content
258
+ { reasoning_effort: effort }
259
+ end
260
+ end
261
+
262
+ def budget_reasoning_config(thinking)
263
+ budget = thinking.respond_to?(:budget) ? thinking.budget : thinking
264
+ return nil unless budget.is_a?(Integer)
265
+
266
+ { reasoning_config: { type: 'enabled', budget_tokens: budget } }
267
+ end
268
+
269
+ def render_thinking_block(thinking)
270
+ return nil unless thinking
271
+
272
+ if thinking.text
273
+ {
274
+ reasoningContent: {
275
+ reasoningText: {
276
+ text: thinking.text,
277
+ signature: thinking.signature
278
+ }.compact
279
+ }
280
+ }
281
+ elsif thinking.signature
282
+ {
283
+ reasoningContent: {
284
+ redactedContent: thinking.signature
285
+ }
286
+ }
110
287
  end
111
288
  end
289
+
290
+ def parse_text_content(content_blocks)
291
+ text = content_blocks.filter_map { |block| block['text'] if block['text'].is_a?(String) }.join
292
+ text.empty? ? nil : text
293
+ end
294
+
295
+ def parse_thinking(content_blocks)
296
+ text = +''
297
+ signature = nil
298
+
299
+ content_blocks.each do |block|
300
+ chunk_text, chunk_signature = parse_reasoning_content_block(block)
301
+ text << chunk_text if chunk_text
302
+ signature ||= chunk_signature
303
+ end
304
+
305
+ [text.empty? ? nil : text, signature]
306
+ end
307
+
308
+ def parse_reasoning_content_block(block)
309
+ reasoning_content = block['reasoningContent']
310
+ return [nil, nil] unless reasoning_content.is_a?(Hash)
311
+
312
+ reasoning_text = reasoning_content['reasoningText'] || {}
313
+ text = reasoning_text['text'].is_a?(String) ? reasoning_text['text'] : nil
314
+ signature = reasoning_text['signature'] if reasoning_text['signature'].is_a?(String)
315
+ signature ||= reasoning_content['redactedContent'] if reasoning_content['redactedContent'].is_a?(String)
316
+ [text, signature]
317
+ end
318
+
319
+ def parse_tool_calls(content_blocks)
320
+ tool_calls = {}
321
+
322
+ content_blocks.each do |block|
323
+ tool_use = block['toolUse']
324
+ next unless tool_use
325
+
326
+ tool_call_id = tool_use['toolUseId']
327
+ tool_calls[tool_call_id] = ToolCall.new(
328
+ id: tool_call_id,
329
+ name: tool_use['name'],
330
+ arguments: tool_use['input'] || {}
331
+ )
332
+ end
333
+
334
+ tool_calls.empty? ? nil : tool_calls
335
+ end
336
+
337
+ def default_input_schema
338
+ {
339
+ 'type' => 'object',
340
+ 'properties' => {},
341
+ 'required' => []
342
+ }
343
+ end
112
344
  end
113
345
  end
114
346
  end
@@ -3,58 +3,87 @@
3
3
  module RubyLLM
4
4
  module Providers
5
5
  class Bedrock
6
- # Media handling methods for the Bedrock API integration
7
- # NOTE: Bedrock does not support url attachments
6
+ # Media formatting for Bedrock Converse content blocks.
8
7
  module Media
9
- extend Anthropic::Media
10
-
11
8
  module_function
12
9
 
13
- def format_content(content) # rubocop:disable Metrics/PerceivedComplexity
14
- return content.value if content.is_a?(RubyLLM::Content::Raw)
15
- return [Anthropic::Media.format_text(content.to_json)] if content.is_a?(Hash) || content.is_a?(Array)
16
- return [Anthropic::Media.format_text(content)] unless content.is_a?(Content)
10
+ def render_content(content, used_document_names: nil)
11
+ return [] if empty_content?(content)
12
+ return render_raw_content(content) if content.is_a?(RubyLLM::Content::Raw)
13
+ return [{ text: content.to_json }] if content.is_a?(Hash) || content.is_a?(Array)
14
+ return [{ text: content }] unless content.is_a?(RubyLLM::Content)
15
+
16
+ render_content_object(content, used_document_names || {})
17
+ end
17
18
 
18
- parts = []
19
- parts << Anthropic::Media.format_text(content.text) if content.text
19
+ def empty_content?(content)
20
+ content.nil? || (content.respond_to?(:empty?) && content.empty?)
21
+ end
20
22
 
23
+ def render_content_object(content, used_document_names)
24
+ blocks = []
25
+ blocks << { text: content.text } if content.text
21
26
  content.attachments.each do |attachment|
22
- case attachment.type
23
- when :image
24
- parts << format_image(attachment)
25
- when :pdf
26
- parts << format_pdf(attachment)
27
- when :text
28
- parts << Anthropic::Media.format_text_file(attachment)
29
- else
30
- raise UnsupportedAttachmentError, attachment.type
31
- end
27
+ blocks << render_attachment(attachment, used_document_names:)
32
28
  end
29
+ blocks
30
+ end
33
31
 
34
- parts
32
+ def render_raw_content(content)
33
+ value = content.value
34
+ value.is_a?(Array) ? value : [value]
35
35
  end
36
36
 
37
- def format_image(image)
37
+ def render_attachment(attachment, used_document_names:)
38
+ case attachment.type
39
+ when :image
40
+ render_image_attachment(attachment)
41
+ when :pdf
42
+ render_document_attachment(attachment, used_document_names:)
43
+ when :text
44
+ { text: attachment.for_llm }
45
+ else
46
+ raise UnsupportedAttachmentError, attachment.mime_type
47
+ end
48
+ end
49
+
50
+ def render_image_attachment(attachment)
38
51
  {
39
- type: 'image',
40
- source: {
41
- type: 'base64',
42
- media_type: image.mime_type,
43
- data: image.encoded
52
+ image: {
53
+ format: attachment.format,
54
+ source: {
55
+ bytes: attachment.encoded
56
+ }
44
57
  }
45
58
  }
46
59
  end
47
60
 
48
- def format_pdf(pdf)
61
+ def render_document_attachment(attachment, used_document_names:)
62
+ document_name = unique_document_name(sanitize_document_name(attachment.filename), used_document_names)
49
63
  {
50
- type: 'document',
51
- source: {
52
- type: 'base64',
53
- media_type: pdf.mime_type,
54
- data: pdf.encoded
64
+ document: {
65
+ format: attachment.format,
66
+ name: document_name,
67
+ source: {
68
+ bytes: attachment.encoded
69
+ }
55
70
  }
56
71
  }
57
72
  end
73
+
74
+ def sanitize_document_name(filename)
75
+ base = File.basename(filename.to_s, '.*')
76
+ safe = base.gsub(/[^a-zA-Z0-9_-]/, '_')
77
+ safe.empty? ? 'document' : safe
78
+ end
79
+
80
+ def unique_document_name(base_name, used_names)
81
+ count = used_names[base_name].to_i
82
+ used_names[base_name] = count + 1
83
+ return base_name if count.zero?
84
+
85
+ "#{base_name}_#{count + 1}"
86
+ end
58
87
  end
59
88
  end
60
89
  end