lex-llm 0.2.0 → 0.3.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e89cf3b81ab7c122b0a4ffd99da2dda0cdc7c79258c43aa06291b8008985815a
4
- data.tar.gz: 8a7e3da631d4595fd4a57de32a6bcdd13004313a00a653bf2b0fd347e85bbd20
3
+ metadata.gz: b88b067707ad4bb1e77ecfd7881d3eb19c81ee5b721c410a45406b7f221bce3d
4
+ data.tar.gz: 22a02b744c5a5748c2f768cd8ec1d9ecf97f70145313a8bad1c8f3f92b1c0266
5
5
  SHA512:
6
- metadata.gz: 11e4dac6953ab61572d539c2e5c7cea634ab96388ba654392ea4b64365bab694f0e420acaf0a098ced97613240b89122fc3e8b5b186e4e78b77d37feca8ffbfd
7
- data.tar.gz: cb0224a1f4130f2783b17464cc182b2b01b6feee21e35246b99a73b568e68d64f1191b39d59a0f82d333cac0df9b15ab86e710269b44141d0039a0997b008340
6
+ metadata.gz: a71c9d5d57eba8a72dc72eecb85eb93fdc1137f49af391e7829fdba2930a0c849118cae5afa9ac4e8ba3ed02b1eb3c108c1d9ba799bd78f0bf699f8812efc7aa
7
+ data.tar.gz: 4ed8982d220363c9cf9d151c0004d17430ddb800e57e7915e6e065e94510c87a5988e469458fa60587f2a6ba156eac75b0440881c705b9fe3de0fb5772c06a49
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-05-01
4
+
5
+ - Add CredentialSources helper: read-only probes for env vars, ~/.claude/settings.json, ~/.codex/auth.json, Legion::Settings, socket/HTTP probes, SHA-256 credential dedup
6
+ - Add AutoRegistration mixin: shared discover_instances/register_discovered_instances/rediscover! for lex-llm-* provider self-registration into Call::Registry
7
+ - Delete Provider.register, .resolve, .for, .providers, .local_providers, .remote_providers, .configured_providers, .configured_remote_providers — replaced by Call::Registry
8
+ - Delete Configuration.register_provider_options — providers accept plain Hash config via new HashConfig wrapper
9
+ - Provider#initialize accepts plain Hash in addition to Configuration objects
10
+ - Models module uses Call::Registry with namespace-scanning fallback for standalone usage
11
+
3
12
  ## 0.2.0 - 2026-04-30
4
13
 
5
14
  - Promote ModelInfo Data.define value object with immutable fields: instance, parameter_count, parameter_size, quantization, size_bytes, modalities_input, modalities_output
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ # Mixin that lex-llm-* provider modules `extend` to get shared
7
+ # registration boilerplate. The provider only needs to override
8
+ # `discover_instances` — everything else is handled here.
9
+ #
10
+ # Prerequisites on the extending module:
11
+ # - `PROVIDER_FAMILY` constant (Symbol, e.g. :ollama)
12
+ # - `provider_class` singleton method returning the Provider subclass
13
+ module AutoRegistration
14
+ # Override in each provider. Returns { instance_id => config_hash }.
15
+ def discover_instances
16
+ {}
17
+ end
18
+
19
+ # Calls discover_instances, creates a LexLLMAdapter for each,
20
+ # and registers into Call::Registry.
21
+ #
22
+ # Strips :tier and :capabilities from config before passing to
23
+ # the adapter (these are metadata, not connection config).
24
+ #
25
+ # Guarded: no-op when Legion::LLM::Call::Registry is not loaded.
26
+ def register_discovered_instances
27
+ return unless defined?(Legion::LLM::Call::Registry)
28
+
29
+ instances = discover_instances
30
+ instances.each do |instance_id, config|
31
+ registry_config = config.except(:tier, :capabilities)
32
+ adapter = Legion::LLM::Call::LexLLMAdapter.new(
33
+ self::PROVIDER_FAMILY, provider_class, instance_config: registry_config
34
+ )
35
+ Legion::LLM::Call::Registry.register(
36
+ self::PROVIDER_FAMILY, adapter, instance: instance_id
37
+ )
38
+ end
39
+ rescue StandardError => e
40
+ log.warn "[#{self::PROVIDER_FAMILY}] self-registration failed: #{e.message}" if respond_to?(:log)
41
+ end
42
+
43
+ # Deregisters all instances for this provider and re-runs discovery.
44
+ #
45
+ # Guarded: no-op when Legion::LLM::Call::Registry is not loaded.
46
+ def rediscover!
47
+ return unless defined?(Legion::LLM::Call::Registry)
48
+
49
+ Legion::LLM::Call::Registry.deregister_provider(self::PROVIDER_FAMILY)
50
+ register_discovered_instances
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -16,10 +16,6 @@ module Legion
16
16
  defaults[key] = default
