ruby_llm 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +80 -133
  3. data/lib/ruby_llm/active_record/acts_as.rb +144 -47
  4. data/lib/ruby_llm/aliases.json +187 -17
  5. data/lib/ruby_llm/attachment.rb +164 -0
  6. data/lib/ruby_llm/chat.rb +31 -20
  7. data/lib/ruby_llm/configuration.rb +34 -1
  8. data/lib/ruby_llm/connection.rb +121 -0
  9. data/lib/ruby_llm/content.rb +27 -79
  10. data/lib/ruby_llm/context.rb +30 -0
  11. data/lib/ruby_llm/embedding.rb +13 -5
  12. data/lib/ruby_llm/error.rb +2 -1
  13. data/lib/ruby_llm/image.rb +15 -8
  14. data/lib/ruby_llm/message.rb +14 -6
  15. data/lib/ruby_llm/mime_type.rb +67 -0
  16. data/lib/ruby_llm/model/info.rb +101 -0
  17. data/lib/ruby_llm/model/modalities.rb +22 -0
  18. data/lib/ruby_llm/model/pricing.rb +51 -0
  19. data/lib/ruby_llm/model/pricing_category.rb +48 -0
  20. data/lib/ruby_llm/model/pricing_tier.rb +34 -0
  21. data/lib/ruby_llm/model.rb +7 -0
  22. data/lib/ruby_llm/models.json +26279 -2362
  23. data/lib/ruby_llm/models.rb +95 -14
  24. data/lib/ruby_llm/provider.rb +48 -90
  25. data/lib/ruby_llm/providers/anthropic/capabilities.rb +76 -13
  26. data/lib/ruby_llm/providers/anthropic/chat.rb +7 -14
  27. data/lib/ruby_llm/providers/anthropic/media.rb +49 -28
  28. data/lib/ruby_llm/providers/anthropic/models.rb +16 -16
  29. data/lib/ruby_llm/providers/anthropic/tools.rb +2 -2
  30. data/lib/ruby_llm/providers/anthropic.rb +3 -3
  31. data/lib/ruby_llm/providers/bedrock/capabilities.rb +61 -2
  32. data/lib/ruby_llm/providers/bedrock/chat.rb +30 -73
  33. data/lib/ruby_llm/providers/bedrock/media.rb +59 -0
  34. data/lib/ruby_llm/providers/bedrock/models.rb +50 -58
  35. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +16 -0
  36. data/lib/ruby_llm/providers/bedrock.rb +14 -25
  37. data/lib/ruby_llm/providers/deepseek/capabilities.rb +35 -2
  38. data/lib/ruby_llm/providers/deepseek.rb +3 -3
  39. data/lib/ruby_llm/providers/gemini/capabilities.rb +84 -3
  40. data/lib/ruby_llm/providers/gemini/chat.rb +8 -37
  41. data/lib/ruby_llm/providers/gemini/embeddings.rb +18 -34
  42. data/lib/ruby_llm/providers/gemini/images.rb +4 -3
  43. data/lib/ruby_llm/providers/gemini/media.rb +28 -111
  44. data/lib/ruby_llm/providers/gemini/models.rb +17 -23
  45. data/lib/ruby_llm/providers/gemini/tools.rb +1 -1
  46. data/lib/ruby_llm/providers/gemini.rb +3 -3
  47. data/lib/ruby_llm/providers/ollama/chat.rb +28 -0
  48. data/lib/ruby_llm/providers/ollama/media.rb +48 -0
  49. data/lib/ruby_llm/providers/ollama.rb +34 -0
  50. data/lib/ruby_llm/providers/openai/capabilities.rb +78 -3
  51. data/lib/ruby_llm/providers/openai/chat.rb +6 -4
  52. data/lib/ruby_llm/providers/openai/embeddings.rb +8 -12
  53. data/lib/ruby_llm/providers/openai/images.rb +3 -2
  54. data/lib/ruby_llm/providers/openai/media.rb +48 -21
  55. data/lib/ruby_llm/providers/openai/models.rb +17 -18
  56. data/lib/ruby_llm/providers/openai/tools.rb +9 -5
  57. data/lib/ruby_llm/providers/openai.rb +7 -5
  58. data/lib/ruby_llm/providers/openrouter/models.rb +88 -0
  59. data/lib/ruby_llm/providers/openrouter.rb +31 -0
  60. data/lib/ruby_llm/stream_accumulator.rb +4 -4
  61. data/lib/ruby_llm/streaming.rb +48 -13
  62. data/lib/ruby_llm/utils.rb +27 -0
  63. data/lib/ruby_llm/version.rb +1 -1
  64. data/lib/ruby_llm.rb +15 -5
  65. data/lib/tasks/aliases.rake +235 -0
  66. data/lib/tasks/models_docs.rake +164 -121
  67. data/lib/tasks/models_update.rake +79 -0
  68. data/lib/tasks/release.rake +32 -0
  69. data/lib/tasks/vcr.rake +4 -2
  70. metadata +56 -32
  71. data/lib/ruby_llm/model_info.rb +0 -56
  72. data/lib/tasks/browser_helper.rb +0 -97
  73. data/lib/tasks/capability_generator.rb +0 -123
  74. data/lib/tasks/capability_scraper.rb +0 -224
  75. data/lib/tasks/cli_helper.rb +0 -22
  76. data/lib/tasks/code_validator.rb +0 -29
  77. data/lib/tasks/model_updater.rb +0 -66
  78. data/lib/tasks/models.rake +0 -43
