ruby_llm 1.3.0rc1 → 1.3.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ruby_llm/active_record/acts_as.rb +66 -148
  3. data/lib/ruby_llm/aliases.json +170 -42
  4. data/lib/ruby_llm/attachment.rb +164 -0
  5. data/lib/ruby_llm/chat.rb +12 -4
  6. data/lib/ruby_llm/configuration.rb +5 -1
  7. data/lib/ruby_llm/connection.rb +28 -2
  8. data/lib/ruby_llm/content.rb +9 -40
  9. data/lib/ruby_llm/error.rb +1 -0
  10. data/lib/ruby_llm/image.rb +2 -3
  11. data/lib/ruby_llm/message.rb +2 -2
  12. data/lib/ruby_llm/mime_type.rb +67 -0
  13. data/lib/ruby_llm/model/info.rb +101 -0
  14. data/lib/ruby_llm/model/modalities.rb +22 -0
  15. data/lib/ruby_llm/model/pricing.rb +51 -0
  16. data/lib/ruby_llm/model/pricing_category.rb +48 -0
  17. data/lib/ruby_llm/model/pricing_tier.rb +34 -0
  18. data/lib/ruby_llm/model.rb +7 -0
  19. data/lib/ruby_llm/models.json +2220 -1915
  20. data/lib/ruby_llm/models.rb +20 -20
  21. data/lib/ruby_llm/provider.rb +1 -1
  22. data/lib/ruby_llm/providers/anthropic/media.rb +14 -3
  23. data/lib/ruby_llm/providers/anthropic/models.rb +1 -1
  24. data/lib/ruby_llm/providers/bedrock/media.rb +7 -4
  25. data/lib/ruby_llm/providers/bedrock/models.rb +2 -2
  26. data/lib/ruby_llm/providers/gemini/images.rb +3 -2
  27. data/lib/ruby_llm/providers/gemini/media.rb +12 -24
  28. data/lib/ruby_llm/providers/gemini/models.rb +1 -1
  29. data/lib/ruby_llm/providers/ollama/media.rb +8 -4
  30. data/lib/ruby_llm/providers/openai/capabilities.rb +1 -1
  31. data/lib/ruby_llm/providers/openai/images.rb +3 -2
  32. data/lib/ruby_llm/providers/openai/media.rb +18 -8
  33. data/lib/ruby_llm/providers/openai/models.rb +1 -1
  34. data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
  35. data/lib/ruby_llm/streaming.rb +46 -11
  36. data/lib/ruby_llm/utils.rb +14 -9
  37. data/lib/ruby_llm/version.rb +1 -1
  38. data/lib/tasks/aliases.rake +235 -0
  39. data/lib/tasks/release.rake +32 -0
  40. metadata +40 -25
  41. data/lib/ruby_llm/attachments/audio.rb +0 -12
  42. data/lib/ruby_llm/attachments/image.rb +0 -9
  43. data/lib/ruby_llm/attachments/pdf.rb +0 -9
  44. data/lib/ruby_llm/attachments.rb +0 -78
  45. data/lib/ruby_llm/mime_types.rb +0 -713
  46. data/lib/ruby_llm/model_info.rb +0 -237
  47. data/lib/tasks/{models.rake → models_update.rake} +13 -13
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ namespace :aliases do # rubocop:disable Metrics/BlockLength
6
+ desc 'Generate aliases.json from models in the registry'
7
+ task :generate do # rubocop:disable Metrics/BlockLength
8
+ require 'ruby_llm'
9
+
10
+ # Group models by provider
11
+ models = Hash.new { |h, k| h[k] = [] }
12
+
13
+ RubyLLM.models.all.each do |model|
14
+ models[model.provider] << model.id
15
+ end
16
+
17
+ aliases = {}
18
+
19
+ # OpenAI models
20
+ models['openai'].each do |model|
21
+ openrouter_model = "openai/#{model}"
22
+ next unless models['openrouter'].include?(openrouter_model)
23
+
24
+ alias_key = model.gsub('-latest', '')
25
+ aliases[alias_key] = {
26
+ 'openai' => model,
27
+ 'openrouter' => openrouter_model
28
+ }
29
+ end
30
+
31
+ # Anthropic models - group by base name and find latest
32
+ anthropic_latest = group_anthropic_models_by_base_name(models['anthropic'])
33
+
34
+ anthropic_latest.each do |base_name, latest_model|
35
+ # Check OpenRouter naming patterns for the BASE NAME (not the full dated model)
36
+ openrouter_variants = [
37
+ "anthropic/#{base_name}", # anthropic/claude-3-5-sonnet
38
+ "anthropic/#{base_name.gsub(/-(\d)/, '.\1')}", # anthropic/claude-3.5-sonnet
39
+ "anthropic/#{base_name.gsub(/claude-(\d+)-(\d+)/, 'claude-\1.\2')}", # claude-3-5 -> claude-3.5
40
+ "anthropic/#{base_name.gsub(/(\d+)-(\d+)/, '\1.\2')}" # any X-Y -> X.Y pattern
41
+ ]
42
+
43
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
44
+
45
+ # Find corresponding Bedrock model
46
+ bedrock_model = find_best_bedrock_model(latest_model, models['bedrock'])
47
+
48
+ # Create alias if we have any match (OpenRouter OR Bedrock) OR if it's Anthropic-only
49
+ next unless openrouter_model || bedrock_model || models['anthropic'].include?(latest_model)
50
+
51
+ aliases[base_name] = {
52
+ 'anthropic' => latest_model
53
+ }
54
+
55
+ aliases[base_name]['openrouter'] = openrouter_model if openrouter_model
56
+ aliases[base_name]['bedrock'] = bedrock_model if bedrock_model
57
+ end
58
+
59
+ # Also check if Bedrock has models that Anthropic doesn't
60
+ models['bedrock'].each do |bedrock_model|
61
+ next unless bedrock_model.start_with?('anthropic.')
62
+
63
+ # Extract the Claude model name
64
+ next unless bedrock_model =~ /anthropic\.(claude-[\d\.]+-[a-z]+)/
65
+
66
+ base_name = Regexp.last_match(1)
67
+ # Normalize to Anthropic naming convention
68
+ anthropic_name = base_name.gsub('.', '-')
69
+
70
+ # Skip if we already have an alias for this
71
+ next if aliases[anthropic_name]
72
+
73
+ # Check if this model exists in OpenRouter
74
+ openrouter_variants = [
75
+ "anthropic/#{anthropic_name}",
76
+ "anthropic/#{base_name}" # Keep the dots
77
+ ]
78
+
79
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
80
+
81
+ aliases[anthropic_name] = {
82
+ 'bedrock' => bedrock_model
83
+ }
84
+
85
+ aliases[anthropic_name]['anthropic'] = anthropic_name if models['anthropic'].include?(anthropic_name)
86
+ aliases[anthropic_name]['openrouter'] = openrouter_model if openrouter_model
87
+ end
88
+
89
+ # Gemini models
90
+ models['gemini'].each do |model|
91
+ # OpenRouter uses "google/" prefix and sometimes different naming
92
+ openrouter_variants = [
93
+ "google/#{model}",
94
+ "google/#{model.gsub('gemini-', 'gemini-').gsub('.', '-')}",
95
+ "google/#{model.gsub('gemini-', 'gemini-')}"
96
+ ]
97
+
98
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
99
+ next unless openrouter_model
100
+
101
+ alias_key = model.gsub('-latest', '')
102
+ aliases[alias_key] = {
103
+ 'gemini' => model,
104
+ 'openrouter' => openrouter_model
105
+ }
106
+ end
107
+
108
+ # DeepSeek models
109
+ models['deepseek'].each do |model|
110
+ openrouter_model = "deepseek/#{model}"
111
+ next unless models['openrouter'].include?(openrouter_model)
112
+
113
+ alias_key = model.gsub('-latest', '')
114
+ aliases[alias_key] = {
115
+ 'deepseek' => model,
116
+ 'openrouter' => openrouter_model
117
+ }
118
+ end
119
+
120
+ # Write the result
121
+ sorted_aliases = aliases.sort.to_h
122
+ File.write('lib/ruby_llm/aliases.json', JSON.pretty_generate(sorted_aliases))
123
+
124
+ puts "Generated #{sorted_aliases.size} aliases"
125
+ end
126
+
127
+ def group_anthropic_models_by_base_name(anthropic_models) # rubocop:disable Rake/MethodDefinitionInTask
128
+ grouped = Hash.new { |h, k| h[k] = [] }
129
+
130
+ anthropic_models.each do |model|
131
+ base_name = extract_base_name(model)
132
+ grouped[base_name] << model
133
+ end
134
+
135
+ # Find the latest model for each base name
136
+ latest_models = {}
137
+ grouped.each do |base_name, model_list|
138
+ if model_list.size == 1
139
+ latest_models[base_name] = model_list.first
140
+ else
141
+ # Sort by date and take the latest
142
+ latest_model = model_list.max_by { |model| extract_date_from_model(model) }
143
+ latest_models[base_name] = latest_model
144
+ end
145
+ end
146
+
147
+ latest_models
148
+ end
149
+
150
+ def extract_base_name(model) # rubocop:disable Rake/MethodDefinitionInTask
151
+ # Remove date suffix (YYYYMMDD) from model name
152
+ if model =~ /^(.+)-(\d{8})$/
153
+ Regexp.last_match(1)
154
+ else
155
+ # Models without date suffix (like claude-2.0, claude-2.1)
156
+ model
157
+ end
158
+ end
159
+
160
+ def extract_date_from_model(model) # rubocop:disable Rake/MethodDefinitionInTask
161
+ # Extract date for comparison, return '00000000' for models without dates
162
+ if model =~ /-(\d{8})$/
163
+ Regexp.last_match(1)
164
+ else
165
+ '00000000' # Ensures models without dates sort before dated ones
166
+ end
167
+ end
168
+
169
+ def find_best_bedrock_model(anthropic_model, bedrock_models) # rubocop:disable Metrics/PerceivedComplexity,Rake/MethodDefinitionInTask
170
+ # Special mapping for Claude 2.x models
171
+ base_pattern = case anthropic_model
172
+ when 'claude-2.0', 'claude-2'
173
+ 'claude-v2'
174
+ when 'claude-2.1'
175
+ 'claude-v2:1'
176
+ when 'claude-instant-v1', 'claude-instant'
177
+ 'claude-instant'
178
+ else
179
+ # For Claude 3+ models, extract base name
180
+ extract_base_name(anthropic_model)
181
+ end
182
+
183
+ # Find all matching Bedrock models by stripping provider prefix and comparing base name
184
+ matching_models = bedrock_models.select do |bedrock_model|
185
+ # Strip any provider prefix (anthropic. or us.anthropic.)
186
+ model_without_prefix = bedrock_model.sub(/^(?:us\.)?anthropic\./, '')
187
+ model_without_prefix.start_with?(base_pattern)
188
+ end
189
+
190
+ return nil if matching_models.empty?
191
+
192
+ # Get model info to check context window
193
+ begin
194
+ model_info = RubyLLM.models.find(anthropic_model)
195
+ target_context = model_info.context_window
196
+ rescue StandardError
197
+ target_context = nil
198
+ end
199
+
200
+ # If we have context window info, try to match it
201
+ if target_context
202
+ # Convert to k format (200000 -> 200k)
203
+ target_k = target_context / 1000
204
+
205
+ # Find models with this specific context window
206
+ with_context = matching_models.select do |m|
207
+ m.include?(":#{target_k}k") || m.include?(":0:#{target_k}k")
208
+ end
209
+
210
+ return with_context.first if with_context.any?
211
+ end
212
+
213
+ # Otherwise, pick the one with the highest context window or latest version
214
+ matching_models.min_by do |model|
215
+ # Extract context window if specified
216
+ context_priority = if model =~ /:(?:\d+:)?(\d+)k/
217
+ -Regexp.last_match(1).to_i # Negative for descending sort
218
+ else
219
+ 0 # No context specified
220
+ end
221
+
222
+ # Extract version if present
223
+ version_priority = if model =~ /-v(\d+):/
224
+ -Regexp.last_match(1).to_i # Negative for descending sort (latest version first)
225
+ else
226
+ 0
227
+ end
228
+
229
+ # Prefer models with explicit context windows
230
+ has_context_priority = model.include?('k') ? -1 : 0
231
+
232
+ [has_context_priority, context_priority, version_priority]
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :release do
4
+ desc 'Verify cassettes are fresh enough for release'
5
+ task :verify_cassettes do
6
+ max_age_days = 1
7
+ cassette_dir = 'spec/fixtures/vcr_cassettes'
8
+ stale_cassettes = []
9
+
10
+ Dir.glob("#{cassette_dir}/**/*.yml").each do |cassette|
11
+ age_days = (Time.now - File.mtime(cassette)) / 86_400
12
+
13
+ next unless age_days > max_age_days
14
+
15
+ stale_cassettes << {
16
+ file: File.basename(cassette),
17
+ age: age_days.round(1)
18
+ }
19
+ end
20
+
21
+ if stale_cassettes.any?
22
+ puts "\n❌ Found stale cassettes (older than #{max_age_days} days):"
23
+ stale_cassettes.each do |c|
24
+ puts " - #{c[:file]} (#{c[:age]} days old)"
25
+ end
26
+ puts "\nRun locally: bundle exec rspec"
27
+ exit 1
28
+ else
29
+ puts "✅ All cassettes are fresh (< #{max_age_days} days old)"
30
+ end
31
+ end
32
+ end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0rc1
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carmine Paolino
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-05-12 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: base64
@@ -42,58 +41,72 @@ dependencies:
42
41
  name: faraday
