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
@@ -13,7 +13,6 @@ module RubyLLM
13
13
 
14
14
  def build_chunk(data)
15
15
  usage = data['usage'] || {}
16
- cached_tokens = usage.dig('prompt_tokens_details', 'cached_tokens')
17
16
  delta = data.dig('choices', 0, 'delta') || {}
18
17
  content_source = delta['content'] || data.dig('choices', 0, 'message', 'content')
19
18
  content, thinking_from_blocks = OpenAI::Chat.extract_content_and_thinking(content_source)
@@ -27,11 +26,11 @@ module RubyLLM
27
26
  signature: delta['reasoning_signature']
28
27
  ),
29
28
  tool_calls: parse_tool_calls(delta['tool_calls'], parse_arguments: false),
30
- input_tokens: usage['prompt_tokens'],
31
- output_tokens: usage['completion_tokens'],
32
- cached_tokens: cached_tokens,
33
- cache_creation_tokens: 0,
34
- thinking_tokens: usage.dig('completion_tokens_details', 'reasoning_tokens')
29
+ input_tokens: OpenAI::Chat.input_tokens(usage),
30
+ output_tokens: OpenAI::Chat.output_tokens(usage),
31
+ cached_tokens: OpenAI::Chat.cache_read_tokens(usage),
32
+ cache_creation_tokens: OpenAI::Chat.cache_write_tokens(usage),
33
+ thinking_tokens: OpenAI::Chat.thinking_tokens(usage)
35
34
  )
36
35
  end
37
36
 
@@ -60,8 +60,7 @@ module RubyLLM
60
60
  return unless message_data
61
61
 
62
62
  usage = data['usage'] || {}
63
- cached_tokens = usage.dig('prompt_tokens_details', 'cached_tokens')
64
- thinking_tokens = usage.dig('completion_tokens_details', 'reasoning_tokens')
63
+ thinking_tokens = thinking_tokens(usage)
65
64
  thinking_text = extract_thinking_text(message_data)
66
65
  thinking_signature = extract_thinking_signature(message_data)
67
66
 
@@ -70,16 +69,41 @@ module RubyLLM
70
69
  content: message_data['content'],
71
70
  thinking: Thinking.build(text: thinking_text, signature: thinking_signature),
72
71
  tool_calls: OpenAI::Tools.parse_tool_calls(message_data['tool_calls']),
73
- input_tokens: usage['prompt_tokens'],
74
- output_tokens: usage['completion_tokens'],
75
- cached_tokens: cached_tokens,
76
- cache_creation_tokens: 0,
72
+ input_tokens: input_tokens(usage),
73
+ output_tokens: output_tokens(usage),
74
+ cached_tokens: cache_read_tokens(usage),
75
+ cache_creation_tokens: cache_write_tokens(usage),
77
76
  thinking_tokens: thinking_tokens,
78
77
  model_id: data['model'],
79
78
  raw: response
80
79
  )
81
80
  end
82
81
 
82
+ def input_tokens(usage)
83
+ return usage['prompt_cache_miss_tokens'] if usage['prompt_cache_miss_tokens']
84
+
85
+ prompt_tokens = usage['prompt_tokens']
86
+ return unless prompt_tokens
87
+
88
+ [prompt_tokens.to_i - cache_read_tokens(usage).to_i - cache_write_tokens(usage).to_i, 0].max
89
+ end
90
+
91
+ def output_tokens(usage)
92
+ OpenAI::Chat.output_tokens(usage)
93
+ end
94
+
95
+ def cache_read_tokens(usage)
96
+ usage.dig('prompt_tokens_details', 'cached_tokens') || usage['prompt_cache_hit_tokens']
97
+ end
98
+
99
+ def cache_write_tokens(usage)
100
+ usage.dig('prompt_tokens_details', 'cache_write_tokens') || 0
101
+ end
102
+
103
+ def thinking_tokens(usage)
104
+ OpenAI::Chat.thinking_tokens(usage)
105
+ end
106
+
83
107
  def format_messages(messages)
84
108
  messages.map do |msg|