data/lib/ruby_llm.rb CHANGED
@@ -16,7 +16,9 @@ loader.inflector.inflect(
16
16
  'openai' => 'OpenAI',
17
17
  'api' => 'API',
18
18
  'deepseek' => 'DeepSeek',
19
- 'bedrock' => 'Bedrock'
19
+ 'bedrock' => 'Bedrock',
20
+ 'openrouter' => 'OpenRouter',
21
+ 'pdf' => 'PDF'
20
22
  )
21
23
  loader.ignore("#{__dir__}/tasks")
22
24
  loader.ignore("#{__dir__}/ruby_llm/railtie")
@@ -30,8 +32,14 @@ module RubyLLM
30
32
  class Error < StandardError; end
31
33
 
32
34
  class << self
33
- def chat(model: nil, provider: nil, assume_model_exists: false)
34
- Chat.new(model:, provider:, assume_model_exists:)
35
+ def context
36
+ context_config = config.dup
37
+ yield context_config if block_given?
38
+ Context.new(context_config)
39
+ end
40
+
41
+ def chat(...)
42
+ Chat.new(...)
35
43
  end
36
44
 
37
45
  def embed(...)
@@ -60,9 +68,9 @@ module RubyLLM
60
68
 
61
69
  def logger
62
70
  @logger ||= Logger.new(
63
- $stdout,
71
+ config.log_file,
64
72
  progname: 'RubyLLM',
65
- level: ENV['RUBYLLM_DEBUG'] ? Logger::DEBUG : Logger::INFO
73
+ level: config.log_level
66
74
  )
67
75
  end
68
76
  end
@@ -73,6 +81,8 @@ RubyLLM::Provider.register :anthropic, RubyLLM::Providers::Anthropic
73
81
  RubyLLM::Provider.register :gemini, RubyLLM::Providers::Gemini
74
82
  RubyLLM::Provider.register :deepseek, RubyLLM::Providers::DeepSeek
75
83
  RubyLLM::Provider.register :bedrock, RubyLLM::Providers::Bedrock
84
+ RubyLLM::Provider.register :openrouter, RubyLLM::Providers::OpenRouter
85
+ RubyLLM::Provider.register :ollama, RubyLLM::Providers::Ollama
76
86
 
77
87
  if defined?(Rails::Railtie)
