ruby_llm 1.14.1 → 1.16.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -7
  3. data/lib/generators/ruby_llm/generator_helpers.rb +8 -0
  4. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +1 -1
  5. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +1 -1
  6. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +3 -3
  7. data/lib/ruby_llm/active_record/acts_as.rb +4 -26
  8. data/lib/ruby_llm/active_record/acts_as_legacy.rb +123 -29
  9. data/lib/ruby_llm/active_record/chat_methods.rb +41 -24
  10. data/lib/ruby_llm/active_record/message_methods.rb +87 -4
  11. data/lib/ruby_llm/active_record/model_methods.rb +7 -9
  12. data/lib/ruby_llm/active_record/payload_helpers.rb +3 -0
  13. data/lib/ruby_llm/active_record/tool_call_methods.rb +3 -0
  14. data/lib/ruby_llm/agent.rb +4 -2
  15. data/lib/ruby_llm/aliases.json +108 -75
  16. data/lib/ruby_llm/aliases.rb +3 -0
  17. data/lib/ruby_llm/attachment.rb +41 -40
  18. data/lib/ruby_llm/chat.rb +229 -59
  19. data/lib/ruby_llm/configuration.rb +14 -1
  20. data/lib/ruby_llm/connection.rb +36 -7
  21. data/lib/ruby_llm/content.rb +15 -1
  22. data/lib/ruby_llm/cost.rb +224 -0
  23. data/lib/ruby_llm/deprecator.rb +24 -0
  24. data/lib/ruby_llm/embedding.rb +31 -1
  25. data/lib/ruby_llm/error.rb +11 -75
  26. data/lib/ruby_llm/error_middleware.rb +81 -0
  27. data/lib/ruby_llm/image.rb +39 -4
  28. data/lib/ruby_llm/instrumentation.rb +36 -0
  29. data/lib/ruby_llm/message.rb +20 -0
  30. data/lib/ruby_llm/mime_type.rb +25 -0
  31. data/lib/ruby_llm/model/info.rb +53 -2
  32. data/lib/ruby_llm/model/pricing.rb +19 -9
  33. data/lib/ruby_llm/model/pricing_category.rb +13 -2
  34. data/lib/ruby_llm/model/pricing_tier.rb +20 -9
  35. data/lib/ruby_llm/model_registry.rb +39 -0
  36. data/lib/ruby_llm/models.json +17817 -13942
  37. data/lib/ruby_llm/models.rb +97 -31
  38. data/lib/ruby_llm/models_schema.json +3 -0
  39. data/lib/ruby_llm/provider.rb +20 -4
  40. data/lib/ruby_llm/providers/anthropic/chat.rb +49 -15
  41. data/lib/ruby_llm/providers/anthropic/models.rb +2 -0
  42. data/lib/ruby_llm/providers/anthropic/streaming.rb +2 -0
  43. data/lib/ruby_llm/providers/anthropic/tools.rb +32 -3
  44. data/lib/ruby_llm/providers/azure/media.rb +1 -1
  45. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -0
  46. data/lib/ruby_llm/providers/bedrock/chat.rb +26 -13
  47. data/lib/ruby_llm/providers/bedrock/media.rb +21 -3
  48. data/lib/ruby_llm/providers/bedrock/models.rb +1 -1
  49. data/lib/ruby_llm/providers/bedrock/streaming.rb +10 -1
  50. data/lib/ruby_llm/providers/bedrock.rb +2 -2
  51. data/lib/ruby_llm/providers/deepseek/capabilities.rb +43 -0
  52. data/lib/ruby_llm/providers/deepseek/chat.rb +9 -0
  53. data/lib/ruby_llm/providers/gemini/chat.rb +10 -4
  54. data/lib/ruby_llm/providers/gemini/images.rb +2 -2
  55. data/lib/ruby_llm/providers/gemini/media.rb +16 -9
  56. data/lib/ruby_llm/providers/gemini/streaming.rb +6 -1
  57. data/lib/ruby_llm/providers/gemini/tools.rb +5 -1
  58. data/lib/ruby_llm/providers/gpustack/chat.rb +8 -1
  59. data/lib/ruby_llm/providers/gpustack/models.rb +2 -0
  60. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -2
  61. data/lib/ruby_llm/providers/mistral/chat.rb +56 -5
  62. data/lib/ruby_llm/providers/mistral/media.rb +55 -0
  63. data/lib/ruby_llm/providers/mistral/models.rb +2 -0
  64. data/lib/ruby_llm/providers/mistral.rb +2 -2
  65. data/lib/ruby_llm/providers/ollama/chat.rb +8 -1
  66. data/lib/ruby_llm/providers/openai/capabilities.rb +82 -12
  67. data/lib/ruby_llm/providers/openai/chat.rb +61 -7
  68. data/lib/ruby_llm/providers/openai/images.rb +58 -6
  69. data/lib/ruby_llm/providers/openai/media.rb +40 -16
  70. data/lib/ruby_llm/providers/openai/streaming.rb +7 -6
  71. data/lib/ruby_llm/providers/openai/tools.rb +2 -0
  72. data/lib/ruby_llm/providers/openai/transcription.rb +1 -0
  73. data/lib/ruby_llm/providers/openrouter/chat.rb +36 -8
  74. data/lib/ruby_llm/providers/openrouter/images.rb +2 -2
  75. data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
  76. data/lib/ruby_llm/providers/openrouter/streaming.rb +5 -6
  77. data/lib/ruby_llm/providers/perplexity/chat.rb +11 -0
  78. data/lib/ruby_llm/providers/perplexity/media.rb +62 -0
  79. data/lib/ruby_llm/providers/perplexity.rb +2 -2
  80. data/lib/ruby_llm/providers/vertexai.rb +5 -1
  81. data/lib/ruby_llm/providers/xai/chat.rb +9 -0
  82. data/lib/ruby_llm/providers/xai/models.rb +15 -27
  83. data/lib/ruby_llm/providers/xai.rb +2 -2
  84. data/lib/ruby_llm/railtie.rb +11 -1
  85. data/lib/ruby_llm/stream_accumulator.rb +45 -30
  86. data/lib/ruby_llm/streaming.rb +4 -0
  87. data/lib/ruby_llm/tokens.rb +8 -0
  88. data/lib/ruby_llm/tool.rb +24 -7
  89. data/lib/ruby_llm/tool_concurrency.rb +105 -0
  90. data/lib/ruby_llm/transcription.rb +2 -1
  91. data/lib/ruby_llm/utils.rb +39 -0
  92. data/lib/ruby_llm/version.rb +1 -1
  93. data/lib/ruby_llm.rb +11 -6
  94. data/lib/tasks/models.rake +45 -16
  95. data/lib/tasks/release.rake +50 -23
  96. metadata +35 -13
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Perplexity
6
+ # Handles Perplexity Sonar media content.
7
+ module Media
8
+ module_function
9
+
10
+ SUPPORTED_DOCUMENT_EXTENSIONS = %w[pdf doc docx txt rtf].freeze
11
+
12
+ def format_content(content) # rubocop:disable Metrics/PerceivedComplexity
13
+ if content.is_a?(RubyLLM::Content::Raw)
14
+ value = content.value
15
+ return value.is_a?(Hash) ? value.to_json : value
16
+ end
17
+ return content.to_json if content.is_a?(Hash) || content.is_a?(Array)
18
+ return content unless content.is_a?(Content)
19
+
20
+ parts = []
21
+ parts << OpenAI::Media.format_text(content.text) if content.text
22
+
23
+ content.attachments.each do |attachment|
24
+ case attachment.type
25
+ when :image
26
+ parts << OpenAI::Media.format_image(attachment)
27
+ when :pdf, :document
28
+ parts << format_document(attachment)
29
+ when :text
30
+ parts << format_text_attachment(attachment)
31
+ else
32
+ raise UnsupportedAttachmentError, attachment.mime_type
33
+ end
34
+ end
35
+
36
+ parts
37
+ end
38
+
39
+ def format_document(attachment)
40
+ raise UnsupportedAttachmentError, attachment.mime_type unless supported_file?(attachment)
41
+
42
+ {
43
+ type: 'file_url',
44
+ file_url: {
45
+ url: attachment.url? ? attachment.source.to_s : attachment.encoded
46
+ }
47
+ }
48
+ end
49
+
50
+ def format_text_attachment(attachment)
51
+ OpenAI::Media.format_text_file(attachment)
52
+ end
53
+
54
+ def supported_file?(attachment)
55
+ return true if attachment.pdf?
56
+
57
+ SUPPORTED_DOCUMENT_EXTENSIONS.include?(attachment.extension)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -8,7 +8,7 @@ module RubyLLM
8
8
  include Perplexity::Models
