ruby_llm 1.12.0 → 1.13.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -0
  3. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +1 -1
  4. data/lib/generators/ruby_llm/generator_helpers.rb +4 -0
  5. data/lib/generators/ruby_llm/install/install_generator.rb +5 -4
  6. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +1 -1
  7. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -1
  8. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +1 -6
  9. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +1 -1
  10. data/lib/ruby_llm/active_record/acts_as.rb +8 -4
  11. data/lib/ruby_llm/active_record/acts_as_legacy.rb +85 -20
  12. data/lib/ruby_llm/active_record/chat_methods.rb +67 -16
  13. data/lib/ruby_llm/agent.rb +39 -8
  14. data/lib/ruby_llm/aliases.json +19 -9
  15. data/lib/ruby_llm/chat.rb +107 -11
  16. data/lib/ruby_llm/configuration.rb +18 -0
  17. data/lib/ruby_llm/connection.rb +10 -4
  18. data/lib/ruby_llm/content.rb +6 -2
  19. data/lib/ruby_llm/error.rb +32 -1
  20. data/lib/ruby_llm/message.rb +5 -3
  21. data/lib/ruby_llm/model/info.rb +1 -1
  22. data/lib/ruby_llm/models.json +3535 -2894
  23. data/lib/ruby_llm/models.rb +5 -3
  24. data/lib/ruby_llm/provider.rb +5 -1
  25. data/lib/ruby_llm/providers/anthropic/capabilities.rb +22 -4
  26. data/lib/ruby_llm/providers/anthropic/chat.rb +22 -5
  27. data/lib/ruby_llm/providers/anthropic/models.rb +1 -1
  28. data/lib/ruby_llm/providers/anthropic/tools.rb +20 -0
  29. data/lib/ruby_llm/providers/anthropic.rb +1 -1
  30. data/lib/ruby_llm/providers/azure/chat.rb +1 -1
  31. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -1
  32. data/lib/ruby_llm/providers/azure/models.rb +1 -1
  33. data/lib/ruby_llm/providers/azure.rb +88 -0
  34. data/lib/ruby_llm/providers/bedrock/chat.rb +50 -5
  35. data/lib/ruby_llm/providers/bedrock/models.rb +17 -1
  36. data/lib/ruby_llm/providers/bedrock/streaming.rb +8 -4
  37. data/lib/ruby_llm/providers/bedrock.rb +5 -1
  38. data/lib/ruby_llm/providers/deepseek/capabilities.rb +8 -0
  39. data/lib/ruby_llm/providers/deepseek.rb +1 -1
  40. data/lib/ruby_llm/providers/gemini/capabilities.rb +8 -0
  41. data/lib/ruby_llm/providers/gemini/chat.rb +19 -4
  42. data/lib/ruby_llm/providers/gemini/images.rb +1 -1
  43. data/lib/ruby_llm/providers/gemini/streaming.rb +1 -1
  44. data/lib/ruby_llm/providers/gemini/tools.rb +19 -0
  45. data/lib/ruby_llm/providers/gpustack/capabilities.rb +20 -0
  46. data/lib/ruby_llm/providers/gpustack.rb +4 -0
  47. data/lib/ruby_llm/providers/mistral/capabilities.rb +8 -0
  48. data/lib/ruby_llm/providers/mistral/chat.rb +2 -1
  49. data/lib/ruby_llm/providers/ollama/capabilities.rb +20 -0
  50. data/lib/ruby_llm/providers/ollama.rb +7 -1
  51. data/lib/ruby_llm/providers/openai/capabilities.rb +10 -2
  52. data/lib/ruby_llm/providers/openai/chat.rb +15 -5
  53. data/lib/ruby_llm/providers/openai/media.rb +4 -1
  54. data/lib/ruby_llm/providers/openai/temperature.rb +2 -2
  55. data/lib/ruby_llm/providers/openai/tools.rb +27 -2
  56. data/lib/ruby_llm/providers/openrouter/chat.rb +19 -5
  57. data/lib/ruby_llm/providers/openrouter/images.rb +69 -0
  58. data/lib/ruby_llm/providers/openrouter.rb +31 -1
  59. data/lib/ruby_llm/providers/vertexai/models.rb +1 -1
  60. data/lib/ruby_llm/providers/vertexai.rb +14 -6
  61. data/lib/ruby_llm/stream_accumulator.rb +10 -5
  62. data/lib/ruby_llm/streaming.rb +6 -6
  63. data/lib/ruby_llm/tool.rb +48 -3
  64. data/lib/ruby_llm/version.rb +1 -1
  65. data/lib/tasks/models.rake +33 -7
  66. data/lib/tasks/release.rake +1 -1
  67. data/lib/tasks/ruby_llm.rake +7 -0
  68. data/lib/tasks/vcr.rake +1 -1
  69. metadata +8 -5