78
88
  require 'ruby_llm/railtie'
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ namespace :aliases do # rubocop:disable Metrics/BlockLength
6
+ desc 'Generate aliases.json from models in the registry'
7
+ task :generate do # rubocop:disable Metrics/BlockLength
8
+ require 'ruby_llm'
9
+
10
+ # Group models by provider
11
+ models = Hash.new { |h, k| h[k] = [] }
12
+
13
+ RubyLLM.models.all.each do |model|
14
+ models[model.provider] << model.id
15
+ end
16
+
17
+ aliases = {}
18
+
19
+ # OpenAI models
20
+ models['openai'].each do |model|
21
+ openrouter_model = "openai/#{model}"
22
+ next unless models['openrouter'].include?(openrouter_model)
23
+
24
+ alias_key = model.gsub('-latest', '')
25
+ aliases[alias_key] = {
26
+ 'openai' => model,
27
+ 'openrouter' => openrouter_model
28
+ }
29
+ end
30
+
31
+ # Anthropic models - group by base name and find latest
32
+ anthropic_latest = group_anthropic_models_by_base_name(models['anthropic'])
33
+
34
+ anthropic_latest.each do |base_name, latest_model|
35
+ # Check OpenRouter naming patterns for the BASE NAME (not the full dated model)
36
+ openrouter_variants = [
37
+ "anthropic/#{base_name}", # anthropic/claude-3-5-sonnet
38
+ "anthropic/#{base_name.gsub(/-(\d)/, '.\1')}", # anthropic/claude-3.5-sonnet
39
+ "anthropic/#{base_name.gsub(/claude-(\d+)-(\d+)/, 'claude-\1.\2')}", # claude-3-5 -> claude-3.5
40
+ "anthropic/#{base_name.gsub(/(\d+)-(\d+)/, '\1.\2')}" # any X-Y -> X.Y pattern
41
+ ]
42
+
43
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
44
+
45
+ # Find corresponding Bedrock model
46
+ bedrock_model = find_best_bedrock_model(latest_model, models['bedrock'])
47
+
48
+ # Create alias if we have any match (OpenRouter OR Bedrock) OR if it's Anthropic-only
49
+ next unless openrouter_model || bedrock_model || models['anthropic'].include?(latest_model)
50
+
51
+ aliases[base_name] = {
52
+ 'anthropic' => latest_model
53
+ }
54
+
55
+ aliases[base_name]['openrouter'] = openrouter_model if openrouter_model
56
+ aliases[base_name]['bedrock'] = bedrock_model if bedrock_model
57
+ end
58
+
59
+ # Also check if Bedrock has models that Anthropic doesn't
60
+ models['bedrock'].each do |bedrock_model|
61
+ next unless bedrock_model.start_with?('anthropic.')
62
+
63
+ # Extract the Claude model name
64
+ next unless bedrock_model =~ /anthropic\.(claude-[\d\.]+-[a-z]+)/
65
+
66
+ base_name = Regexp.last_match(1)
67
+ # Normalize to Anthropic naming convention
68
+ anthropic_name = base_name.gsub('.', '-')
69
+
70
+ # Skip if we already have an alias for this
71
+ next if aliases[anthropic_name]
72
+
73
+ # Check if this model exists in OpenRouter
74
+ openrouter_variants = [
75
+ "anthropic/#{anthropic_name}",
76
+ "anthropic/#{base_name}" # Keep the dots
77
+ ]
78
+
79
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
80
+
81
+ aliases[anthropic_name] = {
82
+ 'bedrock' => bedrock_model
83
+ }
84
+
85
+ aliases[anthropic_name]['anthropic'] = anthropic_name if models['anthropic'].include?(anthropic_name)
86
+ aliases[anthropic_name]['openrouter'] = openrouter_model if openrouter_model
87
+ end
88
+
89
+ # Gemini models
90
+ models['gemini'].each do |model|
91
+ # OpenRouter uses "google/" prefix and sometimes different naming
92
+ openrouter_variants = [
93
+ "google/#{model}",
94
+ "google/#{model.gsub('gemini-', 'gemini-').gsub('.', '-')}",
95
+ "google/#{model.gsub('gemini-', 'gemini-')}"
96
+ ]
97
+
98
+ openrouter_model = openrouter_variants.find { |v| models['openrouter'].include?(v) }
99
+ next unless openrouter_model
100
+
101
+ alias_key = model.gsub('-latest', '')
102
+ aliases[alias_key] = {
103
+ 'gemini' => model,
104
+ 'openrouter' => openrouter_model
105
+ }
106
+ end
107
+
108
+ # DeepSeek models
109
+ models['deepseek'].each do |model|
110
+ openrouter_model = "deepseek/#{model}"
111
+ next unless models['openrouter'].include?(openrouter_model)
112
+
113
+ alias_key = model.gsub('-latest', '')
114
+ aliases[alias_key] = {
115
+ 'deepseek' => model,
116
+ 'openrouter' => openrouter_model
117
+ }
118
+ end
119
+
120
+ # Write the result
121
+ sorted_aliases = aliases.sort.to_h
122
+ File.write('lib/ruby_llm/aliases.json', JSON.pretty_generate(sorted_aliases))
123
+
124
+ puts "Generated #{sorted_aliases.size} aliases"
125
+ end
126
+
127
+ def group_anthropic_models_by_base_name(anthropic_models) # rubocop:disable Rake/MethodDefinitionInTask
128
+ grouped = Hash.new { |h, k| h[k] = [] }
129
+
130
+ anthropic_models.each do |model|
131
+ base_name = extract_base_name(model)
132
+ grouped[base_name] << model
133
+ end
134
+
135
+ # Find the latest model for each base name
136
+ latest_models = {}
137
+ grouped.each do |base_name, model_list|
138
+ if model_list.size == 1
139
+ latest_models[base_name] = model_list.first
140
+ else
141
+ # Sort by date and take the latest
142
+ latest_model = model_list.max_by { |model| extract_date_from_model(model) }
143
+ latest_models[base_name] = latest_model
144
+ end
145
+ end
146
+
147
+ latest_models
148
+ end
149
+
150
+ def extract_base_name(model) # rubocop:disable Rake/MethodDefinitionInTask
151
+ # Remove date suffix (YYYYMMDD) from model name
152
+ if model =~ /^(.+)-(\d{8})$/
153
+ Regexp.last_match(1)
154
+ else
155
+ # Models without date suffix (like claude-2.0, claude-2.1)
156
+ model
157
+ end
158
+ end
159
+
160
+ def extract_date_from_model(model) # rubocop:disable Rake/MethodDefinitionInTask
161
+ # Extract date for comparison, return '00000000' for models without dates
162
+ if model =~ /-(\d{8})$/
163
+ Regexp.last_match(1)
164
+ else
165
+ '00000000' # Ensures models without dates sort before dated ones
166
+ end
167
+ end
168
+
169
+ def find_best_bedrock_model(anthropic_model, bedrock_models) # rubocop:disable Metrics/PerceivedComplexity,Rake/MethodDefinitionInTask
170
+ # Special mapping for Claude 2.x models
171
+ base_pattern = case anthropic_model
172
+ when 'claude-2.0', 'claude-2'
173
+ 'claude-v2'
174
+ when 'claude-2.1'
175
+ 'claude-v2:1'
176
+ when 'claude-instant-v1', 'claude-instant'
177
+ 'claude-instant'
178
+ else
179
+ # For Claude 3+ models, extract base name
180
+ extract_base_name(anthropic_model)
181
+ end
182
+
183
+ # Find all matching Bedrock models by stripping provider prefix and comparing base name
184
+ matching_models = bedrock_models.select do |bedrock_model|
185
+ # Strip any provider prefix (anthropic. or us.anthropic.)
186
+ model_without_prefix = bedrock_model.sub(/^(?:us\.)?anthropic\./, '')
187
+ model_without_prefix.start_with?(base_pattern)
188
+ end
189
+
190
+ return nil if matching_models.empty?
191
+
192
+ # Get model info to check context window
193
+ begin
194
+ model_info = RubyLLM.models.find(anthropic_model)
195
+ target_context = model_info.context_window
196
+ rescue StandardError
197
+ target_context = nil
198
+ end
199
+
200
+ # If we have context window info, try to match it
201
+ if target_context
202
+ # Convert to k format (200000 -> 200k)
203
+ target_k = target_context / 1000
204
+
205
+ # Find models with this specific context window
206
+ with_context = matching_models.select do |m|
207
+ m.include?(":#{target_k}k") || m.include?(":0:#{target_k}k")
208
+ end
209
+
210
+ return with_context.first if with_context.any?
211
+ end
212
+
213
+ # Otherwise, pick the one with the highest context window or latest version
214
+ matching_models.min_by do |model|
215
+ # Extract context window if specified
216
+ context_priority = if model =~ /:(?:\d+:)?(\d+)k/
217
+ -Regexp.last_match(1).to_i # Negative for descending sort
218
+ else
219
+ 0 # No context specified
220
+ end
221
+
222
+ # Extract version if present
223
+ version_priority = if model =~ /-v(\d+):/
224
+ -Regexp.last_match(1).to_i # Negative for descending sort (latest version first)
225
+ else
226
+ 0
227
+ end
228
+
229
+ # Prefer models with explicit context windows
230
+ has_context_priority = model.include?('k') ? -1 : 0
231
+
232
+ [has_context_priority, context_priority, version_priority]
233
+ end
234
+ end
235
+ end
@@ -1,168 +1,211 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ruby_llm'
3
+ require 'dotenv/load'
4
4
  require 'fileutils'