17
17
  end
18
18
 
19
- def register_provider_options(options)
20
- Array(options).each { |key| option(key, nil) }
21
- end
22
-
23
19
  def options
24
20
  option_keys.dup
25
21
  end
@@ -32,8 +28,8 @@ module Legion
32
28
  end
33
29
 
34
30
  # System-level options are declared here.
35
- # Provider-specific options are declared in each provider class via
36
- # `self.configuration_options` and registered through Provider.register.
31
+ # Provider-specific options are declared in each provider extension via
32
+ # `self.configuration_options`.
37
33
  option :default_model, nil
38
34
  option :default_embedding_model, nil
39
35
  option :default_moderation_model, nil
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'uri'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Llm
9
+ # Read-only helpers that provider gems use to probe common credential
10
+ # locations (env vars, Claude config, Codex auth, Legion settings, and
11
+ # network probes). All methods are pure readers — the calling provider
12
+ # decides what to do with the result.
13
+ module CredentialSources
14
+ CLAUDE_SETTINGS = File.expand_path('~/.claude/settings.json')
15
+ CLAUDE_PROJECT = File.join(Dir.pwd, '.claude', 'settings.json')
16
+ CODEX_AUTH = File.expand_path('~/.codex/auth.json')
17
+
18
+ # --- public helpers ------------------------------------------------
19
+
20
+ # Fetch an environment variable, stripping whitespace.
21
+ # Returns nil when the variable is unset or blank.
22
+ def env(key)
23
+ val = ENV.fetch(key, nil)
24
+ return nil if val.nil?
25
+
26
+ stripped = val.strip
27
+ stripped.empty? ? nil : stripped
28
+ end
29
+
30
+ # Merged Claude config (user-level + project-level). Project settings
31
+ # override user settings. Memoized for the lifetime of the process.
32
+ def claude_config
33
+ @claude_config ||= merge_claude_configs
34
+ end
35
+
36
+ # Read a single key from the merged Claude config, trying both symbol
37
+ # and string variants.
38
+ def claude_config_value(key)
39
+ cfg = claude_config
40
+ cfg[key.to_sym] || cfg[key.to_s]
41
+ end
42
+
43
+ # Read a key from the :env hash inside Claude config, trying both
44
+ # symbol and string variants.
45
+ def claude_env_value(key)
46
+ env_hash = claude_config_value(:env)
47
+ return nil unless env_hash.is_a?(Hash)
48
+
49
+ env_hash[key.to_sym] || env_hash[key.to_s]
50
+ end
51
+
52
+ # Read the bearer token from ~/.codex/auth.json when auth_mode is
53
+ # "chatgpt" and the JWT is not expired.
54
+ def codex_token
55
+ data = read_json(CODEX_AUTH)
56
+ mode = data[:auth_mode] || data['auth_mode']
57
+ return nil unless mode == 'chatgpt'
58
+
59
+ token = data[:bearer_token] || data['bearer_token']
60
+ return nil if token.nil? || token.to_s.strip.empty?
61
+ return nil unless token_valid?(token)
62
+
63
+ token
64
+ end
65
+
66
+ # Read the OPENAI_API_KEY from ~/.codex/auth.json.
67
+ def codex_openai_key
68
+ data = read_json(CODEX_AUTH)
69
+ val = data[:OPENAI_API_KEY] || data['OPENAI_API_KEY']
70
+ return nil if val.nil?
71
+
72
+ stripped = val.to_s.strip
73
+ stripped.empty? ? nil : stripped
74
+ end
75
+
76
+ # Dig into Legion::Settings, returning nil if the module is not loaded
77
+ # or the path doesn't exist.
78
+ def setting(*path)
79
+ return nil unless defined?(::Legion::Settings)
80
+
81
+ ::Legion::Settings.dig(*path)
82
+ rescue StandardError
83
+ nil
84
+ end
85
+
86
+ # TCP connect probe with a short timeout. Returns true if the port
87
+ # is reachable, false otherwise.
88
+ def socket_open?(host, port, timeout: 0.1)
89
+ require 'socket'
90
+
91
+ addr = Socket.sockaddr_in(port, host)
92
+ sock = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0)
93
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
94
+
95
+ begin
96
+ sock.connect_nonblock(addr)
97
+ rescue IO::WaitWritable
98
+ return false unless sock.wait_writable(timeout)
99
+
100
+ begin
101
+ sock.connect_nonblock(addr)
102
+ rescue Errno::EISCONN
103
+ # already connected — success
104
+ end
105
+ end
106
+ true
107
+ rescue StandardError
108
+ false
109
+ ensure
110
+ sock&.close
111
+ end
112
+
113
+ # HTTP GET probe via Faraday. Returns true only on a 2xx status.
114
+ def http_ok?(url, path:, timeout: 2)
115
+ require 'faraday'
116
+
117
+ conn = Faraday.new(url: url) do |f|
118
+ f.options.timeout = timeout
119
+ f.options.open_timeout = timeout
120
+ end
121
+ response = conn.get(path)
122
+ response.status >= 200 && response.status < 300
123
+ rescue StandardError
124
+ false
125
+ ensure
126
+ conn&.close if conn.respond_to?(:close)
127
+ end
128
+
129
+ # Deduplicate credential configs by the SHA-256 of their credential
130
+ # value (api_key / bearer_token / access_token). First source wins.
131
+ # Entries without a credential value are always kept.
132
+ def dedup_credentials(candidates)
133
+ seen = {}
134
+ result = {}
135
+
136
+ candidates.each do |instance_id, config|
137
+ hash = credential_hash(config)
138
+ if hash.nil?
139
+ result[instance_id] = config
140
+ elsif !seen.key?(hash)
141
+ seen[hash] = instance_id
142
+ result[instance_id] = config
143
+ end
144
+ end
145
+
146
+ result
147
+ end
148
+
149
+ # SHA-256 hex digest of the first credential value found in the config
150
+ # hash (checks api_key, bearer_token, access_token in order).
151
+ # Returns nil when no credential field is present.
152
+ def credential_hash(config)
153
+ val = config[:api_key] || config['api_key'] ||
154
+ config[:bearer_token] || config['bearer_token'] ||
155
+ config[:access_token] || config['access_token']
156
+ return nil if val.nil?
157
+
158
+ Digest::SHA256.hexdigest(val.to_s)
159
+ end
160
+
161
+ # Returns true when the URL points to localhost / 127.0.0.1 / ::1.
162
+ def localhost?(url)
163
+ return false if url.nil?
164
+
165
+ uri = URI.parse(url.to_s)
166
+ host = uri.host
167
+ return false if host.nil?
168
+
169
+ normalized = host.delete_prefix('[').delete_suffix(']')
170
+ %w[localhost 127.0.0.1 ::1].include?(normalized)
171
+ rescue URI::InvalidURIError
172
+ false
173
+ end
174
+
175
+ module_function :env, :claude_config, :claude_config_value,
176
+ :claude_env_value, :codex_token, :codex_openai_key,
177
+ :setting, :socket_open?, :http_ok?,
178
+ :dedup_credentials, :credential_hash, :localhost?
179
+
180
+ # --- private helpers -----------------------------------------------
181
+
182
+ # Merge user-level (~/.claude/settings.json) and project-level
183
+ # (.claude/settings.json) Claude configs. Project overrides user.
184
+ def merge_claude_configs
185
+ user = read_json(CLAUDE_SETTINGS)
186
+ project = read_json(CLAUDE_PROJECT)
187
+ deep_merge(user, project)
188
+ end
189
+
190
+ # Read and parse a JSON file. Returns an empty hash on any error.
191
+ def read_json(path)
192
+ return {} unless File.exist?(path)
193
+
194
+ raw = File.read(path)
195
+ return {} if raw.strip.empty?
196
+
197
+ if defined?(::Legion::JSON)
198
+ ::Legion::JSON.parse(raw, symbolize_names: true)
199
+ else
200
+ ::JSON.parse(raw, symbolize_names: true)
201
+ end
202
+ rescue StandardError
203
+ {}
204
+ end
205
+
206
+ # JWT expiry check. Decodes the base64 payload segment and checks
207
+ # that exp > now. Returns true on any parse error (benefit of the
208
+ # doubt).
209
+ def token_valid?(token)
210
+ return true if token.nil?
211
+
212
+ require 'base64'
213
+ require 'json'
214
+
215
+ parts = token.to_s.split('.')
216
+ return true unless parts.length >= 2
217
+
218
+ payload = ::JSON.parse(Base64.urlsafe_decode64(parts[1]))
219
+ exp = payload['exp']
220
+ return true if exp.nil?
221
+
222
+ exp.to_i > Time.now.to_i
223
+ rescue StandardError
224
+ true
225
+ end
226
+
227
+ # Simple recursive hash merge (project values override user values).
228
+ def deep_merge(base, override)
229
+ base.merge(override) do |_key, old_val, new_val|
230
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
231
+ deep_merge(old_val, new_val)
232
+ else
233
+ new_val
234
+ end
235
+ end
236
+ end
237
+
238
+ module_function :merge_claude_configs, :read_json,
239
+ :token_valid?, :deep_merge
240
+
241
+ private_class_method :merge_claude_configs, :read_json,
242
+ :token_valid?, :deep_merge
243
+ end
244
+ end
245
+ end
246
+ end
@@ -37,6 +37,27 @@ module Legion
37
37
  class << self
