lex-llm 0.1.2 → 0.1.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.
Files changed (165) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.md +12 -1
  4. data/Gemfile +1 -19
  5. data/README.md +25 -26
  6. data/lex-llm.gemspec +2 -2
  7. data/lib/legion/extensions/llm/agent.rb +366 -0
  8. data/lib/legion/extensions/llm/aliases.rb +42 -0
  9. data/lib/legion/extensions/llm/attachment.rb +229 -0
  10. data/lib/legion/extensions/llm/chat.rb +355 -0
  11. data/lib/legion/extensions/llm/chunk.rb +10 -0
  12. data/lib/legion/extensions/llm/configuration.rb +82 -0
  13. data/lib/legion/extensions/llm/connection.rb +134 -0
  14. data/lib/legion/extensions/llm/content.rb +81 -0
  15. data/lib/legion/extensions/llm/context.rb +33 -0
  16. data/lib/legion/extensions/llm/embedding.rb +33 -0
  17. data/lib/legion/extensions/llm/error.rb +116 -0
  18. data/lib/legion/extensions/llm/image.rb +109 -0
  19. data/lib/legion/extensions/llm/message.rb +111 -0
  20. data/lib/legion/extensions/llm/mime_type.rb +75 -0
  21. data/lib/legion/extensions/llm/model/info.rb +117 -0
  22. data/lib/legion/extensions/llm/model/modalities.rb +26 -0
  23. data/lib/legion/extensions/llm/model/pricing.rb +52 -0
  24. data/lib/legion/extensions/llm/model/pricing_category.rb +50 -0
  25. data/lib/legion/extensions/llm/model/pricing_tier.rb +37 -0
  26. data/lib/legion/extensions/llm/model.rb +11 -0
  27. data/lib/legion/extensions/llm/models.rb +514 -0
  28. data/lib/{lex_llm → legion/extensions/llm}/models_schema.json +1 -1
  29. data/lib/legion/extensions/llm/moderation.rb +60 -0
  30. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +276 -0
  31. data/lib/legion/extensions/llm/provider.rb +337 -0
  32. data/lib/legion/extensions/llm/routing/lane_key.rb +57 -0
  33. data/lib/legion/extensions/llm/routing/model_offering.rb +173 -0
  34. data/lib/legion/extensions/llm/routing.rb +11 -0
  35. data/lib/legion/extensions/llm/stream_accumulator.rb +209 -0
  36. data/lib/legion/extensions/llm/streaming.rb +181 -0
  37. data/lib/legion/extensions/llm/thinking.rb +53 -0
  38. data/lib/legion/extensions/llm/tokens.rb +51 -0
  39. data/lib/legion/extensions/llm/tool.rb +258 -0
  40. data/lib/legion/extensions/llm/tool_call.rb +29 -0
  41. data/lib/legion/extensions/llm/transcription.rb +39 -0
  42. data/lib/legion/extensions/llm/utils.rb +95 -0
  43. data/lib/legion/extensions/llm/version.rb +9 -0
  44. data/lib/legion/extensions/llm.rb +85 -6
  45. metadata +40 -122
  46. data/lib/generators/lex_llm/agent/agent_generator.rb +0 -36
  47. data/lib/generators/lex_llm/agent/templates/agent.rb.tt +0 -6
  48. data/lib/generators/lex_llm/agent/templates/instructions.txt.erb.tt +0 -0
  49. data/lib/generators/lex_llm/chat_ui/chat_ui_generator.rb +0 -256
  50. data/lib/generators/lex_llm/chat_ui/templates/controllers/chats_controller.rb.tt +0 -38
  51. data/lib/generators/lex_llm/chat_ui/templates/controllers/messages_controller.rb.tt +0 -21
  52. data/lib/generators/lex_llm/chat_ui/templates/controllers/models_controller.rb.tt +0 -14
  53. data/lib/generators/lex_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
  54. data/lib/generators/lex_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +0 -12
  55. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +0 -16
  56. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +0 -31
  57. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +0 -31
  58. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +0 -9
  59. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +0 -27
  60. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +0 -14
  61. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +0 -1
  62. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +0 -13
  63. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +0 -23
  64. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +0 -10
  65. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +0 -2
  66. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +0 -4
  67. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +0 -14
  68. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +0 -13
  69. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +0 -21
  70. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +0 -17
  71. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +0 -40
  72. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +0 -27
  73. data/lib/generators/lex_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +0 -16
  74. data/lib/generators/lex_llm/chat_ui/templates/views/chats/_form.html.erb.tt +0 -29
  75. data/lib/generators/lex_llm/chat_ui/templates/views/chats/index.html.erb.tt +0 -28
  76. data/lib/generators/lex_llm/chat_ui/templates/views/chats/new.html.erb.tt +0 -11
  77. data/lib/generators/lex_llm/chat_ui/templates/views/chats/show.html.erb.tt +0 -25
  78. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +0 -9
  79. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_content.html.erb.tt +0 -1
  80. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_error.html.erb.tt +0 -8
  81. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_form.html.erb.tt +0 -21
  82. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_system.html.erb.tt +0 -6
  83. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +0 -2
  84. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +0 -4
  85. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_user.html.erb.tt +0 -9
  86. data/lib/generators/lex_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +0 -7
  87. data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +0 -8
  88. data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +0 -16
  89. data/lib/generators/lex_llm/chat_ui/templates/views/models/_model.html.erb.tt +0 -15
  90. data/lib/generators/lex_llm/chat_ui/templates/views/models/index.html.erb.tt +0 -38
  91. data/lib/generators/lex_llm/chat_ui/templates/views/models/show.html.erb.tt +0 -17
  92. data/lib/generators/lex_llm/generator_helpers.rb +0 -214
  93. data/lib/generators/lex_llm/install/install_generator.rb +0 -109
  94. data/lib/generators/lex_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
  95. data/lib/generators/lex_llm/install/templates/chat_model.rb.tt +0 -3
  96. data/lib/generators/lex_llm/install/templates/create_chats_migration.rb.tt +0 -7
  97. data/lib/generators/lex_llm/install/templates/create_messages_migration.rb.tt +0 -19
  98. data/lib/generators/lex_llm/install/templates/create_models_migration.rb.tt +0 -39
  99. data/lib/generators/lex_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
  100. data/lib/generators/lex_llm/install/templates/initializer.rb.tt +0 -20
  101. data/lib/generators/lex_llm/install/templates/message_model.rb.tt +0 -4
  102. data/lib/generators/lex_llm/install/templates/model_model.rb.tt +0 -3
  103. data/lib/generators/lex_llm/install/templates/tool_call_model.rb.tt +0 -3
  104. data/lib/generators/lex_llm/schema/schema_generator.rb +0 -26
  105. data/lib/generators/lex_llm/schema/templates/schema.rb.tt +0 -2
  106. data/lib/generators/lex_llm/tool/templates/tool.rb.tt +0 -9
  107. data/lib/generators/lex_llm/tool/templates/tool_call.html.erb.tt +0 -13
  108. data/lib/generators/lex_llm/tool/templates/tool_result.html.erb.tt +0 -13
  109. data/lib/generators/lex_llm/tool/tool_generator.rb +0 -96
  110. data/lib/generators/lex_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
  111. data/lib/generators/lex_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
  112. data/lib/generators/lex_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
  113. data/lib/generators/lex_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
  114. data/lib/generators/lex_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
  115. data/lib/generators/lex_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
  116. data/lib/generators/lex_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
  117. data/lib/generators/lex_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
  118. data/lib/lex_llm/active_record/acts_as.rb +0 -180
  119. data/lib/lex_llm/active_record/acts_as_legacy.rb +0 -503
  120. data/lib/lex_llm/active_record/chat_methods.rb +0 -468
  121. data/lib/lex_llm/active_record/message_methods.rb +0 -131
  122. data/lib/lex_llm/active_record/model_methods.rb +0 -76
  123. data/lib/lex_llm/active_record/payload_helpers.rb +0 -26
  124. data/lib/lex_llm/active_record/tool_call_methods.rb +0 -15
  125. data/lib/lex_llm/agent.rb +0 -365
  126. data/lib/lex_llm/aliases.rb +0 -38
  127. data/lib/lex_llm/attachment.rb +0 -223
  128. data/lib/lex_llm/chat.rb +0 -351
  129. data/lib/lex_llm/chunk.rb +0 -6
  130. data/lib/lex_llm/configuration.rb +0 -81
  131. data/lib/lex_llm/connection.rb +0 -130
  132. data/lib/lex_llm/content.rb +0 -77
  133. data/lib/lex_llm/context.rb +0 -29
  134. data/lib/lex_llm/embedding.rb +0 -29
  135. data/lib/lex_llm/error.rb +0 -112
  136. data/lib/lex_llm/image.rb +0 -105
  137. data/lib/lex_llm/message.rb +0 -107
  138. data/lib/lex_llm/mime_type.rb +0 -71
  139. data/lib/lex_llm/model/info.rb +0 -113
  140. data/lib/lex_llm/model/modalities.rb +0 -22
  141. data/lib/lex_llm/model/pricing.rb +0 -48
  142. data/lib/lex_llm/model/pricing_category.rb +0 -46
  143. data/lib/lex_llm/model/pricing_tier.rb +0 -33
  144. data/lib/lex_llm/model.rb +0 -7
  145. data/lib/lex_llm/models.rb +0 -506
  146. data/lib/lex_llm/moderation.rb +0 -56
  147. data/lib/lex_llm/provider/open_ai_compatible.rb +0 -219
  148. data/lib/lex_llm/provider.rb +0 -278
  149. data/lib/lex_llm/railtie.rb +0 -35
  150. data/lib/lex_llm/routing/lane_key.rb +0 -51
  151. data/lib/lex_llm/routing/model_offering.rb +0 -169
  152. data/lib/lex_llm/routing.rb +0 -7
  153. data/lib/lex_llm/stream_accumulator.rb +0 -203
  154. data/lib/lex_llm/streaming.rb +0 -175
  155. data/lib/lex_llm/thinking.rb +0 -49
  156. data/lib/lex_llm/tokens.rb +0 -47
  157. data/lib/lex_llm/tool.rb +0 -254
  158. data/lib/lex_llm/tool_call.rb +0 -25
  159. data/lib/lex_llm/transcription.rb +0 -35
  160. data/lib/lex_llm/utils.rb +0 -91
  161. data/lib/lex_llm/version.rb +0 -5
  162. data/lib/lex_llm.rb +0 -96
  163. data/lib/tasks/lex_llm.rake +0 -23
  164. /data/lib/{lex_llm → legion/extensions/llm}/aliases.json +0 -0
  165. /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
@@ -1,5 +1,5 @@
1
1
  {
2
- "title": "LexLLM Models Schema",
2
+ "title": "Legion LLM Models Schema",
3
3
  "description": "Schema for validating the structure of models.json",
4
4
  "type": "array",
5
5
  "items": {
@@ -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