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
@@ -205,6 +205,25 @@ module RubyLLM
205
205
  else 'STRING'
206
206
  end
207
207
  end
208
+
209
+ def build_tool_config(tool_choice)
210
+ {
211
+ functionCallingConfig: {
212
+ mode: forced_tool_choice?(tool_choice) ? 'any' : tool_choice
213
+ }.tap do |config|
214
+ # Use allowedFunctionNames to simulate specific tool choice
215
+ config[:allowedFunctionNames] = [tool_choice] if specific_tool_choice?(tool_choice)
216
+ end
217
+ }
218
+ end
219
+
220
+ def forced_tool_choice?(tool_choice)
221
+ tool_choice == :required || specific_tool_choice?(tool_choice)
222
+ end
223
+
224
+ def specific_tool_choice?(tool_choice)
225
+ !%i[auto none required].include?(tool_choice)
226
+ end
208
227
  end
209
228
  end
210
229
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class GPUStack
6
+ # Determines capabilities for GPUStack models
7
+ module Capabilities
8
+ module_function
9
+
10
+ def supports_tool_choice?(_model_id)
11
+ false
12
+ end
13
+
14
+ def supports_tool_parallel_control?(_model_id)
15
+ false
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -28,6 +28,10 @@ module RubyLLM
28
28
  def configuration_requirements
29
29
  %i[gpustack_api_base]
30
30
  end
31
+
32
+ def capabilities
33
+ GPUStack::Capabilities
34
+ end
31
35
  end
32
36
  end
33
37
  end
@@ -15,6 +15,14 @@ module RubyLLM
15
15
  !model_id.match?(/embed|moderation|ocr|voxtral|transcriptions|mistral-(tiny|small)-(2312|2402)/)
16
16
  end
17
17
 
18
+ def supports_tool_choice?(_model_id)
19
+ true
20
+ end
21
+
22
+ def supports_tool_parallel_control?(_model_id)
23
+ true
24
+ end
25
+
18
26
  def supports_vision?(model_id)
19
27
  model_id.match?(/pixtral|mistral-small-(2503|2506)|mistral-medium/)
20
28
  end
@@ -23,7 +23,8 @@ module RubyLLM
23
23
  end
24
24
 
25
25
  # rubocop:disable Metrics/ParameterLists
26
- def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil, thinking: nil)
26
+ def render_payload(messages, tools:, temperature:, model:, stream: false,
27
+ schema: nil, thinking: nil, tool_prefs: nil)
27
28
  payload = super
28
29
  payload.delete(:stream_options)
29
30
  payload.delete(:reasoning_effort)
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Ollama
6
+ # Determines capabilities for Ollama models
7
+ module Capabilities
8
+ module_function
9
+
10
+ def supports_tool_choice?(_model_id)
11
+ false
12
+ end
13
+
14
+ def supports_tool_parallel_control?(_model_id)
15
+ false
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -13,7 +13,9 @@ module RubyLLM
13
13
  end
14
14
 
15
15
  def headers
16
- {}
16
+ return {} unless @config.ollama_api_key
17
+
18
+ { 'Authorization' => "Bearer #{@config.ollama_api_key}" }
17
19
  end
18
20
 
19
21
  class << self
@@ -24,6 +26,10 @@ module RubyLLM
24
26
  def local?
25
27
  true
26
28
  end
29
+
30
+ def capabilities
31
+ Ollama::Capabilities
32
+ end
27
33
  end
28
34
  end
29
35
  end
@@ -97,6 +97,14 @@ module RubyLLM
97
97
  end
98
98
  end
99
99
 
100
+ def supports_tool_choice?(_model_id)
101
+ true
102
+ end
103
+
104
+ def supports_tool_parallel_control?(_model_id)
105
+ true
106
+ end
107
+
100
108
  def supports_structured_output?(model_id)
101
109
  case model_family(model_id)
102
110
  when 'gpt5', 'gpt5_mini', 'gpt5_nano', 'gpt41', 'gpt41_mini', 'gpt41_nano', 'chatgpt4o', 'gpt4o',
@@ -225,10 +233,10 @@ module RubyLLM
225
233
 
226
234
  def self.normalize_temperature(temperature, model_id)
227
235
  if model_id.match?(/^(o\d|gpt-5)/) && !temperature.nil? && !temperature_close_to_one?(temperature)
