ruby_llm 1.6.3 → 1.6.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e454eaf845f9c4b9a03f6e5a12567e59d5621d1ff6db3e866be50e836de65c2
4
- data.tar.gz: 8e49e339287432cc9feee74ee9b0b49abffcc282bb6387fc9d2cdd6889f9c39c
3
+ metadata.gz: 36ae61f8e11926aa9a761d4e8ab644eab587edb9091322596ab1ed7e32d65ee4
4
+ data.tar.gz: b5818edd28a449035ed62f58a79465b8705460be012194525930716d5457d458
5
5
  SHA512:
6
- metadata.gz: 06e7fc0118d88631e1c41a9b4b0738b6f55b965ee4569643759489e81191317dfe4cba5e55dcf89d386ef68379b013a4a0a6c9b9b71f3dd3115f7f46186545f4
7
- data.tar.gz: 047a575afaa0cf37934e395bd49460edab5a07e29a8c7f085139c331c94d44bdc226c9c2b8781183d05a7ce6b81b08af943b86c9c3ee76f19188a491585d971b
6
+ metadata.gz: 35fbc3892899c17e12e239d3688542bdc032aee12d6f30d81fff8373f116eab53722cc4871654ebfe65cdd97806ec81f9903919e4a36076b2a18938d68942be0
7
+ data.tar.gz: cf38979b2cf7ea03fc7d4944130fc3023d4536635d28a41f41956ea6445fc21064554fd41e917e8a3694ee798bb7a90dd10f8761c4ba326e79afd38db589e473
data/README.md CHANGED
@@ -9,10 +9,10 @@
9
9
 