38
38
  include Legion::Logging::Helper
39
39
 
40
+ # Discover provider classes from the Llm namespace.
41
+ # Each lex-llm-* extension defines a module under Legion::Extensions::Llm
42
+ # that responds to `provider_class` and has a `PROVIDER_FAMILY` constant.
43
+ def scan_provider_classes
44
+ Legion::Extensions::Llm.constants(false).filter_map do |const_name|
45
+ mod = Legion::Extensions::Llm.const_get(const_name, false)
46
+ next unless mod.is_a?(Module) && mod.respond_to?(:provider_class) &&
47
+ mod.const_defined?(:PROVIDER_FAMILY, false)
48
+
49
+ [mod::PROVIDER_FAMILY.to_sym, mod.provider_class]
50
+ end.to_h
51
+ end
52
+
53
+ # Resolve a single provider class by slug.
54
+ # Returns nil when the provider is unknown.
55
+ def resolve_provider_class(name)
56
+ return nil if name.nil?
57
+
58
+ scan_provider_classes[name.to_sym]
59
+ end
60
+
40
61
  def instance
41
62
  @instance ||= new
42
63
  end
@@ -71,15 +92,12 @@ module Legion
71
92
  @instance = new(merged_models)
72
93
  end
73
94
 
74
- def fetch_provider_models(remote_only: true) # rubocop:disable Metrics/PerceivedComplexity
95
+ def fetch_provider_models(remote_only: true)
75
96
  config = Legion::Extensions::Llm.config