5
5
 
6
- MODEL_KEYS_TO_DISPLAY = %i[
7
- id
8
- type
9
- display_name
10
- provider
11
- context_window
12
- max_tokens
13
- family
14
- input_price_per_million
15
- output_price_per_million
16
- ].freeze
17
-
18
- def to_markdown_table(models) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
19
- to_display_hash = ->(model) { model.to_h.slice(*MODEL_KEYS_TO_DISPLAY) }
20
- model_hashes = Array(models).map { |model| to_display_hash.call(model) }
21
-
22
- # Create abbreviated headers
23
- headers = {
24
- id: 'ID',
25
- type: 'Type',
26
- display_name: 'Name',
27
- provider: 'Provider',
28
- context_window: 'Context',
29
- max_tokens: 'MaxTok',
30
- family: 'Family',
31
- input_price_per_million: 'In$/M',
32
- output_price_per_million: 'Out$/M'
33
- }
6
+ namespace :models do
7
+ desc 'Generate available models documentation'
8
+ task :docs do
9
+ FileUtils.mkdir_p('docs/guides') # ensure output directory exists
34
10
 
35
- # Create header row with alignment markers
36
- # Right-align numbers, left-align text
37
- alignments = {
38
- id: ':--',
39
- type: ':--',
40
- display_name: ':--',
41
- provider: ':--',
42
- context_window: '--:',
43
- max_tokens: '--:',
44
- family: ':--',
45
- input_price_per_million: '--:',
46
- output_price_per_million: '--:'
47
- }
11
+ # Generate markdown content
12
+ output = generate_models_markdown
48
13
 
