lex-llm 0.1.2 → 0.1.3

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 +7 -1
  4. data/Gemfile +1 -19
  5. data/README.md +22 -25
  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 +240 -0
  31. data/lib/legion/extensions/llm/provider.rb +282 -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,240 @@
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
+ Legion::Extensions::Llm::Model::Info.new(
177
+ id: model.fetch('id'),
178
+ name: model['id'],
179
+ provider: provider,
180
+ created_at: model['created'],
181
+ metadata: model
182
+ )
183
+ end
184
+ end
185
+
186
+ def render_embedding_payload(text, model:, dimensions:)
187
+ { model: model, input: text, dimensions: dimensions }.compact
188
+ end
189
+
190
+ def parse_embedding_response(response, model:, text:)
191
+ vectors = response.body.fetch('data', []).map { |item| item['embedding'] }
192
+ vectors = vectors.first unless text.is_a?(Array)
193
+ usage = response.body['usage'] || {}
194
+
195
+ Legion::Extensions::Llm::Embedding.new(vectors: vectors, model: model,
196
+ input_tokens: usage['prompt_tokens'].to_i)
197
+ end
198
+
199
+ def render_moderation_payload(input, model:)
200
+ { model: model, input: input }.compact
201
+ end
202
+
203
+ def parse_moderation_response(response, model:)
204
+ Legion::Extensions::Llm::Moderation.new(id: response.body['id'], model: response.body['model'] || model,
205
+ results: response.body.fetch('results', []))
206
+ end
207
+
208
+ def render_image_payload(prompt, model:, size:, with:, mask:, params:) # rubocop:disable Metrics/ParameterLists
209
+ { model: model, prompt: prompt, size: size, image: with, mask: mask }.merge(params).compact
210
+ end
211
+
212
+ def parse_image_response(response, model:)
213
+ image = response.body.fetch('data', []).first || {}
214
+ Legion::Extensions::Llm::Image.new(
215
+ url: image['url'],
216
+ data: image['b64_json'],
217
+ revised_prompt: image['revised_prompt'],
218
+ model_id: model,
219
+ usage: response.body['usage'] || {}
220
+ )
221
+ end
222
+
223
+ def render_transcription_payload(file_part, model:, language:, **options)
224
+ { model: model, file: file_part, language: language }.merge(options).compact
225
+ end
226
+
227
+ def parse_transcription_response(response, model:)
228
+ Legion::Extensions::Llm::Transcription.new(
229
+ text: response.body['text'],
230
+ model: model,
231
+ language: response.body['language'],
232
+ duration: response.body['duration'],
233
+ segments: response.body['segments']
234
+ )
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,282 @@
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 parse_error(response)
117
+ return if response.body.empty?
118
+
119
+ body = try_parse_json(response.body)
120
+ case body
121
+ when Hash
122
+ error = body['error']
123
+ return error if error.is_a?(String)
124
+
125
+ body.dig('error', 'message')
126
+ when Array
127
+ body.map do |part|
128
+ error = part['error']
129
+ error.is_a?(String) ? error : part.dig('error', 'message')
130
+ end.join('. ')
131
+ else
132
+ body
133
+ end
134
+ end
135
+
136
+ def format_messages(messages)
137
+ messages.map do |msg|
138
+ {
139
+ role: msg.role.to_s,
140
+ content: msg.content
141
+ }
142
+ end
143
+ end
144
+
145
+ def format_tool_calls(_tool_calls)
146
+ nil
147
+ end
148
+
149
+ def parse_tool_calls(_tool_calls)
150
+ nil
151
+ end
152
+
153
+ class << self
154
+ def name
155
+ to_s.split('::').last
156
+ end
157
+
158
+ def slug
159
+ name.downcase
160
+ end
161
+
162
+ def capabilities
163
+ nil
164
+ end
165
+
166
+ def configuration_requirements
167
+ []
168
+ end
169
+
170
+ def configuration_options
171
+ []
172
+ end
173
+
174
+ def local?
175
+ false
176
+ end
177
+
178
+ def remote?
179
+ !local?
180
+ end
181
+
182
+ def assume_models_exist?
183
+ false
184
+ end
185
+
186
+ def resolve_model_id(model_id, config: nil) # rubocop:disable Lint/UnusedMethodArgument
187
+ model_id
188
+ end
189
+
190
+ def configured?(config)
191
+ configuration_requirements.all? { |req| config.send(req) }
192
+ end
193
+
194
+ def register(name, provider_class)
195
+ providers[name.to_sym] = provider_class
196
+ Legion::Extensions::Llm::Configuration.register_provider_options(provider_class.configuration_options)
197
+ end
198
+
199
+ def resolve(name)
200
+ return nil if name.nil?
201
+
202
+ providers[name.to_sym]
203
+ end
204
+
205
+ def for(model)
206
+ model_info = Models.find(model)
207
+ resolve model_info.provider
208
+ end
209
+
210
+ def providers
211
+ @providers ||= {}
212
+ end
213
+
214
+ def local_providers
215
+ providers.select { |_slug, provider_class| provider_class.local? }
216
+ end
217
+
218
+ def remote_providers
219
+ providers.select { |_slug, provider_class| provider_class.remote? }
220
+ end
221
+
222
+ def configured_providers(config)
223
+ providers.select do |_slug, provider_class|
224
+ provider_class.configured?(config)
225
+ end.values
226
+ end
227
+
228
+ def configured_remote_providers(config)
229
+ providers.select do |_slug, provider_class|
230
+ provider_class.remote? && provider_class.configured?(config)
231
+ end.values
232
+ end
233
+ end
234
+
235
+ private
236
+
237
+ def validate_paint_inputs!(with:, mask:)
238
+ return if with.nil? && mask.nil?
239
+
240
+ raise UnsupportedAttachmentError, "#{name} does not support image references in paint"
241
+ end
242
+
243
+ def build_audio_file_part(file_path)
244
+ expanded_path = File.expand_path(file_path)
245
+ mime_type = Marcel::MimeType.for(Pathname.new(expanded_path))
246
+
247
+ Faraday::Multipart::FilePart.new(
248
+ expanded_path,
249
+ mime_type,
250
+ File.basename(expanded_path)
251
+ )
252
+ end
253
+
254
+ def try_parse_json(maybe_json)
255
+ return maybe_json unless maybe_json.is_a?(String)
256
+
257
+ Legion::JSON.parse(maybe_json, symbolize_names: false)
258
+ rescue Legion::JSON::ParseError
259
+ maybe_json
260
+ end
261
+
262
+ def ensure_configured!
263
+ missing = configuration_requirements.reject { |req| @config.send(req) }
264
+ return if missing.empty?
265
+
266
+ raise ConfigurationError, "Missing configuration for #{name}: #{missing.join(', ')}"
267
+ end
268
+
269
+ def maybe_normalize_temperature(temperature, _model)
270
+ temperature
271
+ end
272
+
273
+ def sync_response(connection, payload, additional_headers = {})
274
+ response = connection.post completion_url, payload do |req|
275
+ req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
276
+ end
277
+ parse_completion_response response
278
+ end
279
+ end
280
+ end
281
+ end
282
+ 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