ruby_llm_community 0.0.6 → 1.1.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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -3
  3. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +127 -0
  4. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +39 -0
  5. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +24 -0
  6. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +14 -0
  7. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +12 -0
  8. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +16 -0
  9. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +29 -0
  10. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +16 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +11 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +23 -0
  13. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +21 -0
  14. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +10 -0
  15. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +9 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +16 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +30 -0
  18. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +18 -0
  19. data/lib/generators/ruby_llm/install/install_generator.rb +227 -0
  20. data/lib/generators/ruby_llm/install/templates/chat_model.rb.tt +2 -2
  21. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -4
  22. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +8 -7
  23. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +43 -0
  24. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +6 -5
  25. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +10 -4
  26. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +4 -3
  27. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +3 -0
  28. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +2 -2
  29. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +137 -0
  30. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +170 -0
  31. data/lib/ruby_llm/active_record/acts_as.rb +112 -332
  32. data/lib/ruby_llm/active_record/acts_as_legacy.rb +403 -0
  33. data/lib/ruby_llm/active_record/chat_methods.rb +336 -0
  34. data/lib/ruby_llm/active_record/message_methods.rb +72 -0
  35. data/lib/ruby_llm/active_record/model_methods.rb +84 -0
  36. data/lib/ruby_llm/aliases.json +130 -11
  37. data/lib/ruby_llm/aliases.rb +7 -25
  38. data/lib/ruby_llm/attachment.rb +22 -0
  39. data/lib/ruby_llm/chat.rb +10 -17
  40. data/lib/ruby_llm/configuration.rb +11 -12
  41. data/lib/ruby_llm/connection.rb +4 -4
  42. data/lib/ruby_llm/connection_multipart.rb +19 -0
  43. data/lib/ruby_llm/content.rb +5 -2
  44. data/lib/ruby_llm/embedding.rb +1 -2
  45. data/lib/ruby_llm/error.rb +0 -8
  46. data/lib/ruby_llm/image.rb +23 -8
  47. data/lib/ruby_llm/image_attachment.rb +30 -0
  48. data/lib/ruby_llm/message.rb +7 -7
  49. data/lib/ruby_llm/model/info.rb +12 -10
  50. data/lib/ruby_llm/model/pricing.rb +0 -3
  51. data/lib/ruby_llm/model/pricing_category.rb +0 -2
  52. data/lib/ruby_llm/model/pricing_tier.rb +0 -1
  53. data/lib/ruby_llm/models.json +4705 -2144
  54. data/lib/ruby_llm/models.rb +56 -35
  55. data/lib/ruby_llm/provider.rb +14 -12
  56. data/lib/ruby_llm/providers/anthropic/capabilities.rb +1 -46
  57. data/lib/ruby_llm/providers/anthropic/chat.rb +2 -2
  58. data/lib/ruby_llm/providers/anthropic/media.rb +1 -2
  59. data/lib/ruby_llm/providers/anthropic/tools.rb +1 -2
  60. data/lib/ruby_llm/providers/anthropic.rb +1 -2
  61. data/lib/ruby_llm/providers/bedrock/chat.rb +2 -4
  62. data/lib/ruby_llm/providers/bedrock/media.rb +0 -1
  63. data/lib/ruby_llm/providers/bedrock/models.rb +19 -3
  64. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +0 -12
  65. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +0 -7
  66. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +0 -12
  67. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +0 -12
  68. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +0 -13
  69. data/lib/ruby_llm/providers/bedrock/streaming.rb +0 -18
  70. data/lib/ruby_llm/providers/bedrock.rb +1 -2
  71. data/lib/ruby_llm/providers/deepseek/capabilities.rb +1 -2
  72. data/lib/ruby_llm/providers/deepseek/chat.rb +0 -1
  73. data/lib/ruby_llm/providers/gemini/capabilities.rb +28 -100
  74. data/lib/ruby_llm/providers/gemini/chat.rb +57 -29
  75. data/lib/ruby_llm/providers/gemini/embeddings.rb +0 -2
  76. data/lib/ruby_llm/providers/gemini/images.rb +1 -2
  77. data/lib/ruby_llm/providers/gemini/media.rb +1 -2
  78. data/lib/ruby_llm/providers/gemini/models.rb +1 -2
  79. data/lib/ruby_llm/providers/gemini/streaming.rb +15 -1
  80. data/lib/ruby_llm/providers/gemini/tools.rb +0 -5
  81. data/lib/ruby_llm/providers/gpustack/chat.rb +11 -1
  82. data/lib/ruby_llm/providers/gpustack/media.rb +45 -0
  83. data/lib/ruby_llm/providers/gpustack/models.rb +44 -9
  84. data/lib/ruby_llm/providers/gpustack.rb +1 -0
  85. data/lib/ruby_llm/providers/mistral/capabilities.rb +2 -10
  86. data/lib/ruby_llm/providers/mistral/chat.rb +0 -2
  87. data/lib/ruby_llm/providers/mistral/embeddings.rb +0 -3
  88. data/lib/ruby_llm/providers/mistral/models.rb +0 -1
  89. data/lib/ruby_llm/providers/ollama/chat.rb +0 -1
  90. data/lib/ruby_llm/providers/ollama/media.rb +2 -7
  91. data/lib/ruby_llm/providers/ollama/models.rb +36 -0
  92. data/lib/ruby_llm/providers/ollama.rb +1 -0
  93. data/lib/ruby_llm/providers/openai/capabilities.rb +3 -16
  94. data/lib/ruby_llm/providers/openai/chat.rb +1 -3
  95. data/lib/ruby_llm/providers/openai/embeddings.rb +0 -3
  96. data/lib/ruby_llm/providers/openai/images.rb +73 -3
  97. data/lib/ruby_llm/providers/openai/media.rb +4 -5
  98. data/lib/ruby_llm/providers/openai/response.rb +121 -29
  99. data/lib/ruby_llm/providers/openai/response_media.rb +3 -3
  100. data/lib/ruby_llm/providers/openai/streaming.rb +110 -47
  101. data/lib/ruby_llm/providers/openai/tools.rb +12 -7
  102. data/lib/ruby_llm/providers/openai.rb +1 -3
  103. data/lib/ruby_llm/providers/openai_base.rb +2 -2
  104. data/lib/ruby_llm/providers/openrouter/models.rb +1 -16
  105. data/lib/ruby_llm/providers/perplexity/capabilities.rb +0 -1
  106. data/lib/ruby_llm/providers/perplexity/chat.rb +0 -1
  107. data/lib/ruby_llm/providers/perplexity.rb +1 -5
  108. data/lib/ruby_llm/providers/vertexai/chat.rb +14 -0
  109. data/lib/ruby_llm/providers/vertexai/embeddings.rb +32 -0
  110. data/lib/ruby_llm/providers/vertexai/models.rb +130 -0
  111. data/lib/ruby_llm/providers/vertexai/streaming.rb +14 -0
  112. data/lib/ruby_llm/providers/vertexai.rb +55 -0
  113. data/lib/ruby_llm/providers/xai/capabilities.rb +166 -0
  114. data/lib/ruby_llm/providers/xai/chat.rb +15 -0
  115. data/lib/ruby_llm/providers/xai/models.rb +48 -0
  116. data/lib/ruby_llm/providers/xai.rb +46 -0
  117. data/lib/ruby_llm/railtie.rb +20 -4
  118. data/lib/ruby_llm/stream_accumulator.rb +68 -10
  119. data/lib/ruby_llm/streaming.rb +16 -25
  120. data/lib/ruby_llm/tool.rb +2 -19
  121. data/lib/ruby_llm/tool_call.rb +0 -9
  122. data/lib/ruby_llm/utils.rb +5 -9
  123. data/lib/ruby_llm/version.rb +1 -1
  124. data/lib/ruby_llm_community.rb +8 -5
  125. data/lib/tasks/models.rake +549 -0
  126. data/lib/tasks/release.rake +37 -2
  127. data/lib/tasks/ruby_llm.rake +15 -0
  128. data/lib/tasks/vcr.rake +2 -9
  129. metadata +44 -6
  130. data/lib/generators/ruby_llm/install/templates/INSTALL_INFO.md.tt +0 -108
  131. data/lib/generators/ruby_llm/install_generator.rb +0 -121
  132. data/lib/tasks/aliases.rake +0 -235
  133. data/lib/tasks/models_docs.rake +0 -224
  134. data/lib/tasks/models_update.rake +0 -108