49
- # Build the table
50
- lines = []
14
+ # Write the output
15
+ File.write('docs/guides/available-models.md', output)
16
+ puts 'Generated docs/guides/available-models.md'
17
+ end
18
+ end
51
19
 
52
- # Header row
53
- lines << "| #{MODEL_KEYS_TO_DISPLAY.map { |key| headers[key] }.join(' | ')} |"
20
+ def generate_models_markdown
21
+ <<~MARKDOWN
22
+ ---
23
+ layout: default
24
+ title: Available Models
25
+ parent: Guides
26
+ nav_order: 10
27
+ permalink: /guides/available-models
28
+ ---
54
29
 
55
- # Alignment row
56
- lines << "| #{MODEL_KEYS_TO_DISPLAY.map { |key| alignments[key] }.join(' | ')} |"
30
+ # Available Models
31
+ {: .no_toc }
57
32
 
58
- # Data rows
59
- model_hashes.each do |model_hash|
60
- values = MODEL_KEYS_TO_DISPLAY.map do |key|
61
- if model_hash[key].is_a?(Float)
62
- format('%.2f', model_hash[key])
63
- else
64
- model_hash[key]
65
- end
66
- end
33
+ This guide lists all models available in RubyLLM, automatically generated from the current model registry.
34
+ {: .fs-6 .fw-300 }
67
35
 
68
- lines << "| #{values.join(' | ')} |"
69
- end
36
+ ## Table of contents
37
+ {: .no_toc .text-delta }
38
+
39
+ 1. TOC
40
+ {:toc}
41
+
42
+ ---
43
+
44
+ ## Contributing
45
+
46
+ The model list is automatically generated from the model registry. To add or update models:
47
+
48
+ 1. Edit the appropriate `capabilities.rb` file in `lib/ruby_llm/providers/<provider>/`
49
+ 2. Run `rake models:update` to refresh the model registry
50
+ 3. Submit a pull request with the updated `models.json`
51
+
52
+ See [Contributing Guide](/CONTRIBUTING.md) for more details.
53
+
54
+ ## Last Updated
55
+ {: .d-inline-block }
56
+
57
+ #{Time.now.utc.strftime('%Y-%m-%d')}
58
+ {: .label .label-green }
59
+
60
+ ## Models by Provider
61
+
62
+ #{generate_provider_sections}
63
+
64
+ ## Models by Capability
70
65
 
