dify_llm 1.9.2 → 1.14.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 (168) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -8
  3. data/lib/generators/ruby_llm/agent/agent_generator.rb +36 -0
  4. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -0
  5. data/lib/generators/ruby_llm/agent/templates/instructions.txt.erb.tt +0 -0
  6. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +110 -41
  7. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +14 -15
  8. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +8 -11
  9. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +2 -2
  10. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +25 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +2 -2
  12. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +16 -0
  13. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +31 -0
  14. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +31 -0
  15. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +9 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +27 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +14 -0
  18. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +1 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +13 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +23 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +10 -0
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +2 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -0
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +14 -0
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +13 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +21 -0
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +17 -0
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +40 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +27 -0
  30. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +2 -2
  31. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +2 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +19 -7
  33. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +1 -1
  34. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +5 -3
  35. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +9 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -1
  37. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +8 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +1 -1
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +6 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +2 -0
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -7
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +9 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +5 -7
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +8 -0
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +16 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +11 -12
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +27 -17
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +3 -4
  49. data/lib/generators/ruby_llm/generator_helpers.rb +37 -17
  50. data/lib/generators/ruby_llm/install/install_generator.rb +22 -18
  51. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +1 -1
  52. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +4 -1
  53. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +4 -10
  54. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +2 -1
  55. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -2
  56. data/lib/generators/ruby_llm/schema/schema_generator.rb +26 -0
  57. data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +2 -0
  58. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +9 -0
  59. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +13 -0
  60. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +13 -0
  61. data/lib/generators/ruby_llm/tool/tool_generator.rb +96 -0
  62. data/lib/generators/ruby_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +19 -0
  63. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +50 -0
  64. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
  65. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
  66. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +2 -4
  67. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
  68. data/lib/ruby_llm/active_record/acts_as.rb +10 -4
  69. data/lib/ruby_llm/active_record/acts_as_legacy.rb +132 -27
  70. data/lib/ruby_llm/active_record/chat_methods.rb +132 -28
  71. data/lib/ruby_llm/active_record/message_methods.rb +58 -8
  72. data/lib/ruby_llm/active_record/model_methods.rb +1 -1
  73. data/lib/ruby_llm/active_record/payload_helpers.rb +26 -0
  74. data/lib/ruby_llm/active_record/tool_call_methods.rb +15 -0
  75. data/lib/ruby_llm/agent.rb +365 -0
  76. data/lib/ruby_llm/aliases.json +106 -61
  77. data/lib/ruby_llm/attachment.rb +8 -3
  78. data/lib/ruby_llm/chat.rb +150 -22
  79. data/lib/ruby_llm/configuration.rb +65 -65
  80. data/lib/ruby_llm/connection.rb +11 -7
  81. data/lib/ruby_llm/content.rb +6 -2
  82. data/lib/ruby_llm/error.rb +37 -1
  83. data/lib/ruby_llm/message.rb +43 -15
  84. data/lib/ruby_llm/model/info.rb +15 -13
  85. data/lib/ruby_llm/models.json +25039 -12260
  86. data/lib/ruby_llm/models.rb +185 -24
  87. data/lib/ruby_llm/provider.rb +26 -4
  88. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -119
  89. data/lib/ruby_llm/providers/anthropic/chat.rb +149 -17
  90. data/lib/ruby_llm/providers/anthropic/media.rb +2 -2
  91. data/lib/ruby_llm/providers/anthropic/models.rb +3 -9
  92. data/lib/ruby_llm/providers/anthropic/streaming.rb +25 -1
  93. data/lib/ruby_llm/providers/anthropic/tools.rb +20 -0
  94. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  95. data/lib/ruby_llm/providers/azure/chat.rb +29 -0
  96. data/lib/ruby_llm/providers/azure/embeddings.rb +24 -0
  97. data/lib/ruby_llm/providers/azure/media.rb +45 -0
  98. data/lib/ruby_llm/providers/azure/models.rb +14 -0
  99. data/lib/ruby_llm/providers/azure.rb +148 -0
  100. data/lib/ruby_llm/providers/bedrock/auth.rb +122 -0
  101. data/lib/ruby_llm/providers/bedrock/chat.rb +357 -28
  102. data/lib/ruby_llm/providers/bedrock/media.rb +62 -33
  103. data/lib/ruby_llm/providers/bedrock/models.rb +104 -65
  104. data/lib/ruby_llm/providers/bedrock/streaming.rb +309 -8
  105. data/lib/ruby_llm/providers/bedrock.rb +69 -52
  106. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -114
  107. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  108. data/lib/ruby_llm/providers/dify/chat.rb +82 -7
  109. data/lib/ruby_llm/providers/dify/media.rb +2 -2
  110. data/lib/ruby_llm/providers/dify/streaming.rb +26 -4
  111. data/lib/ruby_llm/providers/dify.rb +4 -0
  112. data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -207
  113. data/lib/ruby_llm/providers/gemini/chat.rb +88 -6
  114. data/lib/ruby_llm/providers/gemini/images.rb +1 -1
  115. data/lib/ruby_llm/providers/gemini/models.rb +2 -4
  116. data/lib/ruby_llm/providers/gemini/streaming.rb +34 -2
  117. data/lib/ruby_llm/providers/gemini/tools.rb +35 -3
  118. data/lib/ruby_llm/providers/gemini.rb +4 -0
  119. data/lib/ruby_llm/providers/gpustack/capabilities.rb +20 -0
  120. data/lib/ruby_llm/providers/gpustack/chat.rb +1 -1
  121. data/lib/ruby_llm/providers/gpustack.rb +8 -0
  122. data/lib/ruby_llm/providers/mistral/capabilities.rb +8 -0
  123. data/lib/ruby_llm/providers/mistral/chat.rb +59 -1
  124. data/lib/ruby_llm/providers/mistral.rb +4 -0
  125. data/lib/ruby_llm/providers/ollama/capabilities.rb +20 -0
  126. data/lib/ruby_llm/providers/ollama/chat.rb +1 -1
  127. data/lib/ruby_llm/providers/ollama.rb +11 -1
  128. data/lib/ruby_llm/providers/openai/capabilities.rb +96 -192
  129. data/lib/ruby_llm/providers/openai/chat.rb +101 -7
  130. data/lib/ruby_llm/providers/openai/media.rb +5 -2
  131. data/lib/ruby_llm/providers/openai/models.rb +2 -4
  132. data/lib/ruby_llm/providers/openai/streaming.rb +11 -3
  133. data/lib/ruby_llm/providers/openai/temperature.rb +28 -0
  134. data/lib/ruby_llm/providers/openai/tools.rb +27 -2
  135. data/lib/ruby_llm/providers/openai.rb +11 -1
  136. data/lib/ruby_llm/providers/openrouter/chat.rb +168 -0
  137. data/lib/ruby_llm/providers/openrouter/images.rb +69 -0
  138. data/lib/ruby_llm/providers/openrouter/streaming.rb +74 -0
  139. data/lib/ruby_llm/providers/openrouter.rb +37 -1
  140. data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
  141. data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
  142. data/lib/ruby_llm/providers/perplexity.rb +4 -0
  143. data/lib/ruby_llm/providers/vertexai/models.rb +1 -1
  144. data/lib/ruby_llm/providers/vertexai.rb +23 -7
  145. data/lib/ruby_llm/providers/xai/chat.rb +15 -0
  146. data/lib/ruby_llm/providers/xai/models.rb +75 -0
  147. data/lib/ruby_llm/providers/xai.rb +32 -0
  148. data/lib/ruby_llm/stream_accumulator.rb +120 -18
  149. data/lib/ruby_llm/streaming.rb +60 -57
  150. data/lib/ruby_llm/thinking.rb +49 -0
  151. data/lib/ruby_llm/tokens.rb +47 -0
  152. data/lib/ruby_llm/tool.rb +48 -3
  153. data/lib/ruby_llm/tool_call.rb +6 -3
  154. data/lib/ruby_llm/version.rb +1 -1
  155. data/lib/ruby_llm.rb +14 -8
  156. data/lib/tasks/models.rake +61 -22
  157. data/lib/tasks/release.rake +1 -1
  158. data/lib/tasks/ruby_llm.rake +9 -1
  159. data/lib/tasks/vcr.rake +33 -1
  160. metadata +67 -16
  161. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
  162. data/lib/ruby_llm/providers/bedrock/capabilities.rb +0 -167
  163. data/lib/ruby_llm/providers/bedrock/signing.rb +0 -831
  164. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +0 -51
  165. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +0 -71
  166. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +0 -67
  167. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +0 -80
  168. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +0 -78
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'openssl'
5
+
6
+ module RubyLLM
7
+ module Providers
8
+ class Bedrock
9
+ # SigV4 authentication helpers for Bedrock.
10
+ module Auth
11
+ private
12
+
13
+ def signed_post(connection, url, payload, additional_headers = {})
14
+ request_payload = api_payload(payload)
15
+ body = JSON.generate(request_payload)
16
+ signed_headers = sign_headers('POST', url, body)
17
+
18
+ response = connection.post(url, request_payload) do |req|
19
+ req.headers.merge!(signed_headers)
20
+ req.headers.merge!(additional_headers) unless additional_headers.empty?
21
+ yield req if block_given?
22
+ end
23
+
24
+ parse_completion_response(response)
25
+ end
26
+
27
+ def signed_get(base_url, url)
28
+ conn = Connection.basic do |f|
29
+ f.request :json
30
+ f.response :json
31
+ f.adapter :net_http
32
+ f.use :llm_errors, provider: self
33
+ end
34
+
35
+ conn.url_prefix = base_url
36
+
37
+ conn.get(url) do |req|
38
+ req.headers.merge!(sign_headers('GET', url, '', base_url: base_url))
39
+ end
40
+ end
41
+
42
+ def sign_headers(method, path, body, base_url: api_base)
43
+ now = Time.now.utc
44
+ amz_date = now.strftime('%Y%m%dT%H%M%SZ')
45
+ date_stamp = now.strftime('%Y%m%d')
46
+
47
+ uri = URI.parse(path)
48
+ canonical_uri = canonical_uri(uri.path)
49
+ canonical_query = canonical_query_string(uri.query)
50
+ payload_hash = Digest::SHA256.hexdigest(body)
51
+
52
+ headers = {
53
+ 'host' => URI.parse(base_url).host,
54
+ 'x-amz-content-sha256' => payload_hash,
55
+ 'x-amz-date' => amz_date
56
+ }
57
+ headers['x-amz-security-token'] = @config.bedrock_session_token if @config.bedrock_session_token
58
+
59
+ signed_headers = headers.keys.sort.join(';')
60
+ canonical_headers = headers.keys.sort.map { |key| "#{key}:#{headers[key].to_s.strip}\n" }.join
61
+
62
+ canonical_request = [
63
+ method,
64
+ canonical_uri,
65
+ canonical_query,
66
+ canonical_headers,
67
+ signed_headers,
68
+ payload_hash
69
+ ].join("\n")
70
+
71
+ credential_scope = "#{date_stamp}/#{bedrock_region}/bedrock/aws4_request"
72
+ string_to_sign = [
73
+ 'AWS4-HMAC-SHA256',
74
+ amz_date,
75
+ credential_scope,
76
+ Digest::SHA256.hexdigest(canonical_request)
77
+ ].join("\n")
78
+
79
+ signing_key = signing_key(date_stamp)
80
+ signature = OpenSSL::HMAC.hexdigest('sha256', signing_key, string_to_sign)
81
+
82
+ {
83
+ 'X-Amz-Date' => amz_date,
84
+ 'X-Amz-Content-Sha256' => payload_hash,
85
+ 'X-Amz-Security-Token' => @config.bedrock_session_token,
86
+ 'Authorization' => "AWS4-HMAC-SHA256 Credential=#{@config.bedrock_api_key}/#{credential_scope}, " \
87
+ "SignedHeaders=#{signed_headers}, Signature=#{signature}",
88
+ 'Content-Type' => 'application/json'
89
+ }.compact
90
+ end
91
+
92
+ def canonical_query_string(raw_query)
93
+ return '' if raw_query.nil? || raw_query.empty?
94
+
95
+ URI.decode_www_form(raw_query)
96
+ .sort_by(&:first)
97
+ .map { |k, v| "#{uri_encode(k)}=#{uri_encode(v)}" }
98
+ .join('&')
99
+ end
100
+
101
+ def canonical_uri(path)
102
+ return '/' if path.nil? || path.empty?
103
+
104
+ segments = path.split('/', -1).map { |segment| uri_encode(segment) }
105
+ canonical = segments.join('/')
106
+ canonical.start_with?('/') ? canonical : "/#{canonical}"
107
+ end
108
+
109
+ def uri_encode(text)
110
+ URI.encode_www_form_component(text.to_s).gsub('+', '%20').gsub('%7E', '~')
111
+ end
112
+
113
+ def signing_key(date_stamp)
114
+ k_date = OpenSSL::HMAC.digest('sha256', "AWS4#{@config.bedrock_secret_key}", date_stamp)
115
+ k_region = OpenSSL::HMAC.digest('sha256', k_date, bedrock_region)
116
+ k_service = OpenSSL::HMAC.digest('sha256', k_region, 'bedrock')
117
+ OpenSSL::HMAC.digest('sha256', k_service, 'aws4_request')
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -3,58 +3,387 @@
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
+ # rubocop:disable Metrics/ParameterLists,Lint/UnusedMethodArgument
15
+ def render_payload(messages, tools:, temperature:, model:, stream: false,
16
+ schema: nil, thinking: nil, tool_prefs: nil)
17
+ tool_prefs ||= {}
18
+ @model = model
19
+ @used_document_names = {}
20
+ system_messages, chat_messages = messages.partition { |msg| msg.role == :system }
21
+ payload = {
22
+ messages: render_messages(chat_messages)
23
+ }
24
+
25
+ system_blocks = render_system(system_messages)
26
+ payload[:system] = system_blocks unless system_blocks.empty?
27
+
28
+ payload[:inferenceConfig] = render_inference_config(model, temperature)
29
+
30
+ tool_config = render_tool_config(tools, tool_prefs)
31
+ if tool_config
32
+ payload[:toolConfig] = tool_config
33
+ payload[:tools] = tool_config[:tools] # Internal mirror for shared payload inspections in specs.
34
+ end
35
+
36
+ additional_fields = render_additional_model_request_fields(thinking)
37
+ payload[:additionalModelRequestFields] = additional_fields if additional_fields
38
+
39
+ output_config = build_output_config(schema)
40
+ payload[:outputConfig] = output_config if output_config
41
+
42
+ payload
43
+ end
44
+ # rubocop:enable Metrics/ParameterLists,Lint/UnusedMethodArgument
45
+
46
+ def parse_completion_response(response)
47
+ data = response.body
48
+ return if data.nil? || data.empty?
49
+
50
+ content_blocks = data.dig('output', 'message', 'content') || []
51
+ usage = data['usage'] || {}
52
+ thinking_text, thinking_signature = parse_thinking(content_blocks)
53
+
54
+ Message.new(
55
+ role: :assistant,
56
+ content: parse_text_content(content_blocks),
57
+ thinking: Thinking.build(text: thinking_text, signature: thinking_signature),
58
+ tool_calls: parse_tool_calls(content_blocks),
59
+ input_tokens: usage['inputTokens'],
60
+ output_tokens: usage['outputTokens'],
61
+ cached_tokens: usage['cacheReadInputTokens'],
62
+ cache_creation_tokens: usage['cacheWriteInputTokens'],
63
+ thinking_tokens: usage['reasoningTokens'],
64
+ model_id: data['modelId'],
65
+ raw: response
66
+ )
67
+ end
68
+
69
+ def render_messages(messages)
70
+ rendered = []
71
+ tool_result_blocks = []
72
+
73
+ messages.each do |msg|
74
+ if msg.tool_result?
75
+ tool_result_blocks << render_tool_result_block(msg)
76
+ next
77
+ end
78
+
79
+ unless tool_result_blocks.empty?
80
+ rendered << { role: 'user', content: tool_result_blocks }
81
+ tool_result_blocks = []
82
+ end
83
+
84
+ message = render_non_tool_message(msg)
85
+ rendered << message if message
15
86
  end
