ruby_llm 1.15.0 → 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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -4
  3. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +1 -1
  4. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +3 -3
  5. data/lib/ruby_llm/active_record/acts_as.rb +1 -26
  6. data/lib/ruby_llm/active_record/acts_as_legacy.rb +71 -4
  7. data/lib/ruby_llm/active_record/chat_methods.rb +2 -2
  8. data/lib/ruby_llm/active_record/message_methods.rb +70 -3
  9. data/lib/ruby_llm/agent.rb +1 -0
  10. data/lib/ruby_llm/aliases.json +78 -75
  11. data/lib/ruby_llm/aliases.rb +3 -0
  12. data/lib/ruby_llm/attachment.rb +34 -17
  13. data/lib/ruby_llm/chat.rb +176 -47
  14. data/lib/ruby_llm/configuration.rb +14 -1
  15. data/lib/ruby_llm/connection.rb +36 -7
  16. data/lib/ruby_llm/content.rb +15 -1
  17. data/lib/ruby_llm/deprecator.rb +24 -0
  18. data/lib/ruby_llm/embedding.rb +31 -1
  19. data/lib/ruby_llm/error.rb +11 -75
  20. data/lib/ruby_llm/error_middleware.rb +81 -0
  21. data/lib/ruby_llm/image.rb +2 -0
  22. data/lib/ruby_llm/instrumentation.rb +36 -0
  23. data/lib/ruby_llm/mime_type.rb +25 -0
  24. data/lib/ruby_llm/model/info.rb +36 -2
  25. data/lib/ruby_llm/model/pricing.rb +19 -9
  26. data/lib/ruby_llm/model/pricing_tier.rb +20 -9
  27. data/lib/ruby_llm/model_registry.rb +39 -0
  28. data/lib/ruby_llm/models.json +18225 -19144
  29. data/lib/ruby_llm/models.rb +95 -30
  30. data/lib/ruby_llm/provider.rb +11 -2
  31. data/lib/ruby_llm/providers/anthropic/chat.rb +49 -15
  32. data/lib/ruby_llm/providers/anthropic/models.rb +2 -0
  33. data/lib/ruby_llm/providers/anthropic/streaming.rb +2 -0
  34. data/lib/ruby_llm/providers/anthropic/tools.rb +28 -2
  35. data/lib/ruby_llm/providers/azure/media.rb +1 -1
  36. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -0
  37. data/lib/ruby_llm/providers/bedrock/chat.rb +2 -0
  38. data/lib/ruby_llm/providers/bedrock/media.rb +21 -3
  39. data/lib/ruby_llm/providers/bedrock/models.rb +1 -1
  40. data/lib/ruby_llm/providers/bedrock/streaming.rb +6 -0
  41. data/lib/ruby_llm/providers/bedrock.rb +2 -2
  42. data/lib/ruby_llm/providers/deepseek/capabilities.rb +43 -0
  43. data/lib/ruby_llm/providers/deepseek/chat.rb +9 -0
  44. data/lib/ruby_llm/providers/gemini/chat.rb +2 -3
  45. data/lib/ruby_llm/providers/gemini/media.rb +16 -9
  46. data/lib/ruby_llm/providers/gemini/streaming.rb +2 -0
  47. data/lib/ruby_llm/providers/gemini/tools.rb +2 -0
  48. data/lib/ruby_llm/providers/gpustack/chat.rb +8 -1
  49. data/lib/ruby_llm/providers/gpustack/models.rb +2 -0
  50. data/lib/ruby_llm/providers/mistral/capabilities.rb +1 -1
  51. data/lib/ruby_llm/providers/mistral/chat.rb +1 -1
  52. data/lib/ruby_llm/providers/mistral/media.rb +55 -0
  53. data/lib/ruby_llm/providers/mistral/models.rb +2 -0
  54. data/lib/ruby_llm/providers/mistral.rb +2 -2
  55. data/lib/ruby_llm/providers/ollama/chat.rb +8 -1
  56. data/lib/ruby_llm/providers/openai/chat.rb +16 -1
  57. data/lib/ruby_llm/providers/openai/images.rb +9 -9
  58. data/lib/ruby_llm/providers/openai/media.rb +40 -16
  59. data/lib/ruby_llm/providers/openai/streaming.rb +2 -0
  60. data/lib/ruby_llm/providers/openai/tools.rb +2 -0
  61. data/lib/ruby_llm/providers/openai/transcription.rb +1 -0
  62. data/lib/ruby_llm/providers/openrouter/chat.rb +6 -2
  63. data/lib/ruby_llm/providers/perplexity/chat.rb +11 -0
  64. data/lib/ruby_llm/providers/perplexity/media.rb +62 -0
  65. data/lib/ruby_llm/providers/perplexity.rb +2 -2
  66. data/lib/ruby_llm/providers/vertexai.rb +5 -1
  67. data/lib/ruby_llm/providers/xai/chat.rb +9 -0
  68. data/lib/ruby_llm/providers/xai/models.rb +15 -27
  69. data/lib/ruby_llm/providers/xai.rb +2 -2
  70. data/lib/ruby_llm/railtie.rb +5 -1
  71. data/lib/ruby_llm/stream_accumulator.rb +45 -30
  72. data/lib/ruby_llm/streaming.rb +4 -0
  73. data/lib/ruby_llm/tool_concurrency.rb +105 -0
  74. data/lib/ruby_llm/transcription.rb +2 -1
  75. data/lib/ruby_llm/utils.rb +39 -0
  76. data/lib/ruby_llm/version.rb +1 -1
  77. data/lib/ruby_llm.rb +9 -2
  78. data/lib/tasks/models.rake +32 -4
  79. data/lib/tasks/release.rake +50 -23
  80. metadata +17 -10