71
- lines.join("\n")
66
+ #{generate_capability_sections}
67
+
68
+ ## Models by Modality
69
+
70
+ #{generate_modality_sections}
71
+ MARKDOWN
72
72
  end
73
73
 
74
- namespace :models do # rubocop:disable Metrics/BlockLength
75
- desc 'Generate available models documentation'
76
- task :docs do # rubocop:disable Metrics/BlockLength
77
- FileUtils.mkdir_p('docs/guides') # ensure output directory exists
74
+ def generate_provider_sections
75
+ RubyLLM::Provider.providers.keys.map do |provider|
76
+ models = RubyLLM.models.by_provider(provider)
77
+ next if models.none?
78
+
79
+ <<~PROVIDER
80
+ ### #{provider.to_s.capitalize} (#{models.count})
78
81
 
79
- output = <<~MARKDOWN
80
- ---
81
- layout: default
82
- title: Available Models
83
- parent: Guides
84
- nav_order: 10
85
- permalink: /guides/available-models
86
- ---
82
+ #{models_table(models)}
83
+ PROVIDER
84
+ end.compact.join("\n\n")
85
+ end
87
86
 
88
- # Available Models
89
- {: .no_toc }
87
+ def generate_capability_sections
88
+ capabilities = {
89
+ 'Function Calling' => RubyLLM.models.select(&:function_calling?),
90
+ 'Structured Output' => RubyLLM.models.select(&:structured_output?),
91
+ 'Streaming' => RubyLLM.models.select { |m| m.capabilities.include?('streaming') },
92
+ # 'Reasoning' => RubyLLM.models.select { |m| m.capabilities.include?('reasoning') },
93
+ 'Batch Processing' => RubyLLM.models.select { |m| m.capabilities.include?('batch') }
94
+ }
90
95
 
91
- This guide lists all models available in RubyLLM, automatically generated from the current model registry.
92
- {: .fs-6 .fw-300 }
96
+ capabilities.map do |capability, models|
97
+ next if models.none?
93
98
 
94
- ## Table of contents
95
- {: .no_toc .text-delta }
99
+ <<~CAPABILITY
100
+ ### #{capability} (#{models.count})
96
101
 
97
- 1. TOC
98
- {:toc}
102
+ #{models_table(models)}
103
+ CAPABILITY
104
+ end.compact.join("\n\n")
105
+ end
99
106
 
100
- ---
107
+ def generate_modality_sections # rubocop:disable Metrics/PerceivedComplexity
108
+ sections = []
101
109
 
102
- ## Contributing
110
+ # Models that support vision/images
111
+ vision_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('image') }
112
+ if vision_models.any?
113
+ sections << <<~SECTION
114
+ ### Vision Models (#{vision_models.count})
103
115
 
104
- The model list is automatically generated from the model registry. To add or update models:
116
+ Models that can process images:
105
117
 
106
- 1. Edit the appropriate `capabilities.rb` file in `lib/ruby_llm/providers/<provider>/`
107
- 2. Run `rake models:update` to refresh the model registry
108
- 3. Submit a pull request with the updated `models.json`
118
+ #{models_table(vision_models)}
119
+ SECTION
120
+ end
109
121
 
110
- See [Contributing Guide](/CONTRIBUTING.md) for more details.
122
+ # Models that support audio
123
+ audio_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('audio') }
124
+ if audio_models.any?
125
+ sections << <<~SECTION
126
+ ### Audio Input Models (#{audio_models.count})
111
127
 
112
- ## Additional Model Information
128
+ Models that can process audio:
113
129
 
114
- The tables below show basic model information including context windows, token limits, and pricing. Models also have additional capabilities not shown in the tables:
130
+ #{models_table(audio_models)}
131
+ SECTION
132
+ end
115
133
 
116
- - **Vision Support**: Whether the model can process images
117
- - **Function Calling**: Whether the model supports function calling
118
- - **JSON Mode**: Whether the model can be constrained to output valid JSON
119
- - **Structured Output**: Whether the model supports structured output formats
134
+ # Models that support PDFs
135
+ pdf_models = RubyLLM.models.select { |m| (m.modalities.input || []).include?('pdf') }
136
+ if pdf_models.any?
137
+ sections << <<~SECTION
138
+ ### PDF Models (#{pdf_models.count})
120
139
 