228
- RubyLLM.logger.debug "Model #{model_id} requires temperature=1.0, setting that instead."
236
+ RubyLLM.logger.debug { "Model #{model_id} requires temperature=1.0, setting that instead." }
229
237
  1.0
230
238
  elsif model_id.match?(/-search/)
231
- RubyLLM.logger.debug "Model #{model_id} does not accept temperature parameter, removing"
239
+ RubyLLM.logger.debug { "Model #{model_id} does not accept temperature parameter, removing" }
232
240
  nil
233
241
  else
234
242
  temperature
@@ -11,7 +11,10 @@ module RubyLLM
11
11
 
12
12
  module_function
13
13
 
14
- def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists
14
+ # rubocop:disable Metrics/ParameterLists,Metrics/PerceivedComplexity
15
+ def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil,
16
+ thinking: nil, tool_prefs: nil)
17
+ tool_prefs ||= {}
15
18
  payload = {
16
19
  model: model.id,
17
20
  messages: format_messages(messages),
@@ -19,16 +22,22 @@ module RubyLLM
19
22
  }
20
23
 
21
24
  payload[:temperature] = temperature unless temperature.nil?
22
- payload[:tools] = tools.map { |_, tool| tool_for(tool) } if tools.any?
25
+ if tools.any?
26
+ payload[:tools] = tools.map { |_, tool| tool_for(tool) }
27
+ payload[:tool_choice] = build_tool_choice(tool_prefs[:choice]) unless tool_prefs[:choice].nil?
28
+ payload[:parallel_tool_calls] = tool_prefs[:calls] == :many unless tool_prefs[:calls].nil?
29
+ end
23
30
 
24
31
  if schema
25
- strict = schema[:strict] != false
32
+ schema_name = schema[:name]
33
+ schema_def = schema[:schema]
34
+ strict = schema[:strict]
26
35
 
27
36
  payload[:response_format] = {
28
37
  type: 'json_schema',
29
38
  json_schema: {
30
- name: 'response',
31
- schema: schema,
39
+ name: schema_name,
40
+ schema: schema_def,
32
41
  strict: strict
33
42
  }
34
43
  }
@@ -40,6 +49,7 @@ module RubyLLM
40
49
  payload[:stream_options] = { include_usage: true } if stream
41
50
  payload
42
51
  end
52
+ # rubocop:enable Metrics/ParameterLists,Metrics/PerceivedComplexity
43
53
 
44
54
  def parse_completion_response(response)
45
55
  data = response.body
@@ -8,7 +8,10 @@ module RubyLLM
8
8
  module_function
9
9
 
10
10
  def format_content(content) # rubocop:disable Metrics/PerceivedComplexity
11
- return content.value if content.is_a?(RubyLLM::Content::Raw)
11
+ if content.is_a?(RubyLLM::Content::Raw)
12
+ value = content.value
13
+ return value.is_a?(Hash) ? value.to_json : value
14
+ end
12
15
  return content.to_json if content.is_a?(Hash) || content.is_a?(Array)
13
16
  return content unless content.is_a?(Content)
14
17
 
@@ -9,10 +9,10 @@ module RubyLLM
9
9
 
10
10
  def normalize(temperature, model_id)
11
11
  if model_id.match?(/^(o\d|gpt-5)/) && !temperature.nil? && !temperature_close_to_one?(temperature)
12
- RubyLLM.logger.debug "Model #{model_id} requires temperature=1.0, setting that instead."
12
+ RubyLLM.logger.debug { "Model #{model_id} requires temperature=1.0, setting that instead." }
13
13
  1.0
14
14
  elsif model_id.include?('-search')
15
- RubyLLM.logger.debug "Model #{model_id} does not accept temperature parameter, removing"
15
+ RubyLLM.logger.debug { "Model #{model_id} does not accept temperature parameter, removing" }
16
16
  nil
17
17
  else
18
18
  temperature
@@ -53,7 +53,7 @@ module RubyLLM
53
53
  return nil unless tool_calls&.any?
54
54
 
55
55
  tool_calls.map do |_, tc|