85
109
  {
@@ -9,11 +9,11 @@ module RubyLLM
9
9
  module Images
10
10
  module_function
11
11
 
12
- def images_url
12
+ def images_url(with: nil, mask: nil) # rubocop:disable Lint/UnusedMethodArgument
13
13
  'chat/completions'
14
14
  end
15
15
 
16
- def render_image_payload(prompt, model:, size:)
16
+ def render_image_payload(prompt, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Lint/UnusedMethodArgument,Metrics/ParameterLists
17
17
  RubyLLM.logger.debug { "Ignoring size #{size}. OpenRouter image generation does not support size parameter." }
18
18
  {
19
19
  model: model,
@@ -23,7 +23,7 @@ module RubyLLM
23
23
  pricing_types = {
24
24
  prompt: :input_per_million,
25
25
  completion: :output_per_million,
26
- input_cache_read: :cached_input_per_million,
26
+ input_cache_read: :cache_read_input_per_million,
27
27
  internal_reasoning: :reasoning_output_per_million
28
28
  }
29
29
 
@@ -13,7 +13,6 @@ module RubyLLM
13
13
 
14
14
  def build_chunk(data)
15
15
  usage = data['usage'] || {}
16
- cached_tokens = usage.dig('prompt_tokens_details', 'cached_tokens')
17
16
  delta = data.dig('choices', 0, 'delta') || {}
18
17
 
19
18
  Chunk.new(
@@ -25,11 +24,11 @@ module RubyLLM
25
24
  signature: extract_thinking_signature(delta)
26
25
  ),
27
26
  tool_calls: OpenAI::Tools.parse_tool_calls(delta['tool_calls'], parse_arguments: false),
28
- input_tokens: usage['prompt_tokens'],
29
- output_tokens: usage['completion_tokens'],
30
- cached_tokens: cached_tokens,
31
- cache_creation_tokens: 0,
32
- thinking_tokens: usage.dig('completion_tokens_details', 'reasoning_tokens')
27
+ input_tokens: OpenRouter::Chat.input_tokens(usage),
28
+ output_tokens: OpenRouter::Chat.output_tokens(usage),
29
+ cached_tokens: OpenRouter::Chat.cache_read_tokens(usage),
30
+ cache_creation_tokens: OpenRouter::Chat.cache_write_tokens(usage),
31
+ thinking_tokens: OpenRouter::Chat.thinking_tokens(usage)
33
32
  )
34
33
  end
35
34
 
@@ -12,6 +12,12 @@ if defined?(Rails::Railtie)
12
12
 
13
13
  initializer 'ruby_llm.active_record' do
14
14
  ActiveSupport.on_load :active_record do
15
+ require 'ruby_llm/active_record/payload_helpers'
16
+ require 'ruby_llm/active_record/chat_methods'
17
+ require 'ruby_llm/active_record/message_methods'
18
+ require 'ruby_llm/active_record/model_methods'
19
+ require 'ruby_llm/active_record/tool_call_methods'
20
+
15
21
  if RubyLLM.config.use_new_acts_as
16
22
  require 'ruby_llm/active_record/acts_as'
17
23
  ::ActiveRecord::Base.include RubyLLM::ActiveRecord::ActsAs
@@ -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 = {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyLLM
4
- VERSION = '1.14.1'
4
+ VERSION = '1.15.0'
5
5
  end
data/lib/ruby_llm.rb CHANGED
@@ -33,6 +33,7 @@ loader.inflector.inflect(
33
33
  )
34
34
  loader.ignore("#{__dir__}/tasks")
35
35
  loader.ignore("#{__dir__}/generators")
36
+ loader.ignore("#{__dir__}/ruby_llm/active_record")
36
37
  loader.ignore("#{__dir__}/ruby_llm/railtie.rb")
37
38
  loader.setup
38
39
 
@@ -107,7 +108,4 @@ RubyLLM::Provider.register :perplexity, RubyLLM::Providers::Perplexity
107
108
  RubyLLM::Provider.register :vertexai, RubyLLM::Providers::VertexAI
108
109
  RubyLLM::Provider.register :xai, RubyLLM::Providers::XAI
109
110
 
110
- if defined?(Rails::Railtie)
111
- require 'ruby_llm/railtie'
112
- require 'ruby_llm/active_record/acts_as'
113
- end
111
+ require 'ruby_llm/railtie' if defined?(Rails::Railtie)
@@ -329,22 +329,23 @@ end
329
329
 
330
330
  def standard_pricing_display(model)
331
331
  pricing_data = model.pricing.to_h[:text_tokens]&.dig(:standard) || {}
332
+ parts = [
333
+ pricing_part(pricing_data, :input_per_million, 'In'),
334
+ pricing_part(pricing_data, :output_per_million, 'Out'),
335
+ pricing_part(pricing_data, %i[cache_read_input_per_million cached_input_per_million], 'Cache Read'),
336
+ pricing_part(pricing_data, %i[cache_write_input_per_million cache_creation_input_per_million], 'Cache Write')
337
+ ].compact
332
338
 
333
- if pricing_data.any?
334
- parts = []
339
+ return parts.join(', ') if parts.any?
335
340
 
336
- parts << "In: $#{format('%.2f', pricing_data[:input_per_million])}" if pricing_data[:input_per_million]
337
-
338
- parts << "Out: $#{format('%.2f', pricing_data[:output_per_million])}" if pricing_data[:output_per_million]
339
-
340
- if pricing_data[:cached_input_per_million]
341
- parts << "Cache: $#{format('%.2f', pricing_data[:cached_input_per_million])}"
342
- end
341
+ '-'
342
+ end
343
343
 
344
- return parts.join(', ') if parts.any?
345
- end
344
+ def pricing_part(pricing_data, key, label)
345
+ key = Array(key).find { |candidate| pricing_data[candidate] }
346
+ return unless key
346
347
 
347
- '-'
348
+ "#{label}: $#{format('%.2f', pricing_data[key])}"
348
349
  end
349
350
 
350
351
  def generate_aliases # rubocop:disable Metrics/PerceivedComplexity
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.14.1
4
+ version: 1.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carmine Paolino
@@ -240,6 +240,7 @@ files:
240
240
  - lib/ruby_llm/connection.rb
241
241
  - lib/ruby_llm/content.rb
242
242
  - lib/ruby_llm/context.rb
243
+ - lib/ruby_llm/cost.rb
243
244
  - lib/ruby_llm/embedding.rb
244
245
  - lib/ruby_llm/error.rb
245
246
  - lib/ruby_llm/image.rb
@@ -354,14 +355,28 @@ licenses:
354
355
  metadata:
355
356
  homepage_uri: https://rubyllm.com
356
357
  source_code_uri: https://github.com/crmne/ruby_llm
357
- changelog_uri: https://github.com/crmne/ruby_llm/commits/main
358
+ changelog_uri: https://github.com/crmne/ruby_llm/releases
358
359
  documentation_uri: https://rubyllm.com
359
360
  bug_tracker_uri: https://github.com/crmne/ruby_llm/issues
360
361
  funding_uri: https://github.com/sponsors/crmne
361
362
  rubygems_mfa_required: 'true'
362
363
  post_install_message: |
363
- Upgrading from RubyLLM < 1.14.x? Check the upgrade guide for new features and migration instructions
364
- --> https://rubyllm.com/upgrading/
364
+ RubyLLM 1.15 upgrade note:
365
+
366
+ Token accounting is now normalized across providers. `input_tokens` means
367
+ standard input tokens; prompt cache reads and writes are exposed separately
368
+ as `cache_read_tokens` and `cache_write_tokens`.
369
+
370
+ Need request-side input activity?
371
+ input_tokens + cache_read_tokens + cache_write_tokens
372
+
373
+ New cost helpers:
374
+ response.cost.total
375
+ chat.cost.total
376
+ agent.cost.total
377
+
378
+ Upgrading from RubyLLM < 1.15? Read the full upgrade guide:
379
+ https://rubyllm.com/upgrading/
365
380
  rdoc_options: []
366
381
  require_paths:
367
382
  - lib