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 +4 -4
- data/CHANGELOG.md +40 -0
- data/Gemfile +13 -9
- data/README.md +1 -1
- data/legionio.gemspec +2 -2
- data/lib/legion/api/llm.rb +45 -87
- data/lib/legion/cli/chat/tools/provider_health.rb +2 -16
- data/lib/legion/extensions/actors/subscription.rb +28 -8
- data/lib/legion/extensions/builders/actors.rb +2 -0
- data/lib/legion/extensions/builders/hooks.rb +1 -0
- data/lib/legion/extensions/builders/runners.rb +15 -0
- data/lib/legion/extensions/catalog/available.rb +0 -1
- data/lib/legion/extensions/core.rb +26 -21
- data/lib/legion/extensions.rb +42 -13
- data/lib/legion/service.rb +2 -2
- data/lib/legion/version.rb +1 -1
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e6be612db3e6de32b7c4baf82b6a38aac19a0a0bb53d198f736ae4d2d638829d
|
|
4
|
+
data.tar.gz: bf294cc20dbb5701b7bd4f5fd474dbc27d6eae37d2c084cf7b06ec2cbb99f2dc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 '
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
data/lib/legion/api/llm.rb
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
metadata =
|
|
157
|
-
|
|
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'
|
|
@@ -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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
data/lib/legion/extensions.rb
CHANGED
|
@@ -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
|
-
|
|
146
|
+
ext_settings = extension_settings_for_entry(entry)
|
|
147
147
|
|
|
148
|
-
if
|
|
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 =
|
|
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
|
-
|
|
431
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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)
|
data/lib/legion/service.rb
CHANGED
|
@@ -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
|
|
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
|
|
221
|
+
Legion::Settings.set_prop(:dev, true)
|
|
222
222
|
|
|
223
223
|
require 'legion/transport/local'
|
|
224
224
|
require 'legion/crypt/mock_vault'
|
data/lib/legion/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
361
|
+
version: 0.9.1
|
|
362
362
|
- !ruby/object:Gem::Dependency
|
|
363
363
|
name: legion-tty
|
|
364
364
|
requirement: !ruby/object:Gem::Requirement
|