ruby_llm 1.10.0 → 1.12.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -2
  3. data/lib/ruby_llm/active_record/acts_as_legacy.rb +41 -7
  4. data/lib/ruby_llm/active_record/chat_methods.rb +41 -7
  5. data/lib/ruby_llm/agent.rb +323 -0
  6. data/lib/ruby_llm/aliases.json +50 -32
  7. data/lib/ruby_llm/chat.rb +27 -3
  8. data/lib/ruby_llm/configuration.rb +4 -0
  9. data/lib/ruby_llm/models.json +19806 -5991
  10. data/lib/ruby_llm/models.rb +35 -6
  11. data/lib/ruby_llm/provider.rb +13 -1
  12. data/lib/ruby_llm/providers/anthropic/media.rb +2 -2
  13. data/lib/ruby_llm/providers/azure/chat.rb +29 -0
  14. data/lib/ruby_llm/providers/azure/embeddings.rb +24 -0
  15. data/lib/ruby_llm/providers/azure/media.rb +45 -0
  16. data/lib/ruby_llm/providers/azure/models.rb +14 -0
  17. data/lib/ruby_llm/providers/azure.rb +56 -0
  18. data/lib/ruby_llm/providers/bedrock/auth.rb +122 -0
  19. data/lib/ruby_llm/providers/bedrock/chat.rb +297 -56
  20. data/lib/ruby_llm/providers/bedrock/media.rb +62 -33
  21. data/lib/ruby_llm/providers/bedrock/models.rb +88 -65
  22. data/lib/ruby_llm/providers/bedrock/streaming.rb +305 -8
  23. data/lib/ruby_llm/providers/bedrock.rb +61 -52
  24. data/lib/ruby_llm/providers/openai/media.rb +1 -1
  25. data/lib/ruby_llm/providers/xai/chat.rb +15 -0
  26. data/lib/ruby_llm/providers/xai/models.rb +75 -0
  27. data/lib/ruby_llm/providers/xai.rb +28 -0
  28. data/lib/ruby_llm/version.rb +1 -1
  29. data/lib/ruby_llm.rb +14 -8
  30. data/lib/tasks/models.rake +10 -4
  31. data/lib/tasks/vcr.rake +32 -0
  32. metadata +16 -13
  33. data/lib/ruby_llm/providers/bedrock/capabilities.rb +0 -167
  34. data/lib/ruby_llm/providers/bedrock/signing.rb +0 -831
  35. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +0 -51
  36. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +0 -128
  37. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +0 -67
  38. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +0 -85
  39. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +0 -78
@@ -3,101 +3,342 @@
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?
10
+ def completion_url
11
+ "/model/#{@model.id}/converse"
12
+ end
13
+
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 }
18
+
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.
15
32
  end
16
- Anthropic::Chat.parse_completion_response response
33
+
34
+ additional_fields = render_additional_model_request_fields(thinking)
35
+ payload[:additionalModelRequestFields] = additional_fields if additional_fields
36
+
37
+ payload
17
38
  end
18
39
 
19
- def format_message(msg, thinking: nil)
20
- thinking_enabled = thinking&.enabled?
40
+ def parse_completion_response(response)
41
+ data = response.body
42
+ return if data.nil? || data.empty?
21
43
 
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)
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
+ )
61
+ end
62
+
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
72
+
73
+ unless tool_result_blocks.empty?
74
+ rendered << { role: 'user', content: tool_result_blocks }
75
+ tool_result_blocks = []
76
+ end
77
+
78
+ message = render_non_tool_message(msg)
79
+ rendered << message if message
28
80
  end
81
+
82
+ rendered << { role: 'user', content: tool_result_blocks } unless tool_result_blocks.empty?
83
+ rendered
29
84
  end
30
85
 
31
- private
86
+ def render_non_tool_message(msg)
87
+ content = render_message_content(msg)
88
+ return nil if content.empty?
32
89
 
33
- def completion_url
34
- "model/#{@model_id}/invoke"
90
+ {
91
+ role: render_role(msg.role),
92
+ content: content
93
+ }
35
94
  end
36
95
 
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
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
39
99
 
40
- system_messages, chat_messages = Anthropic::Chat.separate_messages(messages)
41
- system_content = Anthropic::Chat.build_system_content(system_messages)
100
+ return sanitize_non_assistant_raw_blocks(render_raw_content(msg.content))
101
+ end
102
+
103
+ blocks = []
104
+
105
+ thinking_block = render_thinking_block(msg.thinking)
106
+ blocks << thinking_block if msg.role == :assistant && thinking_block
42
107
 
43
- build_base_payload(chat_messages, model, thinking).tap do |payload|
44
- Anthropic::Chat.add_optional_fields(payload, system_content:, tools:, temperature:)
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
45
121
  end
122
+
123
+ blocks
46
124
  end
47
125
 
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
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)
141
+ {
142
+ toolResult: {
143
+ toolUseId: msg.tool_call_id,
144
+ content: render_tool_result_content(msg.content)
145
+ }
53
146
  }
147
+ end
54
148
 
55
- thinking_payload = Anthropic::Chat.build_thinking_payload(thinking)
56
- payload[:thinking] = thinking_payload if thinking_payload
149
+ def render_tool_result_content(content)
150
+ return render_raw_tool_result_content(content.value) if content.is_a?(RubyLLM::Content::Raw)
57
151
 
58
- payload
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 }]
163
+ end
164
+ end
165
+
166
+ def render_raw_tool_result_content(raw_value)
167
+ blocks = raw_value.is_a?(Array) ? raw_value : [raw_value]
168
+
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
59
174
  end
60
175
 
61
- def format_basic_message_with_thinking(msg, thinking_enabled)
62
- content_blocks = []
176
+ def normalize_tool_result_block(block)
177
+ return nil unless block.is_a?(Hash)
178
+ return block if tool_result_content_block?(block)
63
179
 
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
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)
67
186
  end
187
+ end
68
188
 
69
- Anthropic::Chat.append_formatted_content(content_blocks, msg.content)
189
+ def render_role(role)
190
+ case role
191
+ when :assistant then 'assistant'
192
+ else 'user'
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?
70
208
 
71
209
  {
72
- role: Anthropic::Chat.convert_role(msg.role),
73
- content: content_blocks
210
+ tools: tools.values.map { |tool| render_tool(tool) }
74
211
  }
75
212
  end
76
213
 
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)
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
82
249
 
83
- return { role: 'assistant', content: content_blocks }
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 } }
257
+ else
258
+ { reasoning_effort: effort }
84
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
85
268
 
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?
269
+ def render_thinking_block(thinking)
270
+ return nil unless thinking
88
271
 
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
272
+ if thinking.text
273
+ {
274
+ reasoningContent: {
275
+ reasoningText: {
276
+ text: thinking.text,
277
+ signature: thinking.signature
278
+ }.compact
279
+ }
95
280
  }
281
+ elsif thinking.signature
282
+ {
283
+ reasoningContent: {
284
+ redactedContent: thinking.signature
285
+ }
286
+ }
287
+ end
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
+ )
96
332
  end
97
333
 
334
+ tool_calls.empty? ? nil : tool_calls
335
+ end
336
+
337
+ def default_input_schema
98
338
  {
99
- role: 'assistant',
100
- content: content_blocks
339
+ 'type' => 'object',
340
+ 'properties' => {},
341
+ 'required' => []
101
342
  }
102
343
  end
103
344
  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