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,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Represents the cost of token usage for a model response.
5
+ class Cost
6
+ COMPONENTS = %i[input output cache_read cache_write thinking].freeze
7
+ PER_MILLION = 1_000_000.0
8
+
9
+ attr_reader :tokens, :model, :category
10
+
11
+ def self.aggregate(costs)
12
+ costs = costs.compact.select(&:tokens?)
13
+ return new(amounts: {}, has_tokens: false) if costs.empty?
14
+
15
+ missing = COMPONENTS.select do |component|
16
+ costs.any? { |cost| cost.missing?(component) }
17
+ end
18
+
19
+ amounts = COMPONENTS.to_h do |component|
20
+ [component, missing.include?(component) ? nil : aggregate_component(costs, component)]
21
+ end
22
+
23
+ new(amounts:, missing:, has_tokens: true)
24
+ end
25
+
26
+ # rubocop:disable Metrics/ParameterLists
27
+ def initialize(tokens: nil, model: nil, amounts: nil, missing: [], has_tokens: nil, category: :text_tokens,
28
+ input_details: nil)
29
+ @tokens = tokens
30
+ @model = normalize_model(model)
31
+ @amounts = amounts
32
+ @missing = missing
33
+ @has_tokens = has_tokens
34
+ @category = category.to_sym
35
+ @input_details = input_details
36
+ end
37
+ # rubocop:enable Metrics/ParameterLists
38
+
39
+ def input
40
+ amount_for(:input)
41
+ end
42
+
43
+ def output
44
+ amount_for(:output)
45
+ end
46
+
47
+ def cache_read
48
+ amount_for(:cache_read)
49
+ end
50
+
51
+ def cache_write
52
+ amount_for(:cache_write)
53
+ end
54
+
55
+ def thinking
56
+ amount_for(:thinking)
57
+ end
58
+
59
+ alias reasoning thinking
60
+
61
+ alias cached_input cache_read
62
+ alias cache_creation cache_write
63
+
64
+ def total
65
+ return nil unless tokens?
66
+ return nil if COMPONENTS.any? { |component| missing?(component) }
67
+
68
+ costs = COMPONENTS.filter_map { |component| public_send(component) }
69
+ return nil if costs.empty?
70
+
71
+ costs.sum
72
+ end
73
+
74
+ def to_h
75
+ {
76
+ input: input,
77
+ output: output,
78
+ cache_read: cache_read,
79
+ cache_write: cache_write,
80
+ thinking: thinking,
81
+ total: total
82
+ }.compact
83
+ end
84
+
85
+ def tokens?
86
+ return @has_tokens unless @has_tokens.nil?
87
+
88
+ COMPONENTS.any? { |component| !tokens_for(component).nil? }
89
+ end
90
+
91
+ def missing?(component)
92
+ return @missing.include?(component) if aggregate?
93
+ return image_input_missing? if component == :input && detailed_image_input?
94
+ return false if component == :thinking && !thinking_priced_separately?
95
+
96
+ tokens = tokens_for(component)
97
+ tokens.to_i.positive? && price_for(component).nil?
98
+ end
99
+
100
+ private_class_method def self.aggregate_component(costs, component)
101
+ values = costs.filter_map { |cost| cost.public_send(component) }
102
+ values.empty? ? nil : values.sum
103
+ end
104
+
105
+ private
106
+
107
+ def amount_for(component)
108
+ return @amounts[component] if aggregate?
109
+ return image_input_amount if component == :input && detailed_image_input?
110
+
111
+ token_count = tokens_for(component)
112
+ return nil if token_count.nil?
113
+
114
+ token_count = token_count.to_i
115
+ return 0.0 if token_count.zero?
116
+
117
+ price = price_for(component)
118
+ return nil unless price
119
+
120
+ token_count * price / PER_MILLION
121
+ end
122
+
123
+ def aggregate?
124
+ !@amounts.nil?
125
+ end
126
+
127
+ def tokens_for(component)
128
+ return unless tokens
129
+
130
+ case component
131
+ when :input
132
+ tokens.input
133
+ when :output
134
+ tokens.output
135
+ when :cache_read
136
+ tokens.cache_read
137
+ when :cache_write
138
+ tokens.cache_write
139
+ when :thinking
140
+ tokens.thinking if thinking_priced_separately?
141
+ end
142
+ end
143
+
144
+ def price_for(component)
145
+ case component
146
+ when :input
147
+ text_pricing.input
148
+ when :output
149
+ output_pricing.output
150
+ when :cache_read
151
+ text_pricing.cache_read_input
152
+ when :cache_write
153
+ text_pricing.cache_write_input
154
+ when :thinking
155
+ text_pricing.reasoning_output
156
+ end
157
+ end
158
+
159
+ def text_pricing
160
+ model&.pricing&.text_tokens || RubyLLM::Model::PricingCategory.new
161
+ end
162
+
163
+ def image_pricing
164
+ model&.pricing&.images || RubyLLM::Model::PricingCategory.new
165
+ end
166
+
167
+ def output_pricing
168
+ image_cost? && image_pricing.output ? image_pricing : text_pricing
169
+ end
170
+
171
+ def image_cost?
172
+ %i[image images].include?(category)
173
+ end
174
+
175
+ def detailed_image_input?
176
+ image_cost? && @input_details.is_a?(Hash) && image_input_parts.any? { |_, tokens, _| !tokens.nil? }
177
+ end
178
+
179
+ def image_input_amount
180
+ return nil if image_input_missing?
181
+
182
+ image_input_parts.filter_map do |_, token_count, price|
183
+ next if token_count.nil? || token_count.to_i.zero?
184
+
185
+ token_count.to_i * price / PER_MILLION
186
+ end.sum
187
+ end
188
+
189
+ def image_input_missing?
190
+ image_input_parts.any? do |_, token_count, price|
191
+ token_count.to_i.positive? && price.nil?
192
+ end
193
+ end
194
+
195
+ def image_input_parts
196
+ [
197
+ [:text, input_detail('text_tokens'), text_pricing.input],
198
+ [:image, input_detail('image_tokens'), image_pricing.input || text_pricing.input]
199
+ ]
200
+ end
201
+
202
+ def input_detail(key)
203
+ @input_details[key] || @input_details[key.to_sym]
204
+ end
205
+
206
+ def thinking_priced_separately?
207
+ reasoning_price = text_pricing.reasoning_output
208
+ return false unless reasoning_price
209
+
210
+ output_price = text_pricing.output
211
+ output_price.nil? || reasoning_price != output_price
212
+ end
213
+
214
+ def normalize_model(model)
215
+ return RubyLLM.models.find(model.to_s) if model.is_a?(String) || model.is_a?(Symbol)
216
+ return model.to_llm if model.respond_to?(:to_llm)
217
+ return model if model.respond_to?(:pricing)
218
+
219
+ nil
220
+ rescue ModelNotFoundError
221
+ nil
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Owns RubyLLM deprecation warnings so applications and tests can decide how
5
+ # aggressively to handle compatibility paths.
6
+ class Deprecator
7
+ def warn(message)
8
+ case RubyLLM.config.deprecation_behavior
9
+ when :silence
10
+ nil
11
+ when :raise
12
+ raise DeprecationError, message
13
+ else
14
+ RubyLLM.logger.warn(message)
15
+ end
16
+ end
17
+
18
+ def deprecate(name, replacement:, removal:)
19
+ warn("#{name} is deprecated and will be removed in RubyLLM #{removal}. Use #{replacement} instead.")
20
+ end
21
+ end
22
+
23
+ class DeprecationError < StandardError; end
24
+ end
@@ -23,7 +23,37 @@ module RubyLLM
23
23
  config: config)