@@ -266,8 +266,8 @@ module RubyLLM
266
266
  end
267
267
 
268
268
  def index_by_key(models)
269
- models.each_with_object({}) do |model, hash|
270
- hash["#{model.provider}:#{model.id}"] = model
269
+ models.to_h do |model|
270
+ ["#{model.provider}:#{model.id}", model]
271
271
  end
272
272
  end
273
273
 
@@ -312,12 +312,14 @@ module RubyLLM
312
312
  modalities = normalize_models_dev_modalities(model_data[:modalities])
313
313
  capabilities = models_dev_capabilities(model_data, modalities)
314
314
 
315
+ created_date = model_data[:release_date] || model_data[:last_updated]
316
+
315
317
  data = {
316
318
  id: model_data[:id],
317
319
  name: model_data[:name] || model_data[:id],
318
320
  provider: provider_slug,
319
321
  family: model_data[:family],
320
- created_at: model_data[:release_date] || model_data[:last_updated],
322
+ created_at: "#{created_date} 00:00:00 UTC",
321
323
  context_window: model_data.dig(:limit, :context),
322
324
  max_output_tokens: model_data.dig(:limit, :output),
323
325
  knowledge_cutoff: normalize_models_dev_knowledge(model_data[:knowledge]),
@@ -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
@@ -31,11 +31,28 @@ module RubyLLM
31
31
  end
32
32
 
33
33
  def supports_functions?(model_id)
34
- model_id.match?(/claude-3/)
34
+ !model_id.match?(/claude-[12]/)
35
+ end
36
+
37
+ def supports_tool_choice?(_model_id)
38
+ true
39
+ end
40
+
41
+ def supports_tool_parallel_control?(_model_id)
42
+ true
35
43
  end
36
44
 
37
45
  def supports_json_mode?(model_id)
38
- model_id.match?(/claude-3/)
46
+ !model_id.match?(/claude-[12]/)
47
+ end
48
+
49
+ def supports_structured_output?(model_id)
50
+ match = model_id.match(/claude-(?:sonnet|opus|haiku)-(\d+)-(\d+)/)
51
+ return false unless match
52
+
53
+ major = match[1].to_i
54
+ minor = match[2].to_i
55
+ major > 4 || (major == 4 && minor >= 5)
39
56
  end
40
57
 
41
58
  def supports_extended_thinking?(model_id)
@@ -92,12 +109,13 @@ module RubyLLM
92
109
  def capabilities_for(model_id)
93
110
  capabilities = ['streaming']
94
111
 
95
- if model_id.match?(/claude-3/)
112
+ unless model_id.match?(/claude-[12]/)
96
113
  capabilities << 'function_calling'
97
114
  capabilities << 'batch'
98
115
  end
99
116
 
100
- capabilities << 'reasoning' if model_id.match?(/claude-3-7|-4/)
117
+ capabilities << 'structured_output' if supports_structured_output?(model_id)
118
+ capabilities << 'reasoning' if model_id.match?(/claude-3-7-sonnet|claude-(?:sonnet|opus|haiku)-4/)
101
119
  capabilities << 'citations' if model_id.match?(/claude-3\.5|claude-3-7/)
102
120
  capabilities
103
121
  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,7 +8,7 @@ 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
14
  def parse_list_models_response(response, slug, capabilities)
@@ -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
@@ -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,6 +28,21 @@ 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
29
47
  def configuration_requirements
30
48
  %i[azure_api_base]
@@ -51,6 +69,76 @@ module RubyLLM
51
69
  raise ConfigurationError,
52
70
  "Missing configuration for Azure: #{missing.join(', ')}"
53
71
  end
72
+
73
+ private
74
+
75
+ def azure_base_parts
76
+ @azure_base_parts ||= begin
77
+ raw_base = api_base.to_s.sub(%r{/+\z}, '')
78
+ version = raw_base[/[?&]api-version=([^&]+)/i, 1]
79
+ path_base = raw_base.sub(/\?.*\z/, '')
80
+
81
+ mode = if path_base.include?('/chat/completions')
82
+ :chat_endpoint
83
+ elsif path_base.include?('/openai/deployments/')
84
+ :deployment_base
85
+ elsif path_base.include?('/openai/v1')
86
+ :openai_v1_base
87
+ else
88
+ :resource_base
89
+ end
90
+
91
+ {
92
+ raw_base: raw_base,
93
+ path_base: path_base,
94
+ root: azure_host_root(path_base),
95
+ mode: mode,
96
+ version: version
97
+ }
98
+ end
99
+ end
100
+
101
+ def chat_endpoint(parts)
102
+ case parts[:mode]
103
+ when :chat_endpoint
104
+ ''
105
+ when :deployment_base
106
+ with_api_version('chat/completions', parts[:version] || AZURE_DEFAULT_CHAT_API_VERSION)
107
+ when :openai_v1_base
108
+ with_api_version('chat/completions', parts[:version])
109
+ else
110
+ with_api_version('models/chat/completions', parts[:version] || AZURE_DEFAULT_CHAT_API_VERSION)
111
+ end
112
+ end
113
+
114
+ def embeddings_endpoint(parts)
115
+ case parts[:mode]
116
+ when :deployment_base, :openai_v1_base
117
+ with_api_version('embeddings', parts[:version])
118
+ else
119
+ "#{parts[:root]}/openai/v1/embeddings"
120
+ end
121
+ end
122
+
123
+ def models_endpoint(parts)
124
+ case parts[:mode]
125
+ when :openai_v1_base
126
+ with_api_version('models', parts[:version] || AZURE_DEFAULT_MODELS_API_VERSION)
127
+ else
128
+ "#{parts[:root]}/openai/v1/models?api-version=#{parts[:version] || AZURE_DEFAULT_MODELS_API_VERSION}"
129
+ end
130
+ end
131
+
132
+ def with_api_version(path, version)
133
+ return path unless version
134
+
135
+ separator = path.include?('?') ? '&' : '?'
136
+ "#{path}#{separator}api-version=#{version}"
137
+ end
138
+
139
+ def azure_host_root(base_without_query)
140
+ base_without_query.sub(%r{/(models|openai)/.*\z}, '').sub(%r{/+\z}, '')
141
+ end
54
142
  end
55
143
  end
56
144
  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
 
@@ -18,12 +18,15 @@ module RubyLLM
18
18
  {}
19
19
  end
20
20
 
21
- def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil, &) # rubocop:disable Metrics/ParameterLists
21
+ # rubocop:disable Metrics/ParameterLists
22
+ def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil,
23
+ tool_prefs: nil, &)
22
24
  normalized_params = normalize_params(params, model:)