43
42
  requirement: !ruby/object:Gem::Requirement
44
43
  requirements:
45
- - - "~>"
44
+ - - ">="
46
45
  - !ruby/object:Gem::Version
47
- version: '2'
46
+ version: 1.10.0
48
47
  type: :runtime
49
48
  prerelease: false
50
49
  version_requirements: !ruby/object:Gem::Requirement
51
50
  requirements:
52
- - - "~>"
51
+ - - ">="
53
52
  - !ruby/object:Gem::Version
54
- version: '2'
53
+ version: 1.10.0
55
54
  - !ruby/object:Gem::Dependency
56
55
  name: faraday-multipart
57
56
  requirement: !ruby/object:Gem::Requirement
58
57
  requirements:
59
- - - "~>"
58
+ - - ">="
60
59
  - !ruby/object:Gem::Version
61
60
  version: '1'
62
61
  type: :runtime
63
62
  prerelease: false
64
63
  version_requirements: !ruby/object:Gem::Requirement
65
64
  requirements:
66
- - - "~>"
65
+ - - ">="
67
66
  - !ruby/object:Gem::Version
68
67
  version: '1'
69
68
  - !ruby/object:Gem::Dependency
70
69
  name: faraday-net_http
71
70
  requirement: !ruby/object:Gem::Requirement