24
24
  model_id = model.id
25
25
 
26
- provider_instance.embed(text, model: model_id, dimensions:)
26
+ payload = {
27
+ provider: provider_instance.slug,
28
+ provider_class: provider_instance.class.name,
29
+ model: model_id,
30
+ model_info: model,
31
+ input: text,
32
+ dimensions: dimensions
33
+ }
34
+
35
+ RubyLLM.instrument('embedding.ruby_llm', payload, config: config) do |event|
36
+ result = provider_instance.embed(text, model: model_id, dimensions:)
37
+ event[:result] = result
38
+ event[:response_model] = result.model
39
+ event[:input_tokens] = result.input_tokens
40
+ event[:embedding_dimensions] = vector_dimensions(result.vectors)
41
+ event[:embedding_count] = embedding_count(result.vectors)
42
+ result
43
+ end
44
+ end
45
+
46
+ def self.vector_dimensions(vectors)
47
+ return unless vectors.is_a?(Array)
48
+
49
+ vector = vectors.first.is_a?(Array) ? vectors.first : vectors
50
+ vector.length if vector.respond_to?(:length)
51
+ end
52
+
53
+ def self.embedding_count(vectors)
54
+ return unless vectors.is_a?(Array)
55
+
56
+ vectors.first.is_a?(Array) ? vectors.size : 1
27
57
  end
