lex-llm 0.7.3 → 0.8.0

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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +52 -67
  8. data/lib/legion/extensions/llm/canonical/params.rb +59 -33
  9. data/lib/legion/extensions/llm/canonical/request.rb +67 -54
  10. data/lib/legion/extensions/llm/canonical/response.rb +61 -76
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
  13. data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
  14. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  15. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  16. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  17. data/lib/legion/extensions/llm/canonical.rb +6 -5
  18. data/lib/legion/extensions/llm/configuration.rb +40 -10
  19. data/lib/legion/extensions/llm/connection.rb +8 -30
  20. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  21. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  22. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  23. data/lib/legion/extensions/llm/error.rb +0 -14
  24. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  25. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  26. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  27. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  28. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
  29. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  30. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
  31. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  32. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  33. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  34. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  35. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  36. data/lib/legion/extensions/llm/inventory/records.rb +82 -99
  37. data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
  38. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  39. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
  40. data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
  41. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
  42. data/lib/legion/extensions/llm/provider.rb +192 -315
  43. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  44. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  45. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  46. data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
  47. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  48. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  49. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  50. data/lib/legion/extensions/llm/streaming.rb +50 -35
  51. data/lib/legion/extensions/llm/taxonomies.rb +29 -17
  52. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  53. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  54. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  55. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  56. data/lib/legion/extensions/llm/utils.rb +23 -5
  57. data/lib/legion/extensions/llm/version.rb +1 -1
  58. data/lib/legion/extensions/llm.rb +10 -98
  59. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  60. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  61. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  62. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  63. data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
  64. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  65. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  66. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
  67. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  68. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  69. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  70. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  71. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  72. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  73. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  74. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  75. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
  76. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
  77. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  78. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  79. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  80. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  81. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
  82. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  83. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
  84. data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
  85. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  86. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  87. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  88. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  89. data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
  90. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
  91. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  92. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  93. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
  94. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
  95. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
  96. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  97. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  98. data/spec/legion/extensions/llm/provider_spec.rb +98 -78
  99. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  100. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  101. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  102. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
  103. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  104. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  105. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  106. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  107. data/spec/support/fake_llm_provider.rb +45 -39
  108. data/spec/support/fake_ssot_harness.rb +7 -2
  109. data/spec/support/ssot_registry_helpers.rb +3 -2
  110. metadata +15 -54
  111. data/lib/legion/extensions/llm/agent.rb +0 -366
  112. data/lib/legion/extensions/llm/aliases.json +0 -436
  113. data/lib/legion/extensions/llm/aliases.rb +0 -67
  114. data/lib/legion/extensions/llm/attachment.rb +0 -229
  115. data/lib/legion/extensions/llm/chat.rb +0 -354
  116. data/lib/legion/extensions/llm/chunk.rb +0 -10
  117. data/lib/legion/extensions/llm/content.rb +0 -81
  118. data/lib/legion/extensions/llm/context.rb +0 -33
  119. data/lib/legion/extensions/llm/embedding.rb +0 -33
  120. data/lib/legion/extensions/llm/image.rb +0 -109
  121. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  122. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
  123. data/lib/legion/extensions/llm/message.rb +0 -118
  124. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  125. data/lib/legion/extensions/llm/model/info.rb +0 -286
  126. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  127. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  128. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  129. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  130. data/lib/legion/extensions/llm/model.rb +0 -11
  131. data/lib/legion/extensions/llm/models.json +0 -57313
  132. data/lib/legion/extensions/llm/models.rb +0 -530
  133. data/lib/legion/extensions/llm/models_schema.json +0 -168
  134. data/lib/legion/extensions/llm/moderation.rb +0 -60
  135. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  136. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  137. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  138. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  139. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  140. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  141. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  142. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  143. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  144. data/lib/legion/extensions/llm/thinking.rb +0 -53
  145. data/lib/legion/extensions/llm/tokens.rb +0 -51
  146. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  147. data/lib/legion/extensions/llm/transcription.rb +0 -39
  148. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  149. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  150. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  151. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  152. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  153. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  154. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
  155. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  156. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  157. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  158. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  159. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  160. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  161. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  162. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  163. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