@@ -0,0 +1,549 @@
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
+ # Providers
43
+ config.anthropic_api_key = ENV.fetch('ANTHROPIC_API_KEY', nil)
44
+ config.deepseek_api_key = ENV.fetch('DEEPSEEK_API_KEY', nil)
45
+ config.gemini_api_key = ENV.fetch('GEMINI_API_KEY', nil)
46
+ config.mistral_api_key = ENV.fetch('MISTRAL_API_KEY', nil)
47
+ config.openai_api_key = ENV.fetch('OPENAI_API_KEY', nil)
48
+ config.openrouter_api_key = ENV.fetch('OPENROUTER_API_KEY', nil)
49
+ config.perplexity_api_key = ENV.fetch('PERPLEXITY_API_KEY', nil)
50
+ config.vertexai_location = ENV.fetch('GOOGLE_CLOUD_LOCATION', nil)
51
+ config.vertexai_project_id = ENV.fetch('GOOGLE_CLOUD_PROJECT', nil)
52
+ config.xai_api_key = ENV.fetch('XAI_API_KEY', nil)
53
+ configure_bedrock(config)
54
+ # Requests
55
+ config.request_timeout = 30
56
+ end
57
+ end
58
+
59
+ def configure_bedrock(config)
60
+ config.bedrock_api_key = ENV.fetch('AWS_ACCESS_KEY_ID', nil)
61
+ config.bedrock_secret_key = ENV.fetch('AWS_SECRET_ACCESS_KEY', nil)
62
+ config.bedrock_region = ENV.fetch('AWS_REGION', nil)
63
+ config.bedrock_session_token = ENV.fetch('AWS_SESSION_TOKEN', nil)
64
+ end
65
+
66
+ def refresh_models
67
+ initial_count = RubyLLM.models.all.size
68
+ puts "Refreshing models (#{initial_count} cached)..."
69
+
70
+ models = RubyLLM.models.refresh!
71
+
72
+ if models.all.empty? && initial_count.zero?
73
+ puts 'Error: Failed to fetch models.'
74
+ exit(1)
75
+ elsif models.all.size == initial_count && initial_count.positive?
76
+ puts 'Warning: Model list unchanged.'
77
+ else
78
+ puts 'Validating models...'
79
+ validate_models!(models)
80
+
81
+ puts "Saving models.json (#{models.all.size} models)"
82
+ models.save_to_json
83
+ end
84
+
85
+ @models = models
86
+ end
87
+
88
+ def validate_models!(models)
89
+ schema_path = RubyLLM::Models.schema_file
90
+ models_data = models.all.map(&:to_h)
91
+
92
+ validation_errors = JSON::Validator.fully_validate(schema_path, models_data)
93
+
94
+ unless validation_errors.empty?
95
+ # Save failed models for inspection
96
+ failed_path = File.expand_path('../ruby_llm/models.failed.json', __dir__)
97
+ File.write(failed_path, JSON.pretty_generate(models_data))
98
+
99
+ puts 'ERROR: Models validation failed:'
100
+ puts "\nValidation errors:"
101
+ validation_errors.first(10).each { |error| puts " - #{error}" }
102
+ puts " ... and #{validation_errors.size - 10} more errors" if validation_errors.size > 10
103
+ puts "-> Failed models saved to: #{failed_path}"
104
+ exit(1)
105
+ end
106
+
107
+ puts 'āœ“ Models validation passed'
108
+ end
109
+
110
+ def display_model_stats
111
+ puts "\nModel count:"
112
+ provider_counts = @models.all.group_by(&:provider).transform_values(&:count)
113
+
114
+ RubyLLM::Provider.providers.each do |sym, provider_class|
115
+ name = provider_class.name
116
+ count = provider_counts[sym.to_s] || 0
117
+ status = status(sym)
118
+ puts " #{name}: #{count} models #{status}"
119
+ end
120
+
121
+ puts 'Refresh complete.'
122
+ end
123
+
124
+ def status(provider_sym)
125
+ provider_class = RubyLLM::Provider.providers[provider_sym]
126
+ if provider_class.local?
127
+ ' (LOCAL - SKIP)'
128
+ elsif provider_class.configured?(RubyLLM.config)
129
+ ' (OK)'
130
+ else
131
+ ' (NOT CONFIGURED)'
132
+ end
133
+ end
134
+
135
+ def generate_models_markdown
136
+ <<~MARKDOWN
137
+ ---
138
+ layout: default
139
+ title: Available Models
140
+ nav_order: 1
141
+ description: Browse hundreds of AI models from every major provider. Always up-to-date, automatically generated.
142
+ redirect_from:
143
+ - /guides/available-models
144
+ ---
145
+
146
+ # {{ page.title }}
147
+ {: .no_toc }
148
+
149
+ {{ page.description }}
150
+ {: .fs-6 .fw-300 }
151
+
152
+ ## Table of contents
153
+ {: .no_toc .text-delta }
154
+
155
+ 1. TOC
156
+ {:toc}
157
+
158
+ ---
159
+
160
+ ## Model Data Sources
161
+
162
+ - **OpenAI, Anthropic, DeepSeek, Gemini, VertexAI**: Enriched by [šŸš€ Parsera](https://parsera.org/) *([free LLM metadata API](https://api.parsera.org/v1/llm-specs) - [go say thanks!](https://github.com/parsera-labs/api-llm-specs))*
163
+ - **OpenRouter**: Direct API
164
+ - **Others**: Local capabilities files
165
+
166
+ ## Last Updated
167
+ {: .d-inline-block }
168
+
169
+ #{Time.now.utc.strftime('%Y-%m-%d')}
170
+ {: .label .label-green }
171
+
172
+ ## Models by Provider
173
+
174
+ #{generate_provider_sections}
175
+
176
+ ## Models by Capability
177
+
178
+ #{generate_capability_sections}
179
+
180
+ ## Models by Modality
181
+
182
+ #{generate_modality_sections}
183
+ MARKDOWN
184
+ end
185
+
186
+ def generate_provider_sections
187
+ RubyLLM::Provider.providers.filter_map do |provider, provider_class|
188
+ models = RubyLLM.models.by_provider(provider)
189
+ next if models.none?
190
+
191
+ <<~PROVIDER
192
+ ### #{provider_class.name} (#{models.count})
193
+
194
+ #{models_table(models)}
195
+ PROVIDER
196
+ end.join("\n\n")
197
+ end
198
+
199
+ def generate_capability_sections
200
+ capabilities = {
201
+ 'Function Calling' => RubyLLM.models.select(&:function_calling?),
202
+ 'Structured Output' => RubyLLM.models.select(&:structured_output?),
203
+ 'Streaming' => RubyLLM.models.select { |m| m.capabilities.include?('streaming') },
204
+ 'Batch Processing' => RubyLLM.models.select { |m| m.capabilities.include?('batch') }
205
+ }
206
+
207
+ capabilities.filter_map do |capability, models|
208
+ next if models.none?
209
+
210
+ <<~CAPABILITY
211
+ ### #{capability} (#{models.count})
212
+
213
+ #{models_table(models)}
214
+ CAPABILITY
215
+ end.join("\n\n")
216
+ end
217
+
218
+ def generate_modality_sections # rubocop:disable Metrics/PerceivedComplexity
219
+ sections = []
220
+
221
+ vision_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('image') }
222
+ if vision_models.any?
223
+ sections << <<~SECTION
224
+ ### Vision Models (#{vision_models.count})
225
+
226
+ Models that can process images:
227
+
228
+ #{models_table(vision_models)}
229
+ SECTION
230
+ end
231
+
232
+ audio_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('audio') }
233
+ if audio_models.any?
234
+ sections << <<~SECTION
235
+ ### Audio Input Models (#{audio_models.count})
236
+
237
+ Models that can process audio:
238
+
239
+ #{models_table(audio_models)}
240
+ SECTION
241
+ end
242
+
243
+ pdf_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('pdf') }
244
+ if pdf_models.any?
245
+ sections << <<~SECTION
246
+ ### PDF Models (#{pdf_models.count})
247
+
248
+ Models that can process PDF documents:
249
+
250
+ #{models_table(pdf_models)}
251
+ SECTION
252
+ end
253
+
254
+ embedding_models = RubyLLM.models.select { |m| (m.modalities.output || []).include?('embeddings') }
255
+ if embedding_models.any?
256
+ sections << <<~SECTION
257
+ ### Embedding Models (#{embedding_models.count})
258
+
259
+ Models that generate embeddings:
260
+
261
+ #{models_table(embedding_models)}
262
+ SECTION
263
+ end
264
+
265
+ sections.join("\n\n")
266
+ end
267
+
268
+ def models_table(models)
269
+ return '*No models found*' if models.none?
270
+
271
+ headers = ['Model', 'Provider', 'Context', 'Max Output', 'Standard Pricing (per 1M tokens)']
272
+ alignment = [':--', ':--', '--:', '--:', ':--']
273
+
274
+ rows = models.sort_by { |m| [m.provider, m.name] }.map do |model|
275
+ pricing = standard_pricing_display(model)
276
+
277
+ [
278
+ model.id,
279
+ model.provider,
280
+ model.context_window || '-',
281
+ model.max_output_tokens || '-',
282
+ pricing
283
+ ]
284
+ end
285
+
286
+ table = []
287
+ table << "| #{headers.join(' | ')} |"
288
+ table << "| #{alignment.join(' | ')} |"
289
+
290
+ rows.each do |row|
291
+ table << "| #{row.join(' | ')} |"
292
+ end
293
+
294
+ table.join("\n")
295
+ end
296
+
297
+ def standard_pricing_display(model)
298
+ pricing_data = model.pricing.to_h[:text_tokens]&.dig(:standard) || {}
299
+
300
+ if pricing_data.any?
301
+ parts = []
302
+
303
+ parts << "In: $#{format('%.2f', pricing_data[:input_per_million])}" if pricing_data[:input_per_million]
304
+
305
+ parts << "Out: $#{format('%.2f', pricing_data[:output_per_million])}" if pricing_data[:output_per_million]
306
+
307
+ if pricing_data[:cached_input_per_million]
308
+ parts << "Cache: $#{format('%.2f', pricing_data[:cached_input_per_million])}"
309
+ end
310
+
311
+ return parts.join(', ') if parts.any?
312
+ end
313
+
314
+ '-'
315
+ end
316
+
317
+ def generate_aliases # rubocop:disable Metrics/PerceivedComplexity
318
+ models = Hash.new { |h, k| h[k] = [] }
319
+
320
+ RubyLLM.models.all.each do |model|
321
+ models[model.provider] << model.id
322
+ end
323
+
324
+ aliases = {}
325
+
326
+ # OpenAI models
327
+ models['openai'].each do |model|
328
+ openrouter_model = "openai/#{model}"
329
+ next unless models['openrouter'].include?(openrouter_model)
330
+
331
+ alias_key = model.gsub('-latest', '')
332
+ aliases[alias_key] = {
333
+ 'openai' => model,
334
+ 'openrouter' => openrouter_model
335
+ }
336
+ end
337
+
338
+ anthropic_latest = group_anthropic_models_by_base_name(models['anthropic'])
339
+
340
+ anthropic_latest.each do |base_name, latest_model|
341
+ openrouter_variants = [
342
+ "anthropic/#{base_name}",
343
+ "anthropic/#{base_name.gsub(/-(\d)/, '.\1')}",
344
+ "anthropic/#{base_name.gsub(/claude-(\d+)-(\d+)/, 'claude-\1.\2')}",
345
+ "anthropic/#{base_name.gsub(/(\d+)-(\d+)/, '\1.\2')}"
346
+ ]
347
+
348
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
349
+ bedrock_model = find_best_bedrock_model(latest_model, models['bedrock'])
350
+
351
+ next unless openrouter_model || bedrock_model || models['anthropic'].include?(latest_model)
352
+
353
+ aliases[base_name] = { 'anthropic' => latest_model }
354
+ aliases[base_name]['openrouter'] = openrouter_model if openrouter_model
355
+ aliases[base_name]['bedrock'] = bedrock_model if bedrock_model
356
+ end
357
+
358
+ models['bedrock'].each do |bedrock_model|
359
+ next unless bedrock_model.start_with?('anthropic.')
360
+ next unless bedrock_model =~ /anthropic\.(claude-[\d.]+-[a-z]+)/
361
+
362
+ base_name = Regexp.last_match(1)
363
+ anthropic_name = base_name.tr('.', '-')
364
+
365
+ next if aliases[anthropic_name]
366
+
367
+ openrouter_variants = [
368
+ "anthropic/#{anthropic_name}",
369
+ "anthropic/#{base_name}"
370
+ ]
371
+
372
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
373
+
374
+ aliases[anthropic_name] = { 'bedrock' => bedrock_model }
375
+ aliases[anthropic_name]['anthropic'] = anthropic_name if models['anthropic'].include?(anthropic_name)
376
+ aliases[anthropic_name]['openrouter'] = openrouter_model if openrouter_model
377
+ end
378
+
379
+ # Gemini models (also map to vertexai)
380
+ models['gemini'].each do |model|
381
+ openrouter_variants = [
382
+ "google/#{model}",
383
+ "google/#{model.gsub('gemini-', 'gemini-').tr('.', '-')}",
384
+ "google/#{model.gsub('gemini-', 'gemini-')}"
385
+ ]
386
+
387
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
388
+ vertexai_model = models['vertexai'].include?(model) ? model : nil
389
+
390
+ next unless openrouter_model || vertexai_model
391
+
392
+ alias_key = model.gsub('-latest', '')
393
+ aliases[alias_key] = { 'gemini' => model }
394
+ aliases[alias_key]['openrouter'] = openrouter_model if openrouter_model
395
+ aliases[alias_key]['vertexai'] = vertexai_model if vertexai_model
396
+ end
397
+
398
+ # VertexAI models that aren't in Gemini (e.g. older models like text-bison)
399
+ models['vertexai'].each do |model|
400
+ # Skip if already handled above
401
+ next if models['gemini'].include?(model)
402
+
403
+ # Check if OpenRouter has this Google model
404
+ openrouter_variants = [
405
+ "google/#{model}",
406
+ "google/#{model.tr('.', '-')}"
407
+ ]
408
+
409
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
410
+ gemini_model = models['gemini'].include?(model) ? model : nil
411
+
412
+ next unless openrouter_model || gemini_model
413
+
414
+ alias_key = model.gsub('-latest', '')
415
+ next if aliases[alias_key] # Skip if already created
416
+
417
+ aliases[alias_key] = { 'vertexai' => model }
418
+ aliases[alias_key]['openrouter'] = openrouter_model if openrouter_model
419
+ aliases[alias_key]['gemini'] = gemini_model if gemini_model
420
+ end
421
+
422
+ models['deepseek'].each do |model|
423
+ openrouter_model = "deepseek/#{model}"
424
+ next unless models['openrouter'].include?(openrouter_model)
425
+
426
+ alias_key = model.gsub('-latest', '')
427
+ aliases[alias_key] = {
428
+ 'deepseek' => model,
429
+ 'openrouter' => openrouter_model
430
+ }
431
+ end
432
+
433
+ models['xai'].each do |model|
434
+ # xAI aliases
435
+ m = RubyLLM.models.find(model)
436
+ next unless m.metadata&.dig(:aliases)
437
+
438
+ m.metadata[:aliases].each do |alias_name|
439
+ aliases[alias_name] = { 'xai' => m.id }
440
+ end
441
+
442
+ # OpenRouter aliases.
443
+ # NOTE: OpenRouter uses "x-ai" as the provider slug
444
+ openrouter_model = "x-ai/#{model}"
445
+ next unless models['openrouter'].include?(openrouter_model)
446
+
447
+ alias_key = model.gsub('-latest', '').gsub(/-\d{4}/, '-4')
448
+ aliases[alias_key] = {
449
+ 'xai' => alias_key,
450
+ 'openrouter' => openrouter_model
451
+ }
452
+ end
453
+
454
+ sorted_aliases = aliases.sort.to_h
455
+ File.write(RubyLLM::Aliases.aliases_file, JSON.pretty_generate(sorted_aliases))
456
+
457
+ puts "Generated #{sorted_aliases.size} aliases"
458
+ end
459
+
460
+ def group_anthropic_models_by_base_name(anthropic_models)
461
+ grouped = Hash.new { |h, k| h[k] = [] }
462
+
463
+ anthropic_models.each do |model|
464
+ base_name = extract_base_name(model)
465
+ grouped[base_name] << model
466
+ end
467
+
468
+ latest_models = {}
469
+ grouped.each do |base_name, model_list|
470
+ if model_list.size == 1
471
+ latest_models[base_name] = model_list.first
472
+ else
473
+ latest_model = model_list.max_by { |model| extract_date_from_model(model) }
474
+ latest_models[base_name] = latest_model
475
+ end
476
+ end
477
+
478
+ latest_models
479
+ end
480
+
481
+ def extract_base_name(model)
482
+ if model =~ /^(.+)-(\d{8})$/
483
+ Regexp.last_match(1)
484
+ else
485
+ model
486
+ end
487
+ end
488
+
489
+ def extract_date_from_model(model)
490
+ if model =~ /-(\d{8})$/
491
+ Regexp.last_match(1)
492
+ else
493
+ '00000000'
494
+ end
495
+ end
496
+
497
+ def find_best_bedrock_model(anthropic_model, bedrock_models) # rubocop:disable Metrics/PerceivedComplexity
498
+ base_pattern = case anthropic_model
499
+ when 'claude-2.0', 'claude-2'
500
+ 'claude-v2'
501
+ when 'claude-2.1'
502
+ 'claude-v2:1'
503
+ when 'claude-instant-v1', 'claude-instant'
504
+ 'claude-instant'
505
+ else
506
+ extract_base_name(anthropic_model)
507
+ end
508
+
509
+ matching_models = bedrock_models.select do |bedrock_model|
510
+ model_without_prefix = bedrock_model.sub(/^(?:us\.)?anthropic\./, '')
511
+ model_without_prefix.start_with?(base_pattern)
512
+ end
513
+
514
+ return nil if matching_models.empty?
515
+
516
+ begin
517
+ model_info = RubyLLM.models.find(anthropic_model)
518
+ target_context = model_info.context_window
519
+ rescue StandardError
520
+ target_context = nil
521
+ end
522
+
523
+ if target_context
524
+ target_k = target_context / 1000
525
+
526
+ with_context = matching_models.select do |m|
527
+ m.include?(":#{target_k}k") || m.include?(":0:#{target_k}k")
528
+ end
529
+
530
+ return with_context.first if with_context.any?
531
+ end
532
+
533
+ matching_models.min_by do |model|
534
+ context_priority = if model =~ /:(?:\d+:)?(\d+)k/
535
+ -Regexp.last_match(1).to_i
536
+ else
537
+ 0
538
+ end
539
+
540
+ version_priority = if model =~ /-v(\d+):/
541
+ -Regexp.last_match(1).to_i
542
+ else
543
+ 0
544
+ end
545
+
546
+ has_context_priority = model.include?('k') ? -1 : 0
547
+ [has_context_priority, context_priority, version_priority]
548
+ end
549
+ 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)"
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :ruby_llm do
4
+ desc 'Load models from models.json into the database'
5
+ task load_models: :environment do
6
+ if RubyLLM.config.model_registry_class
7
+ RubyLLM.models.load_from_json!
8
+ model_class = RubyLLM.config.model_registry_class.constantize
9
+ model_class.save_to_database
10
+ puts "āœ… Loaded #{model_class.count} models into database"
11
+ else
12
+ puts 'Model registry not configured. Run rails generate ruby_llm:install'
13
+ end
14
+ end
15
+ end
data/lib/tasks/vcr.rake CHANGED
@@ -2,9 +2,7 @@
2
2
 
