ruby_llm 1.12.0 → 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 (141) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -5
  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 +1 -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 -2
  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/upgrade_to_v1_10_generator.rb +1 -1
  63. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
  64. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
  65. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +2 -4
  66. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
  67. data/lib/ruby_llm/active_record/acts_as.rb +10 -4
  68. data/lib/ruby_llm/active_record/acts_as_legacy.rb +87 -20
  69. data/lib/ruby_llm/active_record/chat_methods.rb +80 -22
  70. data/lib/ruby_llm/active_record/message_methods.rb +17 -0
  71. data/lib/ruby_llm/active_record/model_methods.rb +1 -1
  72. data/lib/ruby_llm/active_record/payload_helpers.rb +26 -0
  73. data/lib/ruby_llm/active_record/tool_call_methods.rb +15 -0
  74. data/lib/ruby_llm/agent.rb +50 -8
  75. data/lib/ruby_llm/aliases.json +60 -21
  76. data/lib/ruby_llm/attachment.rb +4 -1
  77. data/lib/ruby_llm/chat.rb +113 -12
  78. data/lib/ruby_llm/configuration.rb +65 -66
  79. data/lib/ruby_llm/connection.rb +11 -7
  80. data/lib/ruby_llm/content.rb +6 -2
  81. data/lib/ruby_llm/error.rb +37 -1
  82. data/lib/ruby_llm/message.rb +5 -3
  83. data/lib/ruby_llm/model/info.rb +15 -13
  84. data/lib/ruby_llm/models.json +12279 -13517
  85. data/lib/ruby_llm/models.rb +16 -6
  86. data/lib/ruby_llm/provider.rb +10 -1
  87. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -119
  88. data/lib/ruby_llm/providers/anthropic/chat.rb +22 -5
  89. data/lib/ruby_llm/providers/anthropic/models.rb +3 -9
  90. data/lib/ruby_llm/providers/anthropic/tools.rb +20 -0
  91. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  92. data/lib/ruby_llm/providers/azure/chat.rb +1 -1
  93. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -1
  94. data/lib/ruby_llm/providers/azure/models.rb +1 -1
  95. data/lib/ruby_llm/providers/azure.rb +92 -0
  96. data/lib/ruby_llm/providers/bedrock/chat.rb +50 -5
  97. data/lib/ruby_llm/providers/bedrock/models.rb +17 -1
  98. data/lib/ruby_llm/providers/bedrock/streaming.rb +8 -4
  99. data/lib/ruby_llm/providers/bedrock.rb +9 -1
  100. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -114
  101. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  102. data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -207
  103. data/lib/ruby_llm/providers/gemini/chat.rb +20 -4
  104. data/lib/ruby_llm/providers/gemini/images.rb +1 -1
  105. data/lib/ruby_llm/providers/gemini/models.rb +2 -4
  106. data/lib/ruby_llm/providers/gemini/streaming.rb +2 -1
  107. data/lib/ruby_llm/providers/gemini/tools.rb +19 -0
  108. data/lib/ruby_llm/providers/gemini.rb +4 -0
  109. data/lib/ruby_llm/providers/gpustack/capabilities.rb +20 -0
  110. data/lib/ruby_llm/providers/gpustack.rb +8 -0
  111. data/lib/ruby_llm/providers/mistral/capabilities.rb +8 -0
  112. data/lib/ruby_llm/providers/mistral/chat.rb +2 -1
  113. data/lib/ruby_llm/providers/mistral.rb +4 -0
  114. data/lib/ruby_llm/providers/ollama/capabilities.rb +20 -0
  115. data/lib/ruby_llm/providers/ollama.rb +11 -1
  116. data/lib/ruby_llm/providers/openai/capabilities.rb +95 -195
  117. data/lib/ruby_llm/providers/openai/chat.rb +15 -5
  118. data/lib/ruby_llm/providers/openai/media.rb +4 -1
  119. data/lib/ruby_llm/providers/openai/models.rb +2 -4
  120. data/lib/ruby_llm/providers/openai/temperature.rb +2 -2
  121. data/lib/ruby_llm/providers/openai/tools.rb +27 -2
  122. data/lib/ruby_llm/providers/openai.rb +10 -0
  123. data/lib/ruby_llm/providers/openrouter/chat.rb +19 -5
  124. data/lib/ruby_llm/providers/openrouter/images.rb +69 -0
  125. data/lib/ruby_llm/providers/openrouter.rb +35 -1
  126. data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
  127. data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
  128. data/lib/ruby_llm/providers/perplexity.rb +4 -0
  129. data/lib/ruby_llm/providers/vertexai/models.rb +1 -1
  130. data/lib/ruby_llm/providers/vertexai.rb +18 -6
  131. data/lib/ruby_llm/providers/xai.rb +4 -0
  132. data/lib/ruby_llm/stream_accumulator.rb +10 -5
  133. data/lib/ruby_llm/streaming.rb +7 -7
  134. data/lib/ruby_llm/tool.rb +48 -3
  135. data/lib/ruby_llm/version.rb +1 -1
  136. data/lib/tasks/models.rake +33 -7
  137. data/lib/tasks/release.rake +1 -1
  138. data/lib/tasks/ruby_llm.rake +9 -1
  139. data/lib/tasks/vcr.rake +1 -1
  140. metadata +56 -15
  141. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
