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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +157 -0
- data/lib/generators/ruby_llm/install/templates/chat_model.rb.tt +3 -0
- data/lib/generators/ruby_llm/install/templates/create_chats_legacy_migration.rb.tt +8 -0
- data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +8 -0
- data/lib/generators/ruby_llm/install/templates/create_messages_legacy_migration.rb.tt +16 -0
- data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +16 -0
- data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +43 -0
- data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +15 -0
- data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +9 -0
- data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +4 -0
- data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +3 -0
- data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +3 -0
- data/lib/generators/ruby_llm/install_generator.rb +184 -0
- data/lib/generators/ruby_llm/migrate_model_fields/templates/migration.rb.tt +142 -0
- data/lib/generators/ruby_llm/migrate_model_fields_generator.rb +84 -0
- data/lib/ruby_llm/active_record/acts_as.rb +137 -0
- data/lib/ruby_llm/active_record/acts_as_legacy.rb +398 -0
- data/lib/ruby_llm/active_record/chat_methods.rb +315 -0
- data/lib/ruby_llm/active_record/message_methods.rb +72 -0
- data/lib/ruby_llm/active_record/model_methods.rb +84 -0
- data/lib/ruby_llm/aliases.json +274 -0
- data/lib/ruby_llm/aliases.rb +38 -0
- data/lib/ruby_llm/attachment.rb +191 -0
- data/lib/ruby_llm/chat.rb +212 -0
- data/lib/ruby_llm/chunk.rb +6 -0
- data/lib/ruby_llm/configuration.rb +69 -0
- data/lib/ruby_llm/connection.rb +137 -0
- data/lib/ruby_llm/content.rb +50 -0
- data/lib/ruby_llm/context.rb +29 -0
- data/lib/ruby_llm/embedding.rb +29 -0
- data/lib/ruby_llm/error.rb +76 -0
- data/lib/ruby_llm/image.rb +49 -0
- data/lib/ruby_llm/message.rb +76 -0
- data/lib/ruby_llm/mime_type.rb +67 -0
- data/lib/ruby_llm/model/info.rb +103 -0
- data/lib/ruby_llm/model/modalities.rb +22 -0
- data/lib/ruby_llm/model/pricing.rb +48 -0
- data/lib/ruby_llm/model/pricing_category.rb +46 -0
- data/lib/ruby_llm/model/pricing_tier.rb +33 -0
- data/lib/ruby_llm/model.rb +7 -0
- data/lib/ruby_llm/models.json +31418 -0
- data/lib/ruby_llm/models.rb +235 -0
- data/lib/ruby_llm/models_schema.json +168 -0
- data/lib/ruby_llm/provider.rb +215 -0
- data/lib/ruby_llm/providers/anthropic/capabilities.rb +134 -0
- data/lib/ruby_llm/providers/anthropic/chat.rb +106 -0
- data/lib/ruby_llm/providers/anthropic/embeddings.rb +20 -0
- data/lib/ruby_llm/providers/anthropic/media.rb +91 -0
- data/lib/ruby_llm/providers/anthropic/models.rb +48 -0
- data/lib/ruby_llm/providers/anthropic/streaming.rb +43 -0
- data/lib/ruby_llm/providers/anthropic/tools.rb +107 -0
- data/lib/ruby_llm/providers/anthropic.rb +36 -0
- data/lib/ruby_llm/providers/bedrock/capabilities.rb +167 -0
- data/lib/ruby_llm/providers/bedrock/chat.rb +63 -0
- data/lib/ruby_llm/providers/bedrock/media.rb +60 -0
- data/lib/ruby_llm/providers/bedrock/models.rb +98 -0
- data/lib/ruby_llm/providers/bedrock/signing.rb +831 -0
- data/lib/ruby_llm/providers/bedrock/streaming/base.rb +51 -0
- data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +56 -0
- data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +67 -0
- data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +78 -0
- data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +78 -0
- data/lib/ruby_llm/providers/bedrock/streaming.rb +18 -0
- data/lib/ruby_llm/providers/bedrock.rb +82 -0
- data/lib/ruby_llm/providers/deepseek/capabilities.rb +130 -0
- data/lib/ruby_llm/providers/deepseek/chat.rb +16 -0
- data/lib/ruby_llm/providers/deepseek.rb +30 -0
- data/lib/ruby_llm/providers/dify/capabilities.rb +16 -0
- data/lib/ruby_llm/providers/dify/chat.rb +59 -0
- data/lib/ruby_llm/providers/dify/media.rb +37 -0
- data/lib/ruby_llm/providers/dify/streaming.rb +28 -0
- data/lib/ruby_llm/providers/dify.rb +48 -0
- data/lib/ruby_llm/providers/gemini/capabilities.rb +276 -0
- data/lib/ruby_llm/providers/gemini/chat.rb +171 -0
- data/lib/ruby_llm/providers/gemini/embeddings.rb +37 -0
- data/lib/ruby_llm/providers/gemini/images.rb +47 -0
- data/lib/ruby_llm/providers/gemini/media.rb +54 -0
- data/lib/ruby_llm/providers/gemini/models.rb +40 -0
- data/lib/ruby_llm/providers/gemini/streaming.rb +61 -0
- data/lib/ruby_llm/providers/gemini/tools.rb +77 -0
- data/lib/ruby_llm/providers/gemini.rb +36 -0
- data/lib/ruby_llm/providers/gpustack/chat.rb +27 -0
- data/lib/ruby_llm/providers/gpustack/media.rb +45 -0
- data/lib/ruby_llm/providers/gpustack/models.rb +90 -0
- data/lib/ruby_llm/providers/gpustack.rb +34 -0
- data/lib/ruby_llm/providers/mistral/capabilities.rb +155 -0
- data/lib/ruby_llm/providers/mistral/chat.rb +24 -0
- data/lib/ruby_llm/providers/mistral/embeddings.rb +33 -0
- data/lib/ruby_llm/providers/mistral/models.rb +48 -0
- data/lib/ruby_llm/providers/mistral.rb +32 -0
- data/lib/ruby_llm/providers/ollama/chat.rb +27 -0
- data/lib/ruby_llm/providers/ollama/media.rb +45 -0
- data/lib/ruby_llm/providers/ollama/models.rb +36 -0
- data/lib/ruby_llm/providers/ollama.rb +30 -0
- data/lib/ruby_llm/providers/openai/capabilities.rb +291 -0
- data/lib/ruby_llm/providers/openai/chat.rb +83 -0
- data/lib/ruby_llm/providers/openai/embeddings.rb +33 -0
- data/lib/ruby_llm/providers/openai/images.rb +38 -0
- data/lib/ruby_llm/providers/openai/media.rb +80 -0
- data/lib/ruby_llm/providers/openai/models.rb +39 -0
- data/lib/ruby_llm/providers/openai/streaming.rb +41 -0
- data/lib/ruby_llm/providers/openai/tools.rb +78 -0
- data/lib/ruby_llm/providers/openai.rb +42 -0
- data/lib/ruby_llm/providers/openrouter/models.rb +73 -0
- data/lib/ruby_llm/providers/openrouter.rb +26 -0
- data/lib/ruby_llm/providers/perplexity/capabilities.rb +137 -0
- data/lib/ruby_llm/providers/perplexity/chat.rb +16 -0
- data/lib/ruby_llm/providers/perplexity/models.rb +42 -0
- data/lib/ruby_llm/providers/perplexity.rb +48 -0
- data/lib/ruby_llm/providers/vertexai/chat.rb +14 -0
- data/lib/ruby_llm/providers/vertexai/embeddings.rb +32 -0
- data/lib/ruby_llm/providers/vertexai/models.rb +130 -0
- data/lib/ruby_llm/providers/vertexai/streaming.rb +14 -0
- data/lib/ruby_llm/providers/vertexai.rb +55 -0
- data/lib/ruby_llm/railtie.rb +41 -0
- data/lib/ruby_llm/stream_accumulator.rb +97 -0
- data/lib/ruby_llm/streaming.rb +153 -0
- data/lib/ruby_llm/tool.rb +83 -0
- data/lib/ruby_llm/tool_call.rb +22 -0
- data/lib/ruby_llm/utils.rb +45 -0
- data/lib/ruby_llm/version.rb +5 -0
- data/lib/ruby_llm.rb +97 -0
- data/lib/tasks/models.rake +525 -0
- data/lib/tasks/release.rake +67 -0
- data/lib/tasks/ruby_llm.rake +15 -0
- data/lib/tasks/vcr.rake +92 -0
- 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
|