23
25
 
24
26
  super(
25
27
  messages,
26
28
  tools: tools,
29
+ tool_prefs: tool_prefs,
27
30
  temperature: temperature,
28
31
  model: model,
29
32
  params: normalized_params,
@@ -33,6 +36,7 @@ module RubyLLM
33
36
  &
34
37
  )
35
38
  end
39
+ # rubocop:enable Metrics/ParameterLists
36
40
 
37
41
  def parse_error(response)
38
42
  return if response.body.nil? || response.body.empty?
@@ -41,6 +41,14 @@ module RubyLLM
41
41
  model_id.match?(/deepseek-chat/)
42
42
  end
43
43
 
44
+ def supports_tool_choice?(_model_id)
45
+ true
46
+ end
47
+
48
+ def supports_tool_parallel_control?(_model_id)
49
+ false
50
+ end
51
+
44
52
  def supports_json_mode?(_model_id)
45
53
  false
46
54
  end
@@ -7,7 +7,7 @@ module RubyLLM
7
7
  include DeepSeek::Chat
8
8
 
9
9
  def api_base
10
- 'https://api.deepseek.com'
10
+ @config.deepseek_api_base || 'https://api.deepseek.com'
11
11
  end
12
12
 
13
13
  def headers
@@ -62,6 +62,14 @@ module RubyLLM
62
62
  model_id.match?(/gemini|pro|flash/)
