lex-llm 0.1.1

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