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,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ class Provider
7
+ # Shared OpenAI-compatible HTTP payload and response adapter.
8
+ module OpenAICompatible
9
+ def completion_url = '/v1/chat/completions'
10
+ def stream_url = completion_url
11
+ def models_url = '/v1/models'
12
+ def moderation_url = '/v1/moderations'
13
+ def embedding_url(**) = '/v1/embeddings'
14
+ def transcription_url = '/v1/audio/transcriptions'
15
+
16
+ def images_url(with:, mask:)
17
+ with || mask ? '/v1/images/edits' : '/v1/images/generations'
18
+ end
19
+
20
+ private
21
+
22
+ def render_payload(messages, tools:, temperature:, model:, stream:, schema:, thinking:, tool_prefs:) # rubocop:disable Metrics/ParameterLists
23
+ {
24
+ model: model.id,
25
+ messages: format_openai_messages(messages),
26
+ temperature: temperature,
27
+ stream: stream,
28
+ tools: format_openai_tools(tools),
29
+ tool_choice: openai_tool_choice(tool_prefs),
30
+ response_format: openai_response_format(schema),
31
+ reasoning_effort: openai_reasoning_effort(thinking)
32
+ }.compact
33
+ end
34
+
35
+ def format_openai_messages(messages)
36
+ messages.map do |message|
37
+ {
38
+ role: message.role.to_s,
39
+ content: openai_content(message.content),
40
+ tool_call_id: message.tool_call_id,
41
+ tool_calls: format_openai_tool_calls(message.tool_calls)
42
+ }.compact
43
+ end
44
+ end
45
+
46
+ def openai_content(content)
47
+ return content.format if content.is_a?(Legion::Extensions::Llm::Content::Raw)
48
+ return content unless content.respond_to?(:attachments)
49
+ return content.text.to_s if content.attachments.empty?
50
+
51
+ openai_content_parts(content)
52
+ end
53
+
54
+ def openai_content_parts(content)
55
+ parts = []
56
+ parts << { type: 'text', text: content.text.to_s } if content.text
57
+ content.attachments.each do |attachment|
58
+ parts << { type: 'image_url', image_url: { url: attachment.for_llm } } if attachment.image?
59
+ end
60
+ parts
61
+ end
62
+
63
+ def format_openai_tool_calls(tool_calls)
64
+ return nil unless tool_calls&.any?
65
+
66
+ tool_calls.values.map do |tool_call|
67
+ {
68
+ id: tool_call.id,
69
+ type: 'function',
70
+ function: {
71
+ name: tool_call.name,
72
+ arguments: Legion::JSON.generate(tool_call.arguments || {})
73
+ }
74
+ }
75
+ end
76
+ end
77
+
78
+ def format_openai_tools(tools)
79
+ return nil if tools.empty?
80
+
81
+ tools.values.map do |tool|
82
+ {
83
+ type: 'function',
84
+ function: {
85
+ name: tool.name,
86
+ description: tool.description,
87
+ parameters: tool.params_schema || { type: 'object', properties: {} }
88
+ }
89
+ }
90
+ end
91
+ end
92
+
93
+ def openai_tool_choice(tool_prefs)
94
+ choice = tool_prefs && (tool_prefs[:choice] || tool_prefs['choice'])
95
+ return nil unless choice
96
+ return choice.to_s if %i[auto none required].include?(choice.to_sym)
97
+
98
+ { type: 'function', function: { name: choice.to_s } }
99
+ end
100
+
101
+ def openai_response_format(schema)
102
+ return nil unless schema
103
+
104
+ schema_hash = schema.respond_to?(:to_h) ? schema.to_h : schema
105
+ { type: 'json_schema', json_schema: schema_hash }
106
+ end
107
+
108
+ def openai_reasoning_effort(thinking)
109
+ return nil unless thinking.is_a?(Hash)
110
+
111
+ thinking[:effort] || thinking['effort']
112
+ end
113
+
114
+ def parse_completion_response(response)
115
+ body = response.body
116
+ choice = Array(body['choices']).first || {}
117
+ message = choice['message'] || {}
118
+ usage = body['usage'] || {}
119
+
120
+ Legion::Extensions::Llm::Message.new(
121
+ role: :assistant,
122
+ content: message['content'],
123
+ model_id: body['model'],
124
+ tool_calls: parse_tool_calls(message['tool_calls']),
125
+ input_tokens: usage['prompt_tokens'],
126
+ output_tokens: usage['completion_tokens'],
127
+ reasoning_tokens: usage.dig('completion_tokens_details', 'reasoning_tokens'),
128
+ raw: body
129
+ )
130
+ end
131
+
132
+ def build_chunk(data)
133
+ choice = Array(data['choices']).first || {}
134
+ delta = choice['delta'] || {}
135
+ usage = data['usage'] || {}
136
+
137
+ Legion::Extensions::Llm::Chunk.new(
138
+ role: :assistant,
139
+ content: delta['content'],
140
+ model_id: data['model'],
141
+ tool_calls: parse_tool_calls(delta['tool_calls']),
142
+ input_tokens: usage['prompt_tokens'],
143
+ output_tokens: usage['completion_tokens'],
144
+ raw: data
145
+ )
146
+ end
147
+
148
+ def parse_tool_calls(tool_calls)
149
+ return nil unless tool_calls&.any?
150
+
151
+ tool_calls.to_h do |call|
152
+ function = call.fetch('function', {})
153
+ name = function.fetch('name')
154
+ [
155
+ name.to_sym,
156
+ Legion::Extensions::Llm::ToolCall.new(
157
+ id: call['id'] || name,
158
+ name: name,
159
+ arguments: parse_tool_arguments(function['arguments'])
160
+ )
161
+ ]
162
+ end
163
+ end
164
+
165
+ def parse_tool_arguments(arguments)
166
+ return {} if arguments.nil? || arguments == ''
167
+ return arguments if arguments.is_a?(Hash)
168
+
169
+ Legion::JSON.parse(arguments, symbolize_names: false)
170
+ rescue Legion::JSON::ParseError
171
+ {}
172
+ end
173
+
174
+ def parse_list_models_response(response, provider, capabilities)
175
+ response.body.fetch('data', []).map do |model|
176
+ critical_capabilities = critical_capabilities_for(capabilities, model)
177
+ Legion::Extensions::Llm::Model::Info.new(
178
+ id: model.fetch('id'),
179
+ name: model['id'],
180
+ provider: provider,
181
+ created_at: model_created_at(model['created']),
182
+ capabilities: critical_capabilities,
183
+ modalities: modalities_for_capabilities(critical_capabilities),
184
+ metadata: model
185
+ )
186
+ end
187
+ end
188
+
189
+ def model_created_at(value)
190
+ value.is_a?(Numeric) ? Time.at(value).utc : value
191
+ end
192
+
193
+ def critical_capabilities_for(capabilities, model)
194
+ return [] unless capabilities
195
+ return capabilities.critical_capabilities_for(model) if capabilities.respond_to?(:critical_capabilities_for)
196
+
197
+ {
198
+ 'streaming' => :streaming?,
199
+ 'function_calling' => :functions?,
200
+ 'vision' => :vision?,
201
+ 'embeddings' => :embeddings?,
202
+ 'moderation' => :moderation?,
203
+ 'image' => :images?,
204
+ 'audio_transcription' => :audio_transcription?
205
+ }.filter_map do |capability, predicate|
206
+ capability if capabilities.respond_to?(predicate) && capabilities.public_send(predicate, model)
207
+ end
208
+ end
209
+
210
+ def modalities_for_capabilities(capabilities)
211
+ if capabilities.include?('embeddings') && (capabilities - ['embeddings']).empty?
212
+ { input: %w[text], output: %w[embeddings] }
213
+ elsif capabilities.include?('image')
214
+ { input: %w[text image], output: %w[image] }
215
+ elsif capabilities.include?('audio_transcription')
216
+ { input: %w[audio], output: %w[text] }
217
+ else
218
+ { input: %w[text image], output: %w[text] }
219
+ end
220
+ end
221
+
222
+ def render_embedding_payload(text, model:, dimensions:)
223
+ { model: model, input: text, dimensions: dimensions }.compact
224
+ end
225
+
226
+ def parse_embedding_response(response, model:, text:)
227
+ vectors = response.body.fetch('data', []).map { |item| item['embedding'] }
228
+ vectors = vectors.first unless text.is_a?(Array)
229
+ usage = response.body['usage'] || {}
230
+
231
+ Legion::Extensions::Llm::Embedding.new(vectors: vectors, model: model,
232
+ input_tokens: usage['prompt_tokens'].to_i)
233
+ end
234
+
235
+ def render_moderation_payload(input, model:)
236
+ { model: model, input: input }.compact
237
+ end
238
+
239
+ def parse_moderation_response(response, model:)
240
+ Legion::Extensions::Llm::Moderation.new(id: response.body['id'], model: response.body['model'] || model,
241
+ results: response.body.fetch('results', []))
242
+ end
243
+
244
+ def render_image_payload(prompt, model:, size:, with:, mask:, params:) # rubocop:disable Metrics/ParameterLists
245
+ { model: model, prompt: prompt, size: size, image: with, mask: mask }.merge(params).compact
246
+ end
247
+
248
+ def parse_image_response(response, model:)
249
+ image = response.body.fetch('data', []).first || {}
250
+ Legion::Extensions::Llm::Image.new(
251
+ url: image['url'],
252
+ data: image['b64_json'],
253
+ revised_prompt: image['revised_prompt'],
254
+ model_id: model,
255
+ usage: response.body['usage'] || {}
256
+ )
257
+ end
258
+
259
+ def render_transcription_payload(file_part, model:, language:, **options)
260
+ { model: model, file: file_part, language: language }.merge(options).compact
261
+ end
262
+
263
+ def parse_transcription_response(response, model:)
264
+ Legion::Extensions::Llm::Transcription.new(
265
+ text: response.body['text'],
266
+ model: model,
267
+ language: response.body['language'],
268
+ duration: response.body['duration'],
269
+ segments: response.body['segments']
270
+ )
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ # Base class for LLM providers.
7
+ class Provider
8
+ include Streaming
9
+
10
+ attr_reader :config, :connection
11
+
12
+ def initialize(config)
13
+ @config = config
14
+ ensure_configured!
15
+ @connection = Connection.new(self, @config)
16
+ end
17
+
18
+ def api_base
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def headers
23
+ {}
24
+ end
25
+
26
+ def slug
27
+ self.class.slug
28
+ end
29
+
30
+ def name
31
+ self.class.name
32
+ end
33
+
34
+ def capabilities
35
+ self.class.capabilities
36
+ end
37
+
38
+ def configuration_requirements
39
+ self.class.configuration_requirements
40
+ end
41
+
42
+ # rubocop:disable Metrics/ParameterLists
43
+ def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil,
44
+ tool_prefs: nil, &)
45
+ normalized_temperature = maybe_normalize_temperature(temperature, model)
46
+
47
+ payload = Utils.deep_merge(
48
+ render_payload(
49
+ messages,
50
+ tools: tools,
51
+ tool_prefs: tool_prefs,
52
+ temperature: normalized_temperature,
53
+ model: model,
54
+ stream: block_given?,
55
+ schema: schema,
56
+ thinking: thinking
57
+ ),
58
+ params
59
+ )
60
+
61
+ if block_given?
62
+ stream_response @connection, payload, headers, &
63
+ else
64
+ sync_response @connection, payload, headers
65
+ end
66
+ end
67
+ # rubocop:enable Metrics/ParameterLists
68
+
69
+ def list_models
70
+ response = @connection.get models_url
71
+ parse_list_models_response response, slug, capabilities
72
+ end
73
+
74
+ def embed(text, model:, dimensions:)
75
+ payload = render_embedding_payload(text, model:, dimensions:)
76
+ response = @connection.post(embedding_url(model:), payload)
77
+ parse_embedding_response(response, model:, text:)
78
+ end
79
+
80
+ def moderate(input, model:)
81
+ payload = render_moderation_payload(input, model:)
82
+ response = @connection.post moderation_url, payload
83
+ parse_moderation_response(response, model:)
84
+ end
85
+
86
+ def paint(prompt, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Metrics/ParameterLists
87
+ validate_paint_inputs!(with:, mask:)
88
+ payload = render_image_payload(prompt, model:, size:, with:, mask:, params:)
89
+ response = @connection.post images_url(with:, mask:), payload
90
+ parse_image_response(response, model:)
91
+ end
92
+
93
+ def transcribe(audio_file, model:, language:, **)
94
+ file_part = build_audio_file_part(audio_file)
95
+ payload = render_transcription_payload(file_part, model:, language:, **)
96
+ response = @connection.post transcription_url, payload
97
+ parse_transcription_response(response, model:)
98
+ end
99
+
100
+ def configured?
101
+ configuration_requirements.all? { |req| @config.send(req) }
102
+ end
103
+
104
+ def local?
105
+ self.class.local?
106
+ end
107
+
108
+ def remote?
109
+ self.class.remote?
110
+ end
111
+
112
+ def assume_models_exist?
113
+ self.class.assume_models_exist?
114
+ end
115
+
116
+ def readiness(live: false)
117
+ metadata = {
118
+ provider: slug.to_sym,
119
+ name: name,
120
+ configured: configured?,
121
+ ready: configured?,
122
+ local: local?,
123
+ remote: remote?,
124
+ api_base: api_base,
125
+ endpoints: endpoint_manifest,
126
+ live: live
127
+ }
128
+
129
+ return metadata.merge(health: { checked: false }) unless live && metadata[:endpoints][:health]
130
+
131
+ response = @connection.get(metadata[:endpoints][:health])
132
+ metadata.merge(ready: configured? && health_ready?(response.body), health: response.body)
133
+ rescue StandardError => e
134
+ metadata.merge(ready: false, health: { error: e.class.name, message: e.message })
135
+ end
136
+
137
+ def endpoint_manifest
138
+ endpoint_methods.each_with_object({}) do |(key, method_name), result|
139
+ next unless respond_to?(method_name)
140
+
141
+ value = public_send(method_name)
142
+ result[key] = value unless value.nil?
143
+ rescue ArgumentError, NotImplementedError
144
+ next
145
+ end
146
+ end
147
+
148
+ def parse_error(response)
149
+ return if response.body.empty?
150
+
151
+ body = try_parse_json(response.body)
152
+ case body
153
+ when Hash
154
+ error = body['error']
155
+ return error if error.is_a?(String)
156
+
157
+ body.dig('error', 'message')
158
+ when Array
159
+ body.map do |part|
160
+ error = part['error']
161
+ error.is_a?(String) ? error : part.dig('error', 'message')
162
+ end.join('. ')
163
+ else
164
+ body
165
+ end
166
+ end
167
+
168
+ def format_messages(messages)
169
+ messages.map do |msg|
170
+ {
171
+ role: msg.role.to_s,
172
+ content: msg.content
173
+ }
174
+ end
175
+ end
176
+
177
+ def format_tool_calls(_tool_calls)
178
+ nil
179
+ end
180
+
181
+ def parse_tool_calls(_tool_calls)
182
+ nil
183
+ end
184
+
185
+ class << self
186
+ def name
187
+ to_s.split('::').last
188
+ end
189
+
190
+ def slug
191
+ name.downcase
192
+ end
193
+
194
+ def capabilities
195
+ nil
196
+ end
197
+
198
+ def configuration_requirements
199
+ []
200
+ end
201
+
202
+ def configuration_options
203
+ []
204
+ end
205
+
206
+ def local?
207
+ false
208
+ end
209
+
210
+ def remote?
211
+ !local?
212
+ end
213
+
214
+ def assume_models_exist?
215
+ false
216
+ end
217
+
218
+ def resolve_model_id(model_id, config: nil) # rubocop:disable Lint/UnusedMethodArgument
219
+ model_id
220
+ end
221
+
222
+ def configured?(config)
223
+ configuration_requirements.all? { |req| config.send(req) }
224
+ end
225
+
226
+ def register(name, provider_class)
227
+ providers[name.to_sym] = provider_class
228
+ Legion::Extensions::Llm::Configuration.register_provider_options(provider_class.configuration_options)
229
+ end
230
+
231
+ def resolve(name)
232
+ return nil if name.nil?
233
+
234
+ providers[name.to_sym]
235
+ end
236
+
237
+ def for(model)
238
+ model_info = Models.find(model)
239
+ resolve model_info.provider
240
+ end
241
+
242
+ def providers
243
+ @providers ||= {}
244
+ end
245
+
246
+ def local_providers
247
+ providers.select { |_slug, provider_class| provider_class.local? }
248
+ end
249
+
250
+ def remote_providers
251
+ providers.select { |_slug, provider_class| provider_class.remote? }
252
+ end
253
+
254
+ def configured_providers(config)
255
+ providers.select do |_slug, provider_class|
256
+ provider_class.configured?(config)
257
+ end.values
258
+ end
259
+
260
+ def configured_remote_providers(config)
261
+ providers.select do |_slug, provider_class|
262
+ provider_class.remote? && provider_class.configured?(config)
263
+ end.values
264
+ end
265
+ end
266
+
267
+ private
268
+
269
+ def validate_paint_inputs!(with:, mask:)
270
+ return if with.nil? && mask.nil?
271
+
272
+ raise UnsupportedAttachmentError, "#{name} does not support image references in paint"
273
+ end
274
+
275
+ def build_audio_file_part(file_path)
276
+ expanded_path = File.expand_path(file_path)
277
+ mime_type = Marcel::MimeType.for(Pathname.new(expanded_path))
278
+
279
+ Faraday::Multipart::FilePart.new(
280
+ expanded_path,
281
+ mime_type,
282
+ File.basename(expanded_path)
283
+ )
284
+ end
285
+
286
+ def try_parse_json(maybe_json)
287
+ return maybe_json unless maybe_json.is_a?(String)
288
+
289
+ Legion::JSON.parse(maybe_json, symbolize_names: false)
290
+ rescue Legion::JSON::ParseError
291
+ maybe_json
292
+ end
293
+
294
+ def ensure_configured!
295
+ missing = configuration_requirements.reject { |req| @config.send(req) }
296
+ return if missing.empty?
297
+
298
+ raise ConfigurationError, "Missing configuration for #{name}: #{missing.join(', ')}"
299
+ end
300
+
301
+ def maybe_normalize_temperature(temperature, _model)
302
+ temperature
303
+ end
304
+
305
+ def endpoint_methods
306
+ {
307
+ completion: :completion_url,
308
+ stream: :stream_url,
309
+ models: :models_url,
310
+ embeddings: :embedding_url,
311
+ moderation: :moderation_url,
312
+ images: :images_url,
313
+ transcription: :transcription_url,
314
+ health: :health_url,
315
+ version: :version_url
316
+ }
317
+ end
318
+
319
+ def health_ready?(body)
320
+ return body unless body.is_a?(Hash)
321
+
322
+ status = body['status'] || body[:status] || body['state'] || body[:state]
323
+ return true if status.nil?
324
+
325
+ %w[ok ready healthy running].include?(status.to_s.downcase)
326
+ end
327
+
328
+ def sync_response(connection, payload, additional_headers = {})
329
+ response = connection.post completion_url, payload do |req|
330
+ req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
331
+ end
332
+ parse_completion_response response
333
+ end
334
+ end
335
+ end
336
+ end
337
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Routing
7
+ # Builds stable fleet lane keys from provider-neutral model offerings.
8
+ module LaneKey
9
+ module_function
10
+
11
+ def for(offering, prefix: 'llm.fleet', include_context: true, include_fingerprint: false)
12
+ parts = [prefix, lane_kind(offering), model_slug(offering.model)]
13
+ if include_context && offering.inference? && offering.context_window
14
+ parts << "ctx#{offering.context_window}"
15
+ end
16
+ parts.push('elig', eligibility_fingerprint(offering)) if include_fingerprint
17
+ parts.join('.')
18
+ end
19
+
20
+ def lane_kind(offering)
21
+ offering.embedding? ? 'embed' : 'inference'
22
+ end
23
+
24
+ def model_slug(model)
25
+ model.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')
26
+ end
27
+
28
+ def eligibility_fingerprint(offering)
29
+ canonical = {
30
+ usage_type: offering.usage_type,
31
+ capabilities: offering.capabilities.sort,
32
+ context_window: offering.context_window,
33
+ max_output_tokens: offering.max_output_tokens,
34
+ policy_tags: offering.policy_tags.sort,
35
+ metadata: fingerprint_metadata(offering.metadata)
36
+ }
37
+ Digest::SHA1.hexdigest(Legion::JSON.generate(canonical))[0, 10]
38
+ end
39
+
40
+ def fingerprint_metadata(metadata)
41
+ metadata.fetch(:eligibility, {})
42
+ .to_h
43
+ .transform_keys(&:to_sym)
44
+ .reject { |key, _| sensitive_fingerprint_key?(key) }
45
+ .sort
46
+ .to_h
47
+ end
48
+
49
+ def sensitive_fingerprint_key?(key)
50
+ %i[credential credentials endpoint endpoint_url identity path prompt reply_to secret secrets token
51
+ url].include?(key)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end