dify_llm 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.
Files changed (129) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +157 -0
  4. data/lib/generators/ruby_llm/install/templates/chat_model.rb.tt +3 -0
  5. data/lib/generators/ruby_llm/install/templates/create_chats_legacy_migration.rb.tt +8 -0
  6. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +8 -0
  7. data/lib/generators/ruby_llm/install/templates/create_messages_legacy_migration.rb.tt +16 -0
  8. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +16 -0
  9. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +43 -0
  10. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +15 -0
  11. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +9 -0
  12. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +4 -0
  13. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +3 -0
  14. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +3 -0
  15. data/lib/generators/ruby_llm/install_generator.rb +184 -0
  16. data/lib/generators/ruby_llm/migrate_model_fields/templates/migration.rb.tt +142 -0
  17. data/lib/generators/ruby_llm/migrate_model_fields_generator.rb +84 -0
  18. data/lib/ruby_llm/active_record/acts_as.rb +137 -0
  19. data/lib/ruby_llm/active_record/acts_as_legacy.rb +398 -0
  20. data/lib/ruby_llm/active_record/chat_methods.rb +315 -0
  21. data/lib/ruby_llm/active_record/message_methods.rb +72 -0
  22. data/lib/ruby_llm/active_record/model_methods.rb +84 -0
  23. data/lib/ruby_llm/aliases.json +274 -0
  24. data/lib/ruby_llm/aliases.rb +38 -0
  25. data/lib/ruby_llm/attachment.rb +191 -0
  26. data/lib/ruby_llm/chat.rb +212 -0
  27. data/lib/ruby_llm/chunk.rb +6 -0
  28. data/lib/ruby_llm/configuration.rb +69 -0
  29. data/lib/ruby_llm/connection.rb +137 -0
  30. data/lib/ruby_llm/content.rb +50 -0
  31. data/lib/ruby_llm/context.rb +29 -0
  32. data/lib/ruby_llm/embedding.rb +29 -0
  33. data/lib/ruby_llm/error.rb +76 -0
  34. data/lib/ruby_llm/image.rb +49 -0
  35. data/lib/ruby_llm/message.rb +76 -0
  36. data/lib/ruby_llm/mime_type.rb +67 -0
  37. data/lib/ruby_llm/model/info.rb +103 -0
  38. data/lib/ruby_llm/model/modalities.rb +22 -0
  39. data/lib/ruby_llm/model/pricing.rb +48 -0
  40. data/lib/ruby_llm/model/pricing_category.rb +46 -0
  41. data/lib/ruby_llm/model/pricing_tier.rb +33 -0
  42. data/lib/ruby_llm/model.rb +7 -0
  43. data/lib/ruby_llm/models.json +31418 -0
  44. data/lib/ruby_llm/models.rb +235 -0
  45. data/lib/ruby_llm/models_schema.json +168 -0
  46. data/lib/ruby_llm/provider.rb +215 -0
  47. data/lib/ruby_llm/providers/anthropic/capabilities.rb +134 -0
  48. data/lib/ruby_llm/providers/anthropic/chat.rb +106 -0
  49. data/lib/ruby_llm/providers/anthropic/embeddings.rb +20 -0
  50. data/lib/ruby_llm/providers/anthropic/media.rb +91 -0
  51. data/lib/ruby_llm/providers/anthropic/models.rb +48 -0
  52. data/lib/ruby_llm/providers/anthropic/streaming.rb +43 -0
  53. data/lib/ruby_llm/providers/anthropic/tools.rb +107 -0
  54. data/lib/ruby_llm/providers/anthropic.rb +36 -0
  55. data/lib/ruby_llm/providers/bedrock/capabilities.rb +167 -0
  56. data/lib/ruby_llm/providers/bedrock/chat.rb +63 -0
  57. data/lib/ruby_llm/providers/bedrock/media.rb +60 -0
  58. data/lib/ruby_llm/providers/bedrock/models.rb +98 -0
  59. data/lib/ruby_llm/providers/bedrock/signing.rb +831 -0
  60. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +51 -0
  61. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +56 -0
  62. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +67 -0
  63. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +78 -0
  64. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +78 -0
  65. data/lib/ruby_llm/providers/bedrock/streaming.rb +18 -0
  66. data/lib/ruby_llm/providers/bedrock.rb +82 -0
  67. data/lib/ruby_llm/providers/deepseek/capabilities.rb +130 -0
  68. data/lib/ruby_llm/providers/deepseek/chat.rb +16 -0
  69. data/lib/ruby_llm/providers/deepseek.rb +30 -0
  70. data/lib/ruby_llm/providers/dify/capabilities.rb +16 -0
  71. data/lib/ruby_llm/providers/dify/chat.rb +59 -0
  72. data/lib/ruby_llm/providers/dify/media.rb +37 -0
  73. data/lib/ruby_llm/providers/dify/streaming.rb +28 -0
  74. data/lib/ruby_llm/providers/dify.rb +48 -0
  75. data/lib/ruby_llm/providers/gemini/capabilities.rb +276 -0
  76. data/lib/ruby_llm/providers/gemini/chat.rb +171 -0
  77. data/lib/ruby_llm/providers/gemini/embeddings.rb +37 -0
  78. data/lib/ruby_llm/providers/gemini/images.rb +47 -0
  79. data/lib/ruby_llm/providers/gemini/media.rb +54 -0
  80. data/lib/ruby_llm/providers/gemini/models.rb +40 -0
  81. data/lib/ruby_llm/providers/gemini/streaming.rb +61 -0
  82. data/lib/ruby_llm/providers/gemini/tools.rb +77 -0
  83. data/lib/ruby_llm/providers/gemini.rb +36 -0
  84. data/lib/ruby_llm/providers/gpustack/chat.rb +27 -0
  85. data/lib/ruby_llm/providers/gpustack/media.rb +45 -0
  86. data/lib/ruby_llm/providers/gpustack/models.rb +90 -0
  87. data/lib/ruby_llm/providers/gpustack.rb +34 -0
  88. data/lib/ruby_llm/providers/mistral/capabilities.rb +155 -0
  89. data/lib/ruby_llm/providers/mistral/chat.rb +24 -0
  90. data/lib/ruby_llm/providers/mistral/embeddings.rb +33 -0
  91. data/lib/ruby_llm/providers/mistral/models.rb +48 -0
  92. data/lib/ruby_llm/providers/mistral.rb +32 -0
  93. data/lib/ruby_llm/providers/ollama/chat.rb +27 -0
  94. data/lib/ruby_llm/providers/ollama/media.rb +45 -0
  95. data/lib/ruby_llm/providers/ollama/models.rb +36 -0
  96. data/lib/ruby_llm/providers/ollama.rb +30 -0
  97. data/lib/ruby_llm/providers/openai/capabilities.rb +291 -0
  98. data/lib/ruby_llm/providers/openai/chat.rb +83 -0
  99. data/lib/ruby_llm/providers/openai/embeddings.rb +33 -0
  100. data/lib/ruby_llm/providers/openai/images.rb +38 -0
  101. data/lib/ruby_llm/providers/openai/media.rb +80 -0
  102. data/lib/ruby_llm/providers/openai/models.rb +39 -0
  103. data/lib/ruby_llm/providers/openai/streaming.rb +41 -0
  104. data/lib/ruby_llm/providers/openai/tools.rb +78 -0
  105. data/lib/ruby_llm/providers/openai.rb +42 -0
  106. data/lib/ruby_llm/providers/openrouter/models.rb +73 -0
  107. data/lib/ruby_llm/providers/openrouter.rb +26 -0
  108. data/lib/ruby_llm/providers/perplexity/capabilities.rb +137 -0
  109. data/lib/ruby_llm/providers/perplexity/chat.rb +16 -0
  110. data/lib/ruby_llm/providers/perplexity/models.rb +42 -0
  111. data/lib/ruby_llm/providers/perplexity.rb +48 -0
  112. data/lib/ruby_llm/providers/vertexai/chat.rb +14 -0
  113. data/lib/ruby_llm/providers/vertexai/embeddings.rb +32 -0
  114. data/lib/ruby_llm/providers/vertexai/models.rb +130 -0
  115. data/lib/ruby_llm/providers/vertexai/streaming.rb +14 -0
  116. data/lib/ruby_llm/providers/vertexai.rb +55 -0
  117. data/lib/ruby_llm/railtie.rb +41 -0
  118. data/lib/ruby_llm/stream_accumulator.rb +97 -0
  119. data/lib/ruby_llm/streaming.rb +153 -0
  120. data/lib/ruby_llm/tool.rb +83 -0
  121. data/lib/ruby_llm/tool_call.rb +22 -0
  122. data/lib/ruby_llm/utils.rb +45 -0
  123. data/lib/ruby_llm/version.rb +5 -0
  124. data/lib/ruby_llm.rb +97 -0
  125. data/lib/tasks/models.rake +525 -0
  126. data/lib/tasks/release.rake +67 -0
  127. data/lib/tasks/ruby_llm.rake +15 -0
  128. data/lib/tasks/vcr.rake +92 -0
  129. metadata +291 -0