9
9
 
10
10
  def api_base
11
- 'https://api.perplexity.ai'
11
+ @config.perplexity_api_base || 'https://api.perplexity.ai'
12
12
  end
13
13
 
14
14
  def headers
@@ -24,7 +24,7 @@ module RubyLLM
24
24
  end
25
25
 
26
26
  def configuration_options
27
- %i[perplexity_api_key]
27
+ %i[perplexity_api_key perplexity_api_base]
28
28
  end
29
29
 
30
30
  def configuration_requirements
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'stringio'
4
+
3
5
  module RubyLLM
4
6
  module Providers
5
7
  # Google Vertex AI implementation
@@ -21,6 +23,8 @@ module RubyLLM
21
23
  end
22
24
 
23
25
  def api_base
26
+ return @config.vertexai_api_base if @config.vertexai_api_base
27
+
24
28
  if @config.vertexai_location.to_s == 'global'
25
29
  'https://aiplatform.googleapis.com/v1beta1'
26
30
  else
@@ -41,7 +45,7 @@ module RubyLLM
41
45
 
42
46
  class << self
43
47
  def configuration_options
44
- %i[vertexai_project_id vertexai_location vertexai_service_account_key]
48
+ %i[vertexai_project_id vertexai_location vertexai_service_account_key vertexai_api_base]
45
49
  end