56
- {
56
+ call = {
57
57
  id: tc.id,
58
58
  type: 'function',
59
59
  function: {
@@ -61,6 +61,12 @@ module RubyLLM
61
61
  arguments: JSON.generate(tc.arguments)
62
62
  }
63
63
  }
64
+ if tc.thought_signature
65
+ call[:extra_content] = {
66
+ google: { thought_signature: tc.thought_signature }
67
+ }
68
+ end
69
+ call
64
70
  end
65
71
  end
66
72
 
@@ -87,11 +93,30 @@ module RubyLLM
87
93
  parse_tool_call_arguments(tc)
88
94
  else
89
95
  tc.dig('function', 'arguments')
90
- end
96
+ end,
97
+ thought_signature: extract_tool_call_thought_signature(tc)
91
98
  )
92
99
  ]
93
100
  end
94
101
  end
102
+
103
+ def build_tool_choice(tool_choice)
104
+ case tool_choice
105
+ when :auto, :none, :required
106
+ tool_choice
107
+ else
108
+ {
109
+ type: 'function',
110
+ function: {
111
+ name: tool_choice
112
+ }
113
+ }
114
+ end
115
+ end
116
+
117
+ def extract_tool_call_thought_signature(tool_call)
118
+ tool_call.dig('extra_content', 'google', 'thought_signature')
119
+ end
95
120
  end
96
121
  end
97
122
  end
@@ -7,7 +7,10 @@ module RubyLLM
7
7
  module Chat
8
8
  module_function
9
9
 
10
- def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists
10
+ # rubocop:disable Metrics/ParameterLists,Metrics/PerceivedComplexity
11
+ def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil,
12
+ thinking: nil, tool_prefs: nil)
13
+ tool_prefs ||= {}
11
14
  payload = {
12
15
  model: model.id,
13
16
  messages: format_messages(messages),
@@ -15,15 +18,25 @@ module RubyLLM
15
18
  }
16
19
 
17
20
  payload[:temperature] = temperature unless temperature.nil?
18
- payload[:tools] = tools.map { |_, tool| OpenAI::Tools.tool_for(tool) } if tools.any?
21
+ if tools.any?
22
+ payload[:tools] = tools.map { |_, tool| OpenAI::Tools.tool_for(tool) }
23
+ payload[:tool_choice] = OpenAI::Tools.build_tool_choice(tool_prefs[:choice]) unless tool_prefs[:choice].nil?
24
+ payload[:parallel_tool_calls] = tool_prefs[:calls] == :many unless tool_prefs[:calls].nil?
25
+ end
19
26
 
20
27
  if schema
21
- strict = schema[:strict] != false
28
+ schema_name = schema[:name]
29
+ schema_def = RubyLLM::Utils.deep_dup(schema[:schema])
30
+ if schema_def.is_a?(Hash)
31
+ schema_def.delete(:strict)
32
+ schema_def.delete('strict')
33
+ end
34
+ strict = schema[:strict]
22
35
  payload[:response_format] = {
23
36
  type: 'json_schema',
24
37
  json_schema: {
25
- name: 'response',
26
- schema: schema,
38
+ name: schema_name,
39
+ schema: schema_def,
27
40
  strict: strict
28
41
  }
29
42
  }
@@ -35,6 +48,7 @@ module RubyLLM
35
48
  payload[:stream_options] = { include_usage: true } if stream
36
49
  payload
37
50
  end
51
+ # rubocop:enable Metrics/ParameterLists,Metrics/PerceivedComplexity
38
52
 
39
53
  def parse_completion_response(response)
