ruby_llm 1.14.0 → 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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -5
  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 +47 -23
  8. data/lib/ruby_llm/active_record/message_methods.rb +19 -14
  9. data/lib/ruby_llm/active_record/model_methods.rb +7 -9
  10. data/lib/ruby_llm/active_record/payload_helpers.rb +29 -0
  11. data/lib/ruby_llm/active_record/tool_call_methods.rb +5 -15
  12. data/lib/ruby_llm/agent.rb +3 -2
  13. data/lib/ruby_llm/aliases.json +53 -14
  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 +26511 -24930
  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/capabilities.rb +1 -133
  26. data/lib/ruby_llm/providers/anthropic/models.rb +2 -8
  27. data/lib/ruby_llm/providers/anthropic/tools.rb +4 -1
  28. data/lib/ruby_llm/providers/bedrock/chat.rb +24 -13
  29. data/lib/ruby_llm/providers/bedrock/streaming.rb +4 -1
  30. data/lib/ruby_llm/providers/deepseek/capabilities.rb +1 -119
  31. data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -215
  32. data/lib/ruby_llm/providers/gemini/chat.rb +8 -1
  33. data/lib/ruby_llm/providers/gemini/images.rb +2 -2
  34. data/lib/ruby_llm/providers/gemini/models.rb +2 -4
  35. data/lib/ruby_llm/providers/gemini/streaming.rb +4 -1
  36. data/lib/ruby_llm/providers/gemini/tools.rb +3 -1
  37. data/lib/ruby_llm/providers/mistral/capabilities.rb +6 -1
  38. data/lib/ruby_llm/providers/mistral/chat.rb +55 -4
  39. data/lib/ruby_llm/providers/openai/capabilities.rb +157 -195
  40. data/lib/ruby_llm/providers/openai/chat.rb +45 -6
  41. data/lib/ruby_llm/providers/openai/images.rb +58 -6
  42. data/lib/ruby_llm/providers/openai/models.rb +2 -4
  43. data/lib/ruby_llm/providers/openai/streaming.rb +5 -6
  44. data/lib/ruby_llm/providers/openrouter/chat.rb +30 -6
  45. data/lib/ruby_llm/providers/openrouter/images.rb +2 -2
  46. data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
  47. data/lib/ruby_llm/providers/openrouter/streaming.rb +5 -6
  48. data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
  49. data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
  50. data/lib/ruby_llm/railtie.rb +6 -0
  51. data/lib/ruby_llm/tokens.rb +8 -0
  52. data/lib/ruby_llm/tool.rb +24 -7
  53. data/lib/ruby_llm/version.rb +1 -1
  54. data/lib/ruby_llm.rb +2 -4
  55. data/lib/tasks/models.rake +13 -12
  56. metadata +21 -5
@@ -356,7 +356,8 @@ module RubyLLM
356
356
  text_standard = {
357
357
  input_per_million: cost[:input],
358
358
  output_per_million: cost[:output],
359
- cached_input_per_million: cost[:cache_read],
359
+ cache_read_input_per_million: cost[:cache_read],
360
+ cache_write_input_per_million: cost[:cache_write],
360
361
  reasoning_output_per_million: cost[:reasoning]
361
362
  }.compact
362
363
 
@@ -87,7 +87,10 @@
87
87
  "type": "object",
88
88
  "properties": {
89
89
  "input_per_million": {"type": "number", "minimum": 0},
90
+ "cache_read_input_per_million": {"type": "number", "minimum": 0},
91
+ "cache_write_input_per_million": {"type": "number", "minimum": 0},
90
92
  "cached_input_per_million": {"type": "number", "minimum": 0},
93
+ "cache_creation_input_per_million": {"type": "number", "minimum": 0},
91
94
  "output_per_million": {"type": "number", "minimum": 0},
92
95
  "reasoning_output_per_million": {"type": "number", "minimum": 0}
93
96
  }
@@ -81,9 +81,10 @@ module RubyLLM
81
81
  parse_moderation_response(response, model:)
82
82
  end
83
83
 