46
50
 
47
51
  def configuration_requirements
@@ -9,6 +9,15 @@ module RubyLLM
9
9
  def format_role(role)
10
10
  role.to_s
11
11
  end
12
+
13
+ def format_content(content)
14
+ OpenAI::Media.format_content(
15
+ content,
16
+ document_attachments: :none,
17
+ image_attachments: true,
18
+ audio_attachments: false
19
+ )
20
+ end
12
21
  end
13
22
  end
14
23
  end
@@ -7,23 +7,6 @@ module RubyLLM
7
7
  module Models
8
8
  module_function
9
9
 
10
- IMAGE_MODELS = %w[grok-2-image-1212].freeze
11
- VISION_MODELS = %w[
12
- grok-2-vision-1212
13
- grok-4-0709
14
- grok-4-fast-non-reasoning
15
- grok-4-fast-reasoning
16
- grok-4-1-fast-non-reasoning
17
- grok-4-1-fast-reasoning
18
- ].freeze
19
- REASONING_MODELS = %w[
20
- grok-3-mini
21
- grok-4-0709
22
- grok-4-fast-reasoning
23
- grok-4-1-fast-reasoning
24
- grok-code-fast-1
25
- ].freeze
26
-
27
10
  def parse_list_models_response(response, slug, _capabilities)
28
11
  Array(response.body['data']).map do |model_data|
29
12
  model_id = model_data['id']
@@ -48,27 +31,32 @@ module RubyLLM
48
31
  end
49
32
 
50
33
  def modalities_for(model_id)
51
- if IMAGE_MODELS.include?(model_id)
52
- { input: ['text'], output: ['image'] }
34
+ if image_model?(model_id)
35
+ { input: %w[text image], output: ['image'] }
36
+ elsif video_model?(model_id)
37
+ { input: %w[text image video], output: ['video'] }
53
38
  else