@@ -47,7 +47,8 @@ module RubyLLM
47
47
 
48
48
  def read_from_json(file = RubyLLM.config.model_registry_file)
49
49
  data = File.exist?(file) ? File.read(file) : '[]'
50
- JSON.parse(data, symbolize_names: true).map { |model| Model::Info.new(model) }
50
+ models = JSON.parse(data, symbolize_names: true).map { |model| Model::Info.new(model) }
51
+ filter_models(models)
51
52
  rescue JSON::ParserError
52
53
  []
53
54
  end
@@ -232,7 +233,13 @@ module RubyLLM
232
233
  end
233
234
  end
234
235
 
235
- models.sort_by { |m| [m.provider, m.id] }
236
+ filter_models(models).sort_by { |m| [m.provider, m.id] }
237
+ end
238
+
239
+ def filter_models(models)
240
+ models.reject do |model|
241
+ model.provider.to_s == 'vertexai' && model.id.to_s.include?('/')
242
+ end
236
243
  end
237
244
 
238
245
  def find_models_dev_model(key, models_dev_by_key)
@@ -266,8 +273,8 @@ module RubyLLM
266
273
  end
267
274
 
268
275
  def index_by_key(models)
269
- models.each_with_object({}) do |model, hash|
270
- hash["#{model.provider}:#{model.id}"] = model
276
+ models.to_h do |model|
277
+ ["#{model.provider}:#{model.id}", model]
271
278
  end
272
279
  end
273
280
 
@@ -312,12 +319,15 @@ module RubyLLM
312
319
  modalities = normalize_models_dev_modalities(model_data[:modalities])
313
320
  capabilities = models_dev_capabilities(model_data, modalities)
314
321
 