84
- def paint(prompt, model:, size:)
85
- payload = render_image_payload(prompt, model:, size:)
86
- response = @connection.post images_url, payload
84
+ def paint(prompt, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Metrics/ParameterLists
85
+ validate_paint_inputs!(with:, mask:)
86
+ payload = render_image_payload(prompt, model:, size:, with:, mask:, params:)
87
+ response = @connection.post images_url(with:, mask:), payload
87
88
  parse_image_response(response, model:)
88
89
  end
89
90
 
@@ -225,6 +226,12 @@ module RubyLLM
225
226
 
226
227
  private
227
228
 
229
+ def validate_paint_inputs!(with:, mask:)
230
+ return if with.nil? && mask.nil?
231
+
232
+ raise UnsupportedAttachmentError, "#{name} does not support image references in paint"
233
+ end
234
+
228
235
  def build_audio_file_part(file_path)
229
236
  expanded_path = File.expand_path(file_path)
230
237
  mime_type = Marcel::MimeType.for(Pathname.new(expanded_path))
@@ -3,37 +3,10 @@
3
3
  module RubyLLM
4
4
  module Providers
5
5
  class Anthropic
6
- # Determines capabilities and pricing for Anthropic models
6
+ # Provider-level capability checks used outside the model registry.
7
7
  module Capabilities
8
8
  module_function
9
9
 
10
- def determine_context_window(_model_id)
11
- 200_000
12
- end
13
-
14
- def determine_max_tokens(model_id)
15
- case model_id
16
- when /claude-3-7-sonnet/, /claude-3-5/ then 8_192
17
- else 4_096
18
- end
19
- end
20
-
21
- def get_input_price(model_id)
22
- PRICES.dig(model_family(model_id), :input) || default_input_price
23
- end
24
-
25
- def get_output_price(model_id)
26
- PRICES.dig(model_family(model_id), :output) || default_output_price
27
- end
28
-
29
- def supports_vision?(model_id)
30
- !model_id.match?(/claude-[12]/)
31
- end
32
-
33
- def supports_functions?(model_id)
34
- !model_id.match?(/claude-[12]/)
35
- end
36
-
37
10
  def supports_tool_choice?(_model_id)
38
11
  true
39
12
  end
@@ -41,111 +14,6 @@ module RubyLLM
41
14
  def supports_tool_parallel_control?(_model_id)
42
15
  true
43
16
  end
44
-
45
- def supports_json_mode?(model_id)
46
- !model_id.match?(/claude-[12]/)
47
- end
48
-
49
- def supports_structured_output?(model_id)
50
- match = model_id.match(/claude-(?:sonnet|opus|haiku)-(\d+)-(\d+)/)
51
- return false unless match
52
-
53
- major = match[1].to_i
54
- minor = match[2].to_i
55
- major > 4 || (major == 4 && minor >= 5)
56
- end
57
-
58
- def supports_extended_thinking?(model_id)
59
- model_id.match?(/claude-3-7-sonnet/)
60
- end
61
-
62
- def model_family(model_id)
63
- case model_id
64
- when /claude-3-7-sonnet/ then 'claude-3-7-sonnet'
65
- when /claude-3-5-sonnet/ then 'claude-3-5-sonnet'
66
- when /claude-3-5-haiku/ then 'claude-3-5-haiku'
67
- when /claude-3-opus/ then 'claude-3-opus'
68
- when /claude-3-sonnet/ then 'claude-3-sonnet'
69
- when /claude-3-haiku/ then 'claude-3-haiku'
70
- else 'claude-2'
71
- end
72
- end
73
-
74
- def model_type(_)
75
- 'chat'
76
- end
77
-
78
- PRICES = {
79
- 'claude-3-7-sonnet': { input: 3.0, output: 15.0 },
80
- 'claude-3-5-sonnet': { input: 3.0, output: 15.0 },
81
- 'claude-3-5-haiku': { input: 0.80, output: 4.0 },
82
- 'claude-3-opus': { input: 15.0, output: 75.0 },
83
- 'claude-3-haiku': { input: 0.25, output: 1.25 },
84
- 'claude-2': { input: 3.0, output: 15.0 }
85
- }.freeze
86
-
87
- def default_input_price
88
- 3.0
89
- end
90
-
91
- def default_output_price
92
- 15.0
93
- end
94
-
95
- def modalities_for(model_id)
96
- modalities = {
97
- input: ['text'],
98
- output: ['text']
99
- }
100
-
101
- unless model_id.match?(/claude-[12]/)
102
- modalities[:input] << 'image'
103
- modalities[:input] << 'pdf'
104
- end
105
-
106
- modalities
107
- end
108
-
109
- def capabilities_for(model_id)
110
- capabilities = ['streaming']
111
-
112
- unless model_id.match?(/claude-[12]/)
113
- capabilities << 'function_calling'
114
- capabilities << 'batch'
115
- end
116
-
117
- capabilities << 'structured_output' if supports_structured_output?(model_id)
118
- capabilities << 'reasoning' if model_id.match?(/claude-3-7-sonnet|claude-(?:sonnet|opus|haiku)-4/)
119
- capabilities << 'citations' if model_id.match?(/claude-3\.5|claude-3-7/)
120
- capabilities
121
- end
122
-
123
- def pricing_for(model_id)
124
- family = model_family(model_id)
125
- prices = PRICES.fetch(family.to_sym, { input: default_input_price, output: default_output_price })
126
-
127
- standard_pricing = {
128
- input_per_million: prices[:input],
129
- output_per_million: prices[:output]
130
- }
131
-
132
- batch_pricing = {
133
- input_per_million: prices[:input] * 0.5,
134
- output_per_million: prices[:output] * 0.5
135
- }
136
-
137
- if model_id.match?(/claude-3-7/)
138
- standard_pricing[:reasoning_output_per_million] = prices[:output] * 2.5
139
- batch_pricing[:reasoning_output_per_million] = prices[:output] * 1.25
140
- end
141
-
142
- {
143
- text_tokens: {
144
- standard: standard_pricing,
145
- batch: batch_pricing
146
- }
147
- }
148
- end
149
17
  end
150
18
  end
151
19
  end
@@ -11,21 +11,15 @@ module RubyLLM
11
11
  'v1/models'
12
12
  end
13
13
 
14
- def parse_list_models_response(response, slug, capabilities)
14
+ def parse_list_models_response(response, slug, _capabilities)
15
15
  Array(response.body['data']).map do |model_data|
16
16
  model_id = model_data['id']
17
17
 
18
18
  Model::Info.new(
19
19
  id: model_id,
20
- name: model_data['display_name'],
20
+ name: model_data['display_name'] || model_id,
21
21
  provider: slug,
22
- family: capabilities.model_family(model_id),
23
22
  created_at: Time.parse(model_data['created_at']),
24
- context_window: capabilities.determine_context_window(model_id),
25
- max_output_tokens: capabilities.determine_max_tokens(model_id),
26
- modalities: capabilities.modalities_for(model_id),
27
- capabilities: capabilities.capabilities_for(model_id),
28
- pricing: capabilities.pricing_for(model_id),
29
23
  metadata: {}
30
24
  )
31
25
  end
@@ -45,10 +45,13 @@ module RubyLLM
45
45
  end
46
46
 
47
47
  def format_tool_result_block(msg)
48
+ content = msg.content
49
+ content = '(no output)' if content.nil? || (content.respond_to?(:empty?) && content.empty?)
50
+
48
51
  {
49
52
  type: 'tool_result',
50
53
  tool_use_id: msg.tool_call_id,
51
- content: Media.format_content(msg.content)
54
+ content: Media.format_content(content)
52
55
  }
53
56
  end
54
57
 
@@ -56,7 +56,7 @@ module RubyLLM
56
56
  content: parse_text_content(content_blocks),
57
57
  thinking: Thinking.build(text: thinking_text, signature: thinking_signature),
58
58
  tool_calls: parse_tool_calls(content_blocks),
59
- input_tokens: usage['inputTokens'],
59
+ input_tokens: input_tokens(usage),
60
60
  output_tokens: usage['outputTokens'],
61
61
  cached_tokens: usage['cacheReadInputTokens'],
62
62
  cache_creation_tokens: usage['cacheWriteInputTokens'],
@@ -66,6 +66,13 @@ module RubyLLM
66
66
  )
