# frozen_string_literal: true
require ‘dotenv/load’ require ‘ruby_llm’ require ‘json’ require ‘json-schema’ require ‘fileutils’
desc ‘Update models, docs, and aliases’ task models: [‘models:update’, ‘models:docs’, ‘models:aliases’]
namespace :models do
desc 'Update available models from providers (API keys needed)' task :update do puts 'Configuring RubyLLM...' configure_from_env refresh_models display_model_stats end desc 'Generate available models documentation' task :docs do FileUtils.mkdir_p('docs/_reference') output = generate_models_markdown File.write('docs/_reference/available-models.md', output) puts 'Generated docs/_reference/available-models.md' end desc 'Generate model aliases from registry' task :aliases do generate_aliases end
end
# Keep aliases:generate for backwards compatibility namespace :aliases do
task generate: ['models:aliases']
end
def configure_from_env
RubyLLM.configure do |config| config.anthropic_api_key = ENV.fetch('ANTHROPIC_API_KEY', nil) config.azure_api_base = ENV.fetch('AZURE_API_BASE', nil) config.azure_api_key = ENV.fetch('AZURE_API_KEY', nil) config.deepseek_api_key = ENV.fetch('DEEPSEEK_API_KEY', nil) config.gemini_api_key = ENV.fetch('GEMINI_API_KEY', nil) config.mistral_api_key = ENV.fetch('MISTRAL_API_KEY', nil) config.openai_api_key = ENV.fetch('OPENAI_API_KEY', nil) config.openrouter_api_key = ENV.fetch('OPENROUTER_API_KEY', nil) config.perplexity_api_key = ENV.fetch('PERPLEXITY_API_KEY', nil) config.vertexai_location = ENV.fetch('GOOGLE_CLOUD_LOCATION', nil) config.vertexai_project_id = ENV.fetch('GOOGLE_CLOUD_PROJECT', nil) config.xai_api_key = ENV.fetch('XAI_API_KEY', nil) configure_bedrock(config) config.request_timeout = 30 end
end
def configure_bedrock(config)
config.bedrock_api_key = ENV.fetch('AWS_ACCESS_KEY_ID', nil) config.bedrock_secret_key = ENV.fetch('AWS_SECRET_ACCESS_KEY', nil) config.bedrock_region = ENV.fetch('AWS_REGION', nil) config.bedrock_session_token = ENV.fetch('AWS_SESSION_TOKEN', nil)
end
def refresh_models
existing_models = RubyLLM::Models.read_from_json initial_count = existing_models.size puts "Refreshing models (#{initial_count} cached)..." models = RubyLLM.models.refresh! if models.all.empty? && initial_count.zero? puts 'Error: Failed to fetch models.' exit(1) else existing_data = sorted_models_data(existing_models) new_data = sorted_models_data(models.all) if new_data == existing_data && initial_count.positive? puts 'Warning: Model list unchanged.' else puts 'Validating models...' validate_models!(models) puts "Saving models.json (#{models.all.size} models)" models.save_to_json end end @models = models
end
def sorted_models_data(models)
models.map(&:to_h) .sort_by { |model| [model[:provider].to_s, model[:id].to_s] }
end
def validate_models!(models)
schema_path = RubyLLM::Models.schema_file models_data = models.all.map(&:to_h) validation_errors = JSON::Validator.fully_validate(schema_path, models_data) unless validation_errors.empty? # Save failed models for inspection failed_path = File.expand_path('../ruby_llm/models.failed.json', __dir__) File.write(failed_path, JSON.pretty_generate(models_data)) puts 'ERROR: Models validation failed:' puts "\nValidation errors:" validation_errors.first(10).each { |error| puts " - #{error}" } puts " ... and #{validation_errors.size - 10} more errors" if validation_errors.size > 10 puts "-> Failed models saved to: #{failed_path}" exit(1) end puts '✓ Models validation passed'
end
def display_model_stats
puts "\nModel count:" provider_counts = @models.all.group_by(&:provider).transform_values(&:count) RubyLLM::Provider.providers.each do |sym, provider_class| name = provider_class.name count = provider_counts[sym.to_s] || 0 status = status(sym) puts " #{name}: #{count} models #{status}" end puts 'Refresh complete.'
end
def status(provider_sym)
provider_class = RubyLLM::Provider.providers[provider_sym] if provider_class.local? ' (LOCAL - SKIP)' elsif provider_class.configured?(RubyLLM.config) ' (OK)' else ' (NOT CONFIGURED)' end
end
def generate_models_markdown
models = RubyLLM.models.all total_models = models.count provider_count = models.map(&:provider).uniq.count generated_on = Time.now.utc.strftime('%Y-%m-%d') <<~MARKDOWN --- layout: default title: Available Models nav_order: 1 description: Browse #{total_models} AI models across #{provider_count} providers (not including local providers). Updated #{generated_on}. redirect_from: - /guides/available-models --- # {{ page.title }} {: .no_toc } {{ page.description }} {: .fs-6 .fw-300 } ## Table of contents {: .no_toc .text-delta } 1. TOC {:toc} --- _Model information enriched by [models.dev](https://models.dev) and our custom code._ Can't find a newly released model? Refresh your registry: ```ruby # Plain Ruby RubyLLM.models.refresh! # Rails Model.refresh! ``` See [Model Registry: Refreshing the Registry]({% link _advanced/models.md %}#refreshing-the-registry). ## Models by Provider #{generate_provider_sections} ## Models by Capability #{generate_capability_sections} ## Models by Modality #{generate_modality_sections} MARKDOWN
end
def generate_provider_sections
RubyLLM::Provider.providers.filter_map do |provider, provider_class| models = RubyLLM.models.by_provider(provider) next if models.none? <<~PROVIDER ### #{provider_class.name} (#{models.count}) #{models_table(models)} PROVIDER end.join("\n\n")
end
def generate_capability_sections
capabilities = { 'Function Calling' => RubyLLM.models.select(&:function_calling?), 'Structured Output' => RubyLLM.models.select(&:structured_output?), 'Streaming' => RubyLLM.models.select { |m| m.capabilities.include?('streaming') }, 'Batch Processing' => RubyLLM.models.select { |m| m.capabilities.include?('batch') } } capabilities.filter_map do |capability, models| next if models.none? <<~CAPABILITY ### #{capability} (#{models.count}) #{models_table(models)} CAPABILITY end.join("\n\n")
end
def generate_modality_sections # rubocop:disable Metrics/PerceivedComplexity
sections = [] vision_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('image') } if vision_models.any? sections << <<~SECTION ### Vision Models (#{vision_models.count}) Models that can process images: #{models_table(vision_models)} SECTION end audio_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('audio') } if audio_models.any? sections << <<~SECTION ### Audio Input Models (#{audio_models.count}) Models that can process audio: #{models_table(audio_models)} SECTION end pdf_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('pdf') } if pdf_models.any? sections << <<~SECTION ### PDF Models (#{pdf_models.count}) Models that can process PDF documents: #{models_table(pdf_models)} SECTION end embedding_models = RubyLLM.models.select { |m| (m.modalities.output || []).include?('embeddings') } if embedding_models.any? sections << <<~SECTION ### Embedding Models (#{embedding_models.count}) Models that generate embeddings: #{models_table(embedding_models)} SECTION end sections.join("\n\n")
end
def models_table(models)
return '*No models found*' if models.none? headers = ['Model', 'Provider', 'I/O', 'Capabilities', 'Context', 'Max Output', 'Standard Pricing (per 1M tokens)'] alignment = [':--', ':--', ':--', ':--', '--:', '--:', ':--'] rows = models.sort_by { |m| [m.provider, m.name] }.map do |model| pricing = standard_pricing_display(model) [ model.id, model.provider, modalities_display(model), list_display(model.capabilities), model.context_window || '-', model.max_output_tokens || '-', pricing ] end table = [] table << "| #{headers.join(' | ')} |" table << "| #{alignment.join(' | ')} |" rows.each do |row| table << "| #{row.join(' | ')} |" end table.join("\n")
end
def modalities_display(model)
input_modalities = list_display(model.modalities.input) output_modalities = list_display(model.modalities.output) "In: #{input_modalities}; Out: #{output_modalities}"
end
def list_display(values)
items = Array(values).compact.map(&:to_s).reject(&:empty?) return '-' if items.empty? items.join(', ')
end
def standard_pricing_display(model)
pricing_data = model.pricing.to_h[:text_tokens]&.dig(:standard) || {} parts = [ pricing_part(pricing_data, :input_per_million, 'In'), pricing_part(pricing_data, :output_per_million, 'Out'), pricing_part(pricing_data, %i[cache_read_input_per_million cached_input_per_million], 'Cache Read'), pricing_part(pricing_data, %i[cache_write_input_per_million cache_creation_input_per_million], 'Cache Write') ].compact return parts.join(', ') if parts.any? '-'
end
def pricing_part(pricing_data, key, label)
key = Array(key).find { |candidate| pricing_data[candidate] } return unless key "#{label}: $#{format('%.2f', pricing_data[key])}"
end
def generate_aliases # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
models = Hash.new { |h, k| h[k] = [] } RubyLLM.models.all.each do |model| models[model.provider] << model.id end aliases = {} # OpenAI models models['openai'].each do |model| openrouter_model = "openai/#{model}" azure_model = models['azure'].include?(model) ? model : nil next unless models['openrouter'].include?(openrouter_model) alias_key = model.gsub('-latest', '') aliases[alias_key] = { 'openai' => model, 'openrouter' => openrouter_model } aliases[alias_key]['azure'] = azure_model if azure_model end anthropic_latest = group_anthropic_models_by_base_name(models['anthropic']) anthropic_latest.each do |base_name, latest_model| openrouter_variants = [ "anthropic/#{base_name}", "anthropic/#{base_name.gsub(/-(\d)/, '.\1')}", "anthropic/#{base_name.gsub(/claude-(\d+)-(\d+)/, 'claude-\1.\2')}", "anthropic/#{base_name.gsub(/(\d+)-(\d+)/, '\1.\2')}" ] openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) } bedrock_model = find_best_bedrock_model(latest_model, models['bedrock']) next unless openrouter_model || bedrock_model || models['anthropic'].include?(latest_model) aliases[base_name] = { 'anthropic' => latest_model } aliases[base_name]['openrouter'] = openrouter_model if openrouter_model aliases[base_name]['bedrock'] = bedrock_model if bedrock_model aliases[base_name]['azure'] = latest_model if models['azure'].include?(latest_model) end models['bedrock'].each do |bedrock_model| next unless bedrock_model.start_with?('anthropic.') next unless bedrock_model =~ /anthropic\.(claude-[a-z0-9.-]+)-\d{8}/ base_name = Regexp.last_match(1) anthropic_name = base_name.tr('.', '-') next if aliases[anthropic_name] openrouter_variants = [ "anthropic/#{anthropic_name}", "anthropic/#{base_name}" ] openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) } aliases[anthropic_name] = { 'bedrock' => bedrock_model } aliases[anthropic_name]['anthropic'] = anthropic_name if models['anthropic'].include?(anthropic_name) aliases[anthropic_name]['openrouter'] = openrouter_model if openrouter_model end # Gemini models (also map to vertexai) models['gemini'].each do |model| openrouter_variants = [ "google/#{model}", "google/#{model.gsub('gemini-', 'gemini-').tr('.', '-')}", "google/#{model.gsub('gemini-', 'gemini-')}" ] openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) } vertexai_model = models['vertexai'].include?(model) ? model : nil next unless openrouter_model || vertexai_model alias_key = model.gsub('-latest', '') aliases[alias_key] = { 'gemini' => model } aliases[alias_key]['openrouter'] = openrouter_model if openrouter_model aliases[alias_key]['vertexai'] = vertexai_model if vertexai_model end # VertexAI models that aren't in Gemini (e.g. older models like text-bison) models['vertexai'].each do |model| # Skip if already handled above next if models['gemini'].include?(model) # Check if OpenRouter has this Google model openrouter_variants = [ "google/#{model}", "google/#{model.tr('.', '-')}" ] openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) } gemini_model = models['gemini'].include?(model) ? model : nil next unless openrouter_model || gemini_model alias_key = model.gsub('-latest', '') next if aliases[alias_key] # Skip if already created aliases[alias_key] = { 'vertexai' => model } aliases[alias_key]['openrouter'] = openrouter_model if openrouter_model aliases[alias_key]['gemini'] = gemini_model if gemini_model end models['deepseek'].each do |model| openrouter_model = "deepseek/#{model}" next unless models['openrouter'].include?(openrouter_model) alias_key = model.gsub('-latest', '') aliases[alias_key] = { 'deepseek' => model, 'openrouter' => openrouter_model } end add_xai_aliases(aliases, models['xai']) sorted_aliases = aliases.sort.to_h File.write(RubyLLM::Aliases.aliases_file, JSON.pretty_generate(sorted_aliases)) puts "Generated #{sorted_aliases.size} aliases"
end
def add_xai_aliases(aliases, xai_models)
return unless xai_models.include?('grok-4.3') %w[ grok-latest grok-3 grok-3-latest grok-3-mini grok-3-mini-latest grok-4 grok-4-latest grok-4-fast grok-4-fast-reasoning grok-4-fast-reasoning-latest grok-4-fast-non-reasoning grok-4-fast-non-reasoning-latest grok-4-1-fast grok-4-1-fast-reasoning grok-4-1-fast-reasoning-latest grok-4-1-fast-non-reasoning grok-4-1-fast-non-reasoning-latest ].each do |alias_key| aliases[alias_key] ||= { 'xai' => 'grok-4.3' } end
end
def group_anthropic_models_by_base_name(anthropic_models)
grouped = Hash.new { |h, k| h[k] = [] } anthropic_models.each do |model| base_name = extract_base_name(model) grouped[base_name] << model end latest_models = {} grouped.each do |base_name, model_list| if model_list.size == 1 latest_models[base_name] = model_list.first else latest_model = model_list.max_by { |model| extract_date_from_model(model) } latest_models[base_name] = latest_model end end latest_models
end
def extract_base_name(model)
if model =~ /^(.+)-(\d{8})$/ Regexp.last_match(1) else model end
end
def extract_date_from_model(model)
if model =~ /-(\d{8})$/ Regexp.last_match(1) else '00000000' end
end
def find_best_bedrock_model(anthropic_model, bedrock_models) # rubocop:disable Metrics/PerceivedComplexity
base_pattern = case anthropic_model when 'claude-2.0', 'claude-2' 'claude-v2' when 'claude-2.1' 'claude-v2:1' when 'claude-instant-v1', 'claude-instant' 'claude-instant' else anthropic_model end matching_models = bedrock_models.select do |bedrock_model| model_without_prefix = bedrock_model.sub(/^(?:(?:[a-z]{2}|global)\.)?anthropic\./, '') model_without_prefix.match?(/\A#{Regexp.escape(base_pattern)}(?:-v\d+|:\d+k|$)/) end return nil if matching_models.empty? begin model_info = RubyLLM.models.find(anthropic_model) target_context = model_info.context_window rescue StandardError target_context = nil end if target_context target_k = target_context / 1000 with_context = matching_models.select do |m| m.include?(":#{target_k}k") || m.include?(":0:#{target_k}k") end return with_context.first if with_context.any? end matching_models.min_by do |model| context_priority = if model =~ /:(?:\d+:)?(\d+)k/ -Regexp.last_match(1).to_i else 0 end version_priority = if model =~ /-v(\d+):/ -Regexp.last_match(1).to_i else 0 end has_context_priority = model.include?('k') ? -1 : 0 [has_context_priority, context_priority, version_priority] end
end