@@ -23,7 +23,17 @@ module RubyLLM
23
23
  class InvalidRoleError < StandardError; end
24
24
  class InvalidToolChoiceError < StandardError; end
25
25
  class ModelNotFoundError < StandardError; end
26
- class UnsupportedAttachmentError < StandardError; end
26
+
27
+ # Raised when RubyLLM cannot format an attachment for the selected provider.
28
+ class UnsupportedAttachmentError < StandardError
29
+ GUIDANCE = 'Consider using a model that supports this attachment type.'
30
+
31
+ def initialize(type = nil)
32
+ message = 'Unsupported attachment type'
33
+ message = "#{message}: #{type}" if type
34
+ super("#{message}. #{GUIDANCE}")
35
+ end
36
+ end
27
37
 
28
38
  # Error classes for different HTTP status codes
29
39
  class BadRequestError < Error; end
@@ -35,78 +45,4 @@ module RubyLLM
35
45
  class ServerError < Error; end
36
46
  class ServiceUnavailableError < Error; end
37
47
  class UnauthorizedError < Error; end
38
-
39
- # Faraday middleware that maps provider-specific API errors to RubyLLM errors.
40
- class ErrorMiddleware < Faraday::Middleware
41
- def initialize(app, options = {})
42
- super(app)
43
- @provider = options[:provider]
44
- end
45
-
46
- def call(env)
47
- @app.call(env).on_complete do |response|
48
- self.class.parse_error(provider: @provider, response: response)
49
- end
50
- end
51
-
52
- class << self
53
- CONTEXT_LENGTH_PATTERNS = [
54
- /context length/i,
55
- /context window/i,
56
- /maximum context/i,
57
- /request too large/i,
58
- /too many tokens/i,
59
- /token count exceeds/i,
60
- /input[_\s-]?token/i,
61
- /input or output tokens? must be reduced/i,
62
- /reduce the length of messages/i
63
- ].freeze
64
-
65
- def parse_error(provider:, response:) # rubocop:disable Metrics/PerceivedComplexity
66
- message = provider&.parse_error(response)
67
-
68
- case response.status
69
- when 200..399
70
- message
71
- when 400
72
- if context_length_exceeded?(message)
73
- raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
74
- end
75
-
76
- raise BadRequestError.new(response, message || 'Invalid request - please check your input')
77
- when 401
78
- raise UnauthorizedError.new(response, message || 'Invalid API key - check your credentials')
79
- when 402
80
- raise PaymentRequiredError.new(response, message || 'Payment required - please top up your account')
81
- when 403
82
- raise ForbiddenError.new(response,
83
- message || 'Forbidden - you do not have permission to access this resource')
84
- when 429
85
- if context_length_exceeded?(message)
86
- raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
87
- end
88
-
89
- raise RateLimitError.new(response, message || 'Rate limit exceeded - please wait a moment')
90
- when 500
91
- raise ServerError.new(response, message || 'API server error - please try again')
92
- when 502..504
93
- raise ServiceUnavailableError.new(response, message || 'API server unavailable - please try again later')
94
- when 529
95
- raise OverloadedError.new(response, message || 'Service overloaded - please try again later')
96
- else
97
- raise Error.new(response, message || 'An unknown error occurred')
98
- end
99
- end
100
-
101
- private
102
-
103
- def context_length_exceeded?(message)
104
- return false if message.to_s.empty?
105
-
106
- CONTEXT_LENGTH_PATTERNS.any? { |pattern| message.match?(pattern) }
107
- end
108
- end
109
- end
110
48
  end
