legionio 1.9.18 → 1.9.23

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: 1ddd3ea28ab0a066f4e3bc7d7fa6f7016ac1b395eec931a76ece7bc3722f04f8
4
- data.tar.gz: 4c97638f1e52579b2351023fa21c36974412ab60c126fe8825c72f2d22466516
3
+ metadata.gz: e6be612db3e6de32b7c4baf82b6a38aac19a0a0bb53d198f736ae4d2d638829d
4
+ data.tar.gz: bf294cc20dbb5701b7bd4f5fd474dbc27d6eae37d2c084cf7b06ec2cbb99f2dc
5
5
  SHA512:
6
- metadata.gz: 5641aa16fb0c352632286285433596060ac046c1300c9083f0bf851afea6bca5e9626f41984cdd2b5586346a7515dbf6e11ea288555f5a57c543adca5fd1f52c
7
- data.tar.gz: d3e33ed0f6fb0bb5da8688705ef13bfe95841b74faa4e06103d462fccf9771714f1d9efc28f0017cc10f1ad0310560f66c0468d3912d7d23ab8c34359e2abec0
6
+ metadata.gz: 3968a2056a7f9c5c2bdbaeff4934165c0a0abd47bde56202f91befeac65c3bd5e38e321de7df3056f2fb71ff66016a89fc2b1803288b598da8fbdaedcc628e77
7
+ data.tar.gz: cc371efeb8f0ad2b32bccfe8140a3ebba0310bded8718c5736c9338fbf088f407d27f58a27d05ad2f6d371b5be45d3d5d50dfddbb4a5d4edb9888a44430a5d9d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,46 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.9.23] - 2026-05-07
6
+
7
+ ### Fixed
8
+ - Fixed encrypted subscription handling to accept both string-keyed and symbol-keyed IV headers before decrypting `encrypted/cs` AMQP payloads.
9
+
10
+ ## [1.9.22] - 2026-05-06
11
+
12
+ ### Changed
13
+ - Hot-reloading a `lex-llm-*` provider extension now asks `Legion::LLM::Call::Providers` to rediscover loaded provider modules, keeping LLM provider instances aligned after extension updates.
14
+ - Bumped the packaged `legion-llm` dependency floor to `>= 0.9.1` for LLM-owned provider registration and reload-safe registry rebuilds.
15
+
16
+ ## [1.9.21] - 2026-05-06
17
+
18
+ ### Changed
19
+ - LegionIO now mounts `Legion::LLM::Routes` through the library route selector when `legion-llm` is available, leaving LLM API ownership with `legion-llm` instead of registering partial fallback routes first.
20
+ - LLM provider health API and CLI output now require native `Legion::LLM::Inventory` data and return a clear unavailable response when inventory is not loaded.
21
+ - Bumped packaged dependency floors to `legion-llm >= 0.9.0` and `legion-data >= 1.8.0` for the coordinated LLM route/schema sweep.
22
+
23
+ ### Fixed
24
+ - Lite and local mode startup now write development mode through the public `Legion::Settings.set_prop` API.
25
+
26
+ ### Removed
27
+ - Removed active `lex-llm-gateway` fallback paths from LLM chat, provider health, extension catalog, role filtering, and README documentation.
28
+
29
+ ## [1.9.20] - 2026-05-06
30
+
31
+ ### Fixed
32
+ - Nested LEX extensions now merge default settings into their nested `extensions` path (for example `lex-foo-bar` -> `extensions.foo.bar`) while underscored flat extensions continue to use the flat key (for example `lex-foo_bar` -> `extensions.foo_bar`).
33
+ - Extension load-time settings checks now use the discovered settings path for nested extensions, keeping `enabled`, `min_version`, `workers`, and `remote_invocable` overrides aligned with where defaults are merged.
34
+
35
+ ## [1.9.19] - 2026-05-05
36
+
37
+ ### Added
38
+ - `UnrecoverableMessageError` for messages that should be dead-lettered immediately (e.g., missing IV header on encrypted messages) instead of retried.
39
+ - Subscription actors now extract `message_id` and `correlation_id` from AMQP metadata into the message hash for downstream tracing.
40
+ - Runner builder auto-includes `Helpers::Lex` into runner modules when available, ensuring all runners have LEX metadata helpers.
41
+
42
+ ### Fixed
43
+ - Encrypted messages (`encrypted/cs`) with a missing `iv` header now raise `UnrecoverableMessageError` and are dead-lettered rather than crashing with a nil argument to `Crypt.decrypt`.
44
+
5
45
  ## [1.9.18] - 2026-04-29
6
46
 
7
47
  ### Fixed
data/Gemfile CHANGED
@@ -28,28 +28,32 @@ def local_gem_path(name, default_path, version_file, requirement)
28
28
  default_path
29
29
  end
30
30
 
31
+ gem 'legion-apollo', path: '../legion-apollo' if File.exist?(File.expand_path('../legion-apollo', __dir__))
31
32
  gem 'legion-data', path: '../legion-data' if File.exist?(File.expand_path('../legion-data', __dir__))