40
54
  data = response.body
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenRouter
6
+ # Image generation methods for the OpenRouter API integration.
7
+ # OpenRouter uses the chat completions endpoint for image generation
8
+ # instead of a dedicated images endpoint.
9
+ module Images
10
+ module_function
11
+
12
+ def images_url
13
+ 'chat/completions'
14
+ end
15
+
16
+ def render_image_payload(prompt, model:, size:)
17
+ RubyLLM.logger.debug "Ignoring size #{size}. OpenRouter image generation does not support size parameter."
18
+ {
19
+ model: model,
20
+ messages: [
21
+ {
22
+ role: 'user',
23
+ content: prompt
24
+ }
25
+ ],
26
+ modalities: %w[image text]
27
+ }
28
+ end
29
+
30
+ def parse_image_response(response, model:)
31
+ data = response.body
32
+ message = data.dig('choices', 0, 'message')
33
+
34
+ unless message&.key?('images') && message['images']&.any?
35
+ raise Error.new(nil, 'Unexpected response format from OpenRouter image generation API')
36
+ end
37
+
38
+ image_data = message['images'].first
39
+ image_url = image_data.dig('image_url', 'url') || image_data['url']
40
+
41
+ raise Error.new(nil, 'No image URL found in OpenRouter response') unless image_url
42
+
43
+ build_image_from_url(image_url, model)
44
+ end
45
+
46
+ def build_image_from_url(image_url, model)
47
+ if image_url.start_with?('data:')
48
+ # Parse data URL format: data:image/png;base64,<data>
49
+ match = image_url.match(/^data:([^;]+);base64,(.+)$/)
50
+ raise Error.new(nil, 'Invalid data URL format from OpenRouter') unless match
51
+
52
+ Image.new(
53
+ data: match[2],
54
+ mime_type: match[1],
55
+ model_id: model
56
+ )
57
+ else
58
+ # Regular URL
59
+ Image.new(
60
+ url: image_url,
61
+ mime_type: 'image/png',
62
+ model_id: model
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -7,9 +7,10 @@ module RubyLLM
7
7
  include OpenRouter::Chat
8
8
  include OpenRouter::Models
9
9
  include OpenRouter::Streaming
10
+ include OpenRouter::Images
10
11
 
11
12
  def api_base
12
- 'https://openrouter.ai/api/v1'
13
+ @config.openrouter_api_base || 'https://openrouter.ai/api/v1'
13
14
  end
14
15
 
15
16
  def headers
@@ -18,6 +19,35 @@ module RubyLLM
18
19
  }
19
20
  end
20
21
 
22
+ def parse_error(response)
23
+ return if response.body.empty?
24
+
25
+ body = try_parse_json(response.body)
26
+ case body
27
+ when Hash
28
+ parse_error_part_message body
29
+ when Array
30
+ body.map do |part|
31
+ parse_error_part_message part
32
+ end.join('. ')
33
+ else
34
+ body
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def parse_error_part_message(part)
41
+ message = part.dig('error', 'message')
42
+ raw = try_parse_json(part.dig('error', 'metadata', 'raw'))
43
+ return message unless raw.is_a?(Hash)
44
+
45
+ raw_message = raw.dig('error', 'message')
46
+ return [message, raw_message].compact.join(' - ') if raw_message
47
+
48
+ message
49
+ end
50
+
21
51
  class << self
22
52
  def configuration_requirements
23
53
  %i[openrouter_api_key]
@@ -56,7 +56,7 @@ module RubyLLM
56
56
 
57
57
  all_models
58
58
  rescue StandardError => e
59
- RubyLLM.logger.debug "Error fetching Vertex AI models: #{e.message}"
59
+ RubyLLM.logger.debug { "Error fetching Vertex AI models: #{e.message}" }
60
60
  build_known_models
61
61
  end
62
62
 
@@ -10,6 +10,11 @@ module RubyLLM
10
10
  include VertexAI::Models
11
11
  include VertexAI::Transcription
12
12
 
13
+ SCOPES = [
14
+ 'https://www.googleapis.com/auth/cloud-platform',
15
+ 'https://www.googleapis.com/auth/generative-language.retriever'
16
+ ].freeze
17
+
13
18
  def initialize(config)
14
19
  super
15
20
  @authorizer = nil
@@ -44,12 +49,15 @@ module RubyLLM
44
49
 
45
50
  def initialize_authorizer
46
51
  require 'googleauth'
47
- @authorizer = ::Google::Auth.get_application_default(
48
- scope: [
49
- 'https://www.googleapis.com/auth/cloud-platform',
50
- 'https://www.googleapis.com/auth/generative-language.retriever'
51
- ]
52
- )
52
+ @authorizer =
53
+ if @config.vertexai_service_account_key
54
+ ::Google::Auth::ServiceAccountCredentials.make_creds(
55
+ json_key_io: StringIO.new(@config.vertexai_service_account_key),
56
+ scope: SCOPES
57
+ )
58
+ else
59
+ ::Google::Auth.get_application_default(SCOPES)
60
+ end
53
61
  rescue LoadError
54
62
  raise Error,
