ruby_llm 1.14.1 → 1.15.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -3
  3. data/lib/generators/ruby_llm/generator_helpers.rb +8 -0
  4. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +1 -1
  5. data/lib/ruby_llm/active_record/acts_as.rb +3 -0
  6. data/lib/ruby_llm/active_record/acts_as_legacy.rb +52 -25
  7. data/lib/ruby_llm/active_record/chat_methods.rb +39 -22
  8. data/lib/ruby_llm/active_record/message_methods.rb +17 -1
  9. data/lib/ruby_llm/active_record/model_methods.rb +7 -9
  10. data/lib/ruby_llm/active_record/payload_helpers.rb +3 -0
  11. data/lib/ruby_llm/active_record/tool_call_methods.rb +3 -0
  12. data/lib/ruby_llm/agent.rb +3 -2
  13. data/lib/ruby_llm/aliases.json +34 -4
  14. data/lib/ruby_llm/attachment.rb +11 -27
  15. data/lib/ruby_llm/chat.rb +62 -21
  16. data/lib/ruby_llm/cost.rb +224 -0
  17. data/lib/ruby_llm/image.rb +37 -4
  18. data/lib/ruby_llm/message.rb +20 -0
  19. data/lib/ruby_llm/model/info.rb +17 -0
  20. data/lib/ruby_llm/model/pricing_category.rb +13 -2
  21. data/lib/ruby_llm/models.json +25168 -20374
  22. data/lib/ruby_llm/models.rb +2 -1
  23. data/lib/ruby_llm/models_schema.json +3 -0
  24. data/lib/ruby_llm/provider.rb +10 -3
  25. data/lib/ruby_llm/providers/anthropic/tools.rb +4 -1
  26. data/lib/ruby_llm/providers/bedrock/chat.rb +24 -13
  27. data/lib/ruby_llm/providers/bedrock/streaming.rb +4 -1
  28. data/lib/ruby_llm/providers/gemini/chat.rb +8 -1
  29. data/lib/ruby_llm/providers/gemini/images.rb +2 -2
  30. data/lib/ruby_llm/providers/gemini/streaming.rb +4 -1
  31. data/lib/ruby_llm/providers/gemini/tools.rb +3 -1
  32. data/lib/ruby_llm/providers/mistral/capabilities.rb +6 -1
  33. data/lib/ruby_llm/providers/mistral/chat.rb +55 -4
  34. data/lib/ruby_llm/providers/openai/capabilities.rb +82 -12
  35. data/lib/ruby_llm/providers/openai/chat.rb +45 -6
  36. data/lib/ruby_llm/providers/openai/images.rb +58 -6
  37. data/lib/ruby_llm/providers/openai/streaming.rb +5 -6
  38. data/lib/ruby_llm/providers/openrouter/chat.rb +30 -6
  39. data/lib/ruby_llm/providers/openrouter/images.rb +2 -2
  40. data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
  41. data/lib/ruby_llm/providers/openrouter/streaming.rb +5 -6
  42. data/lib/ruby_llm/railtie.rb +6 -0
  43. data/lib/ruby_llm/tokens.rb +8 -0
  44. data/lib/ruby_llm/tool.rb +24 -7
  45. data/lib/ruby_llm/version.rb +1 -1
  46. data/lib/ruby_llm.rb +2 -4
  47. data/lib/tasks/models.rake +13 -12
  48. metadata +19 -4
data/lib/ruby_llm/chat.rb CHANGED
@@ -30,6 +30,7 @@ module RubyLLM
30
30
  tool_call: nil,
31
31
  tool_result: nil
32
32
  }
33
+ @callbacks = Hash.new { |callbacks, name| callbacks[name] = [] }
33
34
  end
34
35
 
35
36
  def ask(message = nil, with: nil, &)
@@ -112,31 +113,47 @@ module RubyLLM
112
113
  self
113
114
  end
114
115
 
115
- def on_new_message(&block)
116
- @on[:new_message] = block
117
- self
116
+ def on_new_message(&)
117
+ set_legacy_callback(:new_message, :on_new_message, :before_message, &)
118
118
  end
119
119
 
120
- def on_end_message(&block)
121
- @on[:end_message] = block
122
- self
120
+ def on_end_message(&)
121
+ set_legacy_callback(:end_message, :on_end_message, :after_message, &)
123
122
  end