72
71
  requirements:
73
- - - "~>"
72
+ - - ">="
74
73
  - !ruby/object:Gem::Version
75
- version: '3'
74
+ version: '1'
76
75
  type: :runtime
77
76
  prerelease: false
78
77
  version_requirements: !ruby/object:Gem::Requirement
79
78
  requirements:
80
- - - "~>"
79
+ - - ">="
81
80
  - !ruby/object:Gem::Version
82
- version: '3'
81
+ version: '1'
83
82
  - !ruby/object:Gem::Dependency
84
83
  name: faraday-retry
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '1'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '1'
96
+ - !ruby/object:Gem::Dependency
97
+ name: marcel
85
98
  requirement: !ruby/object:Gem::Requirement
86
99
  requirements:
87
100
  - - "~>"
88
101
  - !ruby/object:Gem::Version
89
- version: '2'
102
+ version: '1.0'
90
103
  type: :runtime
91
104
  prerelease: false
92
105
  version_requirements: !ruby/object:Gem::Requirement
93
106
  requirements:
94
107
  - - "~>"
95
108
  - !ruby/object:Gem::Version
96
- version: '2'
109
+ version: '1.0'
97
110
  - !ruby/object:Gem::Dependency
98
111
  name: zeitwerk
99
112
  requirement: !ruby/object:Gem::Requirement