76
- provider_classes = remote_only ? Provider.remote_providers.values : Provider.providers.values
77
- configured_classes = if remote_only
78
- Provider.configured_remote_providers(config)
79
- else
80
- Provider.configured_providers(config)
81
- end
82
- configured = configured_classes.select { |klass| provider_classes.include?(klass) }
97
+ all_providers = scan_provider_classes.values
98
+ provider_classes = remote_only ? all_providers.reject(&:local?) : all_providers
99
+ configured = provider_classes.select { |klass| klass.configured?(config) }
100
+
83
101
  result = {
84
102
  models: [],
85
103
  fetched_providers: [],
@@ -87,18 +105,13 @@ module Legion
87
105
  failed: []
88
106
  }
89
107
 
90
- provider_classes.each do |provider_class|
91
- next if remote_only && provider_class.local?
92
- next unless provider_class.configured?(config)
93
-
94
- begin
95
- result[:models].concat(provider_class.new(config).list_models)
96
- result[:fetched_providers] << provider_class.slug
97
- rescue StandardError => e
98
- handle_exception(e, level: :warn, handled: true,
99
- operation: 'llm.models.fetch_provider_models')
100
- result[:failed] << { name: provider_class.name, slug: provider_class.slug, error: e }
101
- end
108
+ configured.each do |provider_class|
109
+ result[:models].concat(provider_class.new(config).list_models)
110
+ result[:fetched_providers] << provider_class.slug
111
+ rescue StandardError => e
112
+ handle_exception(e, level: :warn, handled: true,
113
+ operation: 'llm.models.fetch_provider_models')
114
+ result[:failed] << { name: provider_class.name, slug: provider_class.slug, error: e }
102
115
  end