3
3
  require 'dotenv/load'
4
4
 
5
- # Helper functions at the top level
6
5
  def record_all_cassettes(cassette_dir)
7
- # Re-record all cassettes
8
6
  FileUtils.rm_rf(cassette_dir)
9
7
  FileUtils.mkdir_p(cassette_dir)
10
8
 
@@ -14,10 +12,8 @@ def record_all_cassettes(cassette_dir)
14
12
  end
15
13
 
16
14
  def record_for_providers(providers, cassette_dir)
17
- # Get the list of available providers from RubyLLM itself
18
15
  all_providers = RubyLLM::Provider.providers.keys.map(&:to_s)
19
16
 
20
- # Check for valid providers
21
17
  if providers.empty?
22
18
  puts "Please specify providers or 'all'. Example: rake vcr:record[openai,anthropic]"
23
19
  puts "Available providers: #{all_providers.join(', ')}"
@@ -31,7 +27,6 @@ def record_for_providers(providers, cassette_dir)
31
27
  return
32
28
  end
33
29
 
34
- # Find and delete matching cassettes
35
30
  cassettes_to_delete = find_matching_cassettes(cassette_dir, providers)
36
31
 
37
32
  if cassettes_to_delete.empty?
@@ -54,9 +49,7 @@ def find_matching_cassettes(dir, providers)
54
49
  Dir.glob("#{dir}/**/*.yml").each do |file|
55
50
  basename = File.basename(file)
56
51
 
57
- # Precise matching to avoid cross-provider confusion
58
52
  providers.each do |provider|
59
- # Match only exact provider prefixes
60
53
  next unless basename =~ /^[^_]*_#{provider}_/ || # For first section like "chat_openai_"
61
54
  basename =~ /_#{provider}_[^_]+_/ # For middle sections like "_openai_gpt4_"
62
55
 
@@ -82,11 +75,11 @@ end
82
75
 
83
76
  namespace :vcr do
84
77
  desc 'Record VCR cassettes (rake vcr:record[all] or vcr:record[openai,anthropic])'
85
- task :record, [:providers] do |_, args|
78
+ task :record, :providers do |_, args|
86
79
  require 'fileutils'
87
80
  require 'ruby_llm'
88
81
 
89
- providers = (args[:providers] || '').downcase.split(',')
82
+ providers = args.extras.unshift(args[:providers]).compact.map(&:downcase)
90
83
  cassette_dir = 'spec/fixtures/vcr_cassettes'
91
84
  FileUtils.mkdir_p(cassette_dir)
92
85