121
- For complete model information, you can check the `models.json` file in the RubyLLM source code.
140
+ Models that can process PDF documents:
122
141
 
123
- For more information about working with models, see the [Working with Models](/guides/models) guide.
142
+ #{models_table(pdf_models)}
143
+ SECTION
144
+ end
124
145
 
125
- ## Models by Type
126
- {: .d-inline-block }
146
+ # Models for embeddings
147
+ embedding_models = RubyLLM.models.select { |m| (m.modalities.output || []).include?('embeddings') }
148
+ if embedding_models.any?
149
+ sections << <<~SECTION
150
+ ### Embedding Models (#{embedding_models.count})
127
151
 
128
- Last updated: #{Time.now.utc.strftime('%Y-%m-%d')}
129
- {: .label .label-green }
152
+ Models that generate embeddings:
130
153
 
131
- ### Chat Models (#{RubyLLM.models.chat_models.count})
154
+ #{models_table(embedding_models)}
155
+ SECTION
156
+ end
132
157
 
133
- #{to_markdown_table(RubyLLM.models.chat_models)}
158
+ sections.join("\n\n")
159
+ end
134
160
 
135
- ### Image Models (#{RubyLLM.models.image_models.count})
161
+ def models_table(models)
162
+ return '*No models found*' if models.none?
136
163
 
137
- #{to_markdown_table(RubyLLM.models.image_models)}
164
+ headers = ['Model', 'ID', 'Provider', 'Context', 'Max Output', 'Standard Pricing (per 1M tokens)']
165
+ alignment = [':--', ':--', ':--', '--:', '--:', ':--']
138
166
 
139
- ### Audio Models (#{RubyLLM.models.audio_models.count})
167
+ rows = models.sort_by { |m| [m.provider, m.name] }.map do |model|
168
+ # Format pricing information
169
+ pricing = standard_pricing_display(model)
140
170
 
141
- #{to_markdown_table(RubyLLM.models.audio_models)}
171
+ [
172
+ model.name,
173
+ model.id,
174
+ model.provider,
175
+ model.context_window || '-',
176
+ model.max_output_tokens || '-',
177
+ pricing
178
+ ]
179
+ end
142
180
 
143
- ### Embedding Models (#{RubyLLM.models.embedding_models.count})
181
+ table = []
182
+ table << "| #{headers.join(' | ')} |"
183
+ table << "| #{alignment.join(' | ')} |"
144
184
 
145
- #{to_markdown_table(RubyLLM.models.embedding_models)}
185
+ rows.each do |row|
186
+ table << "| #{row.join(' | ')} |"
187
+ end
146
188
 
147
- ### Moderation Models (#{RubyLLM.models.select { |m| m.type == 'moderation' }.count})
189
+ table.join("\n")
190
+ end
148
191
 
149
- #{to_markdown_table(RubyLLM.models.select { |m| m.type == 'moderation' })}
192
+ def standard_pricing_display(model)
193
+ # Access pricing data using to_h to get the raw hash
194
+ pricing_data = model.pricing.to_h[:text_tokens]&.dig(:standard) || {}
150
195
 
151
- ## Models by Provider
196
+ if pricing_data.any?
197
+ parts = []
152
198
 
153
- #{RubyLLM::Provider.providers.keys.map do |provider|
154
- models = RubyLLM.models.by_provider(provider)
155
- next if models.none?
199
+ parts << "In: $#{format('%.2f', pricing_data[:input_per_million])}" if pricing_data[:input_per_million]
156
200
 
157
- <<~PROVIDER
158
- ### #{provider.to_s.capitalize} Models (#{models.count})
201
+ parts << "Out: $#{format('%.2f', pricing_data[:output_per_million])}" if pricing_data[:output_per_million]
159
202
 
160
- #{to_markdown_table(models)}
161
- PROVIDER
162
- end.compact.join("\n")}
163
- MARKDOWN
203
+ if pricing_data[:cached_input_per_million]
204
+ parts << "Cache: $#{format('%.2f', pricing_data[:cached_input_per_million])}"
205
+ end
164
206
 
165
- File.write('docs/guides/available-models.md', output)
166
- puts 'Generated docs/guides/available-models.md'
207
+ return parts.join(', ') if parts.any?
167
208
  end
209
+
210
+ '-'
168
211
  end