@@ -126,10 +139,7 @@ files:
126
139
  - lib/ruby_llm/active_record/acts_as.rb
127
140
  - lib/ruby_llm/aliases.json
128
141
  - lib/ruby_llm/aliases.rb
129
- - lib/ruby_llm/attachments.rb
130
- - lib/ruby_llm/attachments/audio.rb
131
- - lib/ruby_llm/attachments/image.rb
132
- - lib/ruby_llm/attachments/pdf.rb
142
+ - lib/ruby_llm/attachment.rb
133
143
  - lib/ruby_llm/chat.rb
134
144
  - lib/ruby_llm/chunk.rb
135
145
  - lib/ruby_llm/configuration.rb
@@ -140,8 +150,13 @@ files:
140
150
  - lib/ruby_llm/error.rb
141
151
  - lib/ruby_llm/image.rb
142
152
  - lib/ruby_llm/message.rb
143
- - lib/ruby_llm/mime_types.rb
144
- - lib/ruby_llm/model_info.rb
153
+ - lib/ruby_llm/mime_type.rb
154
+ - lib/ruby_llm/model.rb
155
+ - lib/ruby_llm/model/info.rb
156
+ - lib/ruby_llm/model/modalities.rb
157
+ - lib/ruby_llm/model/pricing.rb
158
+ - lib/ruby_llm/model/pricing_category.rb
159
+ - lib/ruby_llm/model/pricing_tier.rb
145
160
  - lib/ruby_llm/models.json