111
-
112
- Faraday::Middleware.register_middleware(llm_errors: RubyLLM::ErrorMiddleware)
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'ruby_llm/error'
5
+
6
+ module RubyLLM
7
+ # Faraday middleware that maps provider-specific API errors to RubyLLM errors.
8
+ class ErrorMiddleware < Faraday::Middleware
9
+ def initialize(app, options = {})
10
+ super(app)
11
+ @provider = options[:provider]
12
+ end
13
+
14
+ def call(env)
15
+ @app.call(env).on_complete do |response|
16
+ self.class.parse_error(provider: @provider, response: response)
17
+ end
18
+ end
19
+
20
+ class << self
21
+ CONTEXT_LENGTH_PATTERNS = [
22
+ /context length/i,
23
+ /context window/i,
24
+ /maximum context/i,
25
+ /request too large/i,
26
+ /too many tokens/i,
27
+ /token count exceeds/i,
28
+ /input[_\s-]?token/i,
29
+ /input or output tokens? must be reduced/i,
30
+ /reduce the length of messages/i,
31
+ /prompt is too long/i
32
+ ].freeze
33
+
34
+ def parse_error(provider:, response:) # rubocop:disable Metrics/PerceivedComplexity
35
+ message = provider&.parse_error(response)
36
+
37
+ case response.status
38
+ when 200..399
39
+ message
40
+ when 400
41
+ if context_length_exceeded?(message)
42
+ raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
43
+ end
44
+
45
+ raise BadRequestError.new(response, message || 'Invalid request - please check your input')
46
+ when 401
47
+ raise UnauthorizedError.new(response, message || 'Invalid API key - check your credentials')
48
+ when 402
49
+ raise PaymentRequiredError.new(response, message || 'Payment required - please top up your account')
50
+ when 403
51
+ raise ForbiddenError.new(response,
52
+ message || 'Forbidden - you do not have permission to access this resource')
53
+ when 429
54
+ if context_length_exceeded?(message)
55
+ raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
56
+ end
57
+
58
+ raise RateLimitError.new(response, message || 'Rate limit exceeded - please wait a moment')
59
+ when 500
60
+ raise ServerError.new(response, message || 'API server error - please try again')
61
+ when 502..504
62
+ raise ServiceUnavailableError.new(response, message || 'API server unavailable - please try again later')
63
+ when 529
64
+ raise OverloadedError.new(response, message || 'Service overloaded - please try again later')
65
+ else
66
+ raise Error.new(response, message || 'An unknown error occurred')
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def context_length_exceeded?(message)
73
+ return false if message.to_s.empty?
74
+
75
+ CONTEXT_LENGTH_PATTERNS.any? { |pattern| message.match?(pattern) }
76
+ end
77
+ end
78
+ end
79
+ end
80
+
81
+ Faraday::Middleware.register_middleware(llm_errors: RubyLLM::ErrorMiddleware)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'base64'
4
+
3
5
  module RubyLLM
4
6
  # Represents a generated image from an AI model.
5
7
  class Image
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Emits structured RubyLLM events without requiring a specific observability
5
+ # backend. Rails apps can use ActiveSupport::Notifications as the instrumenter.
6
+ module Instrumentation
7
+ module_function
8
+
9
+ def instrument(name, payload = nil, config: nil, **attributes)
10
+ payload = build_payload(payload, attributes)
11
+ instrumenter = instrumenter_for(config)
12
+ return yield(payload) if block_given? && !instrumenter
13
+
14
+ unless instrumenter.respond_to?(:instrument)
15
+ return yield(payload) if block_given?
16
+
17
+ return
18
+ end
19
+
20
+ if block_given?
21
+ instrumenter.instrument(name, payload) { yield(payload) }
22
+ else
23
+ instrumenter.instrument(name, payload)
24
+ end
25
+ end
26
+
27
+ def build_payload(payload, attributes)
28
+ payload ||= {}
29
+ attributes.empty? ? payload : payload.merge(attributes)
30
+ end
31
+
32
+ def instrumenter_for(config)
33
+ (config || RubyLLM.config).instrumenter
34
+ end
35
+ end
36
+ end
@@ -27,6 +27,13 @@ module RubyLLM
27
27
  type == 'application/pdf'