124
123
 
125
- def on_tool_call(&block)
126
- @on[:tool_call] = block
127
- self
124
+ def on_tool_call(&)
125
+ set_legacy_callback(:tool_call, :on_tool_call, :before_tool_call, &)
128
126
  end
129
127
 
130
- def on_tool_result(&block)
131
- @on[:tool_result] = block
132
- self
128
+ def on_tool_result(&)
129
+ set_legacy_callback(:tool_result, :on_tool_result, :after_tool_result, &)
130
+ end
131
+
132
+ def before_message(&)
133
+ add_callback(:before_message, &)
134
+ end
135
+
136
+ def after_message(&)
137
+ add_callback(:after_message, &)
138
+ end
139
+
140
+ def before_tool_call(&)
141
+ add_callback(:before_tool_call, &)
142
+ end
143
+
144
+ def after_tool_result(&)
145
+ add_callback(:after_tool_result, &)
133
146
  end
134
147
 
135
148
  def each(&)
136
149
  messages.each(&)
137
150
  end
138
151
 
139
- def complete(&) # rubocop:disable Metrics/PerceivedComplexity
152
+ def cost
153
+ Cost.aggregate(messages.map(&:cost))
154
+ end
155
+
156
+ def complete(&)
140
157
  response = @provider.complete(
141
158
  messages,
142
159
  tools: @tools,
@@ -150,7 +167,7 @@ module RubyLLM
150
167
  &wrap_streaming_block(&)
151
168
  )
152
169
 
153
- @on[:new_message]&.call unless block_given?
170
+ run_callbacks(:before_message, :new_message) unless block_given?
154
171
 
155
172
  if @schema && response.content.is_a?(String) && !response.tool_call?
156
173
  begin
@@ -161,7 +178,7 @@ module RubyLLM
161
178
  end
162
179
 
163
180
  add_message response
164
- @on[:end_message]&.call(response)
181
+ run_callbacks(:after_message, :end_message, response)
165
182
 
166
183
  if response.tool_call?
167
184
  handle_tool_calls(response, &)
@@ -221,28 +238,52 @@ module RubyLLM
221
238
  sanitized.empty? ? 'response' : sanitized
222
239
  end
223
240
 
241
+ def add_callback(name, &block)
242
+ @callbacks[name] << block if block
243
+ self
244
+ end
245
+
246
+ def set_legacy_callback(name, legacy_name, additive_name, &block)
247
+ warn_legacy_callback_deprecation(legacy_name, additive_name) if block
248
+
249
+ @on[name] = block
250
+ self
251
+ end
252
+
253
+ def warn_legacy_callback_deprecation(legacy_name, additive_name)
254
+ RubyLLM.logger.warn(
255
+ "`#{legacy_name}` is deprecated and will be removed in RubyLLM 2.0. " \
256
+ "Use `#{additive_name}` instead."
257
+ )
258
+ end
259
+
260
+ def run_callbacks(name, legacy_name, *args)
261
+ @callbacks[name].each { |callback| callback.call(*args) }
262
+ @on[legacy_name]&.call(*args)
263
+ end
264
+
224
265
  def wrap_streaming_block(&block)
225
266
  return nil unless block_given?
226
267
 
227
- @on[:new_message]&.call
268
+ run_callbacks(:before_message, :new_message)
228
269
 
229
270
  proc do |chunk|
230
271
  block.call chunk
231
272
  end
232
273
  end
233
274
 
234
- def handle_tool_calls(response, &) # rubocop:disable Metrics/PerceivedComplexity
275
+ def handle_tool_calls(response, &)
235
276
  halt_result = nil
236
277
 
237
278
  response.tool_calls.each_value do |tool_call|
238
- @on[:new_message]&.call
239
- @on[:tool_call]&.call(tool_call)
279
+ run_callbacks(:before_message, :new_message)
280
+ run_callbacks(:before_tool_call, :tool_call, tool_call)
240
281
  result = execute_tool tool_call
241
- @on[:tool_result]&.call(result)
282
+ run_callbacks(:after_tool_result, :tool_result, result)
242
283
  tool_payload = result.is_a?(Tool::Halt) ? result.content : result
243
284
  content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
244
285
  message = add_message role: :tool, content:, tool_call_id: tool_call.id