67
67
  end
68
68
 
69
+ def input_tokens(usage)
70
+ input_tokens = usage['inputTokens']
71
+ return unless input_tokens
72
+
73
+ [input_tokens.to_i - usage['cacheReadInputTokens'].to_i - usage['cacheWriteInputTokens'].to_i, 0].max
74
+ end
75
+
69
76
  def render_messages(messages)
70
77
  rendered = []
71
78
  tool_result_blocks = []
@@ -154,19 +161,23 @@ module RubyLLM
154
161
 
155
162
  def render_tool_result_content(content)
156
163
  return render_raw_tool_result_content(content.value) if content.is_a?(RubyLLM::Content::Raw)
164
+ return [{ json: content }] if content.is_a?(Hash) || content.is_a?(Array)
165
+ return render_content_tool_result_content(content) if content.is_a?(RubyLLM::Content)
157
166
 
158
- if content.is_a?(Hash) || content.is_a?(Array)
159
- [{ json: content }]
160
- elsif content.is_a?(RubyLLM::Content)
161
- blocks = []
162
- blocks << { text: content.text } if content.text
163
- content.attachments.each do |attachment|
164
- blocks << { text: attachment.for_llm }
165
- end
166
- blocks
167
- else
168
- [{ text: content.to_s }]
169
- end
167
+ [text_tool_result_block(content)]
168
+ end
169
+
170
+ def render_content_tool_result_content(content)
171
+ blocks = []
172
+ blocks << text_tool_result_block(content.text) unless content.text.to_s.empty?
173
+ content.attachments.each { |attachment| blocks << text_tool_result_block(attachment.for_llm) }
174
+ blocks.empty? ? [text_tool_result_block(nil)] : blocks
175
+ end
176
+
177
+ def text_tool_result_block(text)
178
+ text = text.to_s
179
+ text = '(no output)' if text.empty?
180
+ { text: text }
170
181
  end