322
+ created_date = [model_data[:release_date], model_data[:last_updated]]
323
+ .find { |value| !value.to_s.strip.empty? }
324
+
315
325
  data = {
316
326
  id: model_data[:id],
317
327
  name: model_data[:name] || model_data[:id],
318
328
  provider: provider_slug,
319
329
  family: model_data[:family],
320
- created_at: model_data[:release_date] || model_data[:last_updated],
330
+ created_at: created_date ? "#{created_date} 00:00:00 UTC" : nil,
321
331
  context_window: model_data.dig(:limit, :context),
322
332
  max_output_tokens: model_data.dig(:limit, :output),
323
333
  knowledge_cutoff: normalize_models_dev_knowledge(model_data[:knowledge]),
@@ -398,7 +408,7 @@ module RubyLLM
398
408
  end
399
409
 
400
410
  def initialize(models = nil)
401
- @models = models || self.class.load_models
411
+ @models = self.class.filter_models(models || self.class.load_models)
402
412
  end
403
413
 
404
414
  def load_from_json!(file = RubyLLM.config.model_registry_file)
@@ -37,13 +37,16 @@ module RubyLLM
37
37
  self.class.configuration_requirements
38
38
  end
39
39
 
40
- def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil, &) # rubocop:disable Metrics/ParameterLists
40
+ # rubocop:disable Metrics/ParameterLists
41
+ def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil,
42
+ tool_prefs: nil, &)
41
43
  normalized_temperature = maybe_normalize_temperature(temperature, model)
42
44
 