28
58
  end
29
59
  end
@@ -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,16 +1,19 @@
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
6
- attr_reader :url, :data, :mime_type, :revised_prompt, :model_id
8
+ attr_reader :url, :data, :mime_type, :revised_prompt, :model_id, :usage
7
9
 
8
- def initialize(url: nil, data: nil, mime_type: nil, revised_prompt: nil, model_id: nil)
10
+ def initialize(url: nil, data: nil, mime_type: nil, revised_prompt: nil, model_id: nil, usage: {}) # rubocop:disable Metrics/ParameterLists
9
11
  @url = url
10
12
  @data = data
11
13
  @mime_type = mime_type
12
14
  @revised_prompt = revised_prompt
13
15
  @model_id = model_id
16
+ @usage = usage
14
17
  end
15
18
 
16
19
  def base64?
@@ -36,14 +39,46 @@ module RubyLLM
36
39
  provider: nil,
37
40
  assume_model_exists: false,
38
41
  size: '1024x1024',
39
- context: nil)
42
+ context: nil,
43
+ with: nil,
44
+ mask: nil,
45
+ params: {})
40
46
  config = context&.config || RubyLLM.config
41
47
  model ||= config.default_image_model
42
48
  model, provider_instance = Models.resolve(model, provider: provider, assume_exists: assume_model_exists,
43
49
  config: config)
44
50
  model_id = model.id
45
51
 
46
- provider_instance.paint(prompt, model: model_id, size:)
52
+ provider_instance.paint(prompt, model: model_id, size:, with:, mask:, params:)
53
+ end
54
+
55
+ def tokens
56
+ @tokens ||= Tokens.build(
57
+ input: usage_value('input_tokens'),
58
+ output: usage_value('output_tokens')
59
+ )
60
+ end
61
+
62
+ def cost
63
+ Cost.new(tokens:, model: model_info, category: :images, input_details: input_tokens_details)
64
+ end
65
+
66
+ def model_info
67
+ return unless model_id
68
+
69
+ @model_info ||= RubyLLM.models.find(model_id)
70
+ rescue ModelNotFoundError
71
+ nil
72
+ end
73
+
74
+ private
75
+
76
+ def input_tokens_details
77
+ usage_value('input_tokens_details')
78
+ end
79
+
80
+ def usage_value(key)
81
+ usage[key] || usage[key.to_sym]
47
82
  end
48
83
  end
49
84
  end
@@ -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
@@ -64,6 +64,14 @@ module RubyLLM
64
64
  tokens&.cache_creation
65
65
  end
66
66
 
67
+ def cache_read_tokens
68
+ tokens&.cache_read
69
+ end
70
+
71
+ def cache_write_tokens
72
+ tokens&.cache_write
73
+ end
74
+
67
75
  def thinking_tokens
68
76
  tokens&.thinking
69
77
  end
@@ -72,6 +80,10 @@ module RubyLLM
72
80
  tokens&.thinking
73
81
  end
74
82
 
83
+ def cost(model: nil)
84
+ Cost.new(tokens:, model: model || model_info)
85
+ end
86
+
75
87
  def to_h
76
88
  {
77
89
  role: role,
@@ -88,6 +100,14 @@ module RubyLLM
88
100
  super - [:@raw]
89
101
  end
90
102
 
103
+ def model_info
104
+ return unless model_id
105
+
106
+ @model_info ||= RubyLLM.models.find(model_id)
107
+ rescue ModelNotFoundError
108
+ nil
109
+ end
110
+
91
111
  private
92
112
 
93
113
  def normalize_content(content, role:, tool_calls:)
@@ -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