33
+ gem 'legion-logging', path: '../legion-logging' if File.exist?(File.expand_path('../legion-logging', __dir__))
34
+ gem 'legion-settings', path: '../legion-settings' if File.exist?(File.expand_path('../legion-settings', __dir__))
35
+ if (legion_tty_path = local_gem_path('legion-tty', '../legion-tty', 'lib/legion/tty/version.rb', '>= 0.5.4'))
36
+ gem 'legion-tty', path: legion_tty_path
37
+ end
38
+
32
39
  gem 'legion-gaia', path: '../legion-gaia' if File.exist?(File.expand_path('../legion-gaia', __dir__))
33
40
  if (legion_llm_path = local_gem_path('legion-llm', '../legion-llm', 'lib/legion/llm/version.rb', '>= 0.8.47'))
34
41
  gem 'legion-llm', path: legion_llm_path
35
42
  end
36
- if (legion_tty_path = local_gem_path('legion-tty', '../legion-tty', 'lib/legion/tty/version.rb', '>= 0.5.4'))
37
- gem 'legion-tty', path: legion_tty_path
38
- end
39
- gem 'legion-logging', path: '../legion-logging' if File.exist?(File.expand_path('../legion-logging', __dir__))
40
43
  gem 'legion-mcp', path: '../legion-mcp' if File.exist?(File.expand_path('../legion-mcp', __dir__))
41
- gem 'legion-settings', path: '../legion-settings' if File.exist?(File.expand_path('../legion-settings', __dir__))
42
44
 
43
- gem 'legion-apollo', path: '../legion-apollo' if File.exist?(File.expand_path('../legion-apollo', __dir__))
44
- gem 'lex-agentic-memory', path: '../extensions-agentic/lex-agentic-memory' if File.exist?(File.expand_path('../extensions-agentic/lex-agentic-memory', __dir__))
45
+ gem 'lex-kerberos'
46
+
47
+ gem 'lex-apollo', path: '../extensions/lex-apollo' if File.exist?(File.expand_path('../extensions/lex-apollo', __dir__))
45
48
  gem 'lex-llm', path: '../extensions-ai/lex-llm' if File.exist?(File.expand_path('../extensions-ai/lex-llm', __dir__))
46
49
  gem 'lex-llm-ledger', path: '../extensions-ai/lex-llm-ledger' if File.exist?(File.expand_path('../extensions-ai/lex-llm-ledger', __dir__))
50
+
47
51
  %w[anthropic azure-foundry bedrock gemini mlx ollama openai vertex vllm].each do |provider|
48
52
  provider_path = "../extensions-ai/lex-llm-#{provider}"
49
53
  gem "lex-llm-#{provider}", path: provider_path if File.exist?(File.expand_path(provider_path, __dir__))
50
54
  end
51
- gem 'lex-llm-gateway', path: '../extensions/lex-llm-gateway' if File.exist?(File.expand_path('../extensions/lex-llm-gateway', __dir__))
52
- gem 'lex-microsoft_teams', path: '../extensions/lex-microsoft_teams' if File.exist?(File.expand_path('../extensions/lex-microsoft_teams', __dir__))
55
+
56
+ # gem 'lex-microsoft_teams', path: '../extensions/lex-microsoft_teams' if File.exist?(File.expand_path('../extensions/lex-microsoft_teams', __dir__))
53
57
 
54
58
  gem 'pg'
55
59
 
