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,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ # Identify potentially harmful content in text.
5
+ # https://platform.openai.com/docs/guides/moderation
6
+ class Moderation
7
+ attr_reader :id, :model, :results
8
+
9
+ def initialize(id:, model:, results:)
10
+ @id = id
11
+ @model = model
12
+ @results = results
13
+ end
14
+
15
+ def self.moderate(input,
16
+ model: nil,
17
+ provider: nil,
18
+ assume_model_exists: false,
19
+ context: nil)
20
+ config = context&.config || LexLLM.config
21
+ model ||= config.default_moderation_model
22
+ model, provider_instance = Models.resolve(model, provider: provider, assume_exists: assume_model_exists,
23
+ config: config)
24
+ model_id = model.id
25
+
26
+ provider_instance.moderate(input, model: model_id)
27
+ end
28
+
29
+ # Convenience method to get content from moderation result
30
+ def content
31
+ results
32
+ end
33
+
34
+ # Check if any content was flagged
35
+ def flagged?
36
+ results.any? { |result| result['flagged'] }
37
+ end
38
+
39
+ # Get all flagged categories across all results
40
+ def flagged_categories
41
+ results.flat_map do |result|
42
+ result['categories']&.select { |_category, flagged| flagged }&.keys || []
43
+ end.uniq
44
+ end
45
+
46
+ # Get category scores for the first result (most common case)
47
+ def category_scores
48
+ results.first&.dig('category_scores') || {}
49
+ end
50
+
51
+ # Get categories for the first result (most common case)
52
+ def categories
53
+ results.first&.dig('categories') || {}
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ # Base class for LLM providers.
5
+ class Provider
6
+ include Streaming
7
+
8
+ attr_reader :config, :connection
9
+
10
+ def initialize(config)
11
+ @config = config
12
+ ensure_configured!
13
+ @connection = Connection.new(self, @config)
14
+ end
15
+
16
+ def api_base
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def headers
21
+ {}
22
+ end
23
+
24
+ def slug
25
+ self.class.slug
26
+ end
27
+
28
+ def name
29
+ self.class.name
30
+ end
31
+
32
+ def capabilities
33
+ self.class.capabilities
34
+ end
35
+
36
+ def configuration_requirements
37
+ self.class.configuration_requirements
38
+ end
39
+
40
+ # rubocop:disable Metrics/ParameterLists
41
+ def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil,
42
+ tool_prefs: nil, &)
43
+ normalized_temperature = maybe_normalize_temperature(temperature, model)
44
+
45
+ payload = Utils.deep_merge(
46
+ render_payload(
47
+ messages,
48
+ tools: tools,
49
+ tool_prefs: tool_prefs,
50
+ temperature: normalized_temperature,
51
+ model: model,
52
+ stream: block_given?,
53
+ schema: schema,
54
+ thinking: thinking
55
+ ),
56
+ params
57
+ )
58
+
59
+ if block_given?
60
+ stream_response @connection, payload, headers, &
61
+ else
62
+ sync_response @connection, payload, headers
63
+ end
64
+ end
65
+ # rubocop:enable Metrics/ParameterLists
66
+
67
+ def list_models
68
+ response = @connection.get models_url
69
+ parse_list_models_response response, slug, capabilities
70
+ end
71
+
72
+ def embed(text, model:, dimensions:)
73
+ payload = render_embedding_payload(text, model:, dimensions:)
74
+ response = @connection.post(embedding_url(model:), payload)
75
+ parse_embedding_response(response, model:, text:)
76
+ end
77
+
78
+ def moderate(input, model:)
79
+ payload = render_moderation_payload(input, model:)
80
+ response = @connection.post moderation_url, payload
81
+ parse_moderation_response(response, model:)
82
+ end
83
+
84
+ def paint(prompt, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Metrics/ParameterLists
85
+ validate_paint_inputs!(with:, mask:)
86
+ payload = render_image_payload(prompt, model:, size:, with:, mask:, params:)
87
+ response = @connection.post images_url(with:, mask:), payload
88
+ parse_image_response(response, model:)
89
+ end
90
+
91
+ def transcribe(audio_file, model:, language:, **)
92
+ file_part = build_audio_file_part(audio_file)
93
+ payload = render_transcription_payload(file_part, model:, language:, **)
94
+ response = @connection.post transcription_url, payload
95
+ parse_transcription_response(response, model:)
96
+ end
97
+
98
+ def configured?
99
+ configuration_requirements.all? { |req| @config.send(req) }
100
+ end
101
+
102
+ def local?
103
+ self.class.local?
104
+ end
105
+
106
+ def remote?
107
+ self.class.remote?
108
+ end
109
+
110
+ def assume_models_exist?
111
+ self.class.assume_models_exist?
112
+ end
113
+
114
+ def parse_error(response)
115
+ return if response.body.empty?
116
+
117
+ body = try_parse_json(response.body)
118
+ case body
119
+ when Hash
120
+ error = body['error']
121
+ return error if error.is_a?(String)
122
+
123
+ body.dig('error', 'message')
124
+ when Array
125
+ body.map do |part|
126
+ error = part['error']
127
+ error.is_a?(String) ? error : part.dig('error', 'message')
128
+ end.join('. ')
129
+ else
130
+ body
131
+ end
132
+ end
133
+
134
+ def format_messages(messages)
135
+ messages.map do |msg|
136
+ {
137
+ role: msg.role.to_s,
138
+ content: msg.content
139
+ }
140
+ end
141
+ end
142
+
143
+ def format_tool_calls(_tool_calls)
144
+ nil
145
+ end
146
+
147
+ def parse_tool_calls(_tool_calls)
148
+ nil
149
+ end
150
+
151
+ class << self
152
+ def name
153
+ to_s.split('::').last
154
+ end
155
+
156
+ def slug
157
+ name.downcase
158
+ end
159
+
160
+ def capabilities
161
+ nil
162
+ end
163
+
164
+ def configuration_requirements
165
+ []
166
+ end
167
+
168
+ def configuration_options
169
+ []
170
+ end
171
+
172
+ def local?
173
+ false
174
+ end
175
+
176
+ def remote?
177
+ !local?
178
+ end
179
+
180
+ def assume_models_exist?
181
+ false
182
+ end
183
+
184
+ def resolve_model_id(model_id, config: nil) # rubocop:disable Lint/UnusedMethodArgument
185
+ model_id
186
+ end
187
+
188
+ def configured?(config)
189
+ configuration_requirements.all? { |req| config.send(req) }
190
+ end
191
+
192
+ def register(name, provider_class)
193
+ providers[name.to_sym] = provider_class
194
+ LexLLM::Configuration.register_provider_options(provider_class.configuration_options)
195
+ end
196
+
197
+ def resolve(name)
198
+ return nil if name.nil?
199
+
200
+ providers[name.to_sym]
201
+ end
202
+
203
+ def for(model)
204
+ model_info = Models.find(model)
205
+ resolve model_info.provider
206
+ end
207
+
208
+ def providers
209
+ @providers ||= {}
210
+ end
211
+
212
+ def local_providers
213
+ providers.select { |_slug, provider_class| provider_class.local? }
214
+ end
215
+
216
+ def remote_providers
217
+ providers.select { |_slug, provider_class| provider_class.remote? }
218
+ end
219
+
220
+ def configured_providers(config)
221
+ providers.select do |_slug, provider_class|
222
+ provider_class.configured?(config)
223
+ end.values
224
+ end
225
+
226
+ def configured_remote_providers(config)
227
+ providers.select do |_slug, provider_class|
228
+ provider_class.remote? && provider_class.configured?(config)
229
+ end.values
230
+ end
231
+ end
232
+
233
+ private
234
+
235
+ def validate_paint_inputs!(with:, mask:)
236
+ return if with.nil? && mask.nil?
237
+
238
+ raise UnsupportedAttachmentError, "#{name} does not support image references in paint"
239
+ end
240
+
241
+ def build_audio_file_part(file_path)
242
+ expanded_path = File.expand_path(file_path)
243
+ mime_type = Marcel::MimeType.for(Pathname.new(expanded_path))
244
+
245
+ Faraday::Multipart::FilePart.new(
246
+ expanded_path,
247
+ mime_type,
248
+ File.basename(expanded_path)
249
+ )
250
+ end
251
+
252
+ def try_parse_json(maybe_json)
253
+ return maybe_json unless maybe_json.is_a?(String)
254
+
255
+ Legion::JSON.parse(maybe_json, symbolize_names: false)
256
+ rescue Legion::JSON::ParseError
257
+ maybe_json
258
+ end
259
+
260
+ def ensure_configured!
261
+ missing = configuration_requirements.reject { |req| @config.send(req) }
262
+ return if missing.empty?
263
+
264
+ raise ConfigurationError, "Missing configuration for #{name}: #{missing.join(', ')}"
265
+ end
266
+
267
+ def maybe_normalize_temperature(temperature, _model)
268
+ temperature
269
+ end
270
+
271
+ def sync_response(connection, payload, additional_headers = {})
272
+ response = connection.post completion_url, payload do |req|
273
+ req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
274
+ end
275
+ parse_completion_response response
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ if defined?(Rails::Railtie)
4
+ module LexLLM
5
+ # Rails integration for LexLLM
6
+ class Railtie < Rails::Railtie
7
+ initializer 'lex_llm.inflections' do
8
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
9
+ inflect.acronym 'LexLLM'
10
+ end
11
+ end
12
+
13
+ initializer 'lex_llm.active_record' do
14
+ ActiveSupport.on_load :active_record do
15
+ if LexLLM.config.use_new_acts_as
16
+ require 'lex_llm/active_record/acts_as'
17
+ ::ActiveRecord::Base.include LexLLM::ActiveRecord::ActsAs
18
+ else
19
+ require 'lex_llm/active_record/acts_as_legacy'
20
+ ::ActiveRecord::Base.include LexLLM::ActiveRecord::ActsAsLegacy
21
+
22
+ Rails.logger.warn(
23
+ "\n!!! LexLLM's legacy acts_as API is deprecated and will be removed in LexLLM 2.0.0. " \
24
+ "Please consult the migration guide at https://github.com/LegionIO/lex-llm\n"
25
+ )
26
+ end
27
+ end
28
+ end
29
+
30
+ rake_tasks do
31
+ load 'tasks/lex_llm.rake'
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ module Routing
5
+ # Builds stable fleet lane keys from provider-neutral model offerings.
6
+ module LaneKey
7
+ module_function
8
+
9
+ def for(offering, prefix: 'llm.fleet', include_context: true, include_fingerprint: false)
10
+ parts = [prefix, lane_kind(offering), model_slug(offering.model)]
11
+ parts << "ctx#{offering.context_window}" if include_context && offering.inference? && offering.context_window
12
+ parts.push('elig', eligibility_fingerprint(offering)) if include_fingerprint
13
+ parts.join('.')
14
+ end
15
+
16
+ def lane_kind(offering)
17
+ offering.embedding? ? 'embed' : 'inference'
18
+ end
19
+
20
+ def model_slug(model)
21
+ model.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')
22
+ end
23
+
24
+ def eligibility_fingerprint(offering)
25
+ canonical = {
26
+ usage_type: offering.usage_type,
27
+ capabilities: offering.capabilities.sort,
28
+ context_window: offering.context_window,
29
+ max_output_tokens: offering.max_output_tokens,
30
+ policy_tags: offering.policy_tags.sort,
31
+ metadata: fingerprint_metadata(offering.metadata)
32
+ }
33
+ Digest::SHA1.hexdigest(Legion::JSON.generate(canonical))[0, 10]
34
+ end
35
+
36
+ def fingerprint_metadata(metadata)
37
+ metadata.fetch(:eligibility, {})
38
+ .to_h
39
+ .transform_keys(&:to_sym)
40
+ .reject { |key, _| sensitive_fingerprint_key?(key) }
41
+ .sort
42
+ .to_h
43
+ end
44
+
45
+ def sensitive_fingerprint_key?(key)
46
+ %i[credential credentials endpoint endpoint_url identity path prompt reply_to secret secrets token
47
+ url].include?(key)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ module Routing
5
+ # Describes one concrete model made available by one provider instance.
6
+ class ModelOffering
7
+ attr_reader :provider_family, :instance_id, :transport, :tier, :model, :usage_type, :capabilities, :limits,
8
+ :credentials, :health, :cost, :policy_tags, :metadata
9
+
10
+ def initialize(data)
11
+ @provider_family = normalize_symbol(fetch_value(data, :provider_family, fetch_value(data, :provider)))
12
+ @instance_id = normalize_symbol(fetch_value(data, :instance_id, @provider_family))
13
+ @transport = normalize_symbol(fetch_value(data, :transport, :http))
14
+ @tier = normalize_symbol(fetch_value(data, :tier, default_tier))
15
+ @model = fetch_value(data, :model).to_s
16
+ @usage_type = normalize_usage_type(fetch_value(data, :usage_type,
17
+ fetch_value(data, :type) ||
18
+ fetch_value(data, :kind) ||
19
+ infer_usage_type(data)))
20
+ @capabilities = normalize_array(fetch_value(data, :capabilities))
21
+ @limits = normalize_hash(fetch_value(data, :limits))
22
+ @credentials = fetch_value(data, :credentials)
23
+ @health = normalize_hash(fetch_value(data, :health))
24
+ @cost = normalize_hash(fetch_value(data, :cost))
25
+ @policy_tags = normalize_array(fetch_value(data, :policy_tags)).map(&:to_sym)
26
+ @metadata = normalize_hash(fetch_value(data, :metadata))
27
+ end
28
+
29
+ def enabled?
30
+ !metadata.key?(:enabled) || metadata[:enabled] != false
31
+ end
32
+
33
+ def embedding?
34
+ usage_type == :embedding
35
+ end
36
+
37
+ def inference?
38
+ %i[chat inference completion].include?(usage_type)
39
+ end
40
+
41
+ def context_window
42
+ integer_limit(:context_window) || integer_limit(:max_input_tokens)
43
+ end
44
+
45
+ def max_output_tokens
46
+ integer_limit(:max_output_tokens)
47
+ end
48
+
49
+ def supports?(capability)
50
+ capabilities.include?(capability.to_sym)
51
+ end
52
+
53
+ def eligible_for?(usage_type: nil, required_capabilities: [], min_context_window: nil, policy_tags: [])
54
+ return false unless enabled?
55
+ return false unless usage_type_matches?(usage_type)
56
+ return false unless capabilities_match?(required_capabilities)
57
+ return false unless context_window_matches?(min_context_window)
58
+ return false unless policy_tags_match?(policy_tags)
59
+
60
+ true
61
+ end
62
+
63
+ def lane_key(prefix: 'llm.fleet', include_context: true, include_fingerprint: false)
64
+ LaneKey.for(self, prefix:, include_context:, include_fingerprint:)
65
+ end
66
+
67
+ def eligibility_fingerprint
68
+ LaneKey.eligibility_fingerprint(self)
69
+ end
70
+
71
+ def to_h
72
+ {
73
+ provider_family: provider_family,
74
+ instance_id: instance_id,
75
+ transport: transport,
76
+ tier: tier,
77
+ model: model,
78
+ usage_type: usage_type,
79
+ capabilities: capabilities,
80
+ limits: limits,
81
+ credentials: credentials,
82
+ health: health,
83
+ cost: cost,
84
+ policy_tags: policy_tags,
85
+ metadata: metadata
86
+ }
87
+ end
88
+
89
+ private
90
+
91
+ def default_tier
92
+ case @transport
93
+ when :local
94
+ :local
95
+ when :rabbitmq
96
+ :fleet
97
+ else
98
+ :private
99
+ end
100
+ end
101
+
102
+ def infer_usage_type(data)
103
+ capabilities = normalize_array(fetch_value(data, :capabilities))
104
+ return :embedding if capabilities.include?(:embedding) || capabilities.include?(:embed)
105
+
106
+ :inference
107
+ end
108
+
109
+ def normalize_usage_type(value)
110
+ case value.to_sym
111
+ when :embed, :embeddings
112
+ :embedding
113
+ when :completion, :text, :chat
114
+ :inference
115
+ else
116
+ value.to_sym
117
+ end
118
+ end
119
+
120
+ def normalize_symbol(value)
121
+ return nil if value.nil?
122
+
123
+ value.to_sym
124
+ end
125
+
126
+ def normalize_array(value)
127
+ Array(value).compact.map(&:to_sym)
128
+ end
129
+
130
+ def normalize_hash(value)
131
+ (value || {}).to_h.transform_keys(&:to_sym)
132
+ end
133
+
134
+ def fetch_value(hash, key, default = nil)
135
+ return default unless hash.respond_to?(:key?)
136
+
137
+ string_key = key.to_s
138
+ return hash[string_key] if hash.key?(string_key)
139
+
140
+ hash.key?(key) ? hash[key] : default
141
+ end
142
+
143
+ def usage_type_matches?(expected)
144
+ expected.nil? || normalize_usage_type(expected) == usage_type
145
+ end
146
+
147
+ def capabilities_match?(required)
148
+ Array(required).all? { |capability| supports?(capability) }
149
+ end
150
+
151
+ def context_window_matches?(minimum)
152
+ minimum.nil? || (!!context_window && context_window >= minimum.to_i)
153
+ end
154
+
155
+ def policy_tags_match?(required)
156
+ Array(required).all? { |tag| policy_tags.include?(tag.to_sym) }
157
+ end
158
+
159
+ def integer_limit(key)
160
+ value = limits[key]
161
+ return nil if value.nil?
162
+
163
+ Integer(value)
164
+ rescue ArgumentError, TypeError
165
+ nil
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ # Provider-neutral routing metadata used by Legion LLM provider gems.
5
+ module Routing
6
+ end
7
+ end