103
116
 
104
117
  result[:fetched_providers].uniq!
@@ -112,7 +125,7 @@ module Legion
112
125
 
113
126
  def resolve(model_id, provider: nil, assume_exists: false, config: nil) # rubocop:disable Metrics/PerceivedComplexity
114
127
  config ||= Legion::Extensions::Llm.config
115
- provider_class = provider ? Provider.providers[provider.to_sym] : nil
128
+ provider_class = provider ? resolve_provider_class(provider) : nil
116
129
 
117
130
  if provider_class
118
131
  temp_instance = provider_class.new(config)
@@ -136,8 +149,8 @@ module Legion
136
149
  model ||= Model::Info.default(model_id, provider_instance.slug)
137
150
  else
138
151
  model = Models.find model_id, provider
139
- provider_class = Provider.providers[model.provider.to_sym] || raise(Error,
140
- "Unknown provider: #{model.provider}")
152
+ provider_class = resolve_provider_class(model.provider) || raise(Error,
153
+ "Unknown provider: #{model.provider}")
141
154
  provider_instance = provider_class.new(config)
142
155
  end
143
156
  [model, provider_instance]
@@ -486,7 +499,7 @@ module Legion
486
499
  end
487
500
 
488
501
  def provider_resolved_model_id(model_id, provider)
489
- provider_class = Provider.resolve(provider)
502
+ provider_class = self.class.resolve_provider_class(provider)
490
503
  return model_id unless provider_class
491
504
 
492
505
  provider_class.resolve_model_id(model_id, config: Legion::Extensions::Llm.config)
@@ -3,6 +3,27 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
+ # Lightweight wrapper that lets a plain Hash behave like a Configuration
7
+ # object, responding to method-style accessors (e.g. +config.api_key+).
8
+ class HashConfig
9
+ def initialize(hash)
10
+ @data = hash.transform_keys(&:to_sym)
11
+ end
12
+
13
+ def respond_to_missing?(name, include_private = false)
14
+ @data.key?(name.to_sym) || super
15
+ end
16
+
17
+ def method_missing(name, *args)
18
+ key = name.to_sym
19
+ if name.to_s.end_with?('=')
20
+ @data[name.to_s.chomp('=').to_sym] = args.first
21
+ elsif @data.key?(key)
22
+ @data[key]
23
+ end
24
+ end
25
+ end
26
+
6
27
  # Base class for LLM providers.
7
28
  class Provider
8
29
  include Streaming
@@ -11,7 +32,7 @@ module Legion
11
32
  attr_reader :config, :connection
12
33
 