data/README.md CHANGED
@@ -435,7 +435,7 @@ Coordinated by [legion-gaia](https://github.com/LegionIO/legion-gaia), the cogni
435
435
 
436
436
  Powered by [legion-llm](https://github.com/LegionIO/legion-llm) with provider-neutral model offerings, local and fleet routing, hosted cloud providers, health tracking, metering, and automatic model discovery.
437
437
 
438
- `lex-llm-gateway` remains available only as legacy compatibility glue for older deployments. New `legion setup llm` and `legion setup agentic` installs use the native `legion-llm` / `lex-llm-*` stack and do not install the gateway by default.
438
+ LLM API routes are mounted from `legion-llm` when available; LegionIO only hosts those route modules and does not provide a provider gateway fallback.
439
439
 
440
440
  ### Service Integrations (8 common + 15 additional)
441
441
 
data/legionio.gemspec CHANGED
@@ -54,7 +54,7 @@ Gem::Specification.new do |spec|
54
54
 
55
55
  spec.add_dependency 'legion-cache', '>= 1.3.22'
56
56
  spec.add_dependency 'legion-crypt', '>= 1.5.1'
57
- spec.add_dependency 'legion-data', '>= 1.6.19'
57
+ spec.add_dependency 'legion-data', '>= 1.8.0'
58
58
  spec.add_dependency 'legion-json', '>= 1.2.1'
59
59
  spec.add_dependency 'legion-logging', '>= 1.5.0'
60
60
  spec.add_dependency 'legion-settings', '>= 1.3.25'
@@ -62,7 +62,7 @@ Gem::Specification.new do |spec|
62
62
 
63
63
  spec.add_dependency 'legion-apollo', '>= 0.4.0'
64
64
  spec.add_dependency 'legion-gaia', '>= 0.9.26'
65
- spec.add_dependency 'legion-llm', '>= 0.8.47'
65
+ spec.add_dependency 'legion-llm', '>= 0.9.1'
66
66
  spec.add_dependency 'legion-tty', '>= 0.5.4'
67
67
  spec.add_dependency 'lex-node'
68
68
  end
@@ -24,49 +24,52 @@ module Legion
24
24
  Legion::Cache.connected?
25
25
  end
26
26
 
27
- define_method(:gateway_available?) do
28
- defined?(Legion::Extensions::Llm::Gateway::Runners::Inference)
29
- end
30
-
31
27
  define_method(:native_provider_stats_available?) do
32
28
  defined?(Legion::LLM::Inventory) && Legion::LLM::Inventory.respond_to?(:providers)
33
29
  end
34
30
 
31
+ define_method(:require_llm_chat!) do
32
+ return if defined?(Legion::LLM) && Legion::LLM.respond_to?(:chat)
33
+
34
+ halt 503, json_error('llm_chat_unavailable',
35
+ 'Legion::LLM.chat is not available',
36
+ status_code: 503)
37
+ end
38
+
39
+ define_method(:require_provider_inventory!) do
40
+ return if native_provider_stats_available?
41
+
42
+ halt 503, json_error('providers_unavailable',
43
+ 'LLM provider inventory is not loaded',
44
+ status_code: 503)
45
+ end
46
+
35
47
  define_method(:provider_health_report) do
36
- if native_provider_stats_available?
37
- groups = Legion::LLM::Inventory.providers
38
- return [] unless groups.respond_to?(:map)
39
-
40
- groups.map do |provider, offerings|
41
- provider_offerings = Array(offerings)
42
- health = provider_offerings.map { |offering| offering_value(offering, :health) }
43
- .find { |entry| entry.is_a?(Hash) } || {}
44
- circuit = health[:circuit_state] || health['circuit_state'] || 'unknown'
45
- {
46
- provider: provider.to_s,
47
- circuit: circuit,
48
- adjustment: health[:adjustment] || health['adjustment'] || 0,
49
- healthy: circuit.to_s != 'open',
50
- offerings: provider_offerings.size,
51
- models: provider_offerings.map { |offering| offering_value(offering, :model) }.compact.uniq,
52
- types: provider_offerings.map { |offering| offering_value(offering, :type) }.compact.uniq,
53
- instances: provider_offerings.map do |offering|
54
- offering_value(offering, :provider_instance) || offering_value(offering, :instance_id)
55
- end.compact.uniq
56
- }
57
- end
58
- elsif defined?(Legion::Extensions::Llm::Gateway::Runners::ProviderStats)
59
- Legion::Extensions::Llm::Gateway::Runners::ProviderStats.health_report
60
- else
61
- []
48
+ groups = Legion::LLM::Inventory.providers
49
+ return [] unless groups.respond_to?(:map)
50
+
51
+ groups.map do |provider, offerings|
52
+ provider_offerings = Array(offerings)
53
+ health = provider_offerings.map { |offering| offering_value(offering, :health) }
54
+ .find { |entry| entry.is_a?(Hash) } || {}
55
+ circuit = health[:circuit_state] || health['circuit_state'] || 'unknown'
56
+ {
57
+ provider: provider.to_s,
58
+ circuit: circuit,
59
+ adjustment: health[:adjustment] || health['adjustment'] || 0,
60
+ healthy: circuit.to_s != 'open',
61
+ offerings: provider_offerings.size,
62
+ models: provider_offerings.map { |offering| offering_value(offering, :model) }.compact.uniq,
63
+ types: provider_offerings.map { |offering| offering_value(offering, :type) }.compact.uniq,
64
+ instances: provider_offerings.map do |offering|
65
+ offering_value(offering, :provider_instance) || offering_value(offering, :instance_id)
66
+ end.compact.uniq
67
+ }
62
68
  end
63
69
  end
64
70
 
65
71
  define_method(:provider_circuit_summary) do
66
72
  report = provider_health_report
67
- return Legion::Extensions::Llm::Gateway::Runners::ProviderStats.circuit_summary if
68
- report.empty? && defined?(Legion::Extensions::Llm::Gateway::Runners::ProviderStats)
69
-
70
73
  circuits = report.map { |entry| entry[:circuit].to_s }
71
74
  {
72
75
  total: report.size,
@@ -78,16 +81,10 @@ module Legion
78
81
 
79
82
  define_method(:provider_detail) do |provider|
80
83
  provider_name = provider.to_s
81
- if native_provider_stats_available?
82
- entry = provider_health_report.find { |candidate| candidate[:provider] == provider_name }
83
- halt 404, json_error('provider_not_found', "Provider '#{provider_name}' not found", status_code: 404) unless entry
84
-
85
- entry
86
- elsif defined?(Legion::Extensions::Llm::Gateway::Runners::ProviderStats)
87
- Legion::Extensions::Llm::Gateway::Runners::ProviderStats.provider_detail(provider: provider_name.to_sym)
88
- else
89
- halt 503, json_error('providers_unavailable', 'LLM provider inventory is not loaded', status_code: 503)
90
- end
84
+ entry = provider_health_report.find { |candidate| candidate[:provider] == provider_name }
85
+ halt 404, json_error('provider_not_found', "Provider '#{provider_name}' not found", status_code: 404) unless entry
86
+
87
+ entry
91
88
  end
92
89
 
93
90
  define_method(:offering_value) do |offering, key|
@@ -157,55 +154,14 @@ module Legion
157
154
  end
158
155
  end
159
156
 
157
+ require_llm_chat!
158
+
160
159
  request_id = body[:request_id] || SecureRandom.uuid
161
160
  model = body[:model]
162
161
  provider = body[:provider]
163
162
 
164
- # Compatibility fallback for legacy gateway installs. Native legion-llm handles routing first.
165
- if !Legion::LLM.respond_to?(:chat) && gateway_available?
166
- ingress_result = Legion::Ingress.run(
167
- payload: { message: message, model: model, provider: provider,
168
- request_id: request_id },
169
- runner_class: 'Legion::Extensions::Llm::Gateway::Runners::Inference',
170
- function: 'chat',
171
- source: 'api'
172
- )
173
-
174
- unless ingress_result[:success]
175
- Legion::Logging.error "[api/llm/chat] ingress failed: #{ingress_result}"
176
- return json_response({ error: ingress_result[:error] || ingress_result[:status] },
177
- status_code: 502)
178
- end
179
-
180
- result = ingress_result[:result]
181
-
182
- if result.nil?
183
- Legion::Logging.warn "[api/llm/chat] runner returned nil (status=#{ingress_result[:status]})"
184
- return json_response({ error: { code: 'empty_result',
185
- message: 'Gateway runner returned no result' } },
186
- status_code: 502)
187
- end
188
-
189
- response_content = if result.respond_to?(:content)
190
- result.content
191
- elsif result.is_a?(Hash) && result[:error]
192
- return json_response({ error: result[:error] }, status_code: 502)
193
- elsif result.is_a?(Hash)
194
- result[:response] || result[:content] || result.to_s
195
- else
196
- result.to_s
197
- end
198
-
199
- meta = { routed_via: 'gateway' }
200
- meta[:model] = result.model.to_s if result.respond_to?(:model)
201
- meta[:tokens_in] = result.input_tokens if result.respond_to?(:input_tokens)
202
- meta[:tokens_out] = result.output_tokens if result.respond_to?(:output_tokens)
203
-
204
- return json_response({ response: response_content, meta: meta }, status_code: 201)
205
- end
206
-
207
163
  # Fallback: direct LLM call (no metering, no task tracking)
208
- if cache_available? && env['HTTP_X_LEGION_SYNC'] != 'true'
164
+ if cache_available? && env['HTTP_X_LEGION_SYNC'] != 'true' && Legion::LLM.respond_to?(:chat_direct)
209
165
  llm = Legion::LLM
210
166
  rc = Legion::LLM::ResponseCache
211
167
  rc.init_request(request_id)
@@ -469,6 +425,7 @@ module Legion
469
425
  def self.register_providers(app)
470
426
  app.get '/api/llm/providers' do
471
427
  require_llm!
428
+ require_provider_inventory!
472
429
 
473
430
  json_response({
474
431
  providers: provider_health_report,
@@ -478,6 +435,7 @@ module Legion
478
435
 
479
436
  app.get '/api/llm/providers/:name' do
480
437
  require_llm!
438
+ require_provider_inventory!
481
439
 
482
440
  json_response(provider_detail(params[:name]))
483
441
  end
@@ -75,7 +75,7 @@ module Legion
75
75
  end
76
76
 
77
77
  def provider_stats_available?
78
- native_provider_stats_available? || gateway_stats_available?
78
+ native_provider_stats_available?
79
79
  end
80
80
 
81
81
  def native_provider_stats_available?
@@ -83,9 +83,7 @@ module Legion
83
83
  end
84
84
 
85
85
  def provider_health_report
86
- return native_provider_health_report if native_provider_stats_available?
87
-
88
- stats_module.health_report
86
+ native_provider_health_report
89
87
  end
90
88
 
91
89
  def native_provider_health_report
@@ -113,8 +111,6 @@ module Legion
113
111
  end
114
112
 
115
113
  def provider_circuit_summary(report)
116
- return stats_module.circuit_summary unless native_provider_stats_available?
117
-
118
114
  circuits = report.map { |entry| entry[:circuit].to_s }
119
115
  {
120
116
  total: report.size,
@@ -126,8 +122,6 @@ module Legion
126
122
 
127
123
  def provider_detail(provider)
128
124
  provider_name = provider.to_s
129
- return stats_module.provider_detail(provider: provider_name.to_sym) unless native_provider_stats_available?
130
-
131
125
  provider_health_report.find { |entry| entry[:provider] == provider_name } || {}
132
126
  end
133
127
 
@@ -136,14 +130,6 @@ module Legion
136
130
 
137
131
  offering[key] || offering[key.to_s]
138
132
  end
139
-
140
- def gateway_stats_available?
141
- defined?(Legion::Extensions::Llm::Gateway::Runners::ProviderStats)
142
- end
143
-
144
- def stats_module
145
- Legion::Extensions::Llm::Gateway::Runners::ProviderStats
146
- end
147
133
  end
148
134
  end
149
135
  end
@@ -9,6 +9,8 @@ require 'securerandom'
9
9
  module Legion
10
10
  module Extensions
11
11
  module Actors
12
+ class UnrecoverableMessageError < StandardError; end
13
+
12
14
  class Subscription
13
15
  extend Legion::Extensions::Actors::Dsl
14
16
  include Concurrent::Async
@@ -62,7 +64,7 @@ module Legion
62
64
  true
63
65
  end
64
66
 
65
- def prepare
67
+ def prepare # rubocop:disable Metrics/AbcSize
66
68
  @queue = queue.new
67
69
  @queue.channel.prefetch(prefetch) if defined? prefetch
68
70
  consumer_tag = "#{Legion::Settings[:client][:name]}_#{lex_name}_#{runner_name}_#{SecureRandom.uuid}"
@@ -90,6 +92,10 @@ module Legion
90
92
  @queue.acknowledge(delivery_info.delivery_tag) if manual_ack
91
93
 
92
94
  cancel if Legion::Settings[:client][:shutting_down]
95
+ rescue UnrecoverableMessageError => e
96
+ handle_exception(e, lex: lex_name, fn: fn, routing_key: delivery_info.routing_key)
97
+ log.warn "[Subscription] dead-lettering unrecoverable message for #{lex_name}/#{fn}: #{e.message}"
98
+ @queue.reject(delivery_info.delivery_tag, requeue: false) if manual_ack
93
99
  rescue StandardError => e
94
100
  handle_exception(e, lex: lex_name, fn: fn, routing_key: delivery_info.routing_key)
95
101
  reject_or_retry(delivery_info, metadata, payload) if manual_ack
@@ -112,9 +118,13 @@ module Legion
112
118
  true
113
119
  end
114
120
 
115
- def process_message(message, metadata, delivery_info)
121
+ def process_message(message, metadata, delivery_info) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
116
122
  payload = if metadata.content_encoding && metadata.content_encoding == 'encrypted/cs'
117
- Legion::Crypt.decrypt(message, metadata.headers['iv'])
123
+ headers = metadata.headers || {}
124
+ iv = headers['iv'] || headers[:iv]
125
+ raise UnrecoverableMessageError, "encrypted/cs message missing iv header (#{lex_name}/#{runner_name})" if iv.nil?
126
+
127
+ Legion::Crypt.decrypt(message, iv)
118
128
  elsif metadata.content_encoding && metadata.content_encoding == 'encrypted/pk'
119
129
  Legion::Crypt.decrypt_from_keypair(metadata.headers[:public_key], message)
120
130
  else
@@ -131,6 +141,9 @@ module Legion
131
141
  message[:routing_key] = delivery_info[:routing_key]
132
142
  end
133
143
 
144
+ message[:message_id] ||= metadata.message_id if metadata.respond_to?(:message_id) && metadata.message_id
145
+ message[:correlation_id] ||= metadata.correlation_id if metadata.respond_to?(:correlation_id) && metadata.correlation_id
146
+
134
147
  message[:timestamp] = (message[:timestamp_in_ms] / 1000).round if message.key?(:timestamp_in_ms) && !message.key?(:timestamp)
135
148
  message[:datetime] = Time.at(message[:timestamp].to_i).to_datetime.to_s if message.key?(:timestamp)
136
149
  message
@@ -152,11 +165,14 @@ module Legion
152
165
  on_cancellation = block { cancel }
153
166
 
154
167
  @consumer = @queue.subscribe(manual_ack: manual_ack, block: false, consumer_tag: consumer_tag, on_cancellation: on_cancellation) do |*rmq_message|
155
- payload = rmq_message.pop
156
- metadata = rmq_message.last
157
- delivery_info = rmq_message.first
158
-
168
+ delivery_info = nil
169
+ metadata = nil
170
+ payload = nil
159
171
  fn = nil
172
+
173
+ delivery_info = rmq_message.first
174
+ metadata = rmq_message.last
175
+ payload = rmq_message.pop
160
176
  message = process_message(payload, metadata, delivery_info)
161
177
  fn = find_function(message)
162
178
  log.debug "[Subscription] message received: #{lex_name}/#{fn}" if defined?(log)
@@ -182,10 +198,14 @@ module Legion
182
198
  @queue.acknowledge(delivery_info.delivery_tag) if manual_ack
183
199
 
184
200
  cancel if Legion::Settings[:client][:shutting_down]
201
+ rescue UnrecoverableMessageError => e
202
+ handle_exception(e, lex: lex_name, fn: fn)
203
+ log.warn "[Subscription] dead-lettering unrecoverable message for #{lex_name}/#{fn}: #{e.message}"
204
+ @queue.reject(delivery_info.delivery_tag, requeue: false) if manual_ack && delivery_info
185
205
  rescue StandardError => e
186
206
  handle_exception(e)
187
207
  log.warn "[Subscription] retry-or-dlq for #{lex_name}/#{fn}"
188
- reject_or_retry(delivery_info, metadata, payload) if manual_ack
208
+ reject_or_retry(delivery_info, metadata, payload) if manual_ack && delivery_info
189
209
  end
190
210
  log.info "[Subscription] subscribed: #{lex_name}/#{runner_name} (consumer registered)" if defined?(log)
191
211
  end
@@ -29,6 +29,7 @@ module Legion
29
29
  @actors[actor_name.to_sym] = {
30
30
  extension: lex_class.to_s.downcase,
31
31
  extension_name: extension_name,
32
+ settings_path: settings_path,
32
33
  actor_name: actor_name,
33
34
  actor_class: Kernel.const_get(actor_class),
34
35
  type: 'literal'
@@ -50,6 +51,7 @@ module Legion
50
51
  @actors[runner.to_sym] = {
51
52
  extension: attr[:extension],
52
53
  extension_name: attr[:extension_name],
54
+ settings_path: attr[:settings_path],
53
55
  actor_name: attr[:runner_name],
54
56
  actor_class: Kernel.const_get(actor_class),
55
57
  type: 'meta'
@@ -34,6 +34,7 @@ module Legion
34
34
  @hooks[hook_name.to_sym] = {
35
35
  extension: lex_class.to_s.downcase,
36
36
  extension_name: extension_name,
37
+ settings_path: settings_path,
37
38
  hook_name: hook_name,
38
39
  hook_class: hook_class,
39
40
  route_path: route_path
@@ -24,6 +24,7 @@ module Legion
24
24
  runner_class = "#{lex_class}::Runners::#{runner_name.split('_').collect(&:capitalize).join}"
25
25
  loaded_runner = Kernel.const_get(runner_class)
26
26
  loaded_runner.extend(Legion::Extensions::Definitions) unless loaded_runner.respond_to?(:definition)
27
+ ensure_lex_helpers(loaded_runner, runner_class)
27
28
  Legion::Logging.debug "[Runners] registered: #{runner_class}" if defined?(Legion::Logging)
28
29
  @runners[runner_name.to_sym] = build_runner_entry(runner_name, runner_class, loaded_runner, file)
29
30
  populate_runner_methods(runner_name, loaded_runner)
@@ -34,6 +35,7 @@ module Legion
34
35
  entry = {
35
36
  extension: lex_class.to_s.downcase,
36
37
  extension_name: extension_name,
38
+ settings_path: settings_path,
37
39
  extension_class: lex_class,
38
40
  runner_name: runner_name,
39
41
  runner_class: runner_class,
@@ -75,6 +77,19 @@ module Legion
75
77
  def runner_files
76
78
  @runner_files ||= find_files('runners')
77
79
  end
80
+
81
+ private
82
+
83
+ def ensure_lex_helpers(runner_module, runner_class)
84
+ return unless Legion::Extensions.const_defined?(:Helpers, false) &&
85
+ Legion::Extensions::Helpers.const_defined?(:Lex, false)
86
+
87
+ lex_mod = Legion::Extensions::Helpers::Lex
88
+ return if runner_module.ancestors.include?(lex_mod)
89
+
90
+ runner_module.include(lex_mod)
91
+ Legion::Logging.info "[Runners] auto-included Helpers::Lex into #{runner_class}" if defined?(Legion::Logging)
92
+ end
78
93
  end
79
94
  end
80
95
  end
@@ -14,7 +14,6 @@ module Legion
14
14
  { name: 'lex-exec', category: 'core', description: 'Shell command execution' },
15
15
  { name: 'lex-health', category: 'core', description: 'Health monitoring and metrics' },
16
16
  { name: 'lex-lex', category: 'core', description: 'Extension management' },
17
- { name: 'lex-llm-gateway', category: 'legacy', description: 'Legacy LLM gateway compatibility' },
18
17
  { name: 'lex-llm-ledger', category: 'core', description: 'LLM cost and usage ledger' },
19
18
  { name: 'lex-log', category: 'core', description: 'Log shipping and aggregation' },
20
19
  { name: 'lex-metering', category: 'core', description: 'Resource metering and accounting' },
@@ -219,27 +219,9 @@ module Legion
219
219
  end
220
220
 
221
221
  def build_settings
222
- defaults = deep_dup_settings_value(Legion::Settings[:default_extension_settings] || {})
223
-
224
- if Legion::Settings[:extensions].key?(lex_name.to_sym)
225
- defaults.each do |key, value|
226
- Legion::Settings[:extensions][lex_name.to_sym][key.to_sym] = if Legion::Settings[:extensions][lex_name.to_sym].key?(key.to_sym)
227
- deep_dup_settings_value(value).merge(Legion::Settings[:extensions][lex_name.to_sym][key.to_sym])
228
- else
229
- deep_dup_settings_value(value)
230
- end
231
- end
232
- else
233
- Legion::Settings[:extensions][lex_name.to_sym] = defaults
234
- end
235
-
236
- default_settings.each do |key, value|
237
- Legion::Settings[:extensions][lex_name.to_sym][key.to_sym] = if Legion::Settings[:extensions][lex_name.to_sym].key?(key.to_sym)
238
- deep_dup_settings_value(value).merge(Legion::Settings[:extensions][lex_name.to_sym][key.to_sym])
239
- else
240
- deep_dup_settings_value(value)
241
- end
242
- end
222
+ target = extension_settings_target
223
+ merge_extension_defaults!(target, Legion::Settings[:default_extension_settings] || {})
224
+ merge_extension_defaults!(target, default_settings)
243
225
  end
244
226
 
245
227
  def default_settings
@@ -284,6 +266,29 @@ module Legion
284
266
  rescue TypeError
285
267
  value
286
268
  end
269
+
270
+ def extension_settings_target
271
+ settings_path.reduce(Legion::Settings[:extensions]) do |current, key|
272
+ current[key] = {} unless current[key].is_a?(Hash)
273
+ current[key]
274
+ end
275
+ end
276
+
277
+ def merge_extension_defaults!(target, defaults)
278
+ defaults.each do |key, value|
279
+ key = key.to_sym
280
+ target[key] = if target.key?(key)
281
+ merge_extension_default_value(deep_dup_settings_value(value), target[key])
282
+ else
283
+ deep_dup_settings_value(value)
284
+ end
285
+ end
286
+ end
287
+
288
+ def merge_extension_default_value(default_value, current_value)
289
+ merge_extension_defaults!(current_value, default_value) if default_value.is_a?(Hash) && current_value.is_a?(Hash)
290
+ current_value
291
+ end
287
292
  end
288
293
  end
289
294
  end
@@ -143,12 +143,9 @@ module Legion
143
143
  def load_phase_extensions(phase_num, entries)
144
144
  eligible = entries.filter_map do |entry|
145
145
  gem_name = entry[:gem_name]
146
- ext_name = entry[:require_path].split('/').last
146
+ ext_settings = extension_settings_for_entry(entry)
147
147
 
148
- if Legion::Settings[:extensions].key?(ext_name.to_sym) &&
149
- Legion::Settings[:extensions][ext_name.to_sym].is_a?(Hash) &&
150
- Legion::Settings[:extensions][ext_name.to_sym].key?(:enabled) &&
151
- !Legion::Settings[:extensions][ext_name.to_sym][:enabled]
148
+ if ext_settings.is_a?(Hash) && ext_settings.key?(:enabled) && !ext_settings[:enabled]
152
149
  Legion::Logging.info "Skipping #{gem_name} because it's disabled"
153
150
  next
154
151
  end
@@ -239,7 +236,7 @@ module Legion
239
236
  extension.extend Legion::Extensions::Core unless extension.singleton_class.include?(Legion::Extensions::Core)
240
237
 
241
238
  ext_name = entry[:segments].join('_')
242
- ext_settings = Legion::Settings[:extensions][ext_name.to_sym]
239
+ ext_settings = extension_settings_for_entry(entry)
243
240
  min_version = ext_settings[:min_version] if ext_settings.is_a?(Hash)
244
241
  if min_version.is_a?(String)
245
242
  begin
@@ -427,8 +424,9 @@ module Legion
427
424
  end
428
425
 
429
426
  def hook_actor(extension:, extension_name:, actor_class:, size: 1, **opts)
430
- size = if Legion::Settings[:extensions].key?(extension_name.to_sym) && Legion::Settings[:extensions][extension_name.to_sym].key?(:workers)
431
- Legion::Settings[:extensions][extension_name.to_sym][:workers]
427
+ ext_settings = extension_settings_for_actor(extension_name, opts[:settings_path])
428
+ size = if ext_settings.is_a?(Hash) && ext_settings.key?(:workers)
429
+ ext_settings[:workers]
432
430
  elsif size.is_a? Integer
433
431
  size
434
432
  else
@@ -595,7 +593,7 @@ module Legion
595
593
 
596
594
  def resolve_subscription_worker_count(actor_hash)
597
595
  ext_name = actor_hash[:extension_name]
598
- ext_settings = Legion::Settings.dig(:extensions, ext_name.to_sym)
596
+ ext_settings = extension_settings_for_actor(ext_name, actor_hash[:settings_path])
599
597
  if ext_settings.is_a?(Hash) && ext_settings.key?(:workers)
600
598
  ext_settings[:workers]
601
599
  elsif actor_hash[:size].is_a?(Integer)
@@ -606,8 +604,7 @@ module Legion
606
604
  end
607
605
 
608
606
  def resolve_remote_invocable(extension_name, opts = {})
609
- ext_key = extension_name.to_sym
610
- ext_settings = Legion::Settings.dig(:extensions, ext_key)
607
+ ext_settings = extension_settings_for_actor(extension_name, opts[:settings_path])
611
608
  runner_name = opts[:actor_name]&.to_sym
612
609
 
613
610
  # 1. Per-runner settings override
@@ -760,7 +757,7 @@ module Legion
760
757
  end
761
758
 
762
759
  def legacy_ai_extension_names
763
- %w[azure-ai bedrock claude foundry gemini llm-gateway ollama openai xai].freeze
760
+ %w[azure-ai bedrock claude foundry gemini ollama openai xai].freeze
764
761
  end
765
762
 
766
763
  def service_extension_names
@@ -914,6 +911,7 @@ module Legion
914
911
  entry = @extensions&.find { |candidate| candidate[:gem_name] == gem_name }
915
912
  raise "#{gem_name} failed to reload" if entry && !load_extension(entry)
916
913
 
914
+ refresh_llm_provider_registry(gem_name)
917
915
  update_extension_handle(gem_name, state: :running, reload_state: :idle, last_error: nil,
918
916
  latest_installed_version: latest_installed_version(gem_name))
919
917
  true
@@ -926,6 +924,13 @@ module Legion
926
924
  @extension_handle_registry ||= HandleRegistry.new
927
925
  end
928
926
 
927
+ def refresh_llm_provider_registry(gem_name)
928
+ return unless gem_name.start_with?('lex-llm-') && gem_name != 'lex-llm-ledger'
929
+ return unless defined?(Legion::LLM::Call::Providers)
930
+
931
+ Legion::LLM::Call::Providers.rediscover_all_providers
932
+ end
933
+
929
934
  def transition_loaded_extensions(state)
930
935
  @loaded_extensions&.each do |name|
931
936
  Catalog.transition(name, state)
@@ -955,6 +960,21 @@ module Legion
955
960
  nil
956
961
  end
957
962
 
963
+ def extension_settings_for_entry(entry)
964
+ extension_settings_for_path(entry[:settings_path])
965
+ end
966
+
967
+ def extension_settings_for_actor(extension_name, settings_path)
968
+ extension_settings_for_path(settings_path) || Legion::Settings.dig(:extensions, extension_name.to_sym)
969
+ end
970
+
971
+ def extension_settings_for_path(settings_path)
972
+ path = Array(settings_path).map(&:to_sym)
973
+ return nil if path.empty?
974
+
975
+ Legion::Settings.dig(:extensions, *path)
976
+ end
977
+
958
978
  def reset_runner_cache
959
979
  return unless defined?(Legion::Ingress) && Legion::Ingress.respond_to?(:reset_runner_cache!)
960
980
 
@@ -1119,7 +1139,16 @@ module Legion
1119
1139
  end
1120
1140
 
1121
1141
  { gem_name: gem_name, category: category, tier: tier,
1122
- segments: segments, const_path: const_path, require_path: require_path }
1142
+ segments: segments, const_path: const_path, require_path: require_path,
1143
+ settings_path: settings_path_for_entry(segments, nesting) }
1144
+ end
1145
+
1146
+ def settings_path_for_entry(segments, nesting)
1147
+ if nesting
1148
+ segments.map(&:to_sym)
1149
+ else
1150
+ [segments.join('_').to_sym]
1151
+ end
1123
1152
  end
1124
1153
 
1125
1154
  def probe_nesting(gem_name, segments)
@@ -209,7 +209,7 @@ module Legion
209
209
  def setup_local_mode
210
210
  if lite_mode?
211
211
  log.info 'Starting in lite mode (zero infrastructure)'
212
- Legion::Settings[:dev] = true
212
+ Legion::Settings.set_prop(:dev, true)
213
213
  require 'legion/transport/local'
214
214
  require 'legion/crypt/mock_vault' if defined?(Legion::Crypt)
215
215
  return
@@ -218,7 +218,7 @@ module Legion
218
218
  return unless local_mode?
219
219
 
220
220
  log.info 'Starting in local development mode'
221
- Legion::Settings[:dev] = true
221
+ Legion::Settings.set_prop(:dev, true)
222
222
 
223
223
  require 'legion/transport/local'
224
224
  require 'legion/crypt/mock_vault'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.9.18'
4
+ VERSION = '1.9.23'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.18
4
+ version: 1.9.23
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -253,14 +253,14 @@ dependencies:
253
253
  requirements:
254
254
  - - ">="
255
255
  - !ruby/object:Gem::Version
256
- version: 1.6.19
256
+ version: 1.8.0
257
257
  type: :runtime
258
258
  prerelease: false
259
259
  version_requirements: !ruby/object:Gem::Requirement
260
260
  requirements:
261
261
  - - ">="
262
262
  - !ruby/object:Gem::Version
263
- version: 1.6.19
263
+ version: 1.8.0
264
264
  - !ruby/object:Gem::Dependency
265
265
  name: legion-json
266
266
  requirement: !ruby/object:Gem::Requirement
@@ -351,14 +351,14 @@ dependencies:
351
351
  requirements:
352
352
  - - ">="
353
353
  - !ruby/object:Gem::Version
354
- version: 0.8.47
354
+ version: 0.9.1
355
355
  type: :runtime
356
356
  prerelease: false
357
357
  version_requirements: !ruby/object:Gem::Requirement
358
358
  requirements:
359
359
  - - ">="
360
360
  - !ruby/object:Gem::Version
361
- version: 0.8.47
361
+ version: 0.9.1
362
362
  - !ruby/object:Gem::Dependency
363
363
  name: legion-tty
364
364
  requirement: !ruby/object:Gem::Requirement