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