55
63
  'The googleauth gem ~> 1.15 is required for Vertex AI. Please add it to your Gemfile: gem "googleauth"'
@@ -21,13 +21,13 @@ module RubyLLM
21
21
  end
22
22
 
23
23
  def add(chunk)
24
- RubyLLM.logger.debug chunk.inspect if RubyLLM.config.log_stream_debug
24
+ RubyLLM.logger.debug { chunk.inspect } if RubyLLM.config.log_stream_debug
25
25
  @model_id ||= chunk.model_id
26
26
 
27
27
  handle_chunk_content(chunk)
28
28
  append_thinking_from_chunk(chunk)
29
29
  count_tokens chunk
30
- RubyLLM.logger.debug inspect if RubyLLM.config.log_stream_debug
30
+ RubyLLM.logger.debug { inspect } if RubyLLM.config.log_stream_debug
31
31
  end
32
32
 
33
33
  def to_message(response)
@@ -73,11 +73,14 @@ module RubyLLM
73
73
  end
74
74
 
75
75
  def accumulate_tool_calls(new_tool_calls) # rubocop:disable Metrics/PerceivedComplexity
76
- RubyLLM.logger.debug "Accumulating tool calls: #{new_tool_calls}" if RubyLLM.config.log_stream_debug
76
+ RubyLLM.logger.debug { "Accumulating tool calls: #{new_tool_calls}" } if RubyLLM.config.log_stream_debug
77
77
  new_tool_calls.each_value do |tool_call|
78
78
  if tool_call.id
79
79
  tool_call_id = tool_call.id.empty? ? SecureRandom.uuid : tool_call.id