16
- Anthropic::Chat.parse_completion_response response
87
+
88
+ rendered << { role: 'user', content: tool_result_blocks } unless tool_result_blocks.empty?
89
+ rendered
17
90
  end
18
91
 
19
- def format_message(msg)
92
+ def render_non_tool_message(msg)
93
+ content = render_message_content(msg)
94
+ return nil if content.empty?
95
+
96
+ {
97
+ role: render_role(msg.role),
98
+ content: content
99
+ }
100
+ end
101
+
102
+ def render_message_content(msg)
103
+ if msg.content.is_a?(RubyLLM::Content::Raw)
104
+ return render_raw_content(msg.content) if msg.role == :assistant
105
+
106
+ return sanitize_non_assistant_raw_blocks(render_raw_content(msg.content))
107
+ end
108
+
109
+ blocks = []
110
+
111
+ thinking_block = render_thinking_block(msg.thinking)
112
+ blocks << thinking_block if msg.role == :assistant && thinking_block
113
+
114
+ text_and_media_blocks = Media.render_content(msg.content, used_document_names: @used_document_names)
115
+ blocks.concat(text_and_media_blocks) if text_and_media_blocks
116
+
20
117
  if msg.tool_call?
21
- Anthropic::Tools.format_tool_call(msg)
22
- elsif msg.tool_result?
23
- Anthropic::Tools.format_tool_result(msg)
118
+ msg.tool_calls.each_value do |tool_call|
119
+ blocks << {
120
+ toolUse: {
121
+ toolUseId: tool_call.id,
122
+ name: tool_call.name,
123
+ input: tool_call.arguments
124
+ }
125
+ }
126
+ end
127
+ end
128
+
129
+ blocks
130
+ end
131
+
132
+ def render_raw_content(content)
133
+ value = content.value
134
+ value.is_a?(Array) ? value : [value]
135
+ end
136
+
137
+ def sanitize_non_assistant_raw_blocks(blocks)
138
+ blocks.filter_map do |block|
139
+ next unless block.is_a?(Hash)
140
+ next if block.key?(:reasoningContent) || block.key?('reasoningContent')
141
+
142
+ block
143
+ end
144
+ end
145
+
146
+ def render_tool_result_block(msg)
147
+ {
148
+ toolResult: {
149
+ toolUseId: msg.tool_call_id,
150
+ content: render_tool_result_content(msg.content)
151
+ }
152
+ }
153
+ end
154
+
155
+ def render_tool_result_content(content)
156
+ return render_raw_tool_result_content(content.value) if content.is_a?(RubyLLM::Content::Raw)
157
+
158
+ if content.is_a?(Hash) || content.is_a?(Array)
159
+ [{ json: content }]
160
+ elsif content.is_a?(RubyLLM::Content)
161
+ blocks = []
162
+ blocks << { text: content.text } if content.text
163
+ content.attachments.each do |attachment|
164
+ blocks << { text: attachment.for_llm }
165
+ end
166
+ blocks
167
+ else
168
+ [{ text: content.to_s }]
169
+ end
170
+ end
171
+
172
+ def render_raw_tool_result_content(raw_value)
173
+ blocks = raw_value.is_a?(Array) ? raw_value : [raw_value]
174
+
175
+ normalized = blocks.filter_map do |block|
176
+ normalize_tool_result_block(block)
177
+ end
178
+
179
+ normalized.empty? ? [{ text: raw_value.to_s }] : normalized
180
+ end
181
+
182
+ def normalize_tool_result_block(block)
183
+ return nil unless block.is_a?(Hash)
184
+ return block if tool_result_content_block?(block)
185
+
186
+ nil
187
+ end
188
+
189
+ def tool_result_content_block?(block)
190
+ %w[text json document image].any? do |key|
191
+ block.key?(key) || block.key?(key.to_sym)
192
+ end
193
+ end
194
+
195
+ def render_role(role)
196
+ case role
197
+ when :assistant then 'assistant'
198
+ else 'user'
199
+ end
200
+ end
201
+
202
+ def render_system(messages)
203
+ messages.flat_map { |msg| Media.render_content(msg.content, used_document_names: @used_document_names) }
204
+ end
205
+
206
+ def render_inference_config(_model, temperature)
207
+ config = {}
208
+ config[:temperature] = temperature unless temperature.nil?
209
+ config
210
+ end
211
+
212
+ def render_tool_config(tools, tool_prefs)
213
+ return nil if tools.empty?
214
+
215
+ config = {
216
+ tools: tools.values.map { |tool| render_tool(tool) }
217
+ }
218
+
219
+ return config if tool_prefs.nil? || tool_prefs[:choice].nil?
220
+
221
+ tool_choice = render_tool_choice(tool_prefs[:choice])
222
+ config[:toolChoice] = tool_choice if tool_choice
223
+ config
224
+ end
225
+
226
+ def render_tool_choice(choice)
227
+ case choice
228
+ when :auto
229
+ { auto: {} }
230
+ when :none
231
+ nil
232
+ when :required
233
+ { any: {} }
24
234
  else