data/lib/ruby_llm.rb ADDED
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'event_stream_parser'
5
+ require 'faraday'
6
+ require 'faraday/retry'
7
+ require 'faraday/multipart'
8
+ require 'json'
9
+ require 'logger'
10
+ require 'securerandom'
11
+ require 'zeitwerk'
12
+
13
+ loader = Zeitwerk::Loader.for_gem
14
+ loader.inflector.inflect(
15
+ 'ruby_llm' => 'RubyLLM',
16
+ 'llm' => 'LLM',
17
+ 'openai' => 'OpenAI',
18
+ 'api' => 'API',
19
+ 'deepseek' => 'DeepSeek',
20
+ 'perplexity' => 'Perplexity',
21
+ 'bedrock' => 'Bedrock',
22
+ 'openrouter' => 'OpenRouter',
23
+ 'gpustack' => 'GPUStack',
24
+ 'mistral' => 'Mistral',
25
+ 'vertexai' => 'VertexAI',
26
+ 'pdf' => 'PDF'
27
+ )
28
+ loader.ignore("#{__dir__}/tasks")
29
+ loader.ignore("#{__dir__}/generators")
30
+ loader.setup
31
+
32
+ # A delightful Ruby interface to modern AI language models.
33
+ module RubyLLM
34
+ class Error < StandardError; end
35
+
36
+ class << self
37
+ def context
38
+ context_config = config.dup
39
+ yield context_config if block_given?
40
+ Context.new(context_config)
41
+ end
42
+
43
+ def chat(...)
44
+ Chat.new(...)
45
+ end
46
+
47
+ def embed(...)
48
+ Embedding.embed(...)
49
+ end
50
+
51
+ def paint(...)
52
+ Image.paint(...)
53
+ end
54
+
55
+ def models
56
+ Models.instance
57
+ end
58
+
59
+ def providers
60
+ Provider.providers.values
61
+ end
62
+
63
+ def configure
64
+ yield config
65
+ end
66
+
67
+ def config
68
+ @config ||= Configuration.new
69
+ end
70
+
71
+ def logger
72
+ @logger ||= config.logger || Logger.new(
73
+ config.log_file,
74
+ progname: 'RubyLLM',
75
+ level: config.log_level
76
+ )
77
+ end
78
+ end
79
+ end
80
+
81
+ RubyLLM::Provider.register :anthropic, RubyLLM::Providers::Anthropic
82
+ RubyLLM::Provider.register :bedrock, RubyLLM::Providers::Bedrock
83
+ RubyLLM::Provider.register :deepseek, RubyLLM::Providers::DeepSeek
84
+ RubyLLM::Provider.register :dify, RubyLLM::Providers::Dify
85
+ RubyLLM::Provider.register :gemini, RubyLLM::Providers::Gemini
86
+ RubyLLM::Provider.register :gpustack, RubyLLM::Providers::GPUStack
87
+ RubyLLM::Provider.register :mistral, RubyLLM::Providers::Mistral
88
+ RubyLLM::Provider.register :ollama, RubyLLM::Providers::Ollama
89
+ RubyLLM::Provider.register :openai, RubyLLM::Providers::OpenAI
90
+ RubyLLM::Provider.register :openrouter, RubyLLM::Providers::OpenRouter
91
+ RubyLLM::Provider.register :perplexity, RubyLLM::Providers::Perplexity
92
+ RubyLLM::Provider.register :vertexai, RubyLLM::Providers::VertexAI
93
+
94
+ if defined?(Rails::Railtie)
95
+ require 'ruby_llm/railtie'
96
+ require 'ruby_llm/active_record/acts_as'
97
+ end
@@ -0,0 +1,525 @@
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
+ config.vertexai_location = ENV.fetch('GOOGLE_CLOUD_LOCATION', nil)
50
+ config.vertexai_project_id = ENV.fetch('GOOGLE_CLOUD_PROJECT', nil)
51
+ configure_bedrock(config)
52
+ config.request_timeout = 30
53
+ end
54
+ end
55
+
56
+ def configure_bedrock(config)
57
+ config.bedrock_api_key = ENV.fetch('AWS_ACCESS_KEY_ID', nil)
58
+ config.bedrock_secret_key = ENV.fetch('AWS_SECRET_ACCESS_KEY', nil)
59
+ config.bedrock_region = ENV.fetch('AWS_REGION', nil)
60
+ config.bedrock_session_token = ENV.fetch('AWS_SESSION_TOKEN', nil)
61
+ end
62
+
63
+ def refresh_models
64
+ initial_count = RubyLLM.models.all.size
65
+ puts "Refreshing models (#{initial_count} cached)..."
66
+
67
+ models = RubyLLM.models.refresh!
68
+
69
+ if models.all.empty? && initial_count.zero?
70
+ puts 'Error: Failed to fetch models.'
71
+ exit(1)
72
+ elsif models.all.size == initial_count && initial_count.positive?
73
+ puts 'Warning: Model list unchanged.'
74
+ else
75
+ puts 'Validating models...'
76
+ validate_models!(models)
77
+
78
+ puts "Saving models.json (#{models.all.size} models)"
79
+ models.save_to_json
80
+ end
81
+
82
+ @models = models
83
+ end
84
+
85
+ def validate_models!(models)
86
+ schema_path = RubyLLM::Models.schema_file
87
+ models_data = models.all.map(&:to_h)
88
+
89
+ validation_errors = JSON::Validator.fully_validate(schema_path, models_data)
90
+
91
+ unless validation_errors.empty?
92
+ # Save failed models for inspection
93
+ failed_path = File.expand_path('../ruby_llm/models.failed.json', __dir__)
94
+ File.write(failed_path, JSON.pretty_generate(models_data))
95
+
96
+ puts 'ERROR: Models validation failed:'
97
+ puts "\nValidation errors:"
98
+ validation_errors.first(10).each { |error| puts " - #{error}" }
99
+ puts " ... and #{validation_errors.size - 10} more errors" if validation_errors.size > 10
100
+ puts "-> Failed models saved to: #{failed_path}"
101
+ exit(1)
102
+ end
103
+
104
+ puts 'āœ“ Models validation passed'
105
+ end
106
+
107
+ def display_model_stats
108
+ puts "\nModel count:"
109
+ provider_counts = @models.all.group_by(&:provider).transform_values(&:count)
110
+
111
+ RubyLLM::Provider.providers.each do |sym, provider_class|
112
+ name = provider_class.name
113
+ count = provider_counts[sym.to_s] || 0
114
+ status = status(sym)
115
+ puts " #{name}: #{count} models #{status}"
116
+ end
117
+
118
+ puts 'Refresh complete.'
119
+ end
120
+
121
+ def status(provider_sym)
122
+ provider_class = RubyLLM::Provider.providers[provider_sym]
123
+ if provider_class.local?
124
+ ' (LOCAL - SKIP)'
125
+ elsif provider_class.configured?(RubyLLM.config)
126
+ ' (OK)'
127
+ else
128
+ ' (NOT CONFIGURED)'
129
+ end
130
+ end
131
+
132
+ def generate_models_markdown
133
+ <<~MARKDOWN
134
+ ---
135
+ layout: default
136
+ title: Available Models
137
+ nav_order: 1
138
+ description: Browse hundreds of AI models from every major provider. Always up-to-date, automatically generated.
139
+ redirect_from:
140
+ - /guides/available-models
141
+ ---
142
+
143
+ # {{ page.title }}
144
+ {: .no_toc }
145
+
146
+ {{ page.description }}
147
+ {: .fs-6 .fw-300 }
148
+
149
+ ## Table of contents
150
+ {: .no_toc .text-delta }
151
+
152
+ 1. TOC
153
+ {:toc}
154
+
155
+ ---
156
+
157
+ ## Model Data Sources
158
+
159
+ - **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))*
160
+ - **OpenRouter**: Direct API
161
+ - **Others**: Local capabilities files
162
+
163
+ ## Last Updated
164
+ {: .d-inline-block }
165
+
166
+ #{Time.now.utc.strftime('%Y-%m-%d')}
167
+ {: .label .label-green }
168
+
169
+ ## Models by Provider
170
+
171
+ #{generate_provider_sections}
172
+
173
+ ## Models by Capability
174
+
175
+ #{generate_capability_sections}
176
+
177
+ ## Models by Modality
178
+
179
+ #{generate_modality_sections}
180
+ MARKDOWN
181
+ end
182
+
183
+ def generate_provider_sections
184
+ RubyLLM::Provider.providers.filter_map do |provider, provider_class|
185
+ models = RubyLLM.models.by_provider(provider)
186
+ next if models.none?
187
+
188
+ <<~PROVIDER
189
+ ### #{provider_class.name} (#{models.count})
190
+
191
+ #{models_table(models)}
192
+ PROVIDER
193
+ end.join("\n\n")
194
+ end
195
+
196
+ def generate_capability_sections
197
+ capabilities = {
198
+ 'Function Calling' => RubyLLM.models.select(&:function_calling?),
199
+ 'Structured Output' => RubyLLM.models.select(&:structured_output?),
200
+ 'Streaming' => RubyLLM.models.select { |m| m.capabilities.include?('streaming') },
201
+ 'Batch Processing' => RubyLLM.models.select { |m| m.capabilities.include?('batch') }
202
+ }
203
+
204
+ capabilities.filter_map do |capability, models|
205
+ next if models.none?
206
+
207
+ <<~CAPABILITY
208
+ ### #{capability} (#{models.count})
209
+
210
+ #{models_table(models)}
211
+ CAPABILITY
212
+ end.join("\n\n")
213
+ end
214
+
215
+ def generate_modality_sections # rubocop:disable Metrics/PerceivedComplexity
216
+ sections = []
217
+
218
+ vision_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('image') }
219
+ if vision_models.any?
220
+ sections << <<~SECTION
221
+ ### Vision Models (#{vision_models.count})
222
+
223
+ Models that can process images:
224
+
225
+ #{models_table(vision_models)}
226
+ SECTION
227
+ end
228
+
229
+ audio_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('audio') }
230
+ if audio_models.any?
231
+ sections << <<~SECTION
232
+ ### Audio Input Models (#{audio_models.count})
233
+
234
+ Models that can process audio:
235
+
236
+ #{models_table(audio_models)}
237
+ SECTION
238
+ end
239
+
240
+ pdf_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('pdf') }
241
+ if pdf_models.any?
242
+ sections << <<~SECTION
243
+ ### PDF Models (#{pdf_models.count})
244
+
245
+ Models that can process PDF documents:
246
+
247
+ #{models_table(pdf_models)}
248
+ SECTION
249
+ end
250
+
251
+ embedding_models = RubyLLM.models.select { |m| (m.modalities.output || []).include?('embeddings') }
252
+ if embedding_models.any?
253
+ sections << <<~SECTION
254
+ ### Embedding Models (#{embedding_models.count})
255
+
256
+ Models that generate embeddings:
257
+
258
+ #{models_table(embedding_models)}
259
+ SECTION
260
+ end
261
+
262
+ sections.join("\n\n")
263
+ end
264
+
265
+ def models_table(models)
266
+ return '*No models found*' if models.none?
267
+
268
+ headers = ['Model', 'Provider', 'Context', 'Max Output', 'Standard Pricing (per 1M tokens)']
269
+ alignment = [':--', ':--', '--:', '--:', ':--']
270
+
271
+ rows = models.sort_by { |m| [m.provider, m.name] }.map do |model|
272
+ pricing = standard_pricing_display(model)
273
+
274
+ [
275
+ model.id,
276
+ model.provider,
277
+ model.context_window || '-',
278
+ model.max_output_tokens || '-',
279
+ pricing
280
+ ]
281
+ end
282
+
283
+ table = []
284
+ table << "| #{headers.join(' | ')} |"
285
+ table << "| #{alignment.join(' | ')} |"
286
+
287
+ rows.each do |row|
288
+ table << "| #{row.join(' | ')} |"
289
+ end
290
+
291
+ table.join("\n")
292
+ end
293
+
294
+ def standard_pricing_display(model)
295
+ pricing_data = model.pricing.to_h[:text_tokens]&.dig(:standard) || {}
296
+
297
+ if pricing_data.any?
298
+ parts = []
299
+
300
+ parts << "In: $#{format('%.2f', pricing_data[:input_per_million])}" if pricing_data[:input_per_million]
301
+
302
+ parts << "Out: $#{format('%.2f', pricing_data[:output_per_million])}" if pricing_data[:output_per_million]
303
+
304
+ if pricing_data[:cached_input_per_million]
305
+ parts << "Cache: $#{format('%.2f', pricing_data[:cached_input_per_million])}"
306
+ end
307
+
308
+ return parts.join(', ') if parts.any?
309
+ end
310
+
311
+ '-'
312
+ end
313
+
314
+ def generate_aliases # rubocop:disable Metrics/PerceivedComplexity
315
+ models = Hash.new { |h, k| h[k] = [] }
316
+
317
+ RubyLLM.models.all.each do |model|
318
+ models[model.provider] << model.id
319
+ end
320
+
321
+ aliases = {}
322
+
323
+ # OpenAI models
324
+ models['openai'].each do |model|
325
+ openrouter_model = "openai/#{model}"
326
+ next unless models['openrouter'].include?(openrouter_model)
327
+
328
+ alias_key = model.gsub('-latest', '')
329
+ aliases[alias_key] = {
330
+ 'openai' => model,
331
+ 'openrouter' => openrouter_model
332
+ }
333
+ end
334
+
335
+ anthropic_latest = group_anthropic_models_by_base_name(models['anthropic'])
336
+
337
+ anthropic_latest.each do |base_name, latest_model|
338
+ openrouter_variants = [
339
+ "anthropic/#{base_name}",
340
+ "anthropic/#{base_name.gsub(/-(\d)/, '.\1')}",
341
+ "anthropic/#{base_name.gsub(/claude-(\d+)-(\d+)/, 'claude-\1.\2')}",
342
+ "anthropic/#{base_name.gsub(/(\d+)-(\d+)/, '\1.\2')}"
343
+ ]
344
+
345
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
346
+ bedrock_model = find_best_bedrock_model(latest_model, models['bedrock'])
347
+
348
+ next unless openrouter_model || bedrock_model || models['anthropic'].include?(latest_model)
349
+
350
+ aliases[base_name] = { 'anthropic' => latest_model }
351
+ aliases[base_name]['openrouter'] = openrouter_model if openrouter_model
352
+ aliases[base_name]['bedrock'] = bedrock_model if bedrock_model
353
+ end
354
+
355
+ models['bedrock'].each do |bedrock_model|
356
+ next unless bedrock_model.start_with?('anthropic.')
357
+ next unless bedrock_model =~ /anthropic\.(claude-[\d.]+-[a-z]+)/
358
+
359
+ base_name = Regexp.last_match(1)
360
+ anthropic_name = base_name.tr('.', '-')
361
+
362
+ next if aliases[anthropic_name]
363
+
364
+ openrouter_variants = [
365
+ "anthropic/#{anthropic_name}",
366
+ "anthropic/#{base_name}"
367
+ ]
368
+
369
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
370
+
371
+ aliases[anthropic_name] = { 'bedrock' => bedrock_model }
372
+ aliases[anthropic_name]['anthropic'] = anthropic_name if models['anthropic'].include?(anthropic_name)
373
+ aliases[anthropic_name]['openrouter'] = openrouter_model if openrouter_model
374
+ end
375
+
376
+ # Gemini models (also map to vertexai)
377
+ models['gemini'].each do |model|
378
+ openrouter_variants = [
379
+ "google/#{model}",
380
+ "google/#{model.gsub('gemini-', 'gemini-').tr('.', '-')}",
381
+ "google/#{model.gsub('gemini-', 'gemini-')}"
382
+ ]
383
+
384
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
385
+ vertexai_model = models['vertexai'].include?(model) ? model : nil
386
+
387
+ next unless openrouter_model || vertexai_model
388
+
389
+ alias_key = model.gsub('-latest', '')
390
+ aliases[alias_key] = { 'gemini' => model }
391
+ aliases[alias_key]['openrouter'] = openrouter_model if openrouter_model
392
+ aliases[alias_key]['vertexai'] = vertexai_model if vertexai_model
393
+ end
394
+
395
+ # VertexAI models that aren't in Gemini (e.g. older models like text-bison)
396
+ models['vertexai'].each do |model|
397
+ # Skip if already handled above
398
+ next if models['gemini'].include?(model)
399
+
400
+ # Check if OpenRouter has this Google model
401
+ openrouter_variants = [
402
+ "google/#{model}",
403
+ "google/#{model.tr('.', '-')}"
404
+ ]
405
+
406
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
407
+ gemini_model = models['gemini'].include?(model) ? model : nil
408
+
409
+ next unless openrouter_model || gemini_model
410
+
411
+ alias_key = model.gsub('-latest', '')
412
+ next if aliases[alias_key] # Skip if already created
413
+
414
+ aliases[alias_key] = { 'vertexai' => model }
415
+ aliases[alias_key]['openrouter'] = openrouter_model if openrouter_model
416
+ aliases[alias_key]['gemini'] = gemini_model if gemini_model
417
+ end
418
+
419
+ models['deepseek'].each do |model|
420
+ openrouter_model = "deepseek/#{model}"
421
+ next unless models['openrouter'].include?(openrouter_model)
422
+
423
+ alias_key = model.gsub('-latest', '')
424
+ aliases[alias_key] = {
425
+ 'deepseek' => model,
426
+ 'openrouter' => openrouter_model
427
+ }
428
+ end
429
+
430
+ sorted_aliases = aliases.sort.to_h
431
+ File.write(RubyLLM::Aliases.aliases_file, JSON.pretty_generate(sorted_aliases))
432
+
433
+ puts "Generated #{sorted_aliases.size} aliases"
434
+ end
435
+
436
+ def group_anthropic_models_by_base_name(anthropic_models)
437
+ grouped = Hash.new { |h, k| h[k] = [] }
438
+
439
+ anthropic_models.each do |model|
440
+ base_name = extract_base_name(model)
441
+ grouped[base_name] << model
442
+ end
443
+
444
+ latest_models = {}
445
+ grouped.each do |base_name, model_list|
446
+ if model_list.size == 1
447
+ latest_models[base_name] = model_list.first
448
+ else
449
+ latest_model = model_list.max_by { |model| extract_date_from_model(model) }
450
+ latest_models[base_name] = latest_model
451
+ end
452
+ end
453
+
454
+ latest_models
455
+ end
456
+
457
+ def extract_base_name(model)
458
+ if model =~ /^(.+)-(\d{8})$/
459
+ Regexp.last_match(1)
460
+ else
461
+ model
462
+ end
463
+ end
464
+
465
+ def extract_date_from_model(model)
466
+ if model =~ /-(\d{8})$/
467
+ Regexp.last_match(1)
468
+ else
469
+ '00000000'
470
+ end
471
+ end
472
+
473
+ def find_best_bedrock_model(anthropic_model, bedrock_models) # rubocop:disable Metrics/PerceivedComplexity
474
+ base_pattern = case anthropic_model
475
+ when 'claude-2.0', 'claude-2'
476
+ 'claude-v2'
477
+ when 'claude-2.1'
478
+ 'claude-v2:1'
479
+ when 'claude-instant-v1', 'claude-instant'
480
+ 'claude-instant'
481
+ else
482
+ extract_base_name(anthropic_model)
483
+ end
484
+
485
+ matching_models = bedrock_models.select do |bedrock_model|
486
+ model_without_prefix = bedrock_model.sub(/^(?:us\.)?anthropic\./, '')
487
+ model_without_prefix.start_with?(base_pattern)
488
+ end
489
+
490
+ return nil if matching_models.empty?
491
+
492
+ begin
493
+ model_info = RubyLLM.models.find(anthropic_model)
494
+ target_context = model_info.context_window
495
+ rescue StandardError
496
+ target_context = nil
497
+ end
498
+
499
+ if target_context
500
+ target_k = target_context / 1000
501
+
502
+ with_context = matching_models.select do |m|
503
+ m.include?(":#{target_k}k") || m.include?(":0:#{target_k}k")
504
+ end
505
+
506
+ return with_context.first if with_context.any?
507
+ end
508
+
509
+ matching_models.min_by do |model|
510
+ context_priority = if model =~ /:(?:\d+:)?(\d+)k/
511
+ -Regexp.last_match(1).to_i
512
+ else
513
+ 0
514
+ end
515
+
516
+ version_priority = if model =~ /-v(\d+):/
517
+ -Regexp.last_match(1).to_i
518
+ else
519
+ 0
520
+ end
521
+
522
+ has_context_priority = model.include?('k') ? -1 : 0
523
+ [has_context_priority, context_priority, version_priority]
524
+ end
525
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
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
+
36
+ desc 'Verify cassettes are fresh enough for release'
37
+ task :verify_cassettes do
38
+ max_age_days = 1
39
+ cassette_dir = 'spec/fixtures/vcr_cassettes'
40
+ stale_cassettes = []
41
+
42
+ Dir.glob("#{cassette_dir}/**/*.yml").each do |cassette|
43
+ age_days = (Time.now - File.mtime(cassette)) / 86_400
44
+
45
+ next unless age_days > max_age_days
46
+
47
+ stale_cassettes << {
48
+ file: File.basename(cassette),
49
+ age: age_days.round(1)
50
+ }
51
+ end
52
+
53
+ if stale_cassettes.any?
54
+ puts "\nāŒ Found stale cassettes (older than #{max_age_days} days):"
55
+ stale_files = []
56
+ stale_cassettes.each do |c|
57
+ puts " - #{c[:file]} (#{c[:age]} days old)"
58
+ stale_files << File.join(cassette_dir, '**', c[:file])
59
+ end
60
+
61
+ puts "\nRun locally: bundle exec rake release:refresh_stale_cassettes"
62
+ exit 1
63
+ else
64
+ puts "āœ… All cassettes are fresh (< #{max_age_days} days old)"
65
+ end
66
+ end
67
+ end
@@ -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