54
- input = ['text']
55
- input << 'image' if VISION_MODELS.include?(model_id)
56
- { input: input, output: ['text'] }
39
+ { input: ['text'], output: ['text'] }
57
40
  end
58
41
  end
59
42
 
60
43
  def capabilities_for(model_id)
61
- return [] if IMAGE_MODELS.include?(model_id)
44
+ return ['vision'] if image_model?(model_id) || video_model?(model_id)
62
45
 
63
- capabilities = %w[streaming function_calling structured_output]
64
- capabilities << 'reasoning' if REASONING_MODELS.include?(model_id)
65
- capabilities << 'vision' if VISION_MODELS.include?(model_id)
66
- capabilities
46
+ ['streaming']
67
47
  end
68
48
 
69
49
  def format_display_name(model_id)
70
50
  model_id.tr('-', ' ').split.map(&:capitalize).join(' ')
71
51
  end
52
+
53
+ def image_model?(model_id)
54
+ model_id.match?(/\Agrok-(?:2-)?imagine-image/) || model_id == 'grok-2-image-1212'
55
+ end
56
+
57
+ def video_model?(model_id)
58
+ model_id.match?(/\Agrok-(?:2-)?imagine-video/)
59
+ end
72
60
  end
73
61
  end
74
62
  end
@@ -8,7 +8,7 @@ module RubyLLM
8
8
  include XAI::Models
9
9
 
10
10
  def api_base
11
- 'https://api.x.ai/v1'
11
+ @config.xai_api_base || 'https://api.x.ai/v1'
12
12
  end
13
13
 
14
14
  def headers
@@ -20,7 +20,7 @@ module RubyLLM
20
20
 
21
21
  class << self
22
22
  def configuration_options
23
- %i[xai_api_key]
23
+ %i[xai_api_key xai_api_base]
24
24
  end
25
25
 
26
26
  def configuration_requirements
@@ -10,8 +10,18 @@ if defined?(Rails::Railtie)
10
10
  end
11
11
  end
12
12
 
13
+ initializer 'ruby_llm.instrumentation' do
14
+ RubyLLM.config.instrumenter ||= ActiveSupport::Notifications
15
+ end
16
+
13
17
  initializer 'ruby_llm.active_record' do
14
18
  ActiveSupport.on_load :active_record do
19
+ require 'ruby_llm/active_record/payload_helpers'
20
+ require 'ruby_llm/active_record/chat_methods'
21
+ require 'ruby_llm/active_record/message_methods'
22
+ require 'ruby_llm/active_record/model_methods'
23
+ require 'ruby_llm/active_record/tool_call_methods'
24
+
15
25
  if RubyLLM.config.use_new_acts_as
16
26
  require 'ruby_llm/active_record/acts_as'
17
27
  ::ActiveRecord::Base.include RubyLLM::ActiveRecord::ActsAs
@@ -19,7 +29,7 @@ if defined?(Rails::Railtie)
19
29
  require 'ruby_llm/active_record/acts_as_legacy'
20
30
  ::ActiveRecord::Base.include RubyLLM::ActiveRecord::ActsAsLegacy
21
31
 
22
- Rails.logger.warn(
32
+ RubyLLM.deprecator.warn(
23
33
  "\n!!! RubyLLM's legacy acts_as API is deprecated and will be removed in RubyLLM 2.0.0. " \
24
34
  "Please consult the migration guide at https://rubyllm.com/upgrading-to-1-7/\n"
25
35
  )
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+ require 'securerandom'
5
+
3
6
  module RubyLLM
4
7
  # Assembles streaming responses from LLMs into complete messages.
5
8
  class StreamAccumulator
@@ -18,6 +21,7 @@ module RubyLLM
18
21
  @inside_think_tag = false
19
22
  @pending_think_tag = +''
20
23
  @latest_tool_call_id = nil
24
+ @tool_call_ids_by_index = {}
21
25
  end
22
26
 
23
27
  def add(chunk)
@@ -72,43 +76,54 @@ module RubyLLM
72
76
  end
73
77
  end
74
78
 
75
- def accumulate_tool_calls(new_tool_calls) # rubocop:disable Metrics/PerceivedComplexity
79
+ def accumulate_tool_calls(new_tool_calls)
76
80
  RubyLLM.logger.debug { "Accumulating tool calls: #{new_tool_calls}" } if RubyLLM.config.log_stream_debug
77
- new_tool_calls.each_value do |tool_call|
81
+ new_tool_calls.each do |stream_key, tool_call|
78
82
  if tool_call.id
79
- tool_call_id = tool_call.id.empty? ? SecureRandom.uuid : tool_call.id
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
84
- @tool_calls[tool_call.id] = ToolCall.new(
85
- id: tool_call_id,
86
- name: tool_call.name,
87
- arguments: tool_call_arguments,
88
- thought_signature: tool_call.thought_signature
89
- )
90
- @latest_tool_call_id = tool_call.id
83
+ start_tool_call(stream_key, tool_call)
91
84
  else
92
- existing = @tool_calls[@latest_tool_call_id]
93
- if existing
94
- fragment = tool_call.arguments
95
- fragment = '' if fragment.nil?
96
- existing.arguments << fragment
97
- if tool_call.thought_signature && existing.thought_signature.nil?
98
- existing.thought_signature = tool_call.thought_signature
99
- end
100
- end
85
+ append_tool_call_fragment(stream_key, tool_call)
101
86
  end
102
87
  end
103
88
  end
104
89
 
105
- def find_tool_call(tool_call_id)
106
- if tool_call_id.nil?
107
- @tool_calls[@latest_tool_call]
108
- else
109
- @latest_tool_call_id = tool_call_id
110
- @tool_calls[tool_call_id]
111
- end
90
+ def start_tool_call(stream_key, tool_call)
91
+ tool_call_id = tool_call.id.empty? ? SecureRandom.uuid : tool_call.id
92
+ tool_call_key = tool_call.id
93
+
94
+ @tool_calls[tool_call_key] = ToolCall.new(
95
+ id: tool_call_id,
96
+ name: tool_call.name,
97
+ arguments: initial_tool_call_arguments(tool_call),
98
+ thought_signature: tool_call.thought_signature
99
+ )
100
+ @tool_call_ids_by_index[stream_key] = tool_call_key unless stream_key.nil?
101
+ @latest_tool_call_id = tool_call_key
102
+ end
103
+
104
+ def initial_tool_call_arguments(tool_call)
105
+ arguments = tool_call.arguments
106
+ return +'' if arguments.nil? || (arguments.respond_to?(:empty?) && arguments.empty?)
107
+
108
+ arguments
109
+ end
110
+
111
+ def append_tool_call_fragment(stream_key, tool_call)
112
+ existing = find_tool_call(stream_key)
113
+ return unless existing
114
+
115
+ fragment = tool_call.arguments
116
+ fragment = '' if fragment.nil?
117
+ existing.arguments << fragment
118
+ return unless tool_call.thought_signature && existing.thought_signature.nil?
119
+
120
+ existing.thought_signature = tool_call.thought_signature
121
+ end
122
+
123
+ def find_tool_call(stream_key)
124
+ return @tool_calls[@latest_tool_call_id] if stream_key.nil?
125
+
126
+ @tool_calls[@tool_call_ids_by_index[stream_key]] || @tool_calls[stream_key]
112
127
  end
113
128
 
114
129
  def count_tokens(chunk)
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'event_stream_parser'
4
+ require 'faraday'
5
+ require 'json'
6
+
3
7
  module RubyLLM
4
8
  # Handles streaming responses from AI providers.
5
9
  module Streaming
@@ -43,5 +43,13 @@ module RubyLLM
43
43
  def reasoning
44
44
  thinking
45
45
  end
46
+
47
+ def cache_read
48
+ cached
49
+ end
50
+
51
+ def cache_write
52
+ cache_creation
53
+ end
46
54
  end
47
55
  end
data/lib/ruby_llm/tool.rb CHANGED
@@ -7,10 +7,10 @@ module RubyLLM
7
7
  class Parameter
8
8
  attr_reader :name, :type, :description, :required
9
9
 
10
- def initialize(name, type: 'string', desc: nil, required: true)
10
+ def initialize(name, type: 'string', desc: nil, description: nil, required: true)
11
11
  @name = name
12
12
  @type = type
13
- @description = desc
13
+ @description = desc || description
14
14
  @required = required
15
15
  end
16
16
  end
@@ -30,6 +30,8 @@ module RubyLLM
30
30
  end
31
31
  end
32
32
 
33
+ POSITIONAL_PARAMETER_KINDS = %i[req opt rest].freeze
34
+
33
35
  class << self
34
36
  attr_reader :params_schema_definition
35
37
 
@@ -38,6 +40,7 @@ module RubyLLM
38
40
 
39
41
  @description = text
40
42
  end
43
+ alias desc description
41
44
 
42
45
  def param(name, **options)
43
46
  parameters[name] = Parameter.new(name, **options)
@@ -94,6 +97,8 @@ module RubyLLM
94
97
  definition.json_schema
95
98
  elsif parameters.any?
96
99
  SchemaDefinition.from_parameters(parameters)&.json_schema
100
+ else
101
+ SchemaDefinition.from_parameters(inferred_parameters, allow_empty: true)&.json_schema
97
102
  end
98
103
  end
99
104
  end
@@ -127,9 +132,10 @@ module RubyLLM
127
132
  end
128
133
 
129
134
  def validate_keyword_arguments(arguments)
130
- required_keywords, optional_keywords, accepts_extra_keywords = execute_keyword_signature
135
+ required_keywords, optional_keywords, accepts_extra_keywords, accepts_positional_arguments =
136
+ execute_keyword_signature
131
137
 
132
- return nil if required_keywords.empty? && optional_keywords.empty?
138
+ return nil if required_keywords.empty? && optional_keywords.empty? && accepts_positional_arguments
133
139
 
134
140
  argument_keys = arguments.keys
135
141
  missing_keyword = first_missing_keyword(required_keywords, argument_keys)
@@ -148,8 +154,11 @@ module RubyLLM
148
154
  required_keywords = keyword_signature.filter_map { |kind, name| name if kind == :keyreq }
149
155
  optional_keywords = keyword_signature.filter_map { |kind, name| name if kind == :key }
150
156
  accepts_extra_keywords = keyword_signature.any? { |kind, _| kind == :keyrest }
157
+ accepts_positional_arguments = keyword_signature.any? do |kind, _|
158
+ POSITIONAL_PARAMETER_KINDS.include?(kind)
159
+ end
151
160
 
152
- [required_keywords, optional_keywords, accepts_extra_keywords]
161
+ [required_keywords, optional_keywords, accepts_extra_keywords, accepts_positional_arguments]
153
162
  end
154
163
 
155
164
  def first_missing_keyword(required_keywords, argument_keys)
@@ -160,11 +169,19 @@ module RubyLLM
160
169
  (argument_keys - allowed_keywords).first
161
170
  end
162
171
 
172
+ def inferred_parameters
173
+ required_keywords, optional_keywords, = execute_keyword_signature
174
+
175
+ (required_keywords + optional_keywords).to_h do |name|
176
+ [name, Parameter.new(name, required: required_keywords.include?(name))]
177
+ end
178
+ end
179
+
163
180
  # Wraps schema handling for tool parameters, supporting JSON Schema hashes,
164
181
  # RubyLLM::Schema instances/classes, and DSL blocks.
165
182
  class SchemaDefinition
166
- def self.from_parameters(parameters)
167
- return nil if parameters.nil? || parameters.empty?
183
+ def self.from_parameters(parameters, allow_empty: false)
184
+ return nil if parameters.nil? || (parameters.empty? && !allow_empty)
168
185
 
169
186
  properties = parameters.to_h do |name, param|
170
187
  schema = {
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Runs multiple tool calls concurrently with Ruby's built-in threads or optional fibers.
5
+ module ToolConcurrency
6
+ MODES = %i[threads fibers].freeze
7
+ Result = Struct.new(:index, :tool_call, :value, :error, keyword_init: true)
8
+
9
+ module_function
10
+
11
+ def modes
12
+ MODES
13
+ end
14
+
15
+ def supported?(name)
16
+ MODES.include?(name)
17
+ end
18
+
19
+ def run(mode, tool_calls, on_result: nil, &)
20
+ case mode
21
+ when :threads
22
+ run_with_threads(tool_calls, on_result:, &)
23
+ when :fibers
24
+ run_with_fibers(tool_calls, on_result:, &)
25
+ end
26
+ end
27
+
28
+ def run_with_threads(tool_calls, on_result:, &execute)
29
+ executor = rails_executor
30
+ queue = Queue.new
31
+ threads = tool_calls.each_value.with_index.map do |tool_call, index|
32
+ thread = Thread.new { queue << capture_result(index, tool_call, executor, execute) }
33
+ thread.report_on_exception = false
34
+ thread
35
+ end
36
+
37
+ collect_results(queue, threads.size, on_result:)
38
+ ensure
39
+ threads&.each(&:join)
40
+ end
41
+
42
+ def run_with_fibers(tool_calls, on_result:, &execute)
43
+ begin
44
+ require 'async'
45
+ require 'async/queue'
46
+ rescue LoadError
47
+ raise LoadError, "The 'async' gem is required for concurrent tool execution with fibers. " \
48
+ "Add `gem 'async'` to your Gemfile or use `concurrency: :threads`."
49
+ end
50
+
51
+ executor = rails_executor
52
+ Async do |task|
53
+ queue = Async::Queue.new
54
+ tasks = tool_calls.each_value.with_index.map do |tool_call, index|
55
+ task.async { queue << capture_result(index, tool_call, executor, execute) }
56
+ end
57
+
58
+ collect_results(queue, tasks.size, on_result:)
59
+ ensure
60
+ tasks&.each(&:wait)
61
+ end.wait
62
+ end
63
+
64
+ def collect_results(queue, count, on_result:)
65
+ results = Array.new(count)
66
+ errors = []
67
+
68
+ count.times do
69
+ result = queue.pop
70
+ if result.error
71
+ errors << result.error
72
+ else
73
+ results[result.index] = [result.tool_call, result.value]
74
+ on_result&.call(result.tool_call, result.value)
75
+ end
76
+ end
77
+
78
+ raise errors.first if errors.any?
79
+
80
+ results
81
+ end
82
+
83
+ def capture_result(index, tool_call, rails_executor, execute)
84
+ tool_call, value = run_tool_call(tool_call, rails_executor, execute)
85
+ Result.new(index:, tool_call:, value:)
86
+ rescue Exception => e # rubocop:disable Lint/RescueException
87
+ Result.new(index:, tool_call:, error: e)
88
+ end
89
+
90
+ def run_tool_call(tool_call, rails_executor, execute)
91
+ if rails_executor
92
+ rails_executor.wrap { [tool_call, execute.call(tool_call)] }
93
+ else
94
+ [tool_call, execute.call(tool_call)]
95
+ end
96
+ end
97
+
98
+ def rails_executor
99
+ defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.executor
100
+ end
101
+
102
+ private_class_method :run_with_threads, :run_with_fibers, :collect_results, :capture_result, :run_tool_call,
103
+ :rails_executor
104
+ end
105
+ end
@@ -3,7 +3,7 @@
3
3
  module RubyLLM
4
4
  # Represents a transcription of audio content.
5
5
  class Transcription
6
- attr_reader :text, :model, :language, :duration, :segments, :input_tokens, :output_tokens
6
+ attr_reader :text, :model, :language, :duration, :segments, :words, :input_tokens, :output_tokens
7
7
 
8
8
  def initialize(text:, model:, **attributes)
9
9
  @text = text
@@ -11,6 +11,7 @@ module RubyLLM
11
11
  @language = attributes[:language]
12
12
  @duration = attributes[:duration]
13
13
  @segments = attributes[:segments]
14
+ @words = attributes[:words]
14
15
  @input_tokens = attributes[:input_tokens]
15
16
  @output_tokens = attributes[:output_tokens]
16
17
  end
@@ -32,6 +32,45 @@ module RubyLLM
32
32
  value.is_a?(Date) ? value : Date.parse(value.to_s)
33
33
  end
34
34
 
35
+ def safe_constantize(name)
36
+ parts = name.to_s.split('::').reject(&:empty?)
37
+ return if parts.empty?
38
+
39
+ namespace = Object
40
+ until parts.empty?
41
+ const_name = parts.shift
42
+ return unless namespace.const_defined?(const_name, false)
43
+
44
+ namespace = namespace.const_get(const_name, false)
45
+ end
46
+ namespace
47
+ rescue NameError
48
+ nil
49
+ end
50
+
51
+ def parse_iso_date_prefix(value)
52
+ return value if value.is_a?(Date)
53
+
54
+ date = value.to_s.strip
55
+ return if date.empty?
56
+
57
+ case date
58
+ when /\A\d{4}-\d{2}-\d{2}\z/
59
+ Date.iso8601(date)
60
+ when /\A\d{4}-\d{2}\z/
61
+ Date.iso8601("#{date}-01")
62
+ when /\A\d{4}\z/
63
+ Date.iso8601("#{date}-01-01")
64
+ end
65
+ rescue ArgumentError
66
+ nil
67
+ end
68
+
69
+ def iso_date_prefix_to_utc_midnight_string(value)
70
+ date = parse_iso_date_prefix(value)
71
+ "#{date.strftime('%Y-%m-%d')} 00:00:00 UTC" if date
72
+ end
73
+
35
74
  def deep_merge(original, overrides)
36
75
  original.merge(overrides) do |_key, original_value, overrides_value|
37
76
  if original_value.is_a?(Hash) && overrides_value.is_a?(Hash)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyLLM
4
- VERSION = '1.14.1'
4
+ VERSION = '1.16.0'
5
5
  end
data/lib/ruby_llm.rb CHANGED
@@ -12,6 +12,7 @@ require 'securerandom'
12
12
  require 'date'
13
13
  require 'time'
14
14
  require 'zeitwerk'
15
+ require 'ruby_llm/error'
15
16
 
16
17
  loader = Zeitwerk::Loader.for_gem
17
18
  loader.inflector.inflect(
@@ -33,14 +34,21 @@ loader.inflector.inflect(
33
34
  )
34
35
  loader.ignore("#{__dir__}/tasks")
35
36
  loader.ignore("#{__dir__}/generators")
37
+ loader.ignore("#{__dir__}/ruby_llm/active_record")
36
38
  loader.ignore("#{__dir__}/ruby_llm/railtie.rb")
37
39
  loader.setup
38
40
 
39
41
  # A delightful Ruby interface to modern AI language models.
40
42
  module RubyLLM
41
- class Error < StandardError; end
42
-
43
43
  class << self
44
+ def deprecator
45
+ @deprecator ||= Deprecator.new
46
+ end
47
+
48
+ def instrument(...)
49
+ Instrumentation.instrument(...)
50
+ end
51
+
44
52
  def context
45
53
  context_config = config.dup
46
54
  yield context_config if block_given?
@@ -107,7 +115,4 @@ RubyLLM::Provider.register :perplexity, RubyLLM::Providers::Perplexity
107
115
  RubyLLM::Provider.register :vertexai, RubyLLM::Providers::VertexAI
108
116
  RubyLLM::Provider.register :xai, RubyLLM::Providers::XAI
109
117
 
110
- if defined?(Rails::Railtie)
111
- require 'ruby_llm/railtie'
112
- require 'ruby_llm/active_record/acts_as'
113
- end
118
+ require 'ruby_llm/railtie' if defined?(Rails::Railtie)