25
- format_basic_message(msg)
235
+ { tool: { name: choice.to_s } }
26
236
  end
27
237
  end
28
238
 
29
- def format_basic_message(msg)
239
+ def render_tool(tool)
240
+ input_schema = tool.params_schema || RubyLLM::Tool::SchemaDefinition.from_parameters(tool.parameters)&.json_schema
241
+
242
+ tool_spec = {
243
+ toolSpec: {
244
+ name: tool.name,
245
+ description: tool.description,
246
+ inputSchema: {
247
+ json: input_schema || default_input_schema
248
+ }
249
+ }
250
+ }
251
+
252
+ return tool_spec if tool.provider_params.empty?
253
+
254
+ RubyLLM::Utils.deep_merge(tool_spec, tool.provider_params)
255
+ end
256
+
257
+ def render_additional_model_request_fields(thinking)
258
+ fields = {}
259
+
260
+ reasoning_fields = render_reasoning_fields(thinking)
261
+ fields = RubyLLM::Utils.deep_merge(fields, reasoning_fields) if reasoning_fields
262
+
263
+ fields.empty? ? nil : fields
264
+ end
265
+
266
+ def build_output_config(schema)
267
+ return nil unless schema
268
+
269
+ cleaned = RubyLLM::Utils.deep_dup(schema[:schema])
270
+ cleaned.delete(:strict)
271
+ cleaned.delete('strict')
272
+
30
273
  {
31
- role: Anthropic::Chat.convert_role(msg.role),
32
- content: Media.format_content(msg.content)
274
+ textFormat: {
275
+ type: 'json_schema',
276
+ structure: {
277
+ jsonSchema: {
278
+ schema: JSON.generate(cleaned),
279
+ name: schema[:name]
280
+ }
281
+ }
282
+ }
33
283
  }
