lex-llm 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.md +12 -1
  4. data/Gemfile +1 -19
  5. data/README.md +25 -26
  6. data/lex-llm.gemspec +2 -2
  7. data/lib/legion/extensions/llm/agent.rb +366 -0
  8. data/lib/legion/extensions/llm/aliases.rb +42 -0
  9. data/lib/legion/extensions/llm/attachment.rb +229 -0
  10. data/lib/legion/extensions/llm/chat.rb +355 -0
  11. data/lib/legion/extensions/llm/chunk.rb +10 -0
  12. data/lib/legion/extensions/llm/configuration.rb +82 -0
  13. data/lib/legion/extensions/llm/connection.rb +134 -0
  14. data/lib/legion/extensions/llm/content.rb +81 -0
  15. data/lib/legion/extensions/llm/context.rb +33 -0
  16. data/lib/legion/extensions/llm/embedding.rb +33 -0
  17. data/lib/legion/extensions/llm/error.rb +116 -0
  18. data/lib/legion/extensions/llm/image.rb +109 -0
  19. data/lib/legion/extensions/llm/message.rb +111 -0
  20. data/lib/legion/extensions/llm/mime_type.rb +75 -0
  21. data/lib/legion/extensions/llm/model/info.rb +117 -0
  22. data/lib/legion/extensions/llm/model/modalities.rb +26 -0
  23. data/lib/legion/extensions/llm/model/pricing.rb +52 -0
  24. data/lib/legion/extensions/llm/model/pricing_category.rb +50 -0
  25. data/lib/legion/extensions/llm/model/pricing_tier.rb +37 -0
  26. data/lib/legion/extensions/llm/model.rb +11 -0
  27. data/lib/legion/extensions/llm/models.rb +514 -0
  28. data/lib/{lex_llm → legion/extensions/llm}/models_schema.json +1 -1
  29. data/lib/legion/extensions/llm/moderation.rb +60 -0
  30. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +276 -0
  31. data/lib/legion/extensions/llm/provider.rb +337 -0
  32. data/lib/legion/extensions/llm/routing/lane_key.rb +57 -0
  33. data/lib/legion/extensions/llm/routing/model_offering.rb +173 -0
  34. data/lib/legion/extensions/llm/routing.rb +11 -0
  35. data/lib/legion/extensions/llm/stream_accumulator.rb +209 -0
  36. data/lib/legion/extensions/llm/streaming.rb +181 -0
  37. data/lib/legion/extensions/llm/thinking.rb +53 -0
  38. data/lib/legion/extensions/llm/tokens.rb +51 -0
  39. data/lib/legion/extensions/llm/tool.rb +258 -0
  40. data/lib/legion/extensions/llm/tool_call.rb +29 -0
  41. data/lib/legion/extensions/llm/transcription.rb +39 -0
  42. data/lib/legion/extensions/llm/utils.rb +95 -0
  43. data/lib/legion/extensions/llm/version.rb +9 -0
  44. data/lib/legion/extensions/llm.rb +85 -6
  45. metadata +40 -122
  46. data/lib/generators/lex_llm/agent/agent_generator.rb +0 -36
  47. data/lib/generators/lex_llm/agent/templates/agent.rb.tt +0 -6
  48. data/lib/generators/lex_llm/agent/templates/instructions.txt.erb.tt +0 -0
  49. data/lib/generators/lex_llm/chat_ui/chat_ui_generator.rb +0 -256
  50. data/lib/generators/lex_llm/chat_ui/templates/controllers/chats_controller.rb.tt +0 -38
  51. data/lib/generators/lex_llm/chat_ui/templates/controllers/messages_controller.rb.tt +0 -21
  52. data/lib/generators/lex_llm/chat_ui/templates/controllers/models_controller.rb.tt +0 -14
  53. data/lib/generators/lex_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
  54. data/lib/generators/lex_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +0 -12
  55. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +0 -16
  56. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +0 -31
  57. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +0 -31
  58. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +0 -9
  59. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +0 -27
  60. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +0 -14
  61. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +0 -1
  62. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +0 -13
  63. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +0 -23
  64. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +0 -10
  65. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +0 -2
  66. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +0 -4
  67. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +0 -14
  68. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +0 -13
  69. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +0 -21
  70. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +0 -17
  71. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +0 -40
  72. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +0 -27
  73. data/lib/generators/lex_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +0 -16
  74. data/lib/generators/lex_llm/chat_ui/templates/views/chats/_form.html.erb.tt +0 -29
  75. data/lib/generators/lex_llm/chat_ui/templates/views/chats/index.html.erb.tt +0 -28
  76. data/lib/generators/lex_llm/chat_ui/templates/views/chats/new.html.erb.tt +0 -11
  77. data/lib/generators/lex_llm/chat_ui/templates/views/chats/show.html.erb.tt +0 -25
  78. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +0 -9
  79. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_content.html.erb.tt +0 -1
  80. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_error.html.erb.tt +0 -8
  81. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_form.html.erb.tt +0 -21
  82. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_system.html.erb.tt +0 -6
  83. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +0 -2
  84. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +0 -4
  85. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_user.html.erb.tt +0 -9
  86. data/lib/generators/lex_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +0 -7
  87. data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +0 -8
  88. data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +0 -16
  89. data/lib/generators/lex_llm/chat_ui/templates/views/models/_model.html.erb.tt +0 -15
  90. data/lib/generators/lex_llm/chat_ui/templates/views/models/index.html.erb.tt +0 -38
  91. data/lib/generators/lex_llm/chat_ui/templates/views/models/show.html.erb.tt +0 -17
  92. data/lib/generators/lex_llm/generator_helpers.rb +0 -214
  93. data/lib/generators/lex_llm/install/install_generator.rb +0 -109
  94. data/lib/generators/lex_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
  95. data/lib/generators/lex_llm/install/templates/chat_model.rb.tt +0 -3
  96. data/lib/generators/lex_llm/install/templates/create_chats_migration.rb.tt +0 -7
  97. data/lib/generators/lex_llm/install/templates/create_messages_migration.rb.tt +0 -19
  98. data/lib/generators/lex_llm/install/templates/create_models_migration.rb.tt +0 -39
  99. data/lib/generators/lex_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
  100. data/lib/generators/lex_llm/install/templates/initializer.rb.tt +0 -20
  101. data/lib/generators/lex_llm/install/templates/message_model.rb.tt +0 -4
  102. data/lib/generators/lex_llm/install/templates/model_model.rb.tt +0 -3
  103. data/lib/generators/lex_llm/install/templates/tool_call_model.rb.tt +0 -3
  104. data/lib/generators/lex_llm/schema/schema_generator.rb +0 -26
  105. data/lib/generators/lex_llm/schema/templates/schema.rb.tt +0 -2
  106. data/lib/generators/lex_llm/tool/templates/tool.rb.tt +0 -9
  107. data/lib/generators/lex_llm/tool/templates/tool_call.html.erb.tt +0 -13
  108. data/lib/generators/lex_llm/tool/templates/tool_result.html.erb.tt +0 -13
  109. data/lib/generators/lex_llm/tool/tool_generator.rb +0 -96
  110. data/lib/generators/lex_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
  111. data/lib/generators/lex_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
  112. data/lib/generators/lex_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
  113. data/lib/generators/lex_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
  114. data/lib/generators/lex_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
  115. data/lib/generators/lex_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
  116. data/lib/generators/lex_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
  117. data/lib/generators/lex_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
  118. data/lib/lex_llm/active_record/acts_as.rb +0 -180
  119. data/lib/lex_llm/active_record/acts_as_legacy.rb +0 -503
  120. data/lib/lex_llm/active_record/chat_methods.rb +0 -468
  121. data/lib/lex_llm/active_record/message_methods.rb +0 -131
  122. data/lib/lex_llm/active_record/model_methods.rb +0 -76
  123. data/lib/lex_llm/active_record/payload_helpers.rb +0 -26
  124. data/lib/lex_llm/active_record/tool_call_methods.rb +0 -15
  125. data/lib/lex_llm/agent.rb +0 -365
  126. data/lib/lex_llm/aliases.rb +0 -38
  127. data/lib/lex_llm/attachment.rb +0 -223
  128. data/lib/lex_llm/chat.rb +0 -351
  129. data/lib/lex_llm/chunk.rb +0 -6
  130. data/lib/lex_llm/configuration.rb +0 -81
  131. data/lib/lex_llm/connection.rb +0 -130
  132. data/lib/lex_llm/content.rb +0 -77
  133. data/lib/lex_llm/context.rb +0 -29
  134. data/lib/lex_llm/embedding.rb +0 -29
  135. data/lib/lex_llm/error.rb +0 -112
  136. data/lib/lex_llm/image.rb +0 -105
  137. data/lib/lex_llm/message.rb +0 -107
  138. data/lib/lex_llm/mime_type.rb +0 -71
  139. data/lib/lex_llm/model/info.rb +0 -113
  140. data/lib/lex_llm/model/modalities.rb +0 -22
  141. data/lib/lex_llm/model/pricing.rb +0 -48
  142. data/lib/lex_llm/model/pricing_category.rb +0 -46
  143. data/lib/lex_llm/model/pricing_tier.rb +0 -33
  144. data/lib/lex_llm/model.rb +0 -7
  145. data/lib/lex_llm/models.rb +0 -506
  146. data/lib/lex_llm/moderation.rb +0 -56
  147. data/lib/lex_llm/provider/open_ai_compatible.rb +0 -219
  148. data/lib/lex_llm/provider.rb +0 -278
  149. data/lib/lex_llm/railtie.rb +0 -35
  150. data/lib/lex_llm/routing/lane_key.rb +0 -51
  151. data/lib/lex_llm/routing/model_offering.rb +0 -169
  152. data/lib/lex_llm/routing.rb +0 -7
  153. data/lib/lex_llm/stream_accumulator.rb +0 -203
  154. data/lib/lex_llm/streaming.rb +0 -175
  155. data/lib/lex_llm/thinking.rb +0 -49
  156. data/lib/lex_llm/tokens.rb +0 -47
  157. data/lib/lex_llm/tool.rb +0 -254
  158. data/lib/lex_llm/tool_call.rb +0 -25
  159. data/lib/lex_llm/transcription.rb +0 -35
  160. data/lib/lex_llm/utils.rb +0 -91
  161. data/lib/lex_llm/version.rb +0 -5
  162. data/lib/lex_llm.rb +0 -96
  163. data/lib/tasks/lex_llm.rake +0 -23
  164. /data/lib/{lex_llm → legion/extensions/llm}/aliases.json +0 -0
  165. /data/lib/{lex_llm → legion/extensions/llm}/models.json +0 -0
@@ -0,0 +1,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