171
182
 
172
183
  def render_raw_tool_result_content(raw_value)
@@ -158,7 +158,10 @@ module RubyLLM
158
158
  end
159
159
 
160
160
  def extract_input_tokens(metadata_usage, usage, message_usage)
161
- metadata_usage['inputTokens'] || usage['inputTokens'] || message_usage['input_tokens']
161
+ bedrock_usage = metadata_usage['inputTokens'] ? metadata_usage : usage
162
+ return Bedrock::Chat.input_tokens(bedrock_usage) if bedrock_usage['inputTokens']
163
+
164
+ message_usage['input_tokens']
162
165
  end
163
166
 
164
167
  def extract_output_tokens(metadata_usage, usage)
@@ -3,44 +3,10 @@
3
3
  module RubyLLM
4
4
  module Providers
5
5
  class DeepSeek
6
- # Determines capabilities and pricing for DeepSeek models
6
+ # Provider-level capability checks used outside the model registry.
7
7
  module Capabilities
8
8
  module_function
9
9
 
10
- def context_window_for(model_id)
11
- case model_id
12
- when /deepseek-(?:chat|reasoner)/ then 64_000
13
- else 32_768
14
- end
15
- end
16
-
17
- def max_tokens_for(model_id)
18
- case model_id
19
- when /deepseek-(?:chat|reasoner)/ then 8_192
20
- else 4_096
21
- end
22
- end
23
-
24
- def input_price_for(model_id)
25
- PRICES.dig(model_family(model_id), :input_miss) || default_input_price
26
- end
27
-
28
- def output_price_for(model_id)
29
- PRICES.dig(model_family(model_id), :output) || default_output_price
30
- end
31
-
32
- def cache_hit_price_for(model_id)
33
- PRICES.dig(model_family(model_id), :input_hit) || default_cache_hit_price
34
- end
35
-
36
- def supports_vision?(_model_id)
37
- false
38
- end
39
-
40
- def supports_functions?(model_id)
41
- model_id.match?(/deepseek-chat/)
42
- end
43
-
44
10
  def supports_tool_choice?(_model_id)
45
11
  true
46
12
  end
@@ -48,90 +14,6 @@ module RubyLLM
48
14
  def supports_tool_parallel_control?(_model_id)
49
15
  false
50
16
  end
51
-
52
- def supports_json_mode?(_model_id)
53
- false
54
- end
55
-
56
- def format_display_name(model_id)
57
- case model_id
58
- when 'deepseek-chat' then 'DeepSeek V3'
59
- when 'deepseek-reasoner' then 'DeepSeek R1'
60
- else
61
- model_id.split('-')
62
- .map(&:capitalize)
63
- .join(' ')
64
- end
65
- end
66
-
67
- def model_type(_model_id)
68
- 'chat'
69
- end
70
-
71
- def model_family(model_id)
72
- case model_id
73
- when /deepseek-reasoner/ then :reasoner
74
- else :chat
75
- end
76
- end
77
-
78
- PRICES = {
79
- chat: {
80
- input_hit: 0.07,
81
- input_miss: 0.27,
82
- output: 1.10
83
- },
84
- reasoner: {
85
- input_hit: 0.14,
86
- input_miss: 0.55,
87
- output: 2.19
88
- }
89
- }.freeze
90
-
91
- def default_input_price
92
- 0.27
93
- end
94
-
95
- def default_output_price
96
- 1.10
97
- end
98
-
99
- def default_cache_hit_price
100
- 0.07
101
- end
102
-
103
- def modalities_for(_model_id)
104
- {
105
- input: ['text'],
106
- output: ['text']
107
- }
108
- end
109
-
110
- def capabilities_for(model_id)
111
- capabilities = ['streaming']
112
-
113
- capabilities << 'function_calling' if model_id.match?(/deepseek-chat/)
114
-
115
- capabilities
116
- end
117
-
118
- def pricing_for(model_id)
119
- family = model_family(model_id)
120
- prices = PRICES.fetch(family, { input_miss: default_input_price, output: default_output_price })
121
-
122
- standard_pricing = {
123
- input_per_million: prices[:input_miss],
124
- output_per_million: prices[:output]
125
- }
126
-
127
- standard_pricing[:cached_input_per_million] = prices[:input_hit] if prices[:input_hit]
128
-
129
- {
130
- text_tokens: {
131
- standard: standard_pricing
132
- }
133
- }
134
- end
135
17
  end
136
18
  end
137
19
  end