80
- tool_call_arguments = tool_call.arguments.empty? ? +'' : tool_call.arguments
80
+ tool_call_arguments = tool_call.arguments
81
+ if tool_call_arguments.nil? || (tool_call_arguments.respond_to?(:empty?) && tool_call_arguments.empty?)
82
+ tool_call_arguments = +''
83
+ end
81
84
  @tool_calls[tool_call.id] = ToolCall.new(
82
85
  id: tool_call_id,
83
86
  name: tool_call.name,
@@ -88,7 +91,9 @@ module RubyLLM
88
91
  else
89
92
  existing = @tool_calls[@latest_tool_call_id]
90
93
  if existing
91
- existing.arguments << tool_call.arguments
94
+ fragment = tool_call.arguments
95
+ fragment = '' if fragment.nil?
96
+ existing.arguments << fragment
92
97
  if tool_call.thought_signature && existing.thought_signature.nil?
93
98
  existing.thought_signature = tool_call.thought_signature
94
99
  end
@@ -24,7 +24,7 @@ module RubyLLM
24
24
  end
25
25
 
26
26
  message = accumulator.to_message(response)
27
- RubyLLM.logger.debug "Stream completed: #{message.content}"
27
+ RubyLLM.logger.debug { "Stream completed: #{message.content}" }
28
28
  message
29
29
  end
30
30
 
@@ -52,7 +52,7 @@ module RubyLLM
52
52
  end
53
53
 
54
54
  def process_stream_chunk(chunk, parser, env, &)
55
- RubyLLM.logger.debug "Received chunk: #{chunk}" if RubyLLM.config.log_stream_debug
55
+ RubyLLM.logger.debug { "Received chunk: #{chunk}" } if RubyLLM.config.log_stream_debug
56
56
 
57
57
  if error_chunk?(chunk)
58
58
  handle_error_chunk(chunk, env)
@@ -85,7 +85,7 @@ module RubyLLM
85
85
  error_data = JSON.parse(buffer)
86
86
  handle_parsed_error(error_data, env)
87
87
  rescue JSON::ParserError
88
- RubyLLM.logger.debug "Accumulating error chunk: #{chunk}"
88
+ RubyLLM.logger.debug { "Accumulating error chunk: #{chunk}" }
89
89
  end
90
90
 
91
91
  def handle_sse(chunk, parser, env, &block)
@@ -105,7 +105,7 @@ module RubyLLM
105
105
 
106
106
  handle_parsed_error(parsed, env)
107
107
  rescue JSON::ParserError => e
108
- RubyLLM.logger.debug "Failed to parse data chunk: #{e.message}"
108
+ RubyLLM.logger.debug { "Failed to parse data chunk: #{e.message}" }
109
109
  end
110
110
 
111
111
  def handle_error_event(data, env)
@@ -116,7 +116,7 @@ module RubyLLM
116
116
  error_data = JSON.parse(data)
117
117
  [500, error_data['message'] || 'Unknown streaming error']
118
118
  rescue JSON::ParserError => e
119
- RubyLLM.logger.debug "Failed to parse streaming error: #{e.message}"
119
+ RubyLLM.logger.debug { "Failed to parse streaming error: #{e.message}" }
120
120
  [500, "Failed to parse error: #{data}"]
121
121
  end
122
122
 
@@ -130,7 +130,7 @@ module RubyLLM
130
130
  parsed_data = JSON.parse(data)
131
131
  handle_parsed_error(parsed_data, env)
132
132
  rescue JSON::ParserError => e
133
- RubyLLM.logger.debug "#{error_message}: #{e.message}"
133
+ RubyLLM.logger.debug { "#{error_message}: #{e.message}" }
134
134
  end
135
135
 
136
136
  def build_stream_error_response(parsed_data, env, status)
data/lib/ruby_llm/tool.rb CHANGED
@@ -99,9 +99,13 @@ module RubyLLM
99
99
  end
100
100
 
101
101
  def call(args)
102
- RubyLLM.logger.debug "Tool #{name} called with: #{args.inspect}"
103
- result = execute(**args.transform_keys(&:to_sym))
104
- RubyLLM.logger.debug "Tool #{name} returned: #{result.inspect}"
102
+ normalized_args = normalize_args(args)
103
+ validation_error = validate_keyword_arguments(normalized_args)
104
+ return { error: "Invalid tool arguments: #{validation_error}" } if validation_error
105
+
106
+ RubyLLM.logger.debug { "Tool #{name} called with: #{normalized_args.inspect}" }
107
+ result = execute(**normalized_args)
108
+ RubyLLM.logger.debug { "Tool #{name} returned: #{result.inspect}" }
105
109
  result
106
110
  end
107
111
 
@@ -115,6 +119,47 @@ module RubyLLM
115
119
  Halt.new(message)
116
120
  end
117
121
 
122
+ def normalize_args(args)
123
+ return {} if args.nil?
124
+ return args.transform_keys(&:to_sym) if args.respond_to?(:transform_keys)
125
+
126
+ {}
127
+ end
128
+
129
+ def validate_keyword_arguments(arguments)
130
+ required_keywords, optional_keywords, accepts_extra_keywords = execute_keyword_signature
131
+
132
+ return nil if required_keywords.empty? && optional_keywords.empty?
133
+
134
+ argument_keys = arguments.keys
135
+ missing_keyword = first_missing_keyword(required_keywords, argument_keys)
136
+ return "missing keyword: #{missing_keyword}" if missing_keyword
137
+ return nil if accepts_extra_keywords
138
+
139
+ allowed_keywords = required_keywords + optional_keywords
140
+ unknown_keyword = first_unknown_keyword(argument_keys, allowed_keywords)
141
+ return "unknown keyword: #{unknown_keyword}" if unknown_keyword
142
+
143
+ nil
144
+ end
145
+
146
+ def execute_keyword_signature
147
+ keyword_signature = method(:execute).parameters
148
+ required_keywords = keyword_signature.filter_map { |kind, name| name if kind == :keyreq }
149
+ optional_keywords = keyword_signature.filter_map { |kind, name| name if kind == :key }
150
+ accepts_extra_keywords = keyword_signature.any? { |kind, _| kind == :keyrest }
151
+
152
+ [required_keywords, optional_keywords, accepts_extra_keywords]
153
+ end
154
+
155
+ def first_missing_keyword(required_keywords, argument_keys)
156
+ (required_keywords - argument_keys).first
157
+ end
158
+
159
+ def first_unknown_keyword(argument_keys, allowed_keywords)
160
+ (argument_keys - allowed_keywords).first
161
+ end
162
+
118
163
  # Wraps schema handling for tool parameters, supporting JSON Schema hashes,
119
164
  # RubyLLM::Schema instances/classes, and DSL blocks.
120
165
  class SchemaDefinition
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyLLM
4
- VERSION = '1.12.0'
4
+ VERSION = '1.13.0'
5
5
  end