28
28
  end
29
29
 
30
+ def document?(type)
31
+ return false if pdf?(type) || text?(type)
32
+
33
+ DOCUMENT_MIME_TYPES.include?(type) ||
34
+ DOCUMENT_MIME_PREFIXES.any? { |prefix| type.start_with?(prefix) }
35
+ end
36
+
30
37
  def text?(type)
31
38
  type.start_with?('text/') ||
32
39
  TEXT_SUFFIXES.any? { |suffix| type.end_with?(suffix) } ||
@@ -67,5 +74,23 @@ module RubyLLM
67
74
  'application/yaml', # Standard for YAML
68
75
  'application/toml' # TOML configuration files
69
76
  ].freeze
77
+
78
+ DOCUMENT_MIME_TYPES = [
79
+ 'application/msword',
80
+ 'application/rtf',
81
+ 'application/vnd.apple.keynote',
82
+ 'application/vnd.apple.numbers',
83
+ 'application/vnd.apple.pages',
84
+ 'application/vnd.google-apps.document',
85
+ 'application/vnd.google-apps.presentation',
86
+ 'application/vnd.google-apps.spreadsheet',
87
+ 'application/vnd.ms-excel',
88
+ 'application/vnd.ms-powerpoint'
89
+ ].freeze
90
+
91
+ DOCUMENT_MIME_PREFIXES = [
92
+ 'application/vnd.openxmlformats-officedocument.',
93
+ 'application/vnd.oasis.opendocument.'
94
+ ].freeze
70
95
  end
71
96
  end
@@ -5,7 +5,7 @@ module RubyLLM
5
5
  # Information about an AI model's capabilities, pricing, and metadata.
6
6
  class Info
7
7
  attr_reader :id, :name, :provider, :family, :created_at, :context_window, :max_output_tokens, :knowledge_cutoff,
8
- :modalities, :capabilities, :pricing, :metadata
8
+ :modalities, :capabilities, :pricing, :metadata, :reasoning_options
9
9
 
10
10
  # Create a default model with assumed capabilities
11
11
  def self.default(model_id, provider)
@@ -31,7 +31,9 @@ module RubyLLM
31
31
  @modalities = Modalities.new(data[:modalities] || {})
32
32
  @capabilities = data[:capabilities] || []
33
33
  @pricing = Pricing.new(data[:pricing] || {})
34
- @metadata = data[:metadata] || {}
34
+ @metadata = data[:metadata]&.dup || {}
35
+ @reasoning_options = normalize_reasoning_options(reasoning_options_from(data))
36
+ store_reasoning_options_metadata
35
37
  end
36
38
 
37
39
  def supports?(capability)
@@ -61,6 +63,14 @@ module RubyLLM
61
63
  modalities.input.include?('image')
62
64
  end
63
65
 
66
+ def reasoning_option(type)
67
+ reasoning_options.find { |option| option[:type] == type.to_s }
68
+ end
69
+
70
+ def reasoning_option_values(type)
71
+ Array(reasoning_option(type)&.fetch(:values, nil))
72
+ end
73
+
64
74
  def supports_video?
65
75
  modalities.input.include?('video')
66
76
  end
@@ -125,6 +135,30 @@ module RubyLLM
125
135
  metadata: metadata
126
136
  }
127
137
  end
138
+
139
+ private
140
+
141
+ def reasoning_options_from(data)
142
+ data[:reasoning_options] || metadata[:reasoning_options] || metadata['reasoning_options']
143
+ end
144
+
145
+ def store_reasoning_options_metadata
146
+ return unless reasoning_options.any?
147
+
148
+ metadata.delete('reasoning_options')
149
+ metadata[:reasoning_options] = reasoning_options
150
+ end
151
+
152
+ def normalize_reasoning_options(options)
153
+ Array(options).filter_map do |option|
154
+ next unless option.is_a?(Hash)
155
+
156
+ normalized = option.to_h.transform_keys(&:to_sym)
157
+ normalized[:type] = normalized[:type].to_s if normalized[:type]
158
+ normalized[:values] = Array(normalized[:values]).map(&:to_s) if normalized.key?(:values)
159
+ normalized
160
+ end
161
+ end
128
162
  end