63
63
  end
64
64
 
65
+ def supports_tool_choice?(_model_id)
66
+ true
67
+ end
68
+
69
+ def supports_tool_parallel_control?(_model_id)
70
+ false
71
+ end
72
+
65
73
  def supports_json_mode?(model_id)
66
74
  if model_id.match?(/text-embedding|embedding-001|aqa|imagen|gemini-2\.0-flash-lite|gemini-2\.5-pro-exp-03-25/)
67
75
  return false
@@ -14,7 +14,10 @@ module RubyLLM
14
14
  "models/#{@model}:generateContent"
15
15
  end
16
16
 
17
- def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists,Lint/UnusedMethodArgument
17
+ # rubocop:disable Metrics/ParameterLists,Lint/UnusedMethodArgument
18
+ def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil,
19
+ thinking: nil, tool_prefs: nil)
20
+ tool_prefs ||= {}
18
21
  @model = model.id
19
22
  payload = {
20
23
  contents: format_messages(messages),
@@ -26,9 +29,15 @@ module RubyLLM
26
29
  payload[:generationConfig].merge!(structured_output_config(schema, model)) if schema
27
30
  payload[:generationConfig][:thinkingConfig] = build_thinking_config(model, thinking) if thinking&.enabled?
28
31
 
29
- payload[:tools] = format_tools(tools) if tools.any?
32
+ if tools.any?
33
+ payload[:tools] = format_tools(tools)
34
+ # Gemini doesn't support controlling parallel tool calls
35
+ payload[:toolConfig] = build_tool_config(tool_prefs[:choice]) unless tool_prefs[:choice].nil?
36
+ end
37
+
30
38
  payload
31
39
  end
40
+ # rubocop:enable Metrics/ParameterLists,Lint/UnusedMethodArgument
32
41
 
33
42
  def build_thinking_config(_model, thinking)
34
43
  config = { includeThoughts: true }
@@ -120,6 +129,9 @@ module RubyLLM
120
129
  def convert_schema_to_gemini(schema)
121
130
  return nil unless schema
122
131
 
132
+ # Extract inner schema if wrapper format (e.g., from RubyLLM::Schema.to_json_schema)
133
+ schema = schema[:schema] || schema
134
+
123
135
  GeminiSchema.new(schema).to_h
124
136
  end
125
137
 
@@ -132,7 +144,10 @@ module RubyLLM
132
144
  parts = candidate.dig('content', 'parts')
133
145
  return '' unless parts&.any?
134
146
 
135
- build_response_content(parts)
147
+ non_thought_parts = parts.reject { |part| part['thought'] }
148
+ return '' unless non_thought_parts.any?
149
+
150
+ build_response_content(non_thought_parts)
136
151
  end
137
152
 
138
153
  def extract_text_parts(parts)
@@ -176,7 +191,7 @@ module RubyLLM
176
191
  end
177
192
 
178
193
  def build_json_schema(schema)
179
- normalized = RubyLLM::Utils.deep_dup(schema)
194
+ normalized = RubyLLM::Utils.deep_dup(schema[:schema])
180
195
  normalized.delete(:strict)
181
196
  normalized.delete('strict')
182
197
  RubyLLM::Utils.deep_stringify_keys(normalized)
@@ -10,7 +10,7 @@ module RubyLLM
10
10
  end
11
11
 
12
12
  def render_image_payload(prompt, model:, size:)
13
- RubyLLM.logger.debug "Ignoring size #{size}. Gemini does not support image size customization."
13
+ RubyLLM.logger.debug { "Ignoring size #{size}. Gemini does not support image size customization." }
14
14
  @model = model
15
15
  {
16
16
  instances: [
@@ -83,7 +83,7 @@ module RubyLLM
83
83
  error_data = JSON.parse(data)
84
84
  [error_data['error']['code'], error_data['error']['message']]
85
85
  rescue JSON::ParserError => e
86
- RubyLLM.logger.debug "Failed to parse streaming error: #{e.message}"
86
+ RubyLLM.logger.debug { "Failed to parse streaming error: #{e.message}" }
87
87
  [500, "Failed to parse error: #{data}"]
88
88
  end
89
89
  end