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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +7 -1
- data/Gemfile +1 -19
- data/README.md +22 -25
- data/lex-llm.gemspec +2 -2
- data/lib/legion/extensions/llm/agent.rb +366 -0
- data/lib/legion/extensions/llm/aliases.rb +42 -0
- data/lib/legion/extensions/llm/attachment.rb +229 -0
- data/lib/legion/extensions/llm/chat.rb +355 -0
- data/lib/legion/extensions/llm/chunk.rb +10 -0
- data/lib/legion/extensions/llm/configuration.rb +82 -0
- data/lib/legion/extensions/llm/connection.rb +134 -0
- data/lib/legion/extensions/llm/content.rb +81 -0
- data/lib/legion/extensions/llm/context.rb +33 -0
- data/lib/legion/extensions/llm/embedding.rb +33 -0
- data/lib/legion/extensions/llm/error.rb +116 -0
- data/lib/legion/extensions/llm/image.rb +109 -0
- data/lib/legion/extensions/llm/message.rb +111 -0
- data/lib/legion/extensions/llm/mime_type.rb +75 -0
- data/lib/legion/extensions/llm/model/info.rb +117 -0
- data/lib/legion/extensions/llm/model/modalities.rb +26 -0
- data/lib/legion/extensions/llm/model/pricing.rb +52 -0
- data/lib/legion/extensions/llm/model/pricing_category.rb +50 -0
- data/lib/legion/extensions/llm/model/pricing_tier.rb +37 -0
- data/lib/legion/extensions/llm/model.rb +11 -0
- data/lib/legion/extensions/llm/models.rb +514 -0
- data/lib/{lex_llm → legion/extensions/llm}/models_schema.json +1 -1
- data/lib/legion/extensions/llm/moderation.rb +60 -0
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +240 -0
- data/lib/legion/extensions/llm/provider.rb +282 -0
- data/lib/legion/extensions/llm/routing/lane_key.rb +57 -0
- data/lib/legion/extensions/llm/routing/model_offering.rb +173 -0
- data/lib/legion/extensions/llm/routing.rb +11 -0
- data/lib/legion/extensions/llm/stream_accumulator.rb +209 -0
- data/lib/legion/extensions/llm/streaming.rb +181 -0
- data/lib/legion/extensions/llm/thinking.rb +53 -0
- data/lib/legion/extensions/llm/tokens.rb +51 -0
- data/lib/legion/extensions/llm/tool.rb +258 -0
- data/lib/legion/extensions/llm/tool_call.rb +29 -0
- data/lib/legion/extensions/llm/transcription.rb +39 -0
- data/lib/legion/extensions/llm/utils.rb +95 -0
- data/lib/legion/extensions/llm/version.rb +9 -0
- data/lib/legion/extensions/llm.rb +85 -6
- metadata +40 -122
- data/lib/generators/lex_llm/agent/agent_generator.rb +0 -36
- data/lib/generators/lex_llm/agent/templates/agent.rb.tt +0 -6
- data/lib/generators/lex_llm/agent/templates/instructions.txt.erb.tt +0 -0
- data/lib/generators/lex_llm/chat_ui/chat_ui_generator.rb +0 -256
- data/lib/generators/lex_llm/chat_ui/templates/controllers/chats_controller.rb.tt +0 -38
- data/lib/generators/lex_llm/chat_ui/templates/controllers/messages_controller.rb.tt +0 -21
- data/lib/generators/lex_llm/chat_ui/templates/controllers/models_controller.rb.tt +0 -14
- data/lib/generators/lex_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
- data/lib/generators/lex_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +0 -12
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +0 -16
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +0 -31
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +0 -31
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +0 -9
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +0 -27
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +0 -14
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +0 -1
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +0 -13
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +0 -23
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +0 -10
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +0 -2
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +0 -4
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +0 -14
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +0 -13
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +0 -21
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +0 -17
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +0 -40
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +0 -27
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +0 -16
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/_form.html.erb.tt +0 -29
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/index.html.erb.tt +0 -28
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/new.html.erb.tt +0 -11
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/show.html.erb.tt +0 -25
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +0 -9
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_content.html.erb.tt +0 -1
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_error.html.erb.tt +0 -8
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_form.html.erb.tt +0 -21
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_system.html.erb.tt +0 -6
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +0 -2
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +0 -4
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_user.html.erb.tt +0 -9
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +0 -7
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +0 -8
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +0 -16
- data/lib/generators/lex_llm/chat_ui/templates/views/models/_model.html.erb.tt +0 -15
- data/lib/generators/lex_llm/chat_ui/templates/views/models/index.html.erb.tt +0 -38
- data/lib/generators/lex_llm/chat_ui/templates/views/models/show.html.erb.tt +0 -17
- data/lib/generators/lex_llm/generator_helpers.rb +0 -214
- data/lib/generators/lex_llm/install/install_generator.rb +0 -109
- data/lib/generators/lex_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
- data/lib/generators/lex_llm/install/templates/chat_model.rb.tt +0 -3
- data/lib/generators/lex_llm/install/templates/create_chats_migration.rb.tt +0 -7
- data/lib/generators/lex_llm/install/templates/create_messages_migration.rb.tt +0 -19
- data/lib/generators/lex_llm/install/templates/create_models_migration.rb.tt +0 -39
- data/lib/generators/lex_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
- data/lib/generators/lex_llm/install/templates/initializer.rb.tt +0 -20
- data/lib/generators/lex_llm/install/templates/message_model.rb.tt +0 -4
- data/lib/generators/lex_llm/install/templates/model_model.rb.tt +0 -3
- data/lib/generators/lex_llm/install/templates/tool_call_model.rb.tt +0 -3
- data/lib/generators/lex_llm/schema/schema_generator.rb +0 -26
- data/lib/generators/lex_llm/schema/templates/schema.rb.tt +0 -2
- data/lib/generators/lex_llm/tool/templates/tool.rb.tt +0 -9
- data/lib/generators/lex_llm/tool/templates/tool_call.html.erb.tt +0 -13
- data/lib/generators/lex_llm/tool/templates/tool_result.html.erb.tt +0 -13
- data/lib/generators/lex_llm/tool/tool_generator.rb +0 -96
- data/lib/generators/lex_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
- data/lib/generators/lex_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
- data/lib/generators/lex_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
- data/lib/generators/lex_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
- data/lib/generators/lex_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
- data/lib/generators/lex_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
- data/lib/generators/lex_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
- data/lib/generators/lex_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
- data/lib/lex_llm/active_record/acts_as.rb +0 -180
- data/lib/lex_llm/active_record/acts_as_legacy.rb +0 -503
- data/lib/lex_llm/active_record/chat_methods.rb +0 -468
- data/lib/lex_llm/active_record/message_methods.rb +0 -131
- data/lib/lex_llm/active_record/model_methods.rb +0 -76
- data/lib/lex_llm/active_record/payload_helpers.rb +0 -26
- data/lib/lex_llm/active_record/tool_call_methods.rb +0 -15
- data/lib/lex_llm/agent.rb +0 -365
- data/lib/lex_llm/aliases.rb +0 -38
- data/lib/lex_llm/attachment.rb +0 -223
- data/lib/lex_llm/chat.rb +0 -351
- data/lib/lex_llm/chunk.rb +0 -6
- data/lib/lex_llm/configuration.rb +0 -81
- data/lib/lex_llm/connection.rb +0 -130
- data/lib/lex_llm/content.rb +0 -77
- data/lib/lex_llm/context.rb +0 -29
- data/lib/lex_llm/embedding.rb +0 -29
- data/lib/lex_llm/error.rb +0 -112
- data/lib/lex_llm/image.rb +0 -105
- data/lib/lex_llm/message.rb +0 -107
- data/lib/lex_llm/mime_type.rb +0 -71
- data/lib/lex_llm/model/info.rb +0 -113
- data/lib/lex_llm/model/modalities.rb +0 -22
- data/lib/lex_llm/model/pricing.rb +0 -48
- data/lib/lex_llm/model/pricing_category.rb +0 -46
- data/lib/lex_llm/model/pricing_tier.rb +0 -33
- data/lib/lex_llm/model.rb +0 -7
- data/lib/lex_llm/models.rb +0 -506
- data/lib/lex_llm/moderation.rb +0 -56
- data/lib/lex_llm/provider/open_ai_compatible.rb +0 -219
- data/lib/lex_llm/provider.rb +0 -278
- data/lib/lex_llm/railtie.rb +0 -35
- data/lib/lex_llm/routing/lane_key.rb +0 -51
- data/lib/lex_llm/routing/model_offering.rb +0 -169
- data/lib/lex_llm/routing.rb +0 -7
- data/lib/lex_llm/stream_accumulator.rb +0 -203
- data/lib/lex_llm/streaming.rb +0 -175
- data/lib/lex_llm/thinking.rb +0 -49
- data/lib/lex_llm/tokens.rb +0 -47
- data/lib/lex_llm/tool.rb +0 -254
- data/lib/lex_llm/tool_call.rb +0 -25
- data/lib/lex_llm/transcription.rb +0 -35
- data/lib/lex_llm/utils.rb +0 -91
- data/lib/lex_llm/version.rb +0 -5
- data/lib/lex_llm.rb +0 -96
- data/lib/tasks/lex_llm.rake +0 -23
- /data/lib/{lex_llm → legion/extensions/llm}/aliases.json +0 -0
- /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
|