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,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
# Connection class for managing API connections to various providers.
|
|
7
|
+
class Connection
|
|
8
|
+
attr_reader :provider, :connection, :config
|
|
9
|
+
|
|
10
|
+
def self.basic(&)
|
|
11
|
+
Faraday.new do |f|
|
|
12
|
+
f.response :logger,
|
|
13
|
+
Legion::Extensions::Llm.logger,
|
|
14
|
+
bodies: false,
|
|
15
|
+
errors: true,
|
|
16
|
+
headers: false,
|
|
17
|
+
log_level: :debug
|
|
18
|
+
f.response :raise_error
|
|
19
|
+
yield f if block_given?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(provider, config)
|
|
24
|
+
@provider = provider
|
|
25
|
+
@config = config
|
|
26
|
+
|
|
27
|
+
ensure_configured!
|
|
28
|
+
@connection ||= Faraday.new(provider.api_base) do |faraday|
|
|
29
|
+
setup_timeout(faraday)
|
|
30
|
+
setup_logging(faraday)
|
|
31
|
+
setup_retry(faraday)
|
|
32
|
+
setup_middleware(faraday)
|
|
33
|
+
setup_http_proxy(faraday)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def post(url, payload, &)
|
|
38
|
+
@connection.post url, payload do |req|
|
|
39
|
+
req.headers.merge! @provider.headers if @provider.respond_to?(:headers)
|
|
40
|
+
yield req if block_given?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def get(url, &)
|
|
45
|
+
@connection.get url do |req|
|
|
46
|
+
req.headers.merge! @provider.headers if @provider.respond_to?(:headers)
|
|
47
|
+
yield req if block_given?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def instance_variables
|
|
52
|
+
super - %i[@config @connection]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def setup_timeout(faraday)
|
|
58
|
+
faraday.options.timeout = @config.request_timeout
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def setup_logging(faraday)
|
|
62
|
+
faraday.response :logger,
|
|
63
|
+
Legion::Extensions::Llm.logger,
|
|
64
|
+
bodies: Legion::Extensions::Llm.logger.debug?,
|
|
65
|
+
errors: true,
|
|
66
|
+
headers: false,
|
|
67
|
+
log_level: :debug do |logger|
|
|
68
|
+
logger.filter(logging_regexp('[A-Za-z0-9+/=]{100,}'), '[BASE64 DATA]')
|
|
69
|
+
logger.filter(logging_regexp('[-\\d.e,\\s]{100,}'), '[EMBEDDINGS ARRAY]')
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def logging_regexp(pattern)
|
|
74
|
+
return Regexp.new(pattern) if @config.log_regexp_timeout.nil? || !Regexp.respond_to?(:timeout)
|
|
75
|
+
|
|
76
|
+
Regexp.new(pattern, timeout: @config.log_regexp_timeout)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def setup_retry(faraday)
|
|
80
|
+
faraday.request :retry, {
|
|
81
|
+
max: @config.max_retries,
|
|
82
|
+
interval: @config.retry_interval,
|
|
83
|
+
interval_randomness: @config.retry_interval_randomness,
|
|
84
|
+
backoff_factor: @config.retry_backoff_factor,
|
|
85
|
+
methods: Faraday::Retry::Middleware::IDEMPOTENT_METHODS + [:post],
|
|
86
|
+
exceptions: retry_exceptions
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def setup_middleware(faraday)
|
|
91
|
+
faraday.request :multipart
|
|
92
|
+
faraday.request :json
|
|
93
|
+
faraday.response :json
|
|
94
|
+
faraday.adapter :net_http
|
|
95
|
+
faraday.use :llm_errors, provider: @provider
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def setup_http_proxy(faraday)
|
|
99
|
+
return unless @config.http_proxy
|
|
100
|
+
|
|
101
|
+
faraday.proxy = @config.http_proxy
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def retry_exceptions
|
|
105
|
+
[
|
|
106
|
+
Errno::ETIMEDOUT,
|
|
107
|
+
Timeout::Error,
|
|
108
|
+
Faraday::TimeoutError,
|
|
109
|
+
Faraday::ConnectionFailed,
|
|
110
|
+
Faraday::RetriableResponse,
|
|
111
|
+
Legion::Extensions::Llm::RateLimitError,
|
|
112
|
+
Legion::Extensions::Llm::ServerError,
|
|
113
|
+
Legion::Extensions::Llm::ServiceUnavailableError,
|
|
114
|
+
Legion::Extensions::Llm::OverloadedError
|
|
115
|
+
]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ensure_configured!
|
|
119
|
+
return if @provider.configured?
|
|
120
|
+
|
|
121
|
+
missing = @provider.configuration_requirements.reject { |req| @config.send(req) }
|
|
122
|
+
config_block = <<~RUBY
|
|
123
|
+
Legion::Extensions::Llm.configure do |config|
|
|
124
|
+
#{missing.map { |key| "config.#{key} = ENV['#{key.to_s.upcase}']" }.join("\n ")}
|
|
125
|
+
end
|
|
126
|
+
RUBY
|
|
127
|
+
|
|
128
|
+
raise ConfigurationError,
|
|
129
|
+
"#{@provider.name} provider is not configured. Add this to your initialization:\n\n#{config_block}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
# Represents the content sent to or received from an LLM.
|
|
7
|
+
class Content
|
|
8
|
+
attr_reader :text, :attachments
|
|
9
|
+
|
|
10
|
+
def initialize(text = nil, attachments = nil)
|
|
11
|
+
@text = text
|
|
12
|
+
@attachments = []
|
|
13
|
+
|
|
14
|
+
process_attachments(attachments)
|
|
15
|
+
raise ArgumentError, 'Text and attachments cannot be both nil' if @text.nil? && @attachments.empty?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add_attachment(source, filename: nil)
|
|
19
|
+
@attachments << Attachment.new(source, filename:)
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def format
|
|
24
|
+
if @text && @attachments.empty?
|
|
25
|
+
@text
|
|
26
|
+
else
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Allows serializers to store the text payload without custom adapters.
|
|
32
|
+
def to_h
|
|
33
|
+
{ text: @text, attachments: @attachments.map(&:to_h) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def process_attachments_array_or_string(attachments)
|
|
39
|
+
Utils.to_safe_array(attachments).each do |file|
|
|
40
|
+
next if blank_attachment_entry?(file)
|
|
41
|
+
|
|
42
|
+
add_attachment(file)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def blank_attachment_entry?(file)
|
|
47
|
+
file.nil? || (file.is_a?(String) && file.strip.empty?)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def process_attachments(attachments)
|
|
51
|
+
if attachments.is_a?(Hash)
|
|
52
|
+
attachments.each_value { |attachment| process_attachments_array_or_string(attachment) }
|
|
53
|
+
else
|
|
54
|
+
process_attachments_array_or_string attachments
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class Content
|
|
60
|
+
# Represents provider-specific payloads that should bypass Legion::Extensions::Llm formatting.
|
|
61
|
+
class Raw
|
|
62
|
+
attr_reader :value
|
|
63
|
+
|
|
64
|
+
def initialize(value)
|
|
65
|
+
raise ArgumentError, 'Raw content payload cannot be nil' if value.nil?
|
|
66
|
+
|
|
67
|
+
@value = value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def format
|
|
71
|
+
@value
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_h
|
|
75
|
+
@value
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
# Holds per-call configs
|
|
7
|
+
class Context
|
|
8
|
+
attr_reader :config
|
|
9
|
+
|
|
10
|
+
def initialize(config)
|
|
11
|
+
@config = config
|
|
12
|
+
@connections = {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def chat(*, **, &)
|
|
16
|
+
Chat.new(*, **, context: self, &)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def embed(*, **, &)
|
|
20
|
+
Embedding.embed(*, **, context: self, &)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def paint(*, **, &)
|
|
24
|
+
Image.paint(*, **, context: self, &)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def connection_for(provider_instance)
|
|
28
|
+
provider_instance.connection
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
# Core embedding interface.
|
|
7
|
+
class Embedding
|
|
8
|
+
attr_reader :vectors, :model, :input_tokens
|
|
9
|
+
|
|
10
|
+
def initialize(vectors:, model:, input_tokens: 0)
|
|
11
|
+
@vectors = vectors
|
|
12
|
+
@model = model
|
|
13
|
+
@input_tokens = input_tokens
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.embed(text, # rubocop:disable Metrics/ParameterLists
|
|
17
|
+
model: nil,
|
|
18
|
+
provider: nil,
|
|
19
|
+
assume_model_exists: false,
|
|
20
|
+
context: nil,
|
|
21
|
+
dimensions: nil)
|
|
22
|
+
config = context&.config || Legion::Extensions::Llm.config
|
|
23
|
+
model ||= config.default_embedding_model
|
|
24
|
+
model, provider_instance = Models.resolve(model, provider: provider, assume_exists: assume_model_exists,
|
|
25
|
+
config: config)
|
|
26
|
+
model_id = model.id
|
|
27
|
+
|
|
28
|
+
provider_instance.embed(text, model: model_id, dimensions:)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
# Custom error class that wraps API errors from different providers
|
|
7
|
+
# into a consistent format with helpful error messages.
|
|
8
|
+
class Error < StandardError
|
|
9
|
+
attr_reader :response
|
|
10
|
+
|
|
11
|
+
def initialize(response = nil, message = nil)
|
|
12
|
+
if response.is_a?(String)
|
|
13
|
+
message = response
|
|
14
|
+
response = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@response = response
|
|
18
|
+
super(message || response&.body)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Error classes for non-HTTP errors
|
|
23
|
+
class ConfigurationError < StandardError; end
|
|
24
|
+
class PromptNotFoundError < StandardError; end
|
|
25
|
+
class InvalidRoleError < StandardError; end
|
|
26
|
+
class InvalidToolChoiceError < StandardError; end
|
|
27
|
+
class ModelNotFoundError < StandardError; end
|
|
28
|
+
class UnsupportedAttachmentError < StandardError; end
|
|
29
|
+
|
|
30
|
+
# Error classes for different HTTP status codes
|
|
31
|
+
class BadRequestError < Error; end
|
|
32
|
+
class ForbiddenError < Error; end
|
|
33
|
+
class ContextLengthExceededError < Error; end
|
|
34
|
+
class OverloadedError < Error; end
|
|
35
|
+
class PaymentRequiredError < Error; end
|
|
36
|
+
class RateLimitError < Error; end
|
|
37
|
+
class ServerError < Error; end
|
|
38
|
+
class ServiceUnavailableError < Error; end
|
|
39
|
+
class UnauthorizedError < Error; end
|
|
40
|
+
|
|
41
|
+
# Faraday middleware that maps provider-specific API errors to Legion::Extensions::Llm errors.
|
|
42
|
+
class ErrorMiddleware < Faraday::Middleware
|
|
43
|
+
def initialize(app, options = {})
|
|
44
|
+
super(app)
|
|
45
|
+
@provider = options[:provider]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def call(env)
|
|
49
|
+
@app.call(env).on_complete do |response|
|
|
50
|
+
self.class.parse_error(provider: @provider, response: response)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
CONTEXT_LENGTH_PATTERNS = [
|
|
56
|
+
/context length/i,
|
|
57
|
+
/context window/i,
|
|
58
|
+
/maximum context/i,
|
|
59
|
+
/request too large/i,
|
|
60
|
+
/too many tokens/i,
|
|
61
|
+
/token count exceeds/i,
|
|
62
|
+
/input[_\s-]?token/i,
|
|
63
|
+
/input or output tokens? must be reduced/i,
|
|
64
|
+
/reduce the length of messages/i
|
|
65
|
+
].freeze
|
|
66
|
+
|
|
67
|
+
def parse_error(provider:, response:) # rubocop:disable Metrics/PerceivedComplexity
|
|
68
|
+
message = provider&.parse_error(response)
|
|
69
|
+
|
|
70
|
+
case response.status
|
|
71
|
+
when 200..399
|
|
72
|
+
message
|
|
73
|
+
when 400
|
|
74
|
+
if context_length_exceeded?(message)
|
|
75
|
+
raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
raise BadRequestError.new(response, message || 'Invalid request - please check your input')
|
|
79
|
+
when 401
|
|
80
|
+
raise UnauthorizedError.new(response, message || 'Invalid API key - check your credentials')
|
|
81
|
+
when 402
|
|
82
|
+
raise PaymentRequiredError.new(response, message || 'Payment required - please top up your account')
|
|
83
|
+
when 403
|
|
84
|
+
raise ForbiddenError.new(response,
|
|
85
|
+
message || 'Forbidden - you do not have permission to access this resource')
|
|
86
|
+
when 429
|
|
87
|
+
if context_length_exceeded?(message)
|
|
88
|
+
raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
raise RateLimitError.new(response, message || 'Rate limit exceeded - please wait a moment')
|
|
92
|
+
when 500
|
|
93
|
+
raise ServerError.new(response, message || 'API server error - please try again')
|
|
94
|
+
when 502..504
|
|
95
|
+
raise ServiceUnavailableError.new(response, message || 'API server unavailable - please try again later')
|
|
96
|
+
when 529
|
|
97
|
+
raise OverloadedError.new(response, message || 'Service overloaded - please try again later')
|
|
98
|
+
else
|
|
99
|
+
raise Error.new(response, message || 'An unknown error occurred')
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def context_length_exceeded?(message)
|
|
106
|
+
return false if message.to_s.empty?
|
|
107
|
+
|
|
108
|
+
CONTEXT_LENGTH_PATTERNS.any? { |pattern| message.match?(pattern) }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
Faraday::Middleware.register_middleware(llm_errors: Legion::Extensions::Llm::ErrorMiddleware)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
# Represents a generated image from an AI model.
|
|
7
|
+
class Image
|
|
8
|
+
attr_reader :url, :data, :mime_type, :revised_prompt, :model_id, :usage
|
|
9
|
+
|
|
10
|
+
def initialize(url: nil, data: nil, mime_type: nil, revised_prompt: nil, model_id: nil, usage: {}) # rubocop:disable Metrics/ParameterLists
|
|
11
|
+
@url = url
|
|
12
|
+
@data = data
|
|
13
|
+
@mime_type = mime_type
|
|
14
|
+
@revised_prompt = revised_prompt
|
|
15
|
+
@model_id = model_id
|
|
16
|
+
@usage = usage
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def base64?
|
|
20
|
+
!@data.nil?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_blob
|
|
24
|
+
if base64?
|
|
25
|
+
Base64.decode64 @data
|
|
26
|
+
else
|
|
27
|
+
response = Connection.basic.get @url
|
|
28
|
+
response.body
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def save(path)
|
|
33
|
+
File.binwrite(File.expand_path(path), to_blob)
|
|
34
|
+
path
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.paint(prompt, # rubocop:disable Metrics/ParameterLists
|
|
38
|
+
model: nil,
|
|
39
|
+
provider: nil,
|
|
40
|
+
assume_model_exists: false,
|
|
41
|
+
size: '1024x1024',
|
|
42
|
+
context: nil,
|
|
43
|
+
with: nil,
|
|
44
|
+
mask: nil,
|
|
45
|
+
params: {})
|
|
46
|
+
config = context&.config || Legion::Extensions::Llm.config
|
|
47
|
+
model ||= config.default_image_model
|
|
48
|
+
model, provider_instance = Models.resolve(model, provider: provider, assume_exists: assume_model_exists,
|
|
49
|
+
config: config)
|
|
50
|
+
model_id = model.id
|
|
51
|
+
|
|
52
|
+
provider_instance.paint(prompt, model: model_id, size:, with:, mask:, params:)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def total_cost
|
|
56
|
+
input_cost + output_cost
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def input_cost
|
|
60
|
+
return flat_input_cost unless detailed_input_usage?
|
|
61
|
+
|
|
62
|
+
text_input_cost + image_input_cost
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def output_cost
|
|
66
|
+
tokens_for('output_tokens') * output_token_price.to_f / 1_000_000
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def model_info
|
|
70
|
+
@model_info ||= Legion::Extensions::Llm.models.find(model_id)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def flat_input_cost
|
|
76
|
+
tokens_for('input_tokens') * model_info.input_price_per_million.to_f / 1_000_000
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def text_input_cost
|
|
80
|
+
input_detail('text_tokens') * model_info.input_price_per_million.to_f / 1_000_000
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def image_input_cost
|
|
84
|
+
input_detail('image_tokens') * image_input_token_price.to_f / 1_000_000
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def output_token_price
|
|
88
|
+
model_info.pricing.images.output || model_info.output_price_per_million
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def image_input_token_price
|
|
92
|
+
model_info.pricing.images.input || model_info.input_price_per_million
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def detailed_input_usage?
|
|
96
|
+
usage['input_tokens_details'].is_a?(Hash)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def input_detail(key)
|
|
100
|
+
usage.dig('input_tokens_details', key).to_i
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def tokens_for(key)
|
|
104
|
+
usage[key].to_i
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
# A single message in a chat conversation.
|
|
7
|
+
class Message
|
|
8
|
+
ROLES = %i[system user assistant tool].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :role, :model_id, :tool_calls, :tool_call_id, :raw, :thinking, :tokens
|
|
11
|
+
attr_writer :content
|
|
12
|
+
|
|
13
|
+
def initialize(options = {})
|
|
14
|
+
@role = options.fetch(:role).to_sym
|
|
15
|
+
@tool_calls = options[:tool_calls]
|
|
16
|
+
@content = normalize_content(options.fetch(:content), role: @role, tool_calls: @tool_calls)
|
|
17
|
+
@model_id = options[:model_id]
|
|
18
|
+
@tool_call_id = options[:tool_call_id]
|
|
19
|
+
@tokens = options[:tokens] || Tokens.build(
|
|
20
|
+
input: options[:input_tokens],
|
|
21
|
+
output: options[:output_tokens],
|
|
22
|
+
cached: options[:cached_tokens],
|
|
23
|
+
cache_creation: options[:cache_creation_tokens],
|
|
24
|
+
thinking: options[:thinking_tokens],
|
|
25
|
+
reasoning: options[:reasoning_tokens]
|
|
26
|
+
)
|
|
27
|
+
@raw = options[:raw]
|
|
28
|
+
@thinking = options[:thinking]
|
|
29
|
+
|
|
30
|
+
ensure_valid_role
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def content
|
|
34
|
+
if @content.is_a?(Content) && @content.text && @content.attachments.empty?
|
|
35
|
+
@content.text
|
|
36
|
+
else
|
|
37
|
+
@content
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def tool_call?
|
|
42
|
+
!tool_calls.nil? && !tool_calls.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def tool_result?
|
|
46
|
+
!tool_call_id.nil? && !tool_call_id.empty?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def tool_results
|
|
50
|
+
content if tool_result?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def input_tokens
|
|
54
|
+
tokens&.input
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def output_tokens
|
|
58
|
+
tokens&.output
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def cached_tokens
|
|
62
|
+
tokens&.cached
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def cache_creation_tokens
|
|
66
|
+
tokens&.cache_creation
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def thinking_tokens
|
|
70
|
+
tokens&.thinking
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reasoning_tokens
|
|
74
|
+
tokens&.thinking
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_h
|
|
78
|
+
{
|
|
79
|
+
role: role,
|
|
80
|
+
content: content,
|
|
81
|
+
model_id: model_id,
|
|
82
|
+
tool_calls: tool_calls,
|
|
83
|
+
tool_call_id: tool_call_id,
|
|
84
|
+
thinking: thinking&.text,
|
|
85
|
+
thinking_signature: thinking&.signature
|
|
86
|
+
}.merge(tokens ? tokens.to_h : {}).compact
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def instance_variables
|
|
90
|
+
super - [:@raw]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def normalize_content(content, role:, tool_calls:)
|
|
96
|
+
return '' if role == :assistant && content.nil? && tool_calls && !tool_calls.empty?
|
|
97
|
+
|
|
98
|
+
case content
|
|
99
|
+
when String then Content.new(content)
|
|
100
|
+
when Hash then Content.new(content[:text], content)
|
|
101
|
+
else content
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def ensure_valid_role
|
|
106
|
+
raise InvalidRoleError, "Expected role to be one of: #{ROLES.join(', ')}" unless ROLES.include?(role)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'marcel'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Llm
|
|
8
|
+
# MimeTypes module provides methods to handle MIME types using Marcel gem
|
|
9
|
+
module MimeType
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def for(...)
|
|
13
|
+
Marcel::MimeType.for(...)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def image?(type)
|
|
17
|
+
type.start_with?('image/')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def video?(type)
|
|
21
|
+
type.start_with?('video/')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def audio?(type)
|
|
25
|
+
type.start_with?('audio/')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def pdf?(type)
|
|
29
|
+
type == 'application/pdf'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def text?(type)
|
|
33
|
+
type.start_with?('text/') ||
|
|
34
|
+
TEXT_SUFFIXES.any? { |suffix| type.end_with?(suffix) } ||
|
|
35
|
+
NON_TEXT_PREFIX_TEXT_MIME_TYPES.include?(type)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# MIME types that have a text/ prefix but need to be handled differently
|
|
39
|
+
TEXT_SUFFIXES = ['+json', '+xml', '+html', '+yaml', '+csv', '+plain', '+javascript', '+svg'].freeze
|
|
40
|
+
|
|
41
|
+
# MIME types that don't have a text/ prefix but should be treated as text
|
|
42
|
+
NON_TEXT_PREFIX_TEXT_MIME_TYPES = [
|
|
43
|
+
'application/json', # Base type, even if specific ones end with +json
|
|
44
|
+
'application/xml', # Base type, even if specific ones end with +xml
|
|
45
|
+
'application/javascript',
|
|
46
|
+
'application/ecmascript',
|
|
47
|
+
'application/rtf',
|
|
48
|
+
'application/sql',
|
|
49
|
+
'application/x-sh',
|
|
50
|
+
'application/x-csh',
|
|
51
|
+
'application/x-httpd-php',
|
|
52
|
+
'application/sdp',
|
|
53
|
+
'application/sparql-query',
|
|
54
|
+
'application/graphql',
|
|
55
|
+
'application/yang', # Data modeling language, often serialized as XML/JSON but the type itself is distinct
|
|
56
|
+
'application/mbox', # Mailbox format
|
|
57
|
+
'application/x-tex',
|
|
58
|
+
'application/x-latex',
|
|
59
|
+
'application/x-perl',
|
|
60
|
+
'application/x-python',
|
|
61
|
+
'application/x-tcl',
|
|
62
|
+
'application/pgp-signature', # Often ASCII armored
|
|
63
|
+
'application/pgp-keys', # Often ASCII armored
|
|
64
|
+
'application/vnd.coffeescript',
|
|
65
|
+
'application/vnd.dart',
|
|
66
|
+
'application/vnd.oai.openapi', # Base for OpenAPI, often with +json or +yaml suffix
|
|
67
|
+
'application/vnd.zul', # ZK User Interface Language (can be XML-like)
|
|
68
|
+
'application/x-yaml', # Common non-standard for YAML
|
|
69
|
+
'application/yaml', # Standard for YAML
|
|
70
|
+
'application/toml' # TOML configuration files
|
|
71
|
+
].freeze
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|