13
34
  def initialize(config)
14
- @config = config
35
+ @config = config.is_a?(Hash) ? HashConfig.new(config) : config
15
36
  ensure_configured!
16
37
  @connection = Connection.new(self, @config)
17
38
  end
@@ -338,50 +359,6 @@ module Legion
338
359
  def configured?(config)
339
360
  configuration_requirements.all? { |req| config.send(req) }
340
361
  end
341
-
342
- # @deprecated Use the extension registry instead. Will be removed in 1.0.
343
- def register(name, provider_class)
344
- providers[name.to_sym] = provider_class
345
- Legion::Extensions::Llm::Configuration.register_provider_options(provider_class.configuration_options)
346
- end
347
-
348
- # @deprecated Use the extension registry instead. Will be removed in 1.0.
349
- def resolve(name)
350
- return nil if name.nil?
351
-
352
- providers[name.to_sym]
353
- end
354
-
355
- # @deprecated Use the extension registry instead. Will be removed in 1.0.
356
- def for(model)
357
- model_info = Models.find(model)
358
- resolve model_info.provider
359
- end
360
-
361
- # @deprecated Use the extension registry instead. Will be removed in 1.0.
362
- def providers
363
- @providers ||= {}
364
- end
365
-
366
- def local_providers
367
- providers.select { |_slug, provider_class| provider_class.local? }
368
- end
369
-
370
- def remote_providers
371
- providers.select { |_slug, provider_class| provider_class.remote? }
372
- end
373
-
374
- def configured_providers(config)
375
- providers.select do |_slug, provider_class|
376
- provider_class.configured?(config)
377
- end.values
378
- end
379
-
380
- def configured_remote_providers(config)
381
- providers.select do |_slug, provider_class|
382
- provider_class.remote? && provider_class.configured?(config)
383
- end.values
384
- end
385
362
  end
386
363
 
387
364
  private
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.2.0'
6
+ VERSION = '0.3.0'
7
7
  end
8
8
  end
9
9
  end
@@ -31,6 +31,8 @@ module Legion
31
31
  'ui' => 'UI'
32
32
  )
33
33
  loader.ignore("#{__dir__}/llm/version.rb")
34
+ loader.ignore("#{__dir__}/llm/auto_registration.rb")
35
+ loader.ignore("#{__dir__}/llm/credential_sources.rb")
34
36
  loader.ignore("#{__dir__}/llm/transport/exchanges")
35
37
  loader.ignore("#{__dir__}/llm/transport/messages")
36
38
  loader.push_dir("#{__dir__}/llm", namespace: self)
@@ -85,7 +87,7 @@ module Legion
85
87
  end
86
88
 
87
89
  def providers
88
- Provider.providers.values
90
+ Models.scan_provider_classes.values
89
91
  end
90
92
 
91
93
  def configure
@@ -131,6 +133,8 @@ module Legion
131
133
  ProviderSettings.build(...)
132
134
  end
133
135
 
136
+ require_relative 'llm/auto_registration'
137
+ require_relative 'llm/credential_sources'
134
138
  loader.eager_load
135
139
  end
136
140
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO
@@ -201,12 +201,14 @@ files:
201
201
  - lib/legion/extensions/llm/aliases.json
202
202
  - lib/legion/extensions/llm/aliases.rb
203
203
  - lib/legion/extensions/llm/attachment.rb
204
+ - lib/legion/extensions/llm/auto_registration.rb
204
205
  - lib/legion/extensions/llm/chat.rb
205
206
  - lib/legion/extensions/llm/chunk.rb
206
207
  - lib/legion/extensions/llm/configuration.rb
207
208
  - lib/legion/extensions/llm/connection.rb
208
209
  - lib/legion/extensions/llm/content.rb
209
210
  - lib/legion/extensions/llm/context.rb
211
+ - lib/legion/extensions/llm/credential_sources.rb
210
212
  - lib/legion/extensions/llm/embedding.rb
211
213
  - lib/legion/extensions/llm/error.rb
212
214
  - lib/legion/extensions/llm/image.rb