lex-llm 0.1.2 → 0.1.3
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/.gitignore +1 -0
- data/CHANGELOG.md +7 -1
- data/Gemfile +1 -19
- data/README.md +22 -25
- data/lex-llm.gemspec +2 -2
- data/lib/legion/extensions/llm/agent.rb +366 -0
- data/lib/legion/extensions/llm/aliases.rb +42 -0
- data/lib/legion/extensions/llm/attachment.rb +229 -0
- data/lib/legion/extensions/llm/chat.rb +355 -0
- data/lib/legion/extensions/llm/chunk.rb +10 -0
- data/lib/legion/extensions/llm/configuration.rb +82 -0
- data/lib/legion/extensions/llm/connection.rb +134 -0
- data/lib/legion/extensions/llm/content.rb +81 -0
- data/lib/legion/extensions/llm/context.rb +33 -0
- data/lib/legion/extensions/llm/embedding.rb +33 -0
- data/lib/legion/extensions/llm/error.rb +116 -0
- data/lib/legion/extensions/llm/image.rb +109 -0
- data/lib/legion/extensions/llm/message.rb +111 -0
- data/lib/legion/extensions/llm/mime_type.rb +75 -0
- data/lib/legion/extensions/llm/model/info.rb +117 -0
- data/lib/legion/extensions/llm/model/modalities.rb +26 -0
- data/lib/legion/extensions/llm/model/pricing.rb +52 -0
- data/lib/legion/extensions/llm/model/pricing_category.rb +50 -0
- data/lib/legion/extensions/llm/model/pricing_tier.rb +37 -0
- data/lib/legion/extensions/llm/model.rb +11 -0
- data/lib/legion/extensions/llm/models.rb +514 -0
- data/lib/{lex_llm → legion/extensions/llm}/models_schema.json +1 -1
- data/lib/legion/extensions/llm/moderation.rb +60 -0
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +240 -0
- data/lib/legion/extensions/llm/provider.rb +282 -0
- data/lib/legion/extensions/llm/routing/lane_key.rb +57 -0
- data/lib/legion/extensions/llm/routing/model_offering.rb +173 -0
- data/lib/legion/extensions/llm/routing.rb +11 -0
- data/lib/legion/extensions/llm/stream_accumulator.rb +209 -0
- data/lib/legion/extensions/llm/streaming.rb +181 -0
- data/lib/legion/extensions/llm/thinking.rb +53 -0
- data/lib/legion/extensions/llm/tokens.rb +51 -0
- data/lib/legion/extensions/llm/tool.rb +258 -0
- data/lib/legion/extensions/llm/tool_call.rb +29 -0
- data/lib/legion/extensions/llm/transcription.rb +39 -0
- data/lib/legion/extensions/llm/utils.rb +95 -0
- data/lib/legion/extensions/llm/version.rb +9 -0
- data/lib/legion/extensions/llm.rb +85 -6
- metadata +40 -122
- data/lib/generators/lex_llm/agent/agent_generator.rb +0 -36
- data/lib/generators/lex_llm/agent/templates/agent.rb.tt +0 -6
- data/lib/generators/lex_llm/agent/templates/instructions.txt.erb.tt +0 -0
- data/lib/generators/lex_llm/chat_ui/chat_ui_generator.rb +0 -256
- data/lib/generators/lex_llm/chat_ui/templates/controllers/chats_controller.rb.tt +0 -38
- data/lib/generators/lex_llm/chat_ui/templates/controllers/messages_controller.rb.tt +0 -21
- data/lib/generators/lex_llm/chat_ui/templates/controllers/models_controller.rb.tt +0 -14
- data/lib/generators/lex_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
- data/lib/generators/lex_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +0 -12
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +0 -16
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +0 -31
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +0 -31
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +0 -9
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +0 -27
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +0 -14
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +0 -1
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +0 -13
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +0 -23
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +0 -10
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +0 -2
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +0 -4
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +0 -14
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +0 -13
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +0 -21
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +0 -17
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +0 -40
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +0 -27
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +0 -16
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/_form.html.erb.tt +0 -29
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/index.html.erb.tt +0 -28
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/new.html.erb.tt +0 -11
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/show.html.erb.tt +0 -25
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +0 -9
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_content.html.erb.tt +0 -1
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_error.html.erb.tt +0 -8
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_form.html.erb.tt +0 -21
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_system.html.erb.tt +0 -6
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +0 -2
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +0 -4
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_user.html.erb.tt +0 -9
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +0 -7
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +0 -8
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +0 -16
- data/lib/generators/lex_llm/chat_ui/templates/views/models/_model.html.erb.tt +0 -15
- data/lib/generators/lex_llm/chat_ui/templates/views/models/index.html.erb.tt +0 -38
- data/lib/generators/lex_llm/chat_ui/templates/views/models/show.html.erb.tt +0 -17
- data/lib/generators/lex_llm/generator_helpers.rb +0 -214
- data/lib/generators/lex_llm/install/install_generator.rb +0 -109
- data/lib/generators/lex_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
- data/lib/generators/lex_llm/install/templates/chat_model.rb.tt +0 -3
- data/lib/generators/lex_llm/install/templates/create_chats_migration.rb.tt +0 -7
- data/lib/generators/lex_llm/install/templates/create_messages_migration.rb.tt +0 -19
- data/lib/generators/lex_llm/install/templates/create_models_migration.rb.tt +0 -39
- data/lib/generators/lex_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
- data/lib/generators/lex_llm/install/templates/initializer.rb.tt +0 -20
- data/lib/generators/lex_llm/install/templates/message_model.rb.tt +0 -4
- data/lib/generators/lex_llm/install/templates/model_model.rb.tt +0 -3
- data/lib/generators/lex_llm/install/templates/tool_call_model.rb.tt +0 -3
- data/lib/generators/lex_llm/schema/schema_generator.rb +0 -26
- data/lib/generators/lex_llm/schema/templates/schema.rb.tt +0 -2
- data/lib/generators/lex_llm/tool/templates/tool.rb.tt +0 -9
- data/lib/generators/lex_llm/tool/templates/tool_call.html.erb.tt +0 -13
- data/lib/generators/lex_llm/tool/templates/tool_result.html.erb.tt +0 -13
- data/lib/generators/lex_llm/tool/tool_generator.rb +0 -96
- data/lib/generators/lex_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
- data/lib/generators/lex_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
- data/lib/generators/lex_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
- data/lib/generators/lex_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
- data/lib/generators/lex_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
- data/lib/generators/lex_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
- data/lib/generators/lex_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
- data/lib/generators/lex_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
- data/lib/lex_llm/active_record/acts_as.rb +0 -180
- data/lib/lex_llm/active_record/acts_as_legacy.rb +0 -503
- data/lib/lex_llm/active_record/chat_methods.rb +0 -468
- data/lib/lex_llm/active_record/message_methods.rb +0 -131
- data/lib/lex_llm/active_record/model_methods.rb +0 -76
- data/lib/lex_llm/active_record/payload_helpers.rb +0 -26
- data/lib/lex_llm/active_record/tool_call_methods.rb +0 -15
- data/lib/lex_llm/agent.rb +0 -365
- data/lib/lex_llm/aliases.rb +0 -38
- data/lib/lex_llm/attachment.rb +0 -223
- data/lib/lex_llm/chat.rb +0 -351
- data/lib/lex_llm/chunk.rb +0 -6
- data/lib/lex_llm/configuration.rb +0 -81
- data/lib/lex_llm/connection.rb +0 -130
- data/lib/lex_llm/content.rb +0 -77
- data/lib/lex_llm/context.rb +0 -29
- data/lib/lex_llm/embedding.rb +0 -29
- data/lib/lex_llm/error.rb +0 -112
- data/lib/lex_llm/image.rb +0 -105
- data/lib/lex_llm/message.rb +0 -107
- data/lib/lex_llm/mime_type.rb +0 -71
- data/lib/lex_llm/model/info.rb +0 -113
- data/lib/lex_llm/model/modalities.rb +0 -22
- data/lib/lex_llm/model/pricing.rb +0 -48
- data/lib/lex_llm/model/pricing_category.rb +0 -46
- data/lib/lex_llm/model/pricing_tier.rb +0 -33
- data/lib/lex_llm/model.rb +0 -7
- data/lib/lex_llm/models.rb +0 -506
- data/lib/lex_llm/moderation.rb +0 -56
- data/lib/lex_llm/provider/open_ai_compatible.rb +0 -219
- data/lib/lex_llm/provider.rb +0 -278
- data/lib/lex_llm/railtie.rb +0 -35
- data/lib/lex_llm/routing/lane_key.rb +0 -51
- data/lib/lex_llm/routing/model_offering.rb +0 -169
- data/lib/lex_llm/routing.rb +0 -7
- data/lib/lex_llm/stream_accumulator.rb +0 -203
- data/lib/lex_llm/streaming.rb +0 -175
- data/lib/lex_llm/thinking.rb +0 -49
- data/lib/lex_llm/tokens.rb +0 -47
- data/lib/lex_llm/tool.rb +0 -254
- data/lib/lex_llm/tool_call.rb +0 -25
- data/lib/lex_llm/transcription.rb +0 -35
- data/lib/lex_llm/utils.rb +0 -91
- data/lib/lex_llm/version.rb +0 -5
- data/lib/lex_llm.rb +0 -96
- data/lib/tasks/lex_llm.rake +0 -23
- /data/lib/{lex_llm → legion/extensions/llm}/aliases.json +0 -0
- /data/lib/{lex_llm → legion/extensions/llm}/models.json +0 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
# Registry of available AI models and their capabilities.
|
|
7
|
+
class Models
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
MODELS_DEV_PROVIDER_MAP = {
|
|
11
|
+
'openai' => 'openai',
|
|
12
|
+
'anthropic' => 'anthropic',
|
|
13
|
+
'google' => 'gemini',
|
|
14
|
+
'google-vertex' => 'vertexai',
|
|
15
|
+
'amazon-bedrock' => 'bedrock',
|
|
16
|
+
'deepseek' => 'deepseek',
|
|
17
|
+
'mistral' => 'mistral',
|
|
18
|
+
'openrouter' => 'openrouter',
|
|
19
|
+
'perplexity' => 'perplexity'
|
|
20
|
+
}.freeze
|
|
21
|
+
PROVIDER_PREFERENCE = %w[
|
|
22
|
+
openai
|
|
23
|
+
anthropic
|
|
24
|
+
gemini
|
|
25
|
+
vertexai
|
|
26
|
+
bedrock
|
|
27
|
+
openrouter
|
|
28
|
+
deepseek
|
|
29
|
+
mistral
|
|
30
|
+
perplexity
|
|
31
|
+
xai
|
|
32
|
+
azure
|
|
33
|
+
ollama
|
|
34
|
+
gpustack
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
def instance
|
|
39
|
+
@instance ||= new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def schema_file
|
|
43
|
+
File.expand_path('models_schema.json', __dir__)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def load_models(file = Legion::Extensions::Llm.config.model_registry_file)
|
|
47
|
+
read_from_json(file)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def read_from_json(file = Legion::Extensions::Llm.config.model_registry_file)
|
|
51
|
+
data = File.exist?(file) ? File.read(file) : '[]'
|
|
52
|
+
models = Legion::JSON.parse(data, symbolize_names: true).map { |model| Model::Info.new(model) }
|
|
53
|
+
filter_models(models)
|
|
54
|
+
rescue Legion::JSON::ParseError
|
|
55
|
+
[]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def refresh!(remote_only: false)
|
|
59
|
+
existing_models = load_existing_models
|
|
60
|
+
|
|
61
|
+
provider_fetch = fetch_provider_models(remote_only: remote_only)
|
|
62
|
+
log_provider_fetch(provider_fetch)
|
|
63
|
+
|
|
64
|
+
models_dev_fetch = fetch_models_dev_models(existing_models)
|
|
65
|
+
log_models_dev_fetch(models_dev_fetch)
|
|
66
|
+
|
|
67
|
+
merged_models = merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
|
|
68
|
+
@instance = new(merged_models)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def fetch_provider_models(remote_only: true) # rubocop:disable Metrics/PerceivedComplexity
|
|
72
|
+
config = Legion::Extensions::Llm.config
|
|
73
|
+
provider_classes = remote_only ? Provider.remote_providers.values : Provider.providers.values
|
|
74
|
+
configured_classes = if remote_only
|
|
75
|
+
Provider.configured_remote_providers(config)
|
|
76
|
+
else
|
|
77
|
+
Provider.configured_providers(config)
|
|
78
|
+
end
|
|
79
|
+
configured = configured_classes.select { |klass| provider_classes.include?(klass) }
|
|
80
|
+
result = {
|
|
81
|
+
models: [],
|
|
82
|
+
fetched_providers: [],
|
|
83
|
+
configured_names: configured.map(&:name),
|
|
84
|
+
failed: []
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
provider_classes.each do |provider_class|
|
|
88
|
+
next if remote_only && provider_class.local?
|
|
89
|
+
next unless provider_class.configured?(config)
|
|
90
|
+
|
|
91
|
+
begin
|
|
92
|
+
result[:models].concat(provider_class.new(config).list_models)
|
|
93
|
+
result[:fetched_providers] << provider_class.slug
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
result[:failed] << { name: provider_class.name, slug: provider_class.slug, error: e }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
result[:fetched_providers].uniq!
|
|
100
|
+
result
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Backwards-compatible wrapper used by specs.
|
|
104
|
+
def fetch_from_providers(remote_only: true)
|
|
105
|
+
fetch_provider_models(remote_only: remote_only)[:models]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def resolve(model_id, provider: nil, assume_exists: false, config: nil) # rubocop:disable Metrics/PerceivedComplexity
|
|
109
|
+
config ||= Legion::Extensions::Llm.config
|
|
110
|
+
provider_class = provider ? Provider.providers[provider.to_sym] : nil
|
|
111
|
+
|
|
112
|
+
if provider_class
|
|
113
|
+
temp_instance = provider_class.new(config)
|
|
114
|
+
assume_exists = true if temp_instance.local? || temp_instance.assume_models_exist?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if assume_exists
|
|
118
|
+
raise ArgumentError, 'Provider must be specified if assume_exists is true' unless provider
|
|
119
|
+
|
|
120
|
+
provider_class ||= raise(Error, "Unknown provider: #{provider.to_sym}")
|
|
121
|
+
provider_instance = provider_class.new(config)
|
|
122
|
+
|
|
123
|
+
model = if provider_instance.local?
|
|
124
|
+
begin
|
|
125
|
+
Models.find(model_id, provider)
|
|
126
|
+
rescue ModelNotFoundError
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
model ||= Model::Info.default(model_id, provider_instance.slug)
|
|
132
|
+
else
|
|
133
|
+
model = Models.find model_id, provider
|
|
134
|
+
provider_class = Provider.providers[model.provider.to_sym] || raise(Error,
|
|
135
|
+
"Unknown provider: #{model.provider}")
|
|
136
|
+
provider_instance = provider_class.new(config)
|
|
137
|
+
end
|
|
138
|
+
[model, provider_instance]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def method_missing(method, ...)
|
|
142
|
+
if instance.respond_to?(method)
|
|
143
|
+
instance.send(method, ...)
|
|
144
|
+
else
|
|
145
|
+
super
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def respond_to_missing?(method, include_private = false)
|
|
150
|
+
instance.respond_to?(method, include_private) || super
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def fetch_models_dev_models(existing_models) # rubocop:disable Metrics/PerceivedComplexity
|
|
154
|
+
Legion::Extensions::Llm.logger.info 'Fetching models from models.dev API...'
|
|
155
|
+
|
|
156
|
+
connection = Connection.basic do |f|
|
|
157
|
+
f.request :json
|
|
158
|
+
f.response :json, parser_options: { symbolize_names: true }
|
|
159
|
+
end
|
|
160
|
+
response = connection.get 'https://models.dev/api.json'
|
|
161
|
+
providers = response.body || {}
|
|
162
|
+
|
|
163
|
+
models = providers.flat_map do |provider_key, provider_data|
|
|
164
|
+
provider_slug = MODELS_DEV_PROVIDER_MAP[provider_key.to_s]
|
|
165
|
+
next [] unless provider_slug
|
|
166
|
+
|
|
167
|
+
(provider_data[:models] || {}).values.map do |model_data|
|
|
168
|
+
Model::Info.new(models_dev_model_to_info(model_data, provider_slug, provider_key.to_s))
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
{ models: models.reject { |model| model.provider.nil? || model.id.nil? }, fetched: true }
|
|
172
|
+
rescue StandardError => e
|
|
173
|
+
Legion::Extensions::Llm.logger.warn(
|
|
174
|
+
"Failed to fetch models.dev (#{e.class}: #{e.message}). Keeping existing."
|
|
175
|
+
)
|
|
176
|
+
{
|
|
177
|
+
models: existing_models.select { |model| model.metadata[:source] == 'models.dev' },
|
|
178
|
+
fetched: false
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def load_existing_models
|
|
183
|
+
existing_models = instance&.all
|
|
184
|
+
existing_models = read_from_json if existing_models.nil? || existing_models.empty?
|
|
185
|
+
existing_models
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def log_provider_fetch(provider_fetch)
|
|
189
|
+
Legion::Extensions::Llm.logger.info(
|
|
190
|
+
"Fetching models from providers: #{provider_fetch[:configured_names].join(', ')}"
|
|
191
|
+
)
|
|
192
|
+
provider_fetch[:failed].each do |failure|
|
|
193
|
+
Legion::Extensions::Llm.logger.warn(
|
|
194
|
+
"Failed to fetch #{failure[:name]} models (#{failure[:error].class}: #{failure[:error].message}). " \
|
|
195
|
+
'Keeping existing.'
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def log_models_dev_fetch(models_dev_fetch)
|
|
201
|
+
return if models_dev_fetch[:fetched]
|
|
202
|
+
|
|
203
|
+
Legion::Extensions::Llm.logger.warn('Using cached models.dev data due to fetch failure.')
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
|
|
207
|
+
existing_by_provider = existing_models.group_by(&:provider)
|
|
208
|
+
preserved_models = existing_by_provider
|
|
209
|
+
.except(*provider_fetch[:fetched_providers])
|
|
210
|
+
.values
|
|
211
|
+
.flatten
|
|
212
|
+
|
|
213
|
+
provider_models = provider_fetch[:models] + preserved_models
|
|
214
|
+
models_dev_models = if models_dev_fetch[:fetched]
|
|
215
|
+
models_dev_fetch[:models]
|
|
216
|
+
else
|
|
217
|
+
existing_models.select { |model| model.metadata[:source] == 'models.dev' }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
merge_models(provider_models, models_dev_models)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def merge_models(provider_models, models_dev_models)
|
|
224
|
+
models_dev_by_key = index_by_key(models_dev_models)
|
|
225
|
+
provider_by_key = index_by_key(provider_models)
|
|
226
|
+
|
|
227
|
+
all_keys = models_dev_by_key.keys | provider_by_key.keys
|
|
228
|
+
|
|
229
|
+
models = all_keys.map do |key|
|
|
230
|
+
models_dev_model = find_models_dev_model(key, models_dev_by_key)
|
|
231
|
+
provider_model = provider_by_key[key]
|
|
232
|
+
|
|
233
|
+
if models_dev_model && provider_model
|
|
234
|
+
add_provider_metadata(models_dev_model, provider_model)
|
|
235
|
+
elsif models_dev_model
|
|
236
|
+
models_dev_model
|
|
237
|
+
else
|
|
238
|
+
provider_model
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
filter_models(models).sort_by { |m| [m.provider, m.id] }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def filter_models(models)
|
|
246
|
+
models.reject do |model|
|
|
247
|
+
model.provider.to_s == 'vertexai' && model.id.to_s.include?('/')
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def find_models_dev_model(key, models_dev_by_key)
|
|
252
|
+
# Direct match
|
|
253
|
+
return models_dev_by_key[key] if models_dev_by_key[key]
|
|
254
|
+
|
|
255
|
+
provider, model_id = key.split(':', 2)
|
|
256
|
+
if provider == 'bedrock'
|
|
257
|
+
normalized_id = model_id.sub(/^[a-z]{2}\./, '')
|
|
258
|
+
context_override = nil
|
|
259
|
+
normalized_id = normalized_id.gsub(/:(\d+)k\b/) do
|
|
260
|
+
context_override = Regexp.last_match(1).to_i * 1000
|
|
261
|
+
''
|
|
262
|
+
end
|
|
263
|
+
bedrock_model = models_dev_by_key["bedrock:#{normalized_id}"]
|
|
264
|
+
if bedrock_model
|
|
265
|
+
data = bedrock_model.to_h.merge(id: model_id)
|
|
266
|
+
data[:context_window] = context_override if context_override
|
|
267
|
+
return Model::Info.new(data)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# VertexAI uses same models as Gemini
|
|
272
|
+
return unless provider == 'vertexai'
|
|
273
|
+
|
|
274
|
+
gemini_model = models_dev_by_key["gemini:#{model_id}"]
|
|
275
|
+
return unless gemini_model
|
|
276
|
+
|
|
277
|
+
# Return Gemini's models.dev data but with VertexAI as provider
|
|
278
|
+
Model::Info.new(gemini_model.to_h.merge(provider: 'vertexai'))
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def index_by_key(models)
|
|
282
|
+
models.to_h do |model|
|
|
283
|
+
["#{model.provider}:#{model.id}", model]
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def add_provider_metadata(models_dev_model, provider_model) # rubocop:disable Metrics/PerceivedComplexity
|
|
288
|
+
data = models_dev_model.to_h
|
|
289
|
+
data[:name] = provider_model.name if blank_value?(data[:name])
|
|
290
|
+
data[:family] = provider_model.family if blank_value?(data[:family])
|
|
291
|
+
data[:created_at] = provider_model.created_at if blank_value?(data[:created_at])
|
|
292
|
+
data[:context_window] = provider_model.context_window if blank_value?(data[:context_window])
|
|
293
|
+
data[:max_output_tokens] = provider_model.max_output_tokens if blank_value?(data[:max_output_tokens])
|
|
294
|
+
data[:modalities] = provider_model.modalities.to_h if blank_value?(data[:modalities])
|
|
295
|
+
data[:pricing] = provider_model.pricing.to_h if blank_value?(data[:pricing])
|
|
296
|
+
data[:metadata] = provider_model.metadata.merge(data[:metadata] || {})
|
|
297
|
+
data[:capabilities] = (models_dev_model.capabilities + provider_model.capabilities).uniq
|
|
298
|
+
normalize_embedding_modalities(data)
|
|
299
|
+
Model::Info.new(data)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def normalize_embedding_modalities(data)
|
|
303
|
+
return unless data[:id].to_s.include?('embedding')
|
|
304
|
+
|
|
305
|
+
modalities = data[:modalities].to_h
|
|
306
|
+
modalities[:input] = ['text'] if modalities[:input].nil? || modalities[:input].empty?
|
|
307
|
+
modalities[:output] = ['embeddings']
|
|
308
|
+
data[:modalities] = modalities
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def blank_value?(value)
|
|
312
|
+
return true if value.nil?
|
|
313
|
+
return value.empty? if value.is_a?(String) || value.is_a?(Array)
|
|
314
|
+
|
|
315
|
+
if value.is_a?(Hash)
|
|
316
|
+
return true if value.empty?
|
|
317
|
+
|
|
318
|
+
return value.values.all? { |nested| blank_value?(nested) }
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
false
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def models_dev_model_to_info(model_data, provider_slug, provider_key)
|
|
325
|
+
modalities = normalize_models_dev_modalities(model_data[:modalities])
|
|
326
|
+
capabilities = models_dev_capabilities(model_data, modalities)
|
|
327
|
+
|
|
328
|
+
created_date = [model_data[:release_date], model_data[:last_updated]]
|
|
329
|
+
.find { |value| !value.to_s.strip.empty? }
|
|
330
|
+
|
|
331
|
+
data = {
|
|
332
|
+
id: model_data[:id],
|
|
333
|
+
name: model_data[:name] || model_data[:id],
|
|
334
|
+
provider: provider_slug,
|
|
335
|
+
family: model_data[:family],
|
|
336
|
+
created_at: created_date ? "#{created_date} 00:00:00 UTC" : nil,
|
|
337
|
+
context_window: model_data.dig(:limit, :context),
|
|
338
|
+
max_output_tokens: model_data.dig(:limit, :output),
|
|
339
|
+
knowledge_cutoff: normalize_models_dev_knowledge(model_data[:knowledge]),
|
|
340
|
+
modalities: modalities,
|
|
341
|
+
capabilities: capabilities,
|
|
342
|
+
pricing: models_dev_pricing(model_data[:cost]),
|
|
343
|
+
metadata: models_dev_metadata(model_data, provider_key)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
normalize_embedding_modalities(data)
|
|
347
|
+
data
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def models_dev_capabilities(model_data, modalities)
|
|
351
|
+
capabilities = []
|
|
352
|
+
capabilities << 'function_calling' if model_data[:tool_call]
|
|
353
|
+
capabilities << 'structured_output' if model_data[:structured_output]
|
|
354
|
+
capabilities << 'reasoning' if model_data[:reasoning]
|
|
355
|
+
capabilities << 'vision' if modalities[:input].intersect?(%w[image video pdf])
|
|
356
|
+
capabilities.uniq
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def models_dev_pricing(cost)
|
|
360
|
+
return {} unless cost
|
|
361
|
+
|
|
362
|
+
text_standard = {
|
|
363
|
+
input_per_million: cost[:input],
|
|
364
|
+
output_per_million: cost[:output],
|
|
365
|
+
cached_input_per_million: cost[:cache_read],
|
|
366
|
+
reasoning_output_per_million: cost[:reasoning]
|
|
367
|
+
}.compact
|
|
368
|
+
|
|
369
|
+
audio_standard = {
|
|
370
|
+
input_per_million: cost[:input_audio],
|
|
371
|
+
output_per_million: cost[:output_audio]
|
|
372
|
+
}.compact
|
|
373
|
+
|
|
374
|
+
pricing = {}
|
|
375
|
+
pricing[:text_tokens] = { standard: text_standard } if text_standard.any?
|
|
376
|
+
pricing[:audio_tokens] = { standard: audio_standard } if audio_standard.any?
|
|
377
|
+
pricing
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def models_dev_metadata(model_data, provider_key)
|
|
381
|
+
metadata = {
|
|
382
|
+
source: 'models.dev',
|
|
383
|
+
provider_id: provider_key,
|
|
384
|
+
open_weights: model_data[:open_weights],
|
|
385
|
+
attachment: model_data[:attachment],
|
|
386
|
+
temperature: model_data[:temperature],
|
|
387
|
+
last_updated: model_data[:last_updated],
|
|
388
|
+
status: model_data[:status],
|
|
389
|
+
interleaved: model_data[:interleaved],
|
|
390
|
+
cost: model_data[:cost],
|
|
391
|
+
limit: model_data[:limit],
|
|
392
|
+
knowledge: model_data[:knowledge]
|
|
393
|
+
}
|
|
394
|
+
metadata.compact
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def normalize_models_dev_modalities(modalities)
|
|
398
|
+
normalized = { input: [], output: [] }
|
|
399
|
+
return normalized unless modalities
|
|
400
|
+
|
|
401
|
+
normalized[:input] = Array(modalities[:input]).compact
|
|
402
|
+
normalized[:output] = Array(modalities[:output]).compact
|
|
403
|
+
normalized
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def normalize_models_dev_knowledge(value)
|
|
407
|
+
return if value.nil?
|
|
408
|
+
return value if value.is_a?(Date)
|
|
409
|
+
|
|
410
|
+
Date.parse(value.to_s)
|
|
411
|
+
rescue ArgumentError
|
|
412
|
+
nil
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def initialize(models = nil)
|
|
417
|
+
@models = self.class.filter_models(models || self.class.load_models)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def load_from_json!(file = Legion::Extensions::Llm.config.model_registry_file)
|
|
421
|
+
@models = self.class.read_from_json(file)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def save_to_json(file = Legion::Extensions::Llm.config.model_registry_file)
|
|
425
|
+
File.write(file, Legion::JSON.pretty_generate(all.map(&:to_h)))
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def all
|
|
429
|
+
@models
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def each(&)
|
|
433
|
+
all.each(&)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def find(model_id, provider = nil)
|
|
437
|
+
if provider
|
|
438
|
+
find_with_provider(model_id, provider)
|
|
439
|
+
else
|
|
440
|
+
find_without_provider(model_id)
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def chat_models
|
|
445
|
+
self.class.new(all.select { |m| m.type == 'chat' })
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def embedding_models
|
|
449
|
+
self.class.new(all.select { |m| m.type == 'embedding' || m.modalities.output.include?('embeddings') })
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def audio_models
|
|
453
|
+
self.class.new(all.select { |m| m.type == 'audio' || m.modalities.output.include?('audio') })
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def image_models
|
|
457
|
+
self.class.new(all.select { |m| m.type == 'image' || m.modalities.output.include?('image') })
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def by_family(family)
|
|
461
|
+
self.class.new(all.select { |m| m.family == family.to_s })
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def by_provider(provider)
|
|
465
|
+
self.class.new(all.select { |m| m.provider == provider.to_s })
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def refresh!(remote_only: false)
|
|
469
|
+
self.class.refresh!(remote_only: remote_only)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def resolve(model_id, provider: nil, assume_exists: false, config: nil)
|
|
473
|
+
self.class.resolve(model_id, provider: provider, assume_exists: assume_exists, config: config)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
private
|
|
477
|
+
|
|
478
|
+
def find_with_provider(model_id, provider)
|
|
479
|
+
resolved_id = provider_resolved_model_id(Aliases.resolve(model_id, provider), provider)
|
|
480
|
+
all.find { |m| m.id == resolved_id && m.provider == provider.to_s } ||
|
|
481
|
+
all.find { |m| m.id == model_id && m.provider == provider.to_s } ||
|
|
482
|
+
raise(ModelNotFoundError, "Unknown model: #{model_id} for provider: #{provider}")
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def provider_resolved_model_id(model_id, provider)
|
|
486
|
+
provider_class = Provider.resolve(provider)
|
|
487
|
+
return model_id unless provider_class
|
|
488
|
+
|
|
489
|
+
provider_class.resolve_model_id(model_id, config: Legion::Extensions::Llm.config)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def find_without_provider(model_id)
|
|
493
|
+
exact_matches = all.select { |m| m.id == model_id }
|
|
494
|
+
return preferred_match(exact_matches) if exact_matches.any?
|
|
495
|
+
|
|
496
|
+
resolved_id = Aliases.resolve(model_id)
|
|
497
|
+
alias_matches = all.select { |m| m.id == resolved_id }
|
|
498
|
+
return preferred_match(alias_matches) if alias_matches.any?
|
|
499
|
+
|
|
500
|
+
raise(ModelNotFoundError, "Unknown model: #{model_id}")
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def preferred_match(candidates)
|
|
504
|
+
return candidates.first if candidates.size == 1
|
|
505
|
+
|
|
506
|
+
candidates.min_by do |model|
|
|
507
|
+
index = PROVIDER_PREFERENCE.index(model.provider)
|
|
508
|
+
index || PROVIDER_PREFERENCE.length
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
# Identify potentially harmful content in text.
|
|
7
|
+
# https://platform.openai.com/docs/guides/moderation
|
|
8
|
+
class Moderation
|
|
9
|
+
attr_reader :id, :model, :results
|
|
10
|
+
|
|
11
|
+
def initialize(id:, model:, results:)
|
|
12
|
+
@id = id
|
|
13
|
+
@model = model
|
|
14
|
+
@results = results
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.moderate(input,
|
|
18
|
+
model: nil,
|
|
19
|
+
provider: nil,
|
|
20
|
+
assume_model_exists: false,
|
|
21
|
+
context: nil)
|
|
22
|
+
config = context&.config || Legion::Extensions::Llm.config
|
|
23
|
+
model ||= config.default_moderation_model
|
|
24
|
+
model, provider_instance = Models.resolve(model, provider: provider, assume_exists: assume_model_exists,
|
|
25
|
+
config: config)
|
|
26
|
+
model_id = model.id
|
|
27
|
+
|
|
28
|
+
provider_instance.moderate(input, model: model_id)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Convenience method to get content from moderation result
|
|
32
|
+
def content
|
|
33
|
+
results
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Check if any content was flagged
|
|
37
|
+
def flagged?
|
|
38
|
+
results.any? { |result| result['flagged'] }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Get all flagged categories across all results
|
|
42
|
+
def flagged_categories
|
|
43
|
+
results.flat_map do |result|
|
|
44
|
+
result['categories']&.select { |_category, flagged| flagged }&.keys || []
|
|
45
|
+
end.uniq
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Get category scores for the first result (most common case)
|
|
49
|
+
def category_scores
|
|
50
|
+
results.first&.dig('category_scores') || {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Get categories for the first result (most common case)
|
|
54
|
+
def categories
|
|
55
|
+
results.first&.dig('categories') || {}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|