245
- @on[:end_message]&.call(message)
286
+ run_callbacks(:after_message, :end_message, message)
246
287
 
247
288
  halt_result = result if result.is_a?(Tool::Halt)
248
289
  end
@@ -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
@@ -3,14 +3,15 @@
3
3
  module RubyLLM
4
4
  # Represents a generated image from an AI model.
5
5
  class Image
6
- attr_reader :url, :data, :mime_type, :revised_prompt, :model_id
6
+ attr_reader :url, :data, :mime_type, :revised_prompt, :model_id, :usage
7
7
 
8
- def initialize(url: nil, data: nil, mime_type: nil, revised_prompt: nil, model_id: nil)
8
+ def initialize(url: nil, data: nil, mime_type: nil, revised_prompt: nil, model_id: nil, usage: {}) # rubocop:disable Metrics/ParameterLists
9
9
  @url = url
10
10
  @data = data
11
11
  @mime_type = mime_type
12
12
  @revised_prompt = revised_prompt
13
13
  @model_id = model_id
14
+ @usage = usage
14
15
  end
15
16
 
16
17
  def base64?
@@ -36,14 +37,46 @@ module RubyLLM
36
37
  provider: nil,
37
38
  assume_model_exists: false,
38
39
  size: '1024x1024',
39
- context: nil)
40
+ context: nil,
41
+ with: nil,
42
+ mask: nil,
43
+ params: {})
40
44
  config = context&.config || RubyLLM.config
41
45
  model ||= config.default_image_model
42
46
  model, provider_instance = Models.resolve(model, provider: provider, assume_exists: assume_model_exists,
43
47
  config: config)
44
48
  model_id = model.id
45
49
 
46
- provider_instance.paint(prompt, model: model_id, size:)
50
+ provider_instance.paint(prompt, model: model_id, size:, with:, mask:, params:)
51
+ end
52
+
53
+ def tokens
54
+ @tokens ||= Tokens.build(
55
+ input: usage_value('input_tokens'),
56
+ output: usage_value('output_tokens')
57
+ )
58
+ end
59
+
60
+ def cost
61
+ Cost.new(tokens:, model: model_info, category: :images, input_details: input_tokens_details)
62
+ end
63
+
64
+ def model_info
65
+ return unless model_id
66
+
67
+ @model_info ||= RubyLLM.models.find(model_id)
68
+ rescue ModelNotFoundError
69
+ nil
70
+ end
71
+
72
+ private
73
+
74
+ def input_tokens_details
75
+ usage_value('input_tokens_details')
76
+ end
77
+
78
+ def usage_value(key)
79
+ usage[key] || usage[key.to_sym]
47
80
  end
48
81
  end
49
82
  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:)
@@ -77,6 +77,23 @@ module RubyLLM
77
77
  pricing.text_tokens.output
78
78
  end
79
79
 
80
+ def cache_read_input_price_per_million
81
+ pricing.text_tokens.cache_read_input
82
+ end
83
+
84
+ def cache_write_input_price_per_million
85
+ pricing.text_tokens.cache_write_input
86
+ end
87
+
88
+ alias cached_input_price_per_million cache_read_input_price_per_million
89
+ alias cache_creation_input_price_per_million cache_write_input_price_per_million
90
+
91
+ def cost_for(tokens)
92
+ tokens = tokens.tokens if tokens.respond_to?(:tokens)
93
+
94
+ Cost.new(tokens:, model: self)
95
+ end
96
+
80
97
  def provider_class
81
98
  RubyLLM::Provider.resolve provider
82
99
  end
@@ -19,10 +19,21 @@ module RubyLLM
19
19
  standard&.output_per_million
20
20
  end
21
21
 
22
- def cached_input
23
- standard&.cached_input_per_million
22
+ def cache_read_input
23
+ standard&.cache_read_input_per_million || standard&.cached_input_per_million
24
24
  end
25
25
 
26
+ def cache_write_input
27
+ standard&.cache_write_input_per_million || standard&.cache_creation_input_per_million
28
+ end
29
+
30
+ def reasoning_output
31
+ standard&.reasoning_output_per_million
32
+ end
33
+
34
+ alias cached_input cache_read_input
35
+ alias cache_creation_input cache_write_input
36
+
26
37
  def [](key)
27
38
  key == :batch ? batch : standard
28
39
  end