10
10
  Battle tested at [<picture><source media="(prefers-color-scheme: dark)" srcset="https://chatwithwork.com/logotype-dark.svg"><img src="https://chatwithwork.com/logotype.svg" alt="Chat with Work" height="30" align="absmiddle"></picture>](https://chatwithwork.com) — *Claude Code for your documents*
11
11
 
12
- [![Gem Version](https://badge.fury.io/rb/ruby_llm.svg?a=5)](https://badge.fury.io/rb/ruby_llm)
12
+ [![Gem Version](https://badge.fury.io/rb/ruby_llm.svg?a=6)](https://badge.fury.io/rb/ruby_llm)
13
13
  [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
14
14
  [![Gem Downloads](https://img.shields.io/gem/dt/ruby_llm)](https://rubygems.org/gems/ruby_llm)
15
- [![codecov](https://codecov.io/gh/crmne/ruby_llm/branch/main/graph/badge.svg)](https://codecov.io/gh/crmne/ruby_llm)
15
+ [![codecov](https://codecov.io/gh/crmne/ruby_llm/branch/main/graph/badge.svg?a=1)](https://codecov.io/gh/crmne/ruby_llm)
16
16
 
17
17
  <a href="https://trendshift.io/repositories/13640" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13640" alt="crmne%2Fruby_llm | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
18
18
  </div>
@@ -232,14 +232,21 @@ module RubyLLM
232
232
  @message = messages.create!(role: :assistant, content: '')
233
233
  end
234
234
 
235
- def persist_message_completion(message)
235
+ def persist_message_completion(message) # rubocop:disable Metrics/PerceivedComplexity
236
236
  return unless message
237
237
 
238
238
  tool_call_id = find_tool_call_id(message.tool_call_id) if message.tool_call_id
239
239
 
240
240
  transaction do
241
241
  content = message.content
242
- content = content.to_json if content.is_a?(Hash) || content.is_a?(Array)
242
+ attachments_to_persist = nil
243
+
244
+ if content.is_a?(RubyLLM::Content)
245
+ attachments_to_persist = content.attachments if content.attachments.any?
246
+ content = content.text
247
+ elsif content.is_a?(Hash) || content.is_a?(Array)
248
+ content = content.to_json
249
+ end
243
250
 
244
251
  @message.update!(
245
252
  role: message.role,
@@ -250,6 +257,8 @@ module RubyLLM
250
257
  )
251
258
  @message.write_attribute(@message.class.tool_call_foreign_key, tool_call_id) if tool_call_id
252
259
  @message.save!
260
+
261
+ persist_content(@message, attachments_to_persist) if attachments_to_persist
253
262
  persist_tool_calls(message.tool_calls) if message.tool_calls.present?
254
263
  end
255
264
  end
@@ -291,7 +300,7 @@ module RubyLLM
291
300
  def convert_to_active_storage_format(source)
292
301
  return if source.blank?
293
302
 
294
- attachment = RubyLLM::Attachment.new(source)
303
+ attachment = source.is_a?(RubyLLM::Attachment) ? source : RubyLLM::Attachment.new(source)
295
304
 
296
305
  {
297
306
  io: StringIO.new(attachment.content),
@@ -150,6 +150,10 @@
150
150
  "openai": "gpt-4o-2024-11-20",
151
151
  "openrouter": "openai/gpt-4o-2024-11-20"
152
152
  },
153
+ "gpt-4o-audio-preview": {
154
+ "openai": "gpt-4o-audio-preview",
155
+ "openrouter": "openai/gpt-4o-audio-preview"
156
+ },
153
157
  "gpt-4o-mini": {
154
158
  "openai": "gpt-4o-mini",
155
159
  "openrouter": "openai/gpt-4o-mini"
data/lib/ruby_llm/chat.rb CHANGED
@@ -181,7 +181,7 @@ module RubyLLM
181
181
  end
182
182
  end
183
183
 
184
- def handle_tool_calls(response, &)
184
+ def handle_tool_calls(response, &) # rubocop:disable Metrics/PerceivedComplexity
185
185
  halt_result = nil
186
186
 
187
187
  response.tool_calls.each_value do |tool_call|
@@ -189,7 +189,8 @@ module RubyLLM
189
189
  @on[:tool_call]&.call(tool_call)
190
190
  result = execute_tool tool_call
191
191
  @on[:tool_result]&.call(result)
192
- message = add_message role: :tool, content: result.to_s, tool_call_id: tool_call.id
192
+ content = result.is_a?(Content) ? result : result.to_s
193
+ message = add_message role: :tool, content:, tool_call_id: tool_call.id
193
194
  @on[:end_message]&.call(message)
194
195
 
195
196
  halt_result = result if result.is_a?(Tool::Halt)
@@ -46,7 +46,7 @@ module RubyLLM
46
46
  {
47
47
  type: 'tool_result',
48
48
  tool_use_id: msg.tool_call_id,
49
- content: msg.content
49
+ content: Media.format_content(msg.content)
50
50
  }
51
51
  end
52
52
 
@@ -62,7 +62,7 @@ module RubyLLM
62
62
  name: msg.tool_call_id,
63
63
  response: {
64
64
  name: msg.tool_call_id,
65
- content: msg.content
65
+ content: Media.format_content(msg.content)
66
66
  }
67
67
  }
68
68
  }]
@@ -86,31 +86,12 @@ module RubyLLM
86
86
  )
87
87
  end
88
88
 
89
- def convert_schema_to_gemini(schema) # rubocop:disable Metrics/PerceivedComplexity
89
+ def convert_schema_to_gemini(schema)
90
90
  return nil unless schema
91
91
 
92
- case schema[:type]
93
- when 'object'
94
- {
95
- type: 'OBJECT',
96
- properties: schema[:properties]&.transform_values { |prop| convert_schema_to_gemini(prop) } || {},
97
- required: schema[:required] || []
98
- }
99
- when 'array'
100
- {
101
- type: 'ARRAY',
102
- items: schema[:items] ? convert_schema_to_gemini(schema[:items]) : { type: 'STRING' }
103
- }
104
- when 'string'
105
- result = { type: 'STRING' }
106
- result[:enum] = schema[:enum] if schema[:enum]
107
- result
108
- when 'number', 'integer'
109
- { type: 'NUMBER' }
110
- when 'boolean'
111
- { type: 'BOOLEAN' }
112
- else
113
- { type: 'STRING' }
92
+ build_base_schema(schema).tap do |result|
93
+ result[:description] = schema[:description] if schema[:description]
94
+ apply_type_specific_attributes(result, schema)
114
95
  end
115
96
  end
116
97
 
@@ -137,6 +118,53 @@ module RubyLLM
137
118
  thoughts = data.dig('usageMetadata', 'thoughtsTokenCount') || 0
138
119
  candidates + thoughts
139
120
  end
121
+
122
+ def build_base_schema(schema)
123
+ case schema[:type]
124
+ when 'object'
125
+ build_object_schema(schema)
126
+ when 'array'
127
+ { type: 'ARRAY', items: schema[:items] ? convert_schema_to_gemini(schema[:items]) : { type: 'STRING' } }
128
+ when 'number'
129
+ { type: 'NUMBER' }
130
+ when 'integer'
131
+ { type: 'INTEGER' }
132
+ when 'boolean'
133
+ { type: 'BOOLEAN' }
134
+ else
135
+ { type: 'STRING' }
136
+ end
137
+ end
138
+
139
+ def build_object_schema(schema)
140
+ {
141
+ type: 'OBJECT',
142
+ properties: (schema[:properties] || {}).transform_values { |prop| convert_schema_to_gemini(prop) },
143
+ required: schema[:required] || []
144
+ }.tap do |object|
145
+ object[:propertyOrdering] = schema[:propertyOrdering] if schema[:propertyOrdering]
146
+ object[:nullable] = schema[:nullable] if schema.key?(:nullable)
147
+ end
148
+ end
149
+
150
+ def apply_type_specific_attributes(result, schema)
151
+ case schema[:type]
152
+ when 'string'
153
+ copy_attributes(result, schema, :enum, :format, :nullable)
154
+ when 'number', 'integer'
155
+ copy_attributes(result, schema, :format, :minimum, :maximum, :enum, :nullable)
156
+ when 'array'
157
+ copy_attributes(result, schema, :minItems, :maxItems, :nullable)
158
+ when 'boolean'
159
+ copy_attributes(result, schema, :nullable)
160
+ end
161
+ end
162
+
163
+ def copy_attributes(target, source, *attributes)
164
+ attributes.each do |attr|
165
+ target[attr] = source[attr] if attr == :nullable ? source.key?(attr) : source[attr]
166
+ end
167
+ end
140
168
  end
141
169
  end
142
170
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyLLM
4
- VERSION = '1.6.3'
4
+ VERSION = '1.6.4'
5
5
  end
@@ -0,0 +1,514 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dotenv/load'
4
+ require 'ruby_llm'
5
+ require 'json'
6
+ require 'json-schema'
7
+ require 'fileutils'
8
+
9
+ desc 'Update models, docs, and aliases'
10
+ task models: ['models:update', 'models:docs', 'models:aliases']
11
+
12
+ namespace :models do
13
+ desc 'Update available models from providers (API keys needed)'
14
+ task :update do
15
+ puts 'Configuring RubyLLM...'
16
+ configure_from_env
17
+ refresh_models
18
+ display_model_stats
19
+ end
20
+
21
+ desc 'Generate available models documentation'
22
+ task :docs do
23
+ FileUtils.mkdir_p('docs/_reference')
24
+ output = generate_models_markdown
25
+ File.write('docs/_reference/available-models.md', output)
26
+ puts 'Generated docs/_reference/available-models.md'
27
+ end
28
+
29
+ desc 'Generate model aliases from registry'
30
+ task :aliases do
31
+ generate_aliases
32
+ end
33
+ end
34
+
35
+ # Keep aliases:generate for backwards compatibility
36
+ namespace :aliases do
37
+ task generate: ['models:aliases']
38
+ end
39
+
40
+ def configure_from_env
41
+ RubyLLM.configure do |config|
42
+ config.openai_api_key = ENV.fetch('OPENAI_API_KEY', nil)
43
+ config.anthropic_api_key = ENV.fetch('ANTHROPIC_API_KEY', nil)
44
+ config.gemini_api_key = ENV.fetch('GEMINI_API_KEY', nil)
45
+ config.deepseek_api_key = ENV.fetch('DEEPSEEK_API_KEY', nil)
46
+ config.perplexity_api_key = ENV.fetch('PERPLEXITY_API_KEY', nil)
47
+ config.openrouter_api_key = ENV.fetch('OPENROUTER_API_KEY', nil)
48
+ config.mistral_api_key = ENV.fetch('MISTRAL_API_KEY', nil)
49
+ configure_bedrock(config)
50
+ config.request_timeout = 30
51
+ end
52
+ end
53
+
54
+ def configure_bedrock(config)
55
+ config.bedrock_api_key = ENV.fetch('AWS_ACCESS_KEY_ID', nil)
56
+ config.bedrock_secret_key = ENV.fetch('AWS_SECRET_ACCESS_KEY', nil)
57
+ config.bedrock_region = ENV.fetch('AWS_REGION', nil)
58
+ config.bedrock_session_token = ENV.fetch('AWS_SESSION_TOKEN', nil)
59
+ end
60
+
61
+ def refresh_models
62
+ initial_count = RubyLLM.models.all.size
63
+ puts "Refreshing models (#{initial_count} cached)..."
64
+
65
+ models = RubyLLM.models.refresh!
66
+
67
+ if models.all.empty? && initial_count.zero?
68
+ puts 'Error: Failed to fetch models.'
69
+ exit(1)
70
+ elsif models.all.size == initial_count && initial_count.positive?
71
+ puts 'Warning: Model list unchanged.'
72
+ else
73
+ puts 'Validating models...'
74
+ validate_models!(models)
75
+
76
+ puts "Saving models.json (#{models.all.size} models)"
77
+ models.save_models
78
+ end
79
+
80
+ @models = models
81
+ end
82
+
83
+ def validate_models!(models)
84
+ schema_path = RubyLLM::Models.schema_file
85
+ models_data = models.all.map(&:to_h)
86
+
87
+ validation_errors = JSON::Validator.fully_validate(schema_path, models_data)
88
+
89
+ unless validation_errors.empty?
90
+ # Save failed models for inspection
91
+ failed_path = File.expand_path('../ruby_llm/models.failed.json', __dir__)
92
+ File.write(failed_path, JSON.pretty_generate(models_data))
93
+
94
+ puts 'ERROR: Models validation failed:'
95
+ puts "\nValidation errors:"
96
+ validation_errors.first(10).each { |error| puts " - #{error}" }
97
+ puts " ... and #{validation_errors.size - 10} more errors" if validation_errors.size > 10
98
+ puts "-> Failed models saved to: #{failed_path}"
99
+ exit(1)
100
+ end
101
+
102
+ puts '✓ Models validation passed'
103
+ end
104
+
105
+ def display_model_stats
106
+ puts "\nModel count:"
107
+ provider_counts = @models.all.group_by(&:provider).transform_values(&:count)
108
+
109
+ RubyLLM::Provider.providers.each do |sym, provider_class|
110
+ name = provider_class.name
111
+ count = provider_counts[sym.to_s] || 0
112
+ status = status(sym)
113
+ puts " #{name}: #{count} models #{status}"
114
+ end
115
+
116
+ puts 'Refresh complete.'
117
+ end
118
+
119
+ def status(provider_sym)
120
+ provider_class = RubyLLM::Provider.providers[provider_sym]
121
+ if provider_class.local?
122
+ ' (LOCAL - SKIP)'
123
+ elsif provider_class.configured?(RubyLLM.config)
124
+ ' (OK)'
125
+ else
126
+ ' (NOT CONFIGURED)'
127
+ end
128
+ end
129
+
130
+ def generate_models_markdown
131
+ <<~MARKDOWN
132
+ ---
133
+ layout: default
134
+ title: Available Models
135
+ nav_order: 1
136
+ description: Browse hundreds of AI models from every major provider. Always up-to-date, automatically generated.
137
+ redirect_from:
138
+ - /guides/available-models
139
+ ---
140
+
141
+ # {{ page.title }}
142
+ {: .no_toc }
143
+
144
+ {{ page.description }}
145
+ {: .fs-6 .fw-300 }
146
+
147
+ ## Table of contents
148
+ {: .no_toc .text-delta }
149
+
150
+ 1. TOC
151
+ {:toc}
152
+
153
+ ---
154
+
155
+ After reading this guide, you will know:
156
+
157
+ * How RubyLLM's model registry works and where data comes from
158
+ * How to find models by provider, capability, or purpose
159
+ * What information is available for each model
160
+ * How to use model aliases for simpler configuration
161
+
162
+ ## How Model Data Works
163
+
164
+ RubyLLM's model registry combines data from multiple sources:
165
+
166
+ - **OpenAI, Anthropic, DeepSeek, Gemini**: Data from [Parsera](https://api.parsera.org/v1/llm-specs)
167
+ - **OpenRouter**: Direct from OpenRouter's API
168
+ - **Other providers**: Defined in `capabilities.rb` files
169
+
170
+ ## Contributing Model Updates
171
+
172
+ **For major providers** (OpenAI, Anthropic, DeepSeek, Gemini): File issues with [Parsera](https://github.com/parsera-labs/api-llm-specs/issues) for public model data corrections.
173
+
174
+ **For other providers**: Edit `lib/ruby_llm/providers/<provider>/capabilities.rb` then run `rake models:update`.
175
+
176
+ See the [Contributing Guide](https://github.com/crmne/ruby_llm/blob/main/CONTRIBUTING.md) for details.
177
+
178
+ ## Last Updated
179
+ {: .d-inline-block }
180
+
181
+ #{Time.now.utc.strftime('%Y-%m-%d')}
182
+ {: .label .label-green }
183
+
184
+ ## Models by Provider
185
+
186
+ #{generate_provider_sections}
187
+
188
+ ## Models by Capability
189
+
190
+ #{generate_capability_sections}
191
+
192
+ ## Models by Modality
193
+
194
+ #{generate_modality_sections}
195
+ MARKDOWN
196
+ end
197
+
198
+ def generate_provider_sections
199
+ RubyLLM::Provider.providers.filter_map do |provider, provider_class|
200
+ models = RubyLLM.models.by_provider(provider)
201
+ next if models.none?
202
+
203
+ <<~PROVIDER
204
+ ### #{provider_class.name} (#{models.count})
205
+
206
+ #{models_table(models)}
207
+ PROVIDER
208
+ end.join("\n\n")
209
+ end
210
+
211
+ def generate_capability_sections
212
+ capabilities = {
213
+ 'Function Calling' => RubyLLM.models.select(&:function_calling?),
214
+ 'Structured Output' => RubyLLM.models.select(&:structured_output?),
215
+ 'Streaming' => RubyLLM.models.select { |m| m.capabilities.include?('streaming') },
216
+ 'Batch Processing' => RubyLLM.models.select { |m| m.capabilities.include?('batch') }
217
+ }
218
+
219
+ capabilities.filter_map do |capability, models|
220
+ next if models.none?
221
+
222
+ <<~CAPABILITY
223
+ ### #{capability} (#{models.count})
224
+
225
+ #{models_table(models)}
226
+ CAPABILITY
227
+ end.join("\n\n")
228
+ end
229
+
230
+ def generate_modality_sections # rubocop:disable Metrics/PerceivedComplexity
231
+ sections = []
232
+
233
+ vision_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('image') }
234
+ if vision_models.any?
235
+ sections << <<~SECTION
236
+ ### Vision Models (#{vision_models.count})
237
+
238
+ Models that can process images:
239
+
240
+ #{models_table(vision_models)}
241
+ SECTION
242
+ end
243
+
244
+ audio_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('audio') }
245
+ if audio_models.any?
246
+ sections << <<~SECTION
247
+ ### Audio Input Models (#{audio_models.count})
248
+
249
+ Models that can process audio:
250
+
251
+ #{models_table(audio_models)}
252
+ SECTION
253
+ end
254
+
255
+ pdf_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('pdf') }
256
+ if pdf_models.any?
257
+ sections << <<~SECTION
258
+ ### PDF Models (#{pdf_models.count})
259
+
260
+ Models that can process PDF documents:
261
+
262
+ #{models_table(pdf_models)}
263
+ SECTION
264
+ end
265
+
266
+ embedding_models = RubyLLM.models.select { |m| (m.modalities.output || []).include?('embeddings') }
267
+ if embedding_models.any?
268
+ sections << <<~SECTION
269
+ ### Embedding Models (#{embedding_models.count})
270
+
271
+ Models that generate embeddings:
272
+
273
+ #{models_table(embedding_models)}
274
+ SECTION
275
+ end
276
+
277
+ sections.join("\n\n")
278
+ end
279
+
280
+ def models_table(models)
281
+ return '*No models found*' if models.none?
282
+
283
+ headers = ['Model', 'Provider', 'Context', 'Max Output', 'Standard Pricing (per 1M tokens)']
284
+ alignment = [':--', ':--', '--:', '--:', ':--']
285
+
286
+ rows = models.sort_by { |m| [m.provider, m.name] }.map do |model|
287
+ pricing = standard_pricing_display(model)
288
+
289
+ [
290
+ model.id,
291
+ model.provider,
292
+ model.context_window || '-',
293
+ model.max_output_tokens || '-',
294
+ pricing
295
+ ]
296
+ end
297
+
298
+ table = []
299
+ table << "| #{headers.join(' | ')} |"
300
+ table << "| #{alignment.join(' | ')} |"
301
+
302
+ rows.each do |row|
303
+ table << "| #{row.join(' | ')} |"
304
+ end
305
+
306
+ table.join("\n")
307
+ end
308
+
309
+ def standard_pricing_display(model)
310
+ pricing_data = model.pricing.to_h[:text_tokens]&.dig(:standard) || {}
311
+
312
+ if pricing_data.any?
313
+ parts = []
314
+
315
+ parts << "In: $#{format('%.2f', pricing_data[:input_per_million])}" if pricing_data[:input_per_million]
316
+
317
+ parts << "Out: $#{format('%.2f', pricing_data[:output_per_million])}" if pricing_data[:output_per_million]
318
+
319
+ if pricing_data[:cached_input_per_million]
320
+ parts << "Cache: $#{format('%.2f', pricing_data[:cached_input_per_million])}"
321
+ end
322
+
323
+ return parts.join(', ') if parts.any?
324
+ end
325
+
326
+ '-'
327
+ end
328
+
329
+ def generate_aliases # rubocop:disable Metrics/PerceivedComplexity
330
+ models = Hash.new { |h, k| h[k] = [] }
331
+
332
+ RubyLLM.models.all.each do |model|
333
+ models[model.provider] << model.id
334
+ end
335
+
336
+ aliases = {}
337
+
338
+ # OpenAI models
339
+ models['openai'].each do |model|
340
+ openrouter_model = "openai/#{model}"
341
+ next unless models['openrouter'].include?(openrouter_model)
342
+
343
+ alias_key = model.gsub('-latest', '')
344
+ aliases[alias_key] = {
345
+ 'openai' => model,
346
+ 'openrouter' => openrouter_model
347
+ }
348
+ end
349
+
350
+ anthropic_latest = group_anthropic_models_by_base_name(models['anthropic'])
351
+
352
+ anthropic_latest.each do |base_name, latest_model|
353
+ openrouter_variants = [
354
+ "anthropic/#{base_name}",
355
+ "anthropic/#{base_name.gsub(/-(\d)/, '.\1')}",
356
+ "anthropic/#{base_name.gsub(/claude-(\d+)-(\d+)/, 'claude-\1.\2')}",
357
+ "anthropic/#{base_name.gsub(/(\d+)-(\d+)/, '\1.\2')}"
358
+ ]
359
+
360
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
361
+ bedrock_model = find_best_bedrock_model(latest_model, models['bedrock'])
362
+
363
+ next unless openrouter_model || bedrock_model || models['anthropic'].include?(latest_model)
364
+
365
+ aliases[base_name] = { 'anthropic' => latest_model }
366
+ aliases[base_name]['openrouter'] = openrouter_model if openrouter_model
367
+ aliases[base_name]['bedrock'] = bedrock_model if bedrock_model
368
+ end
369
+
370
+ models['bedrock'].each do |bedrock_model|
371
+ next unless bedrock_model.start_with?('anthropic.')
372
+ next unless bedrock_model =~ /anthropic\.(claude-[\d\.]+-[a-z]+)/
373
+
374
+ base_name = Regexp.last_match(1)
375
+ anthropic_name = base_name.tr('.', '-')
376
+
377
+ next if aliases[anthropic_name]
378
+
379
+ openrouter_variants = [
380
+ "anthropic/#{anthropic_name}",
381
+ "anthropic/#{base_name}"
382
+ ]
383
+
384
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
385
+
386
+ aliases[anthropic_name] = { 'bedrock' => bedrock_model }
387
+ aliases[anthropic_name]['anthropic'] = anthropic_name if models['anthropic'].include?(anthropic_name)
388
+ aliases[anthropic_name]['openrouter'] = openrouter_model if openrouter_model
389
+ end
390
+
391
+ models['gemini'].each do |model|
392
+ openrouter_variants = [
393
+ "google/#{model}",
394
+ "google/#{model.gsub('gemini-', 'gemini-').tr('.', '-')}",
395
+ "google/#{model.gsub('gemini-', 'gemini-')}"
396
+ ]
397
+
398
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
399
+ next unless openrouter_model
400
+
401
+ alias_key = model.gsub('-latest', '')
402
+ aliases[alias_key] = {
403
+ 'gemini' => model,
404
+ 'openrouter' => openrouter_model
405
+ }
406
+ end
407
+
408
+ models['deepseek'].each do |model|
409
+ openrouter_model = "deepseek/#{model}"
410
+ next unless models['openrouter'].include?(openrouter_model)
411
+
412
+ alias_key = model.gsub('-latest', '')
413
+ aliases[alias_key] = {
414
+ 'deepseek' => model,
415
+ 'openrouter' => openrouter_model
416
+ }
417
+ end
418
+
419
+ sorted_aliases = aliases.sort.to_h
420
+ File.write(RubyLLM::Aliases.aliases_file, JSON.pretty_generate(sorted_aliases))
421
+
422
+ puts "Generated #{sorted_aliases.size} aliases"
423
+ end
424
+
425
+ def group_anthropic_models_by_base_name(anthropic_models)
426
+ grouped = Hash.new { |h, k| h[k] = [] }
427
+
428
+ anthropic_models.each do |model|
429
+ base_name = extract_base_name(model)
430
+ grouped[base_name] << model
431
+ end
432
+
433
+ latest_models = {}
434
+ grouped.each do |base_name, model_list|
435
+ if model_list.size == 1
436
+ latest_models[base_name] = model_list.first
437
+ else
438
+ latest_model = model_list.max_by { |model| extract_date_from_model(model) }
439
+ latest_models[base_name] = latest_model
440
+ end
441
+ end
442
+
443
+ latest_models
444
+ end
445
+
446
+ def extract_base_name(model)
447
+ if model =~ /^(.+)-(\d{8})$/
448
+ Regexp.last_match(1)
449
+ else
450
+ model
451
+ end
452
+ end
453
+
454
+ def extract_date_from_model(model)
455
+ if model =~ /-(\d{8})$/
456
+ Regexp.last_match(1)
457
+ else
458
+ '00000000'
459
+ end
460
+ end
461
+
462
+ def find_best_bedrock_model(anthropic_model, bedrock_models) # rubocop:disable Metrics/PerceivedComplexity
463
+ base_pattern = case anthropic_model
464
+ when 'claude-2.0', 'claude-2'
465
+ 'claude-v2'
466
+ when 'claude-2.1'
467
+ 'claude-v2:1'
468
+ when 'claude-instant-v1', 'claude-instant'
469
+ 'claude-instant'
470
+ else
471
+ extract_base_name(anthropic_model)
472
+ end
473
+
474
+ matching_models = bedrock_models.select do |bedrock_model|
475
+ model_without_prefix = bedrock_model.sub(/^(?:us\.)?anthropic\./, '')
476
+ model_without_prefix.start_with?(base_pattern)
477
+ end
478
+
479
+ return nil if matching_models.empty?
480
+
481
+ begin
482
+ model_info = RubyLLM.models.find(anthropic_model)
483
+ target_context = model_info.context_window
484
+ rescue StandardError
485
+ target_context = nil
486
+ end
487
+
488
+ if target_context
489
+ target_k = target_context / 1000
490
+
491
+ with_context = matching_models.select do |m|
492
+ m.include?(":#{target_k}k") || m.include?(":0:#{target_k}k")
493
+ end
494
+
495
+ return with_context.first if with_context.any?
496
+ end
497
+
498
+ matching_models.min_by do |model|
499
+ context_priority = if model =~ /:(?:\d+:)?(\d+)k/
500
+ -Regexp.last_match(1).to_i
501
+ else
502
+ 0
503
+ end
504
+
505
+ version_priority = if model =~ /-v(\d+):/
506
+ -Regexp.last_match(1).to_i
507
+ else
508
+ 0
509
+ end
510
+
511
+ has_context_priority = model.include?('k') ? -1 : 0
512
+ [has_context_priority, context_priority, version_priority]
513
+ end
514
+ end
@@ -1,6 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- namespace :release do
3
+ namespace :release do # rubocop:disable Metrics/BlockLength
4
+ desc 'Prepare for release'
5
+ task :prepare do
6
+ Rake::Task['release:refresh_stale_cassettes'].invoke
7
+ sh 'overcommit --run'
8
+ Rake::Task['models'].invoke
9
+ end
10
+
11
+ desc 'Remove stale cassettes and re-record them'
12
+ task :refresh_stale_cassettes do
13
+ max_age_days = 1
14
+ cassette_dir = 'spec/fixtures/vcr_cassettes'
15
+
16
+ stale_count = 0
17
+ Dir.glob("#{cassette_dir}/**/*.yml").each do |cassette|
18
+ age_days = (Time.now - File.mtime(cassette)) / 86_400
19
+ next unless age_days > max_age_days
20
+
21
+ puts "Removing stale cassette: #{File.basename(cassette)} (#{age_days.round(1)} days old)"
22
+ File.delete(cassette)
23
+ stale_count += 1
24
+ end
25
+
26
+ if stale_count.positive?
27
+ puts "\n🗑️ Removed #{stale_count} stale cassettes"
28
+ puts '🔄 Re-recording cassettes...'
29
+ system('bundle exec rspec') || exit(1)
30
+ puts '✅ Cassettes refreshed!'
31
+ else
32
+ puts '✅ No stale cassettes found'
33
+ end
34
+ end
35
+
4
36
  desc 'Verify cassettes are fresh enough for release'
5
37
  task :verify_cassettes do
6
38
  max_age_days = 1
@@ -20,10 +52,13 @@ namespace :release do
20
52
 
21
53
  if stale_cassettes.any?
22
54
  puts "\n❌ Found stale cassettes (older than #{max_age_days} days):"
55
+ stale_files = []
23
56
  stale_cassettes.each do |c|
24
57
  puts " - #{c[:file]} (#{c[:age]} days old)"
58
+ stale_files << File.join(cassette_dir, '**', c[:file])
25
59
  end
26
- puts "\nRun locally: bundle exec rspec"
60
+
61
+ puts "\nRun locally: bundle exec rake release:refresh_stale_cassettes"
27
62
  exit 1
28
63
  else
29
64
  puts "✅ All cassettes are fresh (< #{max_age_days} days old)"
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.6.3
4
+ version: 1.6.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carmine Paolino
@@ -236,9 +236,7 @@ files:
236
236
  - lib/ruby_llm/tool_call.rb
237
237
  - lib/ruby_llm/utils.rb
238
238
  - lib/ruby_llm/version.rb
239
- - lib/tasks/aliases.rake
240
- - lib/tasks/models_docs.rake
241
- - lib/tasks/models_update.rake
239
+ - lib/tasks/models.rake
242
240
  - lib/tasks/release.rake
243
241
  - lib/tasks/vcr.rake
244
242
  homepage: https://rubyllm.com
@@ -1,205 +0,0 @@
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_latest = group_anthropic_models_by_base_name(models['anthropic'])
32
-
33
- anthropic_latest.each do |base_name, latest_model|
34
- openrouter_variants = [
35
- "anthropic/#{base_name}", # anthropic/claude-3-5-sonnet
36
- "anthropic/#{base_name.gsub(/-(\d)/, '.\1')}", # anthropic/claude-3.5-sonnet
37
- "anthropic/#{base_name.gsub(/claude-(\d+)-(\d+)/, 'claude-\1.\2')}", # claude-3-5 -> claude-3.5
38
- "anthropic/#{base_name.gsub(/(\d+)-(\d+)/, '\1.\2')}" # any X-Y -> X.Y pattern
39
- ]
40
-
41
- openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
42
-
43
- bedrock_model = find_best_bedrock_model(latest_model, models['bedrock'])
44
-
45
- next unless openrouter_model || bedrock_model || models['anthropic'].include?(latest_model)
46
-
47
- aliases[base_name] = {
48
- 'anthropic' => latest_model
49
- }
50
-
51
- aliases[base_name]['openrouter'] = openrouter_model if openrouter_model
52
- aliases[base_name]['bedrock'] = bedrock_model if bedrock_model
53
- end
54
-
55
- models['bedrock'].each do |bedrock_model|
56
- next unless bedrock_model.start_with?('anthropic.')
57
-
58
- next unless bedrock_model =~ /anthropic\.(claude-[\d\.]+-[a-z]+)/
59
-
60
- base_name = Regexp.last_match(1)
61
- anthropic_name = base_name.tr('.', '-')
62
-
63
- next if aliases[anthropic_name]
64
-
65
- openrouter_variants = [
66
- "anthropic/#{anthropic_name}",
67
- "anthropic/#{base_name}" # Keep the dots
68
- ]
69
-
70
- openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
71
-
72
- aliases[anthropic_name] = {
73
- 'bedrock' => bedrock_model
74
- }
75
-
76
- aliases[anthropic_name]['anthropic'] = anthropic_name if models['anthropic'].include?(anthropic_name)
77
- aliases[anthropic_name]['openrouter'] = openrouter_model if openrouter_model
78
- end
79
-
80
- models['gemini'].each do |model|
81
- openrouter_variants = [
82
- "google/#{model}",
83
- "google/#{model.gsub('gemini-', 'gemini-').tr('.', '-')}",
84
- "google/#{model.gsub('gemini-', 'gemini-')}"
85
- ]
86
-
87
- openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
88
- next unless openrouter_model
89
-
90
- alias_key = model.gsub('-latest', '')
91
- aliases[alias_key] = {
92
- 'gemini' => model,
93
- 'openrouter' => openrouter_model
94
- }
95
- end
96
-
97
- models['deepseek'].each do |model|
98
- openrouter_model = "deepseek/#{model}"
99
- next unless models['openrouter'].include?(openrouter_model)
100
-
101
- alias_key = model.gsub('-latest', '')
102
- aliases[alias_key] = {
103
- 'deepseek' => model,
104
- 'openrouter' => openrouter_model
105
- }
106
- end
107
-
108
- sorted_aliases = aliases.sort.to_h
109
- File.write(RubyLLM::Aliases.aliases_file, JSON.pretty_generate(sorted_aliases))
110
-
111
- puts "Generated #{sorted_aliases.size} aliases"
112
- end
113
-
114
- def group_anthropic_models_by_base_name(anthropic_models) # rubocop:disable Rake/MethodDefinitionInTask
115
- grouped = Hash.new { |h, k| h[k] = [] }
116
-
117
- anthropic_models.each do |model|
118
- base_name = extract_base_name(model)
119
- grouped[base_name] << model
120
- end
121
-
122
- latest_models = {}
123
- grouped.each do |base_name, model_list|
124
- if model_list.size == 1
125
- latest_models[base_name] = model_list.first
126
- else
127
- latest_model = model_list.max_by { |model| extract_date_from_model(model) }
128
- latest_models[base_name] = latest_model
129
- end
130
- end
131
-
132
- latest_models
133
- end
134
-
135
- def extract_base_name(model) # rubocop:disable Rake/MethodDefinitionInTask
136
- if model =~ /^(.+)-(\d{8})$/
137
- Regexp.last_match(1)
138
- else
139
- model
140
- end
141
- end
142
-
143
- def extract_date_from_model(model) # rubocop:disable Rake/MethodDefinitionInTask
144
- if model =~ /-(\d{8})$/
145
- Regexp.last_match(1)
146
- else
147
- '00000000'
148
- end
149
- end
150
-
151
- def find_best_bedrock_model(anthropic_model, bedrock_models) # rubocop:disable Metrics/PerceivedComplexity,Rake/MethodDefinitionInTask
152
- base_pattern = case anthropic_model
153
- when 'claude-2.0', 'claude-2'
154
- 'claude-v2'
155
- when 'claude-2.1'
156
- 'claude-v2:1'
157
- when 'claude-instant-v1', 'claude-instant'
158
- 'claude-instant'
159
- else
160
- extract_base_name(anthropic_model)
161
- end
162
-
163
- matching_models = bedrock_models.select do |bedrock_model|
164
- model_without_prefix = bedrock_model.sub(/^(?:us\.)?anthropic\./, '')
165
- model_without_prefix.start_with?(base_pattern)
166
- end
167
-
168
- return nil if matching_models.empty?
169
-
170
- begin
171
- model_info = RubyLLM.models.find(anthropic_model)
172
- target_context = model_info.context_window
173
- rescue StandardError
174
- target_context = nil
175
- end
176
-
177
- if target_context
178
- target_k = target_context / 1000
179
-
180
- with_context = matching_models.select do |m|
181
- m.include?(":#{target_k}k") || m.include?(":0:#{target_k}k")
182
- end
183
-
184
- return with_context.first if with_context.any?
185
- end
186
-
187
- matching_models.min_by do |model|
188
- context_priority = if model =~ /:(?:\d+:)?(\d+)k/
189
- -Regexp.last_match(1).to_i
190
- else
191
- 0
192
- end
193
-
194
- version_priority = if model =~ /-v(\d+):/
195
- -Regexp.last_match(1).to_i
196
- else
197
- 0
198
- end
199
-
200
- # Prefer models with explicit context windows
201
- has_context_priority = model.include?('k') ? -1 : 0
202
- [has_context_priority, context_priority, version_priority]
203
- end
204
- end
205
- end
@@ -1,214 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'dotenv/load'
4
- require 'fileutils'
5
-
6
- namespace :models do
7
- desc 'Generate available models documentation'
8
- task :docs do
9
- FileUtils.mkdir_p('docs')
10
- output = generate_models_markdown
11
- File.write('docs/_reference/available-models.md', output)
12
- puts 'Generated docs/_reference/available-models.md'
13
- end
14
- end
15
-
16
- def generate_models_markdown
17
- <<~MARKDOWN
18
- ---
19
- layout: default
20
- title: Available Models
21
- nav_order: 1
22
- description: Browse hundreds of AI models from every major provider. Always up-to-date, automatically generated.
23
- redirect_from:
24
- - /guides/available-models
25
- ---
26
-
27
- # {{ page.title }}
28
- {: .no_toc }
29
-
30
- {{ page.description }}
31
- {: .fs-6 .fw-300 }
32
-
33
- ## Table of contents
34
- {: .no_toc .text-delta }
35
-
36
- 1. TOC
37
- {:toc}
38
-
39
- ---
40
-
41
- After reading this guide, you will know:
42
-
43
- * How RubyLLM's model registry works and where data comes from
44
- * How to find models by provider, capability, or purpose
45
- * What information is available for each model
46
- * How to use model aliases for simpler configuration
47
-
48
- ## How Model Data Works
49
-
50
- RubyLLM's model registry combines data from multiple sources:
51
-
52
- - **OpenAI, Anthropic, DeepSeek, Gemini**: Data from [Parsera](https://api.parsera.org/v1/llm-specs)
53
- - **OpenRouter**: Direct from OpenRouter's API
54
- - **Other providers**: Defined in `capabilities.rb` files
55
-
56
- ## Contributing Model Updates
57
-
58
- **For major providers** (OpenAI, Anthropic, DeepSeek, Gemini): File issues with [Parsera](https://github.com/parsera-labs/api-llm-specs/issues) for public model data corrections.
59
-
60
- **For other providers**: Edit `lib/ruby_llm/providers/<provider>/capabilities.rb` then run `rake models:update`.
61
-
62
- See the [Contributing Guide](https://github.com/crmne/ruby_llm/blob/main/CONTRIBUTING.md) for details.
63
-
64
- ## Last Updated
65
- {: .d-inline-block }
66
-
67
- #{Time.now.utc.strftime('%Y-%m-%d')}
68
- {: .label .label-green }
69
-
70
- ## Models by Provider
71
-
72
- #{generate_provider_sections}
73
-
74
- ## Models by Capability
75
-
76
- #{generate_capability_sections}
77
-
78
- ## Models by Modality
79
-
80
- #{generate_modality_sections}
81
- MARKDOWN
82
- end
83
-
84
- def generate_provider_sections
85
- RubyLLM::Provider.providers.filter_map do |provider, provider_class|
86
- models = RubyLLM.models.by_provider(provider)
87
- next if models.none?
88
-
89
- <<~PROVIDER
90
- ### #{provider_class.name} (#{models.count})
91
-
92
- #{models_table(models)}
93
- PROVIDER
94
- end.join("\n\n")
95
- end
96
-
97
- def generate_capability_sections
98
- capabilities = {
99
- 'Function Calling' => RubyLLM.models.select(&:function_calling?),
100
- 'Structured Output' => RubyLLM.models.select(&:structured_output?),
101
- 'Streaming' => RubyLLM.models.select { |m| m.capabilities.include?('streaming') },
102
- # 'Reasoning' => RubyLLM.models.select { |m| m.capabilities.include?('reasoning') },
103
- 'Batch Processing' => RubyLLM.models.select { |m| m.capabilities.include?('batch') }
104
- }
105
-
106
- capabilities.filter_map do |capability, models|
107
- next if models.none?
108
-
109
- <<~CAPABILITY
110
- ### #{capability} (#{models.count})
111
-
112
- #{models_table(models)}
113
- CAPABILITY
114
- end.join("\n\n")
115
- end
116
-
117
- def generate_modality_sections # rubocop:disable Metrics/PerceivedComplexity
118
- sections = []
119
-
120
- vision_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('image') }
121
- if vision_models.any?
122
- sections << <<~SECTION
123
- ### Vision Models (#{vision_models.count})
124
-
125
- Models that can process images:
126
-
127
- #{models_table(vision_models)}
128
- SECTION
129
- end
130
-
131
- audio_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('audio') }
132
- if audio_models.any?
133
- sections << <<~SECTION
134
- ### Audio Input Models (#{audio_models.count})
135
-
136
- Models that can process audio:
137
-
138
- #{models_table(audio_models)}
139
- SECTION
140
- end
141
-
142
- pdf_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('pdf') }
143
- if pdf_models.any?
144
- sections << <<~SECTION
145
- ### PDF Models (#{pdf_models.count})
146
-
147
- Models that can process PDF documents:
148
-
149
- #{models_table(pdf_models)}
150
- SECTION
151
- end
152
-
153
- embedding_models = RubyLLM.models.select { |m| (m.modalities.output || []).include?('embeddings') }
154
- if embedding_models.any?
155
- sections << <<~SECTION
156
- ### Embedding Models (#{embedding_models.count})
157
-
158
- Models that generate embeddings:
159
-
160
- #{models_table(embedding_models)}
161
- SECTION
162
- end
163
-
164
- sections.join("\n\n")
165
- end
166
-
167
- def models_table(models)
168
- return '*No models found*' if models.none?
169
-
170
- headers = ['Model', 'Provider', 'Context', 'Max Output', 'Standard Pricing (per 1M tokens)']
171
- alignment = [':--', ':--', '--:', '--:', ':--']
172
-
173
- rows = models.sort_by { |m| [m.provider, m.name] }.map do |model|
174
- pricing = standard_pricing_display(model)
175
-
176
- [
177
- model.id,
178
- model.provider,
179
- model.context_window || '-',
180
- model.max_output_tokens || '-',
181
- pricing
182
- ]
183
- end
184
-
185
- table = []
186
- table << "| #{headers.join(' | ')} |"
187
- table << "| #{alignment.join(' | ')} |"
188
-
189
- rows.each do |row|
190
- table << "| #{row.join(' | ')} |"
191
- end
192
-
193
- table.join("\n")
194
- end
195
-
196
- def standard_pricing_display(model)
197
- pricing_data = model.pricing.to_h[:text_tokens]&.dig(:standard) || {}
198
-
199
- if pricing_data.any?
200
- parts = []
201
-
202
- parts << "In: $#{format('%.2f', pricing_data[:input_per_million])}" if pricing_data[:input_per_million]
203
-
204
- parts << "Out: $#{format('%.2f', pricing_data[:output_per_million])}" if pricing_data[:output_per_million]
205
-
206
- if pricing_data[:cached_input_per_million]
207
- parts << "Cache: $#{format('%.2f', pricing_data[:cached_input_per_million])}"
208
- end
209
-
210
- return parts.join(', ') if parts.any?
211
- end
212
-
213
- '-'
214
- end
@@ -1,108 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'dotenv/load'
4
- require 'ruby_llm'
5
- require 'json-schema'
6
-
7
- task default: ['models:update']
8
-
9
- namespace :models do
10
- desc 'Update available models from providers (API keys needed)'
11
- task :update do
12
- puts 'Configuring RubyLLM...'
13
- configure_from_env
14
-
15
- refresh_models
16
- display_model_stats
17
- end
18
- end
19
-
20
- def configure_from_env
21
- RubyLLM.configure do |config|
22
- config.openai_api_key = ENV.fetch('OPENAI_API_KEY', nil)
23
- config.anthropic_api_key = ENV.fetch('ANTHROPIC_API_KEY', nil)
24
- config.gemini_api_key = ENV.fetch('GEMINI_API_KEY', nil)
25
- config.deepseek_api_key = ENV.fetch('DEEPSEEK_API_KEY', nil)
26
- config.perplexity_api_key = ENV.fetch('PERPLEXITY_API_KEY', nil)
27
- config.openrouter_api_key = ENV.fetch('OPENROUTER_API_KEY', nil)
28
- config.mistral_api_key = ENV.fetch('MISTRAL_API_KEY', nil)
29
- configure_bedrock(config)
30
- config.request_timeout = 30
31
- end
32
- end
33
-
34
- def configure_bedrock(config)
35
- config.bedrock_api_key = ENV.fetch('AWS_ACCESS_KEY_ID', nil)
36
- config.bedrock_secret_key = ENV.fetch('AWS_SECRET_ACCESS_KEY', nil)
37
- config.bedrock_region = ENV.fetch('AWS_REGION', nil)
38
- config.bedrock_session_token = ENV.fetch('AWS_SESSION_TOKEN', nil)
39
- end
40
-
41
- def refresh_models
42
- initial_count = RubyLLM.models.all.size
43
- puts "Refreshing models (#{initial_count} cached)..."
44
-
45
- models = RubyLLM.models.refresh!
46
-
47
- if models.all.empty? && initial_count.zero?
48
- puts 'Error: Failed to fetch models.'
49
- exit(1)
50
- elsif models.all.size == initial_count && initial_count.positive?
51
- puts 'Warning: Model list unchanged.'
52
- else
53
- puts 'Validating models...'
54
- validate_models!(models)
55
-
56
- puts "Saving models.json (#{models.all.size} models)"
57
- models.save_models
58
- end
59
-
60
- @models = models
61
- end
62
-
63
- def validate_models!(models)
64
- schema_path = RubyLLM::Models.schema_file
65
- models_data = models.all.map(&:to_h)
66
-
67
- validation_errors = JSON::Validator.fully_validate(schema_path, models_data)
68
-
69
- unless validation_errors.empty?
70
- # Save failed models for inspection
71
- failed_path = File.expand_path('../ruby_llm/models.failed.json', __dir__)
72
- File.write(failed_path, JSON.pretty_generate(models_data))
73
-
74
- puts 'ERROR: Models validation failed:'
75
- puts "\nValidation errors:"
76
- validation_errors.first(10).each { |error| puts " - #{error}" }
77
- puts " ... and #{validation_errors.size - 10} more errors" if validation_errors.size > 10
78
- puts "-> Failed models saved to: #{failed_path}"
79
- exit(1)
80
- end
81
-
82
- puts '✓ Models validation passed'
83
- end
84
-
85
- def display_model_stats
86
- puts "\nModel count:"
87
- provider_counts = @models.all.group_by(&:provider).transform_values(&:count)
88
-
89
- RubyLLM::Provider.providers.each do |sym, provider_class|
90
- name = provider_class.name
91
- count = provider_counts[sym.to_s] || 0
92
- status = status(sym)
93
- puts " #{name}: #{count} models #{status}"
94
- end
95
-
96
- puts 'Refresh complete.'
97
- end
98
-
99
- def status(provider_sym)
100
- provider_class = RubyLLM::Provider.providers[provider_sym]
101
- if provider_class.local?
102
- ' (LOCAL - SKIP)'
103
- elsif provider_class.configured?(RubyLLM.config)
104
- ' (OK)'
105
- else
106
- ' (NOT CONFIGURED)'
107
- end
108
- end