34
284
  end
35
285
 
36
- private
286
+ def render_reasoning_fields(thinking)
287
+ return nil unless thinking&.enabled?
37
288
 
38
- def completion_url
39
- "model/#{@model_id}/invoke"
289
+ effort_config = effort_reasoning_config(thinking)
290
+ return effort_config if effort_config
291
+
292
+ budget_reasoning_config(thinking)
293
+ end
294
+
295
+ def effort_reasoning_config(thinking)
296
+ effort = thinking.respond_to?(:effort) ? thinking.effort : nil
297
+ effort = effort.to_s if effort
298
+ return nil if effort.nil? || effort.empty? || effort == 'none'
299
+
300
+ if reasoning_embedded?(@model)
301
+ { reasoning_config: { type: 'enabled', reasoning_effort: effort } }
302
+ else
303
+ { reasoning_effort: effort }
304
+ end
305
+ end
306
+
307
+ def budget_reasoning_config(thinking)
308
+ budget = thinking.respond_to?(:budget) ? thinking.budget : thinking
309
+ return nil unless budget.is_a?(Integer)
310
+
311
+ { reasoning_config: { type: 'enabled', budget_tokens: budget } }
312
+ end
313
+
314
+ def render_thinking_block(thinking)
315
+ return nil unless thinking
316
+
317
+ if thinking.text
318
+ {
319
+ reasoningContent: {
320
+ reasoningText: {
321
+ text: thinking.text,
322
+ signature: thinking.signature
323
+ }.compact
324
+ }
325
+ }
326
+ elsif thinking.signature
327
+ {
328
+ reasoningContent: {
329
+ redactedContent: thinking.signature
330
+ }
331
+ }
332
+ end
333
+ end
334
+
335
+ def parse_text_content(content_blocks)
336
+ text = content_blocks.filter_map { |block| block['text'] if block['text'].is_a?(String) }.join
337
+ text.empty? ? nil : text
338
+ end
339
+
340
+ def parse_thinking(content_blocks)
341
+ text = +''
342
+ signature = nil
343
+
344
+ content_blocks.each do |block|
345
+ chunk_text, chunk_signature = parse_reasoning_content_block(block)
346
+ text << chunk_text if chunk_text
347
+ signature ||= chunk_signature
348
+ end
349
+
350
+ [text.empty? ? nil : text, signature]
40
351
  end