146
161
  - lib/ruby_llm/models.rb
147
162
  - lib/ruby_llm/provider.rb
@@ -198,8 +213,10 @@ files:
198
213
  - lib/ruby_llm/tool_call.rb
199
214
  - lib/ruby_llm/utils.rb
200
215
  - lib/ruby_llm/version.rb
201
- - lib/tasks/models.rake
216
+ - lib/tasks/aliases.rake
202
217
  - lib/tasks/models_docs.rake
218
+ - lib/tasks/models_update.rake
219
+ - lib/tasks/release.rake
203
220
  - lib/tasks/vcr.rake
204
221
  homepage: https://rubyllm.com
205
222
  licenses:
@@ -211,7 +228,6 @@ metadata:
211
228
  documentation_uri: https://rubyllm.com
212
229
  bug_tracker_uri: https://github.com/crmne/ruby_llm/issues
213
230
  rubygems_mfa_required: 'true'
214
- post_install_message:
215
231
  rdoc_options: []
216
232
  require_paths:
217
233
  - lib
@@ -226,8 +242,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
226
242
  - !ruby/object:Gem::Version
227
243
  version: '0'
228
244
  requirements: []
229
- rubygems_version: 3.5.22
230
- signing_key:
245
+ rubygems_version: 3.6.7
231
246
  specification_version: 4
232
247
  summary: A single delightful Ruby way to work with AI.
233
248
  test_files: []
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RubyLLM
4
- module Attachments
5
- # Represents an audio attachment
6
- class Audio < Base
7
- def format
8
- File.extname(@source).downcase.delete('.') || 'wav'
9
- end
10
- end
11
- end
12
- end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RubyLLM
4
- module Attachments
5
- # Represents an audio attachment
6
- class Image < Base
7
- end
8
- end
9
- end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RubyLLM
4
- module Attachments
5
- # Represents a PDF attachment
6
- class PDF < Base
7
- end
8
- end
9
- end
@@ -1,78 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RubyLLM
4
- # A module for handling attachments and file operations in RubyLLM.
5
- module Attachments
6
- # Base class for attachments
7
- class Base
8
- attr_reader :source, :filename
9
-
10
- def initialize(source, filename: nil)
11
- @source = source
12
- @filename = filename ||
13
- (@source.respond_to?(:original_filename) && @source.original_filename) ||
14
- (@source.respond_to?(:path) && File.basename(@source.path)) ||
15
- (@source.is_a?(String) && File.basename(@source.split('?').first)) || # Basic URL basename
16
- nil
17
- end
18
-
19
- def url?
20
- @source.is_a?(String) && @source.match?(%r{^https?://})
21
- end
22
-
23
- def file_path?
24
- @source.is_a?(String) && !url?
25
- end
26
-
27
- def io_like?
28
- @source.respond_to?(:read) && !file_path?
29
- end
30
-
31
- def content
32
- return @content if defined?(@content) && !@content.nil?
33
-
34
- if url?
35
- fetch_content
36
- elsif file_path?
37
- load_content_from_path
38
- elsif io_like?
39
- load_content_from_io
40
- else
41
- RubyLLM.logger.warn "Attachment source is neither a String nor an IO-like object: #{@source}"
42
- nil
43
- end
44
-
45
- @content
46
- end
47
-
48
- def type
49
- self.class.name.demodulize.downcase
50
- end
51
-
52
- def encoded
53
- Base64.strict_encode64(content)
54
- end
55
-
56
- def mime_type
57
- RubyLLM::MimeTypes.detect_from_path(@filename)
58
- end
59
-
60
- private
61
-
62
- def fetch_content
63
- RubyLLM.logger.debug("Fetching content from URL: #{@source}")
64
- response = Faraday.get(@source)
65
- @content = response.body if response.success?
66
- end
67
-
68
- def load_content_from_path
69
- @content = File.read(File.expand_path(@source))
70
- end
71
-
72
- def load_content_from_io
73
- @source.rewind if source.respond_to? :rewind
74
- @content = @source.read
75
- end
76
- end
77
- end
78
- end