43
45
  payload = Utils.deep_merge(
44
46
  render_payload(
45
47
  messages,
46
48
  tools: tools,
49
+ tool_prefs: tool_prefs,
47
50
  temperature: normalized_temperature,
48
51
  model: model,
49
52
  stream: block_given?,
@@ -59,6 +62,7 @@ module RubyLLM
59
62
  sync_response @connection, payload, headers
60
63
  end
61
64
  end
65
+ # rubocop:enable Metrics/ParameterLists
62
66
 
63
67
  def list_models
64
68
  response = @connection.get models_url
@@ -160,6 +164,10 @@ module RubyLLM
160
164
  []
161
165
  end
162
166
 
167
+ def configuration_options
168
+ []
169
+ end
170
+
163
171
  def local?
164
172
  false
165
173
  end
@@ -178,6 +186,7 @@ module RubyLLM
178
186
 
179
187
  def register(name, provider_class)
180
188
  providers[name.to_sym] = provider_class
189
+ RubyLLM::Configuration.register_provider_options(provider_class.configuration_options)
181
190
  end
182
191
 
183
192
  def resolve(name)
@@ -3,130 +3,16 @@
3
3
  module RubyLLM
4
4
  module Providers
5
5
  class Anthropic
6
- # Determines capabilities and pricing for Anthropic models
6
+ # Provider-level capability checks used outside the model registry.
7
7
  module Capabilities
8
8
  module_function
9
9
 
10
- def determine_context_window(_model_id)
11
- 200_000
10
+ def supports_tool_choice?(_model_id)
11
+ true
12
12
  end
13
13
 
14
- def determine_max_tokens(model_id)
15
- case model_id
16
- when /claude-3-7-sonnet/, /claude-3-5/ then 8_192
17
- else 4_096
18
- end
19
- end
20
-
21
- def get_input_price(model_id)
22
- PRICES.dig(model_family(model_id), :input) || default_input_price
23
- end
24
-
25
- def get_output_price(model_id)
26
- PRICES.dig(model_family(model_id), :output) || default_output_price
27
- end
28
-
29
- def supports_vision?(model_id)
30
- !model_id.match?(/claude-[12]/)
31
- end
32
-
33
- def supports_functions?(model_id)
34
- model_id.match?(/claude-3/)
35
- end
36
-
37
- def supports_json_mode?(model_id)
38
- model_id.match?(/claude-3/)
39
- end
40
-
41
- def supports_extended_thinking?(model_id)
42
- model_id.match?(/claude-3-7-sonnet/)
43
- end
44
-
45
- def model_family(model_id)
46
- case model_id
47
- when /claude-3-7-sonnet/ then 'claude-3-7-sonnet'
48
- when /claude-3-5-sonnet/ then 'claude-3-5-sonnet'
49
- when /claude-3-5-haiku/ then 'claude-3-5-haiku'
50
- when /claude-3-opus/ then 'claude-3-opus'
51
- when /claude-3-sonnet/ then 'claude-3-sonnet'
52
- when /claude-3-haiku/ then 'claude-3-haiku'
53
- else 'claude-2'
54
- end
55
- end
56
-
57
- def model_type(_)
58
- 'chat'
59
- end
60
-
61
- PRICES = {
62
- 'claude-3-7-sonnet': { input: 3.0, output: 15.0 },
63
- 'claude-3-5-sonnet': { input: 3.0, output: 15.0 },
64
- 'claude-3-5-haiku': { input: 0.80, output: 4.0 },
65
- 'claude-3-opus': { input: 15.0, output: 75.0 },
66
- 'claude-3-haiku': { input: 0.25, output: 1.25 },
67
- 'claude-2': { input: 3.0, output: 15.0 }
68
- }.freeze
69
-
70
- def default_input_price
71
- 3.0
72
- end
73
-
74
- def default_output_price
75
- 15.0
76
- end
77
-
78
- def modalities_for(model_id)
79
- modalities = {
80
- input: ['text'],
81
- output: ['text']
82
- }
83
-
84
- unless model_id.match?(/claude-[12]/)
85
- modalities[:input] << 'image'
86
- modalities[:input] << 'pdf'
87
- end
88
-
89
- modalities
90
- end
91
-
92
- def capabilities_for(model_id)
93
- capabilities = ['streaming']
94
-
95
- if model_id.match?(/claude-3/)
96
- capabilities << 'function_calling'
97
- capabilities << 'batch'
98
- end
99
-
100
- capabilities << 'reasoning' if model_id.match?(/claude-3-7|-4/)
101
- capabilities << 'citations' if model_id.match?(/claude-3\.5|claude-3-7/)
102
- capabilities
103
- end
104
-
105
- def pricing_for(model_id)
106
- family = model_family(model_id)
107
- prices = PRICES.fetch(family.to_sym, { input: default_input_price, output: default_output_price })
108
-
109
- standard_pricing = {
110
- input_per_million: prices[:input],
111
- output_per_million: prices[:output]
112
- }
113
-
114
- batch_pricing = {
115
- input_per_million: prices[:input] * 0.5,
116
- output_per_million: prices[:output] * 0.5
117
- }
118
-
119
- if model_id.match?(/claude-3-7/)
120
- standard_pricing[:reasoning_output_per_million] = prices[:output] * 2.5
121
- batch_pricing[:reasoning_output_per_million] = prices[:output] * 1.25
122
- end
123
-
124
- {
125
- text_tokens: {
126
- standard: standard_pricing,
127
- batch: batch_pricing
128
- }
129
- }
14
+ def supports_tool_parallel_control?(_model_id)
15
+ true
130
16
  end
131
17
  end
132
18
  end
@@ -8,17 +8,21 @@ module RubyLLM
8
8
  module_function
9
9
 
10
10
  def completion_url
11
- '/v1/messages'
11
+ 'v1/messages'
12
12
  end
13
13
 
14
- def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists,Lint/UnusedMethodArgument
14
+ # rubocop:disable Metrics/ParameterLists
15
+ def render_payload(messages, tools:, temperature:, model:, stream: false,
16
+ schema: nil, thinking: nil, tool_prefs: nil)
17
+ tool_prefs ||= {}
15
18
  system_messages, chat_messages = separate_messages(messages)
16
19
  system_content = build_system_content(system_messages)
17
20
 
18
21
  build_base_payload(chat_messages, model, stream, thinking).tap do |payload|
19
- add_optional_fields(payload, system_content:, tools:, temperature:)
22
+ add_optional_fields(payload, system_content:, tools:, tool_prefs:, temperature:, schema:)
20
23
  end
21
24
  end
25
+ # rubocop:enable Metrics/ParameterLists
22
26
 
23
27
  def separate_messages(messages)
24
28
  messages.partition { |msg| msg.role == :system }
@@ -59,10 +63,23 @@ module RubyLLM
59
63
  payload
60
64
  end
61
65
 
62
- def add_optional_fields(payload, system_content:, tools:, temperature:)
63
- payload[:tools] = tools.values.map { |t| Tools.function_for(t) } if tools.any?
66
+ def add_optional_fields(payload, system_content:, tools:, tool_prefs:, temperature:, schema: nil) # rubocop:disable Metrics/ParameterLists
67
+ if tools.any?
68
+ payload[:tools] = tools.values.map { |t| Tools.function_for(t) }
69
+ unless tool_prefs[:choice].nil? && tool_prefs[:calls].nil?
70
+ payload[:tool_choice] = Tools.build_tool_choice(tool_prefs)
71
+ end
72
+ end
64
73
  payload[:system] = system_content unless system_content.empty?
65
74
  payload[:temperature] = temperature unless temperature.nil?
75
+ payload[:output_config] = build_output_config(schema) if schema
76
+ end
77
+
78
+ def build_output_config(schema)
79
+ normalized = RubyLLM::Utils.deep_dup(schema[:schema])
80
+ normalized.delete(:strict)
81
+ normalized.delete('strict')
82
+ { format: { type: 'json_schema', schema: normalized } }
66
83
  end
67
84
 
68
85
  def parse_completion_response(response)
@@ -8,24 +8,18 @@ module RubyLLM
8
8
  module_function
9
9
 
10
10
  def models_url
11
- '/v1/models'
11
+ 'v1/models'
12
12
  end
13
13
 
14
- def parse_list_models_response(response, slug, capabilities)
14
+ def parse_list_models_response(response, slug, _capabilities)
15
15
  Array(response.body['data']).map do |model_data|
16
16
  model_id = model_data['id']
17
17
 
18
18
  Model::Info.new(
19
19
  id: model_id,
20
- name: model_data['display_name'],
20
+ name: model_data['display_name'] || model_id,
21
21
  provider: slug,
22
- family: capabilities.model_family(model_id),
23
22
  created_at: Time.parse(model_data['created_at']),
24
- context_window: capabilities.determine_context_window(model_id),
25
- max_output_tokens: capabilities.determine_max_tokens(model_id),
26
- modalities: capabilities.modalities_for(model_id),
27
- capabilities: capabilities.capabilities_for(model_id),
28
- pricing: capabilities.pricing_for(model_id),
29
23
  metadata: {}
30
24
  )
31
25
  end
@@ -103,6 +103,26 @@ module RubyLLM
103
103
  'strict' => true
104
104
  }
105
105
  end
106
+
107
+ def build_tool_choice(tool_prefs)
108
+ tool_choice = tool_prefs[:choice]
109
+ calls_in_response = tool_prefs[:calls]
110
+ tool_choice = :auto if tool_choice.nil?
111
+
112
+ {
113
+ type: case tool_choice
114
+ when :auto, :none
115
+ tool_choice
116
+ when :required
117
+ :any
118
+ else
119
+ :tool
120
+ end
121
+ }.tap do |tc|
122
+ tc[:name] = tool_choice if tc[:type] == :tool
123
+ tc[:disable_parallel_tool_use] = calls_in_response == :one if tc[:type] != :none && !calls_in_response.nil?
124
+ end
125
+ end
106
126
  end
107
127
  end
108
128
  end
@@ -12,7 +12,7 @@ module RubyLLM
12
12
  include Anthropic::Tools
13
13
 
14
14
  def api_base
15
- 'https://api.anthropic.com'
15
+ @config.anthropic_api_base || 'https://api.anthropic.com'
16
16
  end
17
17
 
18
18
  def headers
@@ -27,6 +27,10 @@ module RubyLLM
27
27
  Anthropic::Capabilities
28
28
  end
29
29
 
30
+ def configuration_options
31
+ %i[anthropic_api_key anthropic_api_base]
32
+ end
33
+
30
34
  def configuration_requirements
31
35
  %i[anthropic_api_key]
32
36
  end
@@ -6,7 +6,7 @@ module RubyLLM
6
6
  # Chat methods of the Azure AI Foundry API integration
7
7
  module Chat
8
8
  def completion_url
9
- 'models/chat/completions?api-version=2024-05-01-preview'
9
+ azure_endpoint(:chat)
10
10
  end
11
11
 
12
12
  def format_messages(messages)
@@ -8,7 +8,7 @@ module RubyLLM
8
8
  module_function
9
9
 
10
10
  def embedding_url(...)
11
- 'openai/v1/embeddings'
11
+ azure_endpoint(:embeddings)
12
12
  end
13
13
 
14
14
  def render_embedding_payload(text, model:, dimensions:)
@@ -6,7 +6,7 @@ module RubyLLM
6
6
  # Models methods of the Azure AI Foundry API integration
7
7
  module Models
8
8
  def models_url
9
- 'openai/v1/models?api-version=preview'
9
+ azure_endpoint(:models)
10
10
  end
11
11
  end
12
12
  end
@@ -4,6 +4,9 @@ module RubyLLM
4
4
  module Providers
5
5
  # Azure AI Foundry / OpenAI-compatible API integration.
6
6
  class Azure < OpenAI
7
+ AZURE_DEFAULT_CHAT_API_VERSION = '2024-05-01-preview'
8
+ AZURE_DEFAULT_MODELS_API_VERSION = 'preview'
9
+
7
10
  include Azure::Chat
8
11
  include Azure::Embeddings
9
12
  include Azure::Media
@@ -25,7 +28,26 @@ module RubyLLM
25
28
  self.class.configured?(@config)
26
29
  end
27
30
 
31
+ def azure_endpoint(kind)
32
+ parts = azure_base_parts
33
+
34
+ case kind
35
+ when :chat
36
+ chat_endpoint(parts)
37
+ when :embeddings
38
+ embeddings_endpoint(parts)
39
+ when :models
40
+ models_endpoint(parts)
41
+ else
42
+ raise ArgumentError, "Unknown Azure endpoint kind: #{kind.inspect}"
43
+ end
44
+ end
45
+
28
46
  class << self
47
+ def configuration_options
48
+ %i[azure_api_base azure_api_key azure_ai_auth_token]
49
+ end
50
+
29
51
  def configuration_requirements
30
52
  %i[azure_api_base]
31
53
  end
@@ -51,6 +73,76 @@ module RubyLLM
51
73
  raise ConfigurationError,
52
74
  "Missing configuration for Azure: #{missing.join(', ')}"
53
75
  end
76
+
77
+ private
78
+
79
+ def azure_base_parts
80
+ @azure_base_parts ||= begin
81
+ raw_base = api_base.to_s.sub(%r{/+\z}, '')
82
+ version = raw_base[/[?&]api-version=([^&]+)/i, 1]
83
+ path_base = raw_base.sub(/\?.*\z/, '')
84
+
85
+ mode = if path_base.include?('/chat/completions')
86
+ :chat_endpoint
87
+ elsif path_base.include?('/openai/deployments/')
88
+ :deployment_base
89
+ elsif path_base.include?('/openai/v1')
90
+ :openai_v1_base
91
+ else
92
+ :resource_base
93
+ end
94
+
95
+ {
96
+ raw_base: raw_base,
97
+ path_base: path_base,
98
+ root: azure_host_root(path_base),
99
+ mode: mode,
100
+ version: version
101
+ }
102
+ end
103
+ end
104
+
105
+ def chat_endpoint(parts)
106
+ case parts[:mode]
107
+ when :chat_endpoint
108
+ ''
109
+ when :deployment_base
110
+ with_api_version('chat/completions', parts[:version] || AZURE_DEFAULT_CHAT_API_VERSION)
111
+ when :openai_v1_base
112
+ with_api_version('chat/completions', parts[:version])
113
+ else
114
+ with_api_version('models/chat/completions', parts[:version] || AZURE_DEFAULT_CHAT_API_VERSION)
115
+ end
116
+ end
117
+
118
+ def embeddings_endpoint(parts)
119
+ case parts[:mode]
120
+ when :deployment_base, :openai_v1_base
121
+ with_api_version('embeddings', parts[:version])
122
+ else
123
+ "#{parts[:root]}/openai/v1/embeddings"
124
+ end
125
+ end
126
+
127
+ def models_endpoint(parts)
128
+ case parts[:mode]
129
+ when :openai_v1_base
130
+ with_api_version('models', parts[:version] || AZURE_DEFAULT_MODELS_API_VERSION)
131
+ else
132
+ "#{parts[:root]}/openai/v1/models?api-version=#{parts[:version] || AZURE_DEFAULT_MODELS_API_VERSION}"
133
+ end
134
+ end
135
+
136
+ def with_api_version(path, version)
137
+ return path unless version
138
+
139
+ separator = path.include?('?') ? '&' : '?'
140
+ "#{path}#{separator}api-version=#{version}"
141
+ end
142
+
143
+ def azure_host_root(base_without_query)
144
+ base_without_query.sub(%r{/(models|openai)/.*\z}, '').sub(%r{/+\z}, '')
145
+ end
54
146
  end
55
147
  end
56
148
  end
@@ -11,11 +11,13 @@ module RubyLLM
11
11
  "/model/#{@model.id}/converse"
12
12
  end
13
13
 
14
- def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists,Lint/UnusedMethodArgument
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 ||= {}
15
18
  @model = model
16
19
  @used_document_names = {}
17
20
  system_messages, chat_messages = messages.partition { |msg| msg.role == :system }
18
-
19
21
  payload = {
20
22
  messages: render_messages(chat_messages)
21
23
  }
@@ -25,7 +27,7 @@ module RubyLLM
25
27
 
26
28
  payload[:inferenceConfig] = render_inference_config(model, temperature)
27
29
 
28
- tool_config = render_tool_config(tools)
30
+ tool_config = render_tool_config(tools, tool_prefs)
29
31
  if tool_config
30
32
  payload[:toolConfig] = tool_config
31
33
  payload[:tools] = tool_config[:tools] # Internal mirror for shared payload inspections in specs.
@@ -34,8 +36,12 @@ module RubyLLM
34
36
  additional_fields = render_additional_model_request_fields(thinking)
35
37
  payload[:additionalModelRequestFields] = additional_fields if additional_fields
36
38
 
39
+ output_config = build_output_config(schema)
40
+ payload[:outputConfig] = output_config if output_config
41
+
37
42
  payload
38
43
  end
44
+ # rubocop:enable Metrics/ParameterLists,Lint/UnusedMethodArgument
39
45
 
40
46
  def parse_completion_response(response)
41
47
  data = response.body
@@ -203,12 +209,31 @@ module RubyLLM
203
209
  config
204
210
  end
205
211
 
206
- def render_tool_config(tools)
212
+ def render_tool_config(tools, tool_prefs)
207
213
  return nil if tools.empty?
208
214
 
209
- {
215
+ config = {
210
216
  tools: tools.values.map { |tool| render_tool(tool) }
211
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: {} }
234
+ else
235
+ { tool: { name: choice.to_s } }
236
+ end
212
237
  end
213
238
 
214
239
  def render_tool(tool)
@@ -238,6 +263,26 @@ module RubyLLM
238
263
  fields.empty? ? nil : fields
239
264
  end
240
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
+
273
+ {
274
+ textFormat: {
275
+ type: 'json_schema',
276
+ structure: {
277
+ jsonSchema: {
278
+ schema: JSON.generate(cleaned),
279
+ name: schema[:name]
280
+ }
281
+ }
282
+ }
283
+ }
284
+ end
285
+
241
286
  def render_reasoning_fields(thinking)
242
287
  return nil unless thinking&.enabled?
243
288
 
@@ -7,7 +7,7 @@ module RubyLLM
7
7
  module Models
8
8
  module_function
9
9
 
10
- REGION_PREFIXES = %w[us eu ap sa ca me af il].freeze
10
+ REGION_PREFIXES = %w[global us eu ap sa ca me af il].freeze
11
11
 
12
12
  def models_api_base
13
13
  "https://bedrock.#{bedrock_region}.amazonaws.com"
@@ -100,10 +100,26 @@ module RubyLLM
100
100
  converse = model_data['converse'] || {}
101
101
  capabilities << 'function_calling' if converse.is_a?(Hash)
102
102
  capabilities << 'reasoning' if converse.dig('reasoningSupported', 'embedded')
103
+ capabilities << 'structured_output' if supports_structured_output?(model_data['modelId'])
103
104
 
104
105
  capabilities
105
106
  end
106
107
 
108
+ # Structured output supported on Claude 4.5+ and assumed for future major versions.
109
+ # Bedrock IDs look like: us.anthropic.claude-haiku-4-5-20251001-v1:0
110
+ # Must handle optional region prefix (us./eu./global.) and anthropic. prefix.
111
+ def supports_structured_output?(model_id)
112
+ return false unless model_id
113
+
114
+ normalized = model_id.sub(/\A(?:#{REGION_PREFIXES.join('|')})\./, '').delete_prefix('anthropic.')
115
+ match = normalized.match(/claude-(?:opus|sonnet|haiku)-(\d+)-(\d{1,2})(?:\b|-)/)
116
+ return false unless match
117
+
118
+ major = match[1].to_i
119
+ minor = match[2].to_i
120
+ major > 4 || (major == 4 && minor >= 5)
121
+ end
122
+
107
123
  def reasoning_embedded?(model)
108
124
  metadata = RubyLLM::Utils.deep_symbolize_keys(model.metadata || {})
109
125
  converse = metadata[:converse] || {}
@@ -38,7 +38,7 @@ module RubyLLM
38
38
  end
39
39
 
40
40
  message = accumulator.to_message(response)
41
- RubyLLM.logger.debug "Stream completed: #{message.content}"
41
+ RubyLLM.logger.debug { "Stream completed: #{message.content}" }
42
42
  message
43
43
  end
44
44
 
@@ -56,7 +56,7 @@ module RubyLLM
56
56
  error_response = env.merge(body: data)
57
57
  ErrorMiddleware.parse_error(provider: self, response: error_response)
58
58
  rescue JSON::ParserError
59
- RubyLLM.logger.debug "Failed Bedrock stream error chunk: #{chunk}"
59
+ RubyLLM.logger.debug { "Failed Bedrock stream error chunk: #{chunk}" }
60
60
  end
61
61
 
62
62
  def parse_stream_chunk(decoder, raw_chunk, accumulator)
@@ -100,7 +100,11 @@ module RubyLLM
100
100
 
101
101
  while message
102
102
  event = decode_event_payload(message.payload.read)
103
- RubyLLM.logger.debug("Bedrock stream event keys: #{event.keys}") if event && RubyLLM.config.log_stream_debug
103
+ if event && RubyLLM.config.log_stream_debug
104
+ RubyLLM.logger.debug do
105
+ "Bedrock stream event keys: #{event.keys}"
106
+ end
107
+ end
104
108
  events << event if event
105
109
  break if eof
106
110
 
@@ -119,7 +123,7 @@ module RubyLLM
119
123
  outer
120
124
  end
121
125
  rescue JSON::ParserError => e
122
- RubyLLM.logger.debug "Failed to decode Bedrock stream event payload: #{e.message}"
126
+ RubyLLM.logger.debug { "Failed to decode Bedrock stream event payload: #{e.message}" }
123
127
  nil
124
128
  end
125
129