41
352
 
42
- def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil) # rubocop:disable Lint/UnusedMethodArgument,Metrics/ParameterLists
43
- @model_id = model.id
353
+ def parse_reasoning_content_block(block)
354
+ reasoning_content = block['reasoningContent']
355
+ return [nil, nil] unless reasoning_content.is_a?(Hash)
44
356
 
45
- system_messages, chat_messages = Anthropic::Chat.separate_messages(messages)
46
- system_content = Anthropic::Chat.build_system_content(system_messages)
357
+ reasoning_text = reasoning_content['reasoningText'] || {}
358
+ text = reasoning_text['text'].is_a?(String) ? reasoning_text['text'] : nil
359
+ signature = reasoning_text['signature'] if reasoning_text['signature'].is_a?(String)
360
+ signature ||= reasoning_content['redactedContent'] if reasoning_content['redactedContent'].is_a?(String)
361
+ [text, signature]
362
+ end
47
363
 
48
- build_base_payload(chat_messages, model).tap do |payload|
49
- Anthropic::Chat.add_optional_fields(payload, system_content:, tools:, temperature:)
364
+ def parse_tool_calls(content_blocks)
365
+ tool_calls = {}
366
+
367
+ content_blocks.each do |block|
368
+ tool_use = block['toolUse']
369
+ next unless tool_use
370
+
371
+ tool_call_id = tool_use['toolUseId']
372
+ tool_calls[tool_call_id] = ToolCall.new(
373
+ id: tool_call_id,
374
+ name: tool_use['name'],
375
+ arguments: tool_use['input'] || {}
376
+ )
50
377
  end
378
+
379
+ tool_calls.empty? ? nil : tool_calls
51
380
  end
52
381
 
53
- def build_base_payload(chat_messages, model)
382
+ def default_input_schema
54
383
  {
55
- anthropic_version: 'bedrock-2023-05-31',
56
- messages: chat_messages.map { |msg| format_message(msg) },
57
- max_tokens: model.max_tokens || 4096
384
+ 'type' => 'object',
385
+ 'properties' => {},
386
+ 'required' => []
58
387
  }
59
388
  end
60
389
  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