@@ -1,530 +0,0 @@
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
- include Legion::Logging::Helper
39
-
40
- # Discover provider classes from the Llm namespace.
41
- # Each lex-llm-* extension defines a module under Legion::Extensions::Llm
42
- # that responds to `provider_class` and has a `PROVIDER_FAMILY` constant.
43
- def scan_provider_classes
44
- Legion::Extensions::Llm.constants(false).filter_map do |const_name|
45
- mod = Legion::Extensions::Llm.const_get(const_name, false)
46
- next unless mod.is_a?(Module) && mod.respond_to?(:provider_class) &&
47
- mod.const_defined?(:PROVIDER_FAMILY, false)
48
-
49
- [mod::PROVIDER_FAMILY.to_sym, mod.provider_class]
50
- end.to_h
51
- end
52
-
53
- # Resolve a single provider class by slug.
54
- # Returns nil when the provider is unknown.
55
- def resolve_provider_class(name)
56
- return nil if name.nil?
57
-
58
- scan_provider_classes[name.to_sym]
59
- end
60
-
61
- def instance
62
- @instance ||= new
63
- end
64
-
65
- def schema_file
66
- File.expand_path('models_schema.json', __dir__)
67
- end
68
-
69
- def load_models(file = Legion::Extensions::Llm.config.model_registry_file)
70
- read_from_json(file)
71
- end
72
-
73
- def read_from_json(file = Legion::Extensions::Llm.config.model_registry_file)
74
- data = File.exist?(file) ? File.read(file) : '[]'
75
- models = Legion::JSON.parse(data, symbolize_names: true).map { |model| Model::Info.from_hash(model) }
76
- filter_models(models)
77
- rescue Legion::JSON::ParseError => e
78
- handle_exception(e, level: :warn, handled: true, operation: 'llm.models.read_from_json')
79
- []
80
- end
81
-
82
- def refresh!(remote_only: false)
83
- existing_models = load_existing_models
84
-
85
- provider_fetch = fetch_provider_models(remote_only: remote_only)
86
- log_provider_fetch(provider_fetch)
87
-
88
- models_dev_fetch = fetch_models_dev_models(existing_models)
89
- log_models_dev_fetch(models_dev_fetch)
90
-
91
- merged_models = merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
92
- @instance = new(merged_models)
93
- end
94
-
95
- def fetch_provider_models(remote_only: true)
96
- config = Legion::Extensions::Llm.config
97
- all_providers = scan_provider_classes.values
98
- provider_classes = remote_only ? all_providers.reject(&:local?) : all_providers
99
- configured = provider_classes.select { |klass| klass.configured?(config) }
100
-
101
- result = {
102
- models: [],
103
- fetched_providers: [],
104
- configured_names: configured.map(&:name),
105
- failed: []
106
- }
107
-
108
- configured.each do |provider_class|
109
- result[:models].concat(provider_class.new(config).list_models)
110
- result[:fetched_providers] << provider_class.slug
111
- rescue StandardError => e
112
- handle_exception(e, level: :warn, handled: true,
113
- operation: 'llm.models.fetch_provider_models')
114
- result[:failed] << { name: provider_class.name, slug: provider_class.slug, error: e }
115
- end
116
-
117
- result[:fetched_providers].uniq!
118
- result
119
- end
120
-
121
- # Backwards-compatible wrapper used by specs.
122
- def fetch_from_providers(remote_only: true)
123
- fetch_provider_models(remote_only: remote_only)[:models]
124
- end
125
-
126
- def resolve(model_id, provider: nil, assume_exists: false, config: nil)
127
- config ||= Legion::Extensions::Llm.config
128
- provider_class = provider ? resolve_provider_class(provider) : nil
129
-
130
- if provider_class
131
- temp_instance = provider_class.new(config)
132
- assume_exists = true if temp_instance.local? || temp_instance.assume_models_exist?
133
- end
134
-
135
- if assume_exists
136
- raise ArgumentError, 'Provider must be specified if assume_exists is true' unless provider
137
-
138
- provider_class ||= raise(Error, "Unknown provider: #{provider.to_sym}")
139
- provider_instance = provider_class.new(config)
140
-
141
- model = if provider_instance.local?
142
- begin
143
- Models.find(model_id, provider)
144
- rescue ModelNotFoundError
145
- nil
146
- end
147
- end
148
-
149
- model ||= Model::Info.default(model_id, provider_instance.slug)
150
- else
151
- model = Models.find model_id, provider
152
- provider_class = resolve_provider_class(model.provider) || raise(Error,
153
- "Unknown provider: #{model.provider}")
154
- provider_instance = provider_class.new(config)
155
- end
156
- [model, provider_instance]
157
- end
158
-
159
- def method_missing(method, ...)
160
- if instance.respond_to?(method)
161
- instance.send(method, ...)
162
- else
163
- super
164
- end
165
- end
166
-
167
- def respond_to_missing?(method, include_private = false)
168
- instance.respond_to?(method, include_private) || super
169
- end
170
-
171
- def fetch_models_dev_models(existing_models)
172
- log.info 'Fetching models from models.dev API...'
173
-
174
- connection = Connection.basic do |f|
175
- f.request :json
176
- f.response :json, parser_options: { symbolize_names: true }
177
- end
178
- response = connection.get 'https://models.dev/api.json'
179
- providers = response.body || {}
180
-
181
- models = providers.flat_map do |provider_key, provider_data|
182
- provider_slug = MODELS_DEV_PROVIDER_MAP[provider_key.to_s]
183
- next [] unless provider_slug
184
-
185
- (provider_data[:models] || {}).values.map do |model_data|
186
- Model::Info.from_hash(models_dev_model_to_info(model_data, provider_slug, provider_key.to_s))
187
- end
188
- end
189
- { models: models.reject { |model| model.provider.nil? || model.id.nil? }, fetched: true }
190
- rescue StandardError => e
191
- handle_exception(e, level: :warn, handled: true, operation: 'llm.models.fetch_models_dev')
192
- {
193
- models: existing_models.select { |model| model.metadata[:source] == 'models.dev' },
194
- fetched: false
195
- }
196
- end
197
-
198
- def load_existing_models
199
- existing_models = instance&.all
200
- existing_models = read_from_json if existing_models.nil? || existing_models.empty?
201
- existing_models
202
- end
203
-
204
- def log_provider_fetch(provider_fetch)
205
- log.info(
206
- "Fetching models from providers: #{provider_fetch[:configured_names].join(', ')}"
207
- )
208
- provider_fetch[:failed].each do |failure|
209
- log.warn(
210
- "Failed to fetch #{failure[:name]} models (#{failure[:error].class}: #{failure[:error].message}). " \
211
- 'Keeping existing.'
212
- )
213
- end
214
- end
215
-
216
- def log_models_dev_fetch(models_dev_fetch)
217
- return if models_dev_fetch[:fetched]
218
-
219
- log.warn('Using cached models.dev data due to fetch failure.')
220
- end
221
-
222
- def merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
223
- existing_by_provider = existing_models.group_by(&:provider)
224
- preserved_models = existing_by_provider
225
- .except(*provider_fetch[:fetched_providers])
226
- .values
227
- .flatten
228
-
229
- provider_models = provider_fetch[:models] + preserved_models
230
- models_dev_models = if models_dev_fetch[:fetched]
231
- models_dev_fetch[:models]
232
- else
233
- existing_models.select { |model| model.metadata[:source] == 'models.dev' }
234
- end
235
-
236
- merge_models(provider_models, models_dev_models)
237
- end
238
-
239
- def merge_models(provider_models, models_dev_models)
240
- models_dev_by_key = index_by_key(models_dev_models)
241
- provider_by_key = index_by_key(provider_models)
242
-
243
- all_keys = models_dev_by_key.keys | provider_by_key.keys
244
-
245
- models = all_keys.map do |key|
246
- models_dev_model = find_models_dev_model(key, models_dev_by_key)
247
- provider_model = provider_by_key[key]
248
-
249
- if models_dev_model && provider_model
250
- add_provider_metadata(models_dev_model, provider_model)
251
- elsif models_dev_model
252
- models_dev_model
253
- else
254
- provider_model
255
- end
256
- end
257
-
258
- filter_models(models).sort_by { |m| [m.provider, m.id] }
259
- end
260
-
261
- def filter_models(models)
262
- models.reject do |model|
263
- model.provider.to_s == 'vertexai' && model.id.to_s.include?('/')
264
- end
265
- end
266
-
267
- def find_models_dev_model(key, models_dev_by_key)
268
- # Direct match
269
- return models_dev_by_key[key] if models_dev_by_key[key]
270
-
271
- provider, model_id = key.split(':', 2)
272
- if provider == 'bedrock'
273
- normalized_id = model_id.sub(/^[a-z]{2}\./, '')
274
- context_override = nil
275
- normalized_id = normalized_id.gsub(/:(\d+)k\b/) do
276
- context_override = Regexp.last_match(1).to_i * 1000
277
- ''
278
- end
279
- bedrock_model = models_dev_by_key["bedrock:#{normalized_id}"]
280
- if bedrock_model
281
- data = bedrock_model.to_h.merge(id: model_id)
282
- data[:context_window] = context_override if context_override
283
- return Model::Info.from_hash(data)
284
- end
285
- end
286
-
287
- # VertexAI uses same models as Gemini
288
- return unless provider == 'vertexai'
289
-
290
- gemini_model = models_dev_by_key["gemini:#{model_id}"]
291
- return unless gemini_model
292
-
293
- # Return Gemini's models.dev data but with VertexAI as provider
294
- Model::Info.from_hash(gemini_model.to_h.merge(provider: 'vertexai'))
295
- end
296
-
297
- def index_by_key(models)
298
- models.to_h do |model|
299
- ["#{model.provider}:#{model.id}", model]
300
- end
301
- end
302
-
303
- def add_provider_metadata(models_dev_model, provider_model)
304
- data = models_dev_model.to_h
305
- data[:name] = provider_model.name if blank_value?(data[:name])
306
- data[:family] = provider_model.family if blank_value?(data[:family])
307
- data[:created_at] = provider_model.created_at if blank_value?(data[:created_at])
308
- data[:context_window] = provider_model.context_window if blank_value?(data[:context_window])
309
- data[:max_output_tokens] = provider_model.max_output_tokens if blank_value?(data[:max_output_tokens])
310
- data[:modalities] = provider_model.modalities.to_h if blank_value?(data[:modalities])
311
- data[:pricing] = provider_model.pricing.to_h if blank_value?(data[:pricing])
312
- data[:metadata] = provider_model.metadata.merge(data[:metadata] || {})
313
- data[:capabilities] = (models_dev_model.capabilities + provider_model.capabilities).uniq
314
- normalize_embedding_modalities(data)
315
- Model::Info.from_hash(data)
316
- end
317
-
318
- def normalize_embedding_modalities(data)
319
- return unless data[:id].to_s.include?('embedding')
320
-
321
- modalities = data[:modalities].to_h
322
- modalities[:input] = ['text'] if modalities[:input].nil? || modalities[:input].empty?
323
- modalities[:output] = ['embeddings']
324
- data[:modalities] = modalities
325
- end
326
-
327
- def blank_value?(value)
328
- return true if value.nil?
329
- return value.empty? if value.is_a?(String) || value.is_a?(Array)
330
-
331
- if value.is_a?(Hash)
332
- return true if value.empty?
333
-
334
- return value.values.all? { |nested| blank_value?(nested) }
335
- end
336
-
337
- false
338
- end
339
-
340
- def models_dev_model_to_info(model_data, provider_slug, provider_key)
341
- modalities = normalize_models_dev_modalities(model_data[:modalities])
342
- capabilities = models_dev_capabilities(model_data, modalities)
343
-
344
- created_date = [model_data[:release_date], model_data[:last_updated]]
345
- .find { |value| !value.to_s.strip.empty? }
346
-
347
- data = {
348
- id: model_data[:id],
349
- name: model_data[:name] || model_data[:id],
350
- provider: provider_slug,
351
- family: model_data[:family],
352
- created_at: created_date ? "#{created_date} 00:00:00 UTC" : nil,
353
- context_window: model_data.dig(:limit, :context),
354
- max_output_tokens: model_data.dig(:limit, :output),
355
- knowledge_cutoff: normalize_models_dev_knowledge(model_data[:knowledge]),
356
- modalities: modalities,
357
- capabilities: capabilities,
358
- pricing: models_dev_pricing(model_data[:cost]),
359
- metadata: models_dev_metadata(model_data, provider_key)
360
- }
361
-
362
- normalize_embedding_modalities(data)
363
- data
364
- end
365
-
366
- def models_dev_capabilities(model_data, modalities)
367
- capabilities = []
368
- capabilities << 'function_calling' if model_data[:tool_call]
369
- capabilities << 'structured_output' if model_data[:structured_output]
370
- capabilities << 'reasoning' if model_data[:reasoning]
371
- capabilities << 'vision' if modalities[:input].intersect?(%w[image video pdf])
372
- capabilities.uniq
373
- end
374
-
375
- def models_dev_pricing(cost)
376
- return {} unless cost
377
-
378
- text_standard = {
379
- input_per_million: cost[:input],
380
- output_per_million: cost[:output],
381
- cached_input_per_million: cost[:cache_read],
382
- reasoning_output_per_million: cost[:reasoning]
383
- }.compact
384
-
385
- audio_standard = {
386
- input_per_million: cost[:input_audio],
387
- output_per_million: cost[:output_audio]
388
- }.compact
389
-
390
- pricing = {}
391
- pricing[:text_tokens] = { standard: text_standard } if text_standard.any?
392
- pricing[:audio_tokens] = { standard: audio_standard } if audio_standard.any?
393
- pricing
394
- end
395
-
396
- def models_dev_metadata(model_data, provider_key)
397
- metadata = {
398
- source: 'models.dev',
399
- provider_id: provider_key,
400
- open_weights: model_data[:open_weights],
401
- attachment: model_data[:attachment],
402
- temperature: model_data[:temperature],
403
- last_updated: model_data[:last_updated],
404
- status: model_data[:status],
405
- interleaved: model_data[:interleaved],
406
- cost: model_data[:cost],
407
- limit: model_data[:limit],
408
- knowledge: model_data[:knowledge]
409
- }
410
- metadata.compact
411
- end
412
-
413
- def normalize_models_dev_modalities(modalities)
414
- normalized = { input: [], output: [] }
415
- return normalized unless modalities
416
-
417
- normalized[:input] = Array(modalities[:input]).compact
418
- normalized[:output] = Array(modalities[:output]).compact
419
- normalized
420
- end
421
-
422
- def normalize_models_dev_knowledge(value)
423
- return if value.nil?
424
- return value if value.is_a?(Date)
425
-
426
- Date.parse(value.to_s)
427
- rescue ArgumentError
428
- nil
429
- end
430
- end
431
-
432
- def initialize(models = nil)
433
- @models = self.class.filter_models(models || self.class.load_models)
434
- end
435
-
436
- def load_from_json!(file = Legion::Extensions::Llm.config.model_registry_file)
437
- @models = self.class.read_from_json(file)
438
- end
439
-
440
- def save_to_json(file = Legion::Extensions::Llm.config.model_registry_file)
441
- File.write(file, Legion::JSON.pretty_generate(all.map(&:to_h)))
442
- end
443
-
444
- def all
445
- @models
446
- end
447
-
448
- def each(&)
449
- all.each(&)
450
- end
451
-
452
- def find(model_id, provider = nil)
453
- if provider
454
- find_with_provider(model_id, provider)
455
- else
456
- find_without_provider(model_id)
457
- end
458
- end
459
-
460
- def chat_models
461
- self.class.new(all.select { |m| m.type == 'chat' })
462
- end
463
-
464
- def embedding_models
465
- self.class.new(all.select { |m| m.type == 'embedding' || m.modalities.output.include?('embeddings') })
466
- end
467
-
468
- def audio_models
469
- self.class.new(all.select { |m| m.type == 'audio' || m.modalities.output.include?('audio') })
470
- end
471
-
472
- def image_models
473
- self.class.new(all.select { |m| m.type == 'image' || m.modalities.output.include?('image') })
474
- end
475
-
476
- def by_family(family)
477
- self.class.new(all.select { |m| m.family.to_s == family.to_s })
478
- end
479
-
480
- def by_provider(provider)
481
- self.class.new(all.select { |m| m.provider.to_s == provider.to_s })
482
- end
483
-
484
- def refresh!(remote_only: false)
485
- self.class.refresh!(remote_only: remote_only)
486
- end
487
-
488
- def resolve(model_id, provider: nil, assume_exists: false, config: nil)
489
- self.class.resolve(model_id, provider: provider, assume_exists: assume_exists, config: config)
490
- end
491
-
492
- private
493
-
494
- def find_with_provider(model_id, provider)
495
- resolved_id = provider_resolved_model_id(Aliases.resolve(model_id, provider), provider)
496
- all.find { |m| m.id == resolved_id && m.provider.to_s == provider.to_s } ||
497
- all.find { |m| m.id == model_id && m.provider.to_s == provider.to_s } ||
498
- raise(ModelNotFoundError, "Unknown model: #{model_id} for provider: #{provider}")
499
- end
500
-
501
- def provider_resolved_model_id(model_id, provider)
502
- provider_class = self.class.resolve_provider_class(provider)
503
- return model_id unless provider_class
504
-
505
- provider_class.resolve_model_id(model_id, config: Legion::Extensions::Llm.config)
506
- end
507
-
508
- def find_without_provider(model_id)
509
- exact_matches = all.select { |m| m.id == model_id }
510
- return preferred_match(exact_matches) if exact_matches.any?
511
-
512
- resolved_id = Aliases.resolve(model_id)
513
- alias_matches = all.select { |m| m.id == resolved_id }
514
- return preferred_match(alias_matches) if alias_matches.any?
515
-
516
- raise(ModelNotFoundError, "Unknown model: #{model_id}")
517
- end
518
-
519
- def preferred_match(candidates)
520
- return candidates.first if candidates.size == 1
521
-
522
- candidates.min_by do |model|
523
- index = PROVIDER_PREFERENCE.index(model.provider.to_s)
524
- index || PROVIDER_PREFERENCE.length
525
- end
526
- end
527
- end
528
- end
529
- end
530
- end
@@ -1,168 +0,0 @@
1
- {
2
- "title": "Legion LLM 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
- }