129
163
  end
130
164
  end
@@ -4,24 +4,30 @@ module RubyLLM
4
4
  module Model
5
5
  # A collection that manages and provides access to different categories of pricing information
6
6
  class Pricing
7
+ CATEGORIES = %i[text_tokens images audio_tokens embeddings].freeze
8
+
7
9
  def initialize(data)
8
10
  @data = {}
9
11
 
10
- %i[text_tokens images audio_tokens embeddings].each do |category|
12
+ CATEGORIES.each do |category|
11
13
  @data[category] = PricingCategory.new(data[category]) if data[category] && !empty_pricing?(data[category])
12
14
  end
13
15
  end
14
16
 
15
- def method_missing(method, *args)
16
- if respond_to_missing?(method)
17
- @data[method.to_sym] || PricingCategory.new
18
- else
19
- super
20
- end
17
+ def text_tokens
18
+ category(:text_tokens)
19
+ end
20
+
21
+ def images
22
+ category(:images)
21
23
  end
22
24
 
23
- def respond_to_missing?(method, include_private = false)
24
- %i[text_tokens images audio_tokens embeddings].include?(method.to_sym) || super
25
+ def audio_tokens
26
+ category(:audio_tokens)
27
+ end
28
+
29
+ def embeddings
30
+ category(:embeddings)
25
31
  end
26
32
 
27
33
  def to_h
@@ -30,6 +36,10 @@ module RubyLLM
30
36
 
31
37
  private
32
38
 
39
+ def category(name)
40
+ @data[name] || PricingCategory.new
41
+ end
42
+
33
43
  def empty_pricing?(data)
34
44
  return true unless data
35
45
 
@@ -2,8 +2,18 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Model
5
- # A dynamic class for storing non-zero pricing values with flexible attribute access
5
+ # Stores non-zero pricing values for a single pricing tier.
6
6
  class PricingTier
7
+ ATTRIBUTES = %i[
8
+ input_per_million
9
+ output_per_million
10
+ cache_read_input_per_million
11
+ cache_write_input_per_million
12
+ cached_input_per_million
13
+ cache_creation_input_per_million
14
+ reasoning_output_per_million
15
+ ].freeze
16
+
7
17
  def initialize(data = {})
8
18
  @values = {}
9
19
 
@@ -12,17 +22,18 @@ module RubyLLM
12
22
  end
13
23
  end
14
24
 
15
- def method_missing(method, *args)
16
- if method.to_s.end_with?('=')
17
- key = method.to_s.chomp('=').to_sym
18
- @values[key] = args.first if args.first && args.first != 0.0
19
- elsif @values.key?(method)
20
- @values[method]
25
+ ATTRIBUTES.each do |attribute|
26
+ define_method(attribute) do
27
+ @values[attribute]
28
+ end
29
+
30
+ define_method("#{attribute}=") do |value|
31
+ @values[attribute] = value if value && value != 0.0
21
32
  end
22
33
  end
23
34
 
24
- def respond_to_missing?(method, include_private = false)
25
- method.to_s.end_with?('=') || @values.key?(method.to_sym) || super
35
+ def [](key)
36
+ @values[key.to_sym]
26
37
  end
27
38
 
28
39
  def to_h
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Sources for model registry data.
5
+ module ModelRegistry
6
+ # Reads model registry data from the configured JSON file.
7
+ class JsonSource
8
+ def initialize(file = nil)
9
+ @file = file
10
+ end
11
+
12
+ def read
13
+ Models.read_from_json(@file || RubyLLM.config.model_registry_file)
14
+ end
15
+ end
16
+
17
+ # Reads model registry data from the configured Active Record model class.
18
+ class ActiveRecordSource
19
+ def read
20
+ model_class = resolve_model_class
21
+ return [] unless model_class.respond_to?(:table_exists?) && model_class.table_exists?
22
+
23
+ model_class.all.map(&:to_llm)
24
+ rescue StandardError => e
25
+ RubyLLM.logger.debug { "Failed to load models from database: #{e.message}, falling back to JSON" }
26
+ []
27
+ end
28
+
29
+ private
30
+
31
+ def resolve_model_class
32
+ model_class = RubyLLM.config.model_registry_class
33
+ return model_class unless model_class.is_a?(String)
34
+
35
+ model_class.split('::').inject(Object) { |scope, name| scope.const_get(name) }
36
+ end
37
+ end
38
+ end
39
+ end