legionio 1.9.2 → 1.9.18

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: 1dee00872a482d84ca7cffed624add4a568595383b31d93ffe1983d5bfb89d91
4
- data.tar.gz: 0e8eaba327808c586a3cdf6d9e8da177831ac0ce9f5fb1919de115f48b63346f
3
+ metadata.gz: 1ddd3ea28ab0a066f4e3bc7d7fa6f7016ac1b395eec931a76ece7bc3722f04f8
4
+ data.tar.gz: 4c97638f1e52579b2351023fa21c36974412ab60c126fe8825c72f2d22466516
5
5
  SHA512:
6
- metadata.gz: 7b7124a1ed4fa62aaa5da4e55391961973435b324cc17142245c52af7b32d43d6f7c161341f12d27aca47310dc9e6289a3d1a4d781561b001baa724c03ddc8ba
7
- data.tar.gz: 952d0045d985d1f14ac76e15e015fa8bd279ed92ae123cd43e7f5139ed7efbee9a3cba93856ce2f6c8603e74f9549495e415fcd8459a27d3b35cb169eebec617
6
+ metadata.gz: 5641aa16fb0c352632286285433596060ac046c1300c9083f0bf851afea6bca5e9626f41984cdd2b5586346a7515dbf6e11ea288555f5a57c543adca5fd1f52c
7
+ data.tar.gz: d3e33ed0f6fb0bb5da8688705ef13bfe95841b74faa4e06103d462fccf9771714f1d9efc28f0017cc10f1ad0310560f66c0468d3912d7d23ab8c34359e2abec0
data/.gitignore CHANGED
@@ -1,6 +1,7 @@
1
1
  /.bundle/
2
2
  /.yardoc
3
3
  Gemfile.lock
4
+ *.gem
4
5
  /_yardoc/
5
6
  /coverage/
6
7
  /doc/
data/CHANGELOG.md CHANGED
@@ -2,6 +2,111 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.9.18] - 2026-04-29
6
+
7
+ ### Fixed
8
+ - API-submitted LLM tools now build native `Legion::LLM::Types::ToolDefinition` objects instead of attempting to require RubyLLM at runtime.
9
+ - Provider route coverage now locks LegionIO's `/api/llm/providers` compatibility response ahead of later colliding LLM library route registrations.
10
+
11
+ ## [1.9.17] - 2026-04-29
12
+
13
+ ### Fixed
14
+ - LegionIO now requires `legion-llm >= 0.8.47` and only uses a local sibling `legion-llm` checkout when it satisfies that release floor, preventing stale local worktrees from breaking Bundler resolution.
15
+ - Native LLM provider health API responses now preserve model, type, health, and instance fields when inventory offerings are loaded from string-keyed data.
16
+
17
+ ## [1.9.16] - 2026-04-28
18
+
19
+ ### Fixed
20
+ - LegionIO now requires `legion-llm >= 0.8.44` so packaged installs include the unified identity migration for LLM caller metadata and Broker token audit context.
21
+
22
+ ## [1.9.15] - 2026-04-28
23
+
24
+ ### Fixed
25
+ - The static extension catalog now classifies `lex-llm-gateway` as legacy compatibility, and setup pack tests explicitly prevent it from returning to default LLM or agentic installs.
26
+ - README LLM documentation now calls out `lex-llm-gateway` as legacy-only compatibility glue that is not installed by default.
27
+
28
+ ## [1.9.14] - 2026-04-28
29
+
30
+ ### Fixed
31
+ - LegionIO now requires `legion-tty >= 0.5.4` so packaged installs include the Legion-native LLM probe instead of the legacy direct RubyLLM probe.
32
+
33
+ ## [1.9.13] - 2026-04-28
34
+
35
+ ### Fixed
36
+ - LegionIO now requires `legion-llm >= 0.8.43` so packaged installs get the optional RubyLLM compatibility layer and native dispatch fallback defaults.
37
+
38
+ ## [1.9.12] - 2026-04-28
39
+
40
+ ### Fixed
41
+ - LegionIO now requires `legion-llm >= 0.8.42` so packaged installs resolve the validated LLM routing uplift release.
42
+
43
+ ## [1.9.11] - 2026-04-28
44
+
45
+ ### Fixed
46
+ - LLM chat API routing now prefers native `Legion::LLM.chat` even when legacy `lex-llm-gateway` compatibility code is loaded.
47
+
48
+ ## [1.9.10] - 2026-04-28
49
+
50
+ ### Fixed
51
+ - LLM provider health endpoints and CLI health checks now use the native `legion-llm` provider inventory before falling back to legacy `lex-llm-gateway` provider stats.
52
+
53
+ ## [1.9.9] - 2026-04-28
54
+
55
+ ### Fixed
56
+ - Registry governance and security scanning now accept nested `lex-*` extension gem names such as `lex-llm-openai` and `lex-llm-azure-foundry`.
57
+
58
+ ## [1.9.8] - 2026-04-28
59
+
60
+ ### Fixed
61
+ - The `agentic` setup pack now installs the Legion-native `lex-llm-*` provider stack without also installing retired legacy LLM provider gems.
62
+ - Role profiles now treat `lex-llm-*` gems as the active AI extension set and exclude legacy LLM providers from default `core`, `dev`, and `cognitive` profile loading.
63
+ - LegionIO now requires `legion-llm >= 0.8.41` so packaged installs get the router dependency cleanup that removes retired legacy provider runtime dependencies.
64
+
65
+ ## [1.9.7] - 2026-04-28
66
+
67
+ ### Fixed
68
+ - Extension discovery now maps `lex-llm-azure-foundry` to `Legion::Extensions::Llm::AzureFoundry` and `legion/extensions/llm/azure_foundry`.
69
+ - LegionIO now requires `legion-llm >= 0.8.40` so packaged installs include the native provider bridge needed by the Legion-native LLM stack.
70
+ - README LLM provider documentation now describes the `lex-llm-*` provider stack instead of the retired legacy provider list.
71
+
72
+ ## [1.9.6] - 2026-04-28
73
+
74
+ ### Fixed
75
+ - LLM API gateway checks now use the `Legion::Extensions::Llm::Gateway` namespace loaded by Legion extension autoloading.
76
+ - LLM inference and skill invocation routes now call the current `Legion::LLM::Inference` request/executor API instead of the retired pipeline constants.
77
+ - `legionio llm ping` now routes through `Legion::LLM.ask_direct` instead of bypassing Legion routing with a raw RubyLLM call.
78
+ - API client tool construction now degrades cleanly when the RubyLLM tool base is unavailable.
79
+
80
+ ## [1.9.5] - 2026-04-28
81
+
82
+ ### Added
83
+ - Extension catalog, setup packs, and local development wiring now include the Legion-native `lex-llm` provider stack, including Bedrock, Azure Foundry, and Vertex hosted provider extensions.
84
+
85
+ ### Fixed
86
+ - Local development Gemfile wiring now includes guarded `lex-llm-ledger` resolution so the local bundle matches the LLM setup pack.
87
+ - Local development Gemfile wiring now points `lex-llm-gateway` at the workspace extension path actually used by LegionIO checkouts.
88
+ - Default setup packs no longer install legacy `lex-llm-gateway`; the extension catalog now labels it as compatibility glue rather than active LLM routing.
89
+ - `require 'legion/extensions'` now loads its logging dependency directly instead of relying on `require 'legion'` order.
90
+
91
+ ## [1.9.4] - 2026-04-27
92
+
93
+ ### Added
94
+ - Extension boot now runs a dedicated LLM load phase so `lex-llm` loads before any `lex-llm-*` extension gems.
95
+ - `/api/health` now reports `uptime_seconds` and `uptime` for dashboard and monitor consumers. Fixes #168
96
+ - `/api/extensions` now returns a flat loaded-extension summary for dashboard consumers. Fixes #169
97
+
98
+ ### Fixed
99
+ - `legionio doctor` no longer reports extension-loader config keys as missing `lex-*` gems. Fixes #157
100
+ - `/api/metering` now returns dashboard headline totals instead of the routing breakdown shape. Fixes #170
101
+ - Extension autobuild now runs per-extension data migrations when migration files are present, even when an extension does not opt into general data models. Fixes #171
102
+ - `/api/webhooks` now loads its `Legion::Webhooks` runtime dependency before route handlers execute. Fixes #172
103
+ - `/api/tenants` now passes positional response data and uses `json_error` for missing tenants. Fixes #173
104
+
105
+ ## [1.9.3] - 2026-04-27
106
+
107
+ ### Fixed
108
+ - Extension catalog persistence now skips no-op startup updates when the stored state already matches, reducing local SQLite write churn. Fixes #176
109
+
5
110
  ## [1.9.2] - 2026-04-27
6
111
 
7
112
  ### Fixed
data/Gemfile CHANGED
@@ -4,16 +4,51 @@ source 'https://rubygems.org'
4
4
 
5
5
  gemspec
6
6
 
7
+ def local_gem_version(path, version_file)
8
+ version_path = File.expand_path(File.join(path, version_file), __dir__)
9
+ return unless File.file?(version_path)
10
+
11
+ version_source = File.read(version_path)
12
+ version_source[/VERSION\s*=\s*['"]([^'"]+)['"]/, 1]
13
+ end
14
+
15
+ def local_gem_satisfies?(path, version_file, requirement)
16
+ version = local_gem_version(path, version_file)
17
+ version && Gem::Requirement.new(requirement).satisfied_by?(Gem::Version.new(version))
18
+ end
19
+
20
+ def local_gem_path(name, default_path, version_file, requirement)
21
+ env_name = "#{name.upcase.tr('-', '_')}_PATH"
22
+ env_path = ENV.fetch(env_name, nil)
23
+ return env_path if env_path && File.exist?(File.expand_path(env_path, __dir__))
24
+
25
+ return unless File.exist?(File.expand_path(default_path, __dir__))
26
+ return unless local_gem_satisfies?(default_path, version_file, requirement)
27
+
28
+ default_path
29
+ end
30
+
7
31
  gem 'legion-data', path: '../legion-data' if File.exist?(File.expand_path('../legion-data', __dir__))
8
32
  gem 'legion-gaia', path: '../legion-gaia' if File.exist?(File.expand_path('../legion-gaia', __dir__))
9
- gem 'legion-llm', path: '../legion-llm' if File.exist?(File.expand_path('../legion-llm', __dir__))
33
+ if (legion_llm_path = local_gem_path('legion-llm', '../legion-llm', 'lib/legion/llm/version.rb', '>= 0.8.47'))
34
+ gem 'legion-llm', path: legion_llm_path
35
+ 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
10
39
  gem 'legion-logging', path: '../legion-logging' if File.exist?(File.expand_path('../legion-logging', __dir__))
11
40
  gem 'legion-mcp', path: '../legion-mcp' if File.exist?(File.expand_path('../legion-mcp', __dir__))
12
41
  gem 'legion-settings', path: '../legion-settings' if File.exist?(File.expand_path('../legion-settings', __dir__))
13
42
 
14
43
  gem 'legion-apollo', path: '../legion-apollo' if File.exist?(File.expand_path('../legion-apollo', __dir__))
15
44
  gem 'lex-agentic-memory', path: '../extensions-agentic/lex-agentic-memory' if File.exist?(File.expand_path('../extensions-agentic/lex-agentic-memory', __dir__))
16
- gem 'lex-llm-gateway', path: '../extensions-core/lex-llm-gateway' if File.exist?(File.expand_path('../extensions-core/lex-llm-gateway', __dir__))
45
+ gem 'lex-llm', path: '../extensions-ai/lex-llm' if File.exist?(File.expand_path('../extensions-ai/lex-llm', __dir__))
46
+ gem 'lex-llm-ledger', path: '../extensions-ai/lex-llm-ledger' if File.exist?(File.expand_path('../extensions-ai/lex-llm-ledger', __dir__))
47
+ %w[anthropic azure-foundry bedrock gemini mlx ollama openai vertex vllm].each do |provider|
48
+ provider_path = "../extensions-ai/lex-llm-#{provider}"
49
+ gem "lex-llm-#{provider}", path: provider_path if File.exist?(File.expand_path(provider_path, __dir__))
50
+ end
51
+ gem 'lex-llm-gateway', path: '../extensions/lex-llm-gateway' if File.exist?(File.expand_path('../extensions/lex-llm-gateway', __dir__))
17
52
  gem 'lex-microsoft_teams', path: '../extensions/lex-microsoft_teams' if File.exist?(File.expand_path('../extensions/lex-microsoft_teams', __dir__))
18
53
 
19
54
  gem 'pg'
data/README.md CHANGED
@@ -18,7 +18,7 @@ Schedule tasks, chain services into dependency graphs, run them concurrently via
18
18
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.4-red.svg)](https://www.ruby-lang.org/)
19
19
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
20
20
 
21
- **Ruby >= 3.4** | **v1.8.12** | **Apache-2.0** | [@Esity](https://github.com/Esity)
21
+ **Ruby >= 3.4** | **v1.9.18** | **Apache-2.0** | [@Esity](https://github.com/Esity)
22
22
 
23
23
  ---
24
24
 
@@ -411,9 +411,9 @@ Access Vault secrets inline: `<%= Legion::Crypt.read('pushover/token') %>`
411
411
 
412
412
  Browse: [LegionIO GitHub](https://github.com/LegionIO) | [legionio topic](https://github.com/topics/legionio?l=ruby)
413
413
 
414
- ### Core (16 operational extensions)
414
+ ### Core (14 operational extensions)
415
415
 
416
- `lex-node` `lex-tasker` `lex-conditioner` `lex-transformer` `lex-synapse` `lex-scheduler` `lex-health` `lex-log` `lex-ping` `lex-exec` `lex-lex` `lex-codegen` `lex-metering` `lex-telemetry` `lex-audit` `task_pruner`
416
+ `lex-node` `lex-tasker` `lex-conditioner` `lex-transformer` `lex-scheduler` `lex-health` `lex-log` `lex-ping` `lex-exec` `lex-lex` `lex-codegen` `lex-metering` `lex-telemetry` `lex-task_pruner`
417
417
 
418
418
  ### Agentic (242 cognitive extensions)
419
419
 
@@ -429,11 +429,13 @@ Brain-modeled cognitive architecture. 20 core orchestration extensions plus 222
429
429
 
430
430
  Coordinated by [legion-gaia](https://github.com/LegionIO/legion-gaia), the cognitive coordination layer with tick-cycle scheduling, channel abstraction, and weighted routing across cognitive modules.
431
431
 
432
- ### AI / LLM (7 provider extensions)
432
+ ### AI / LLM
433
433
 
434
- `lex-azure-ai` `lex-bedrock` `lex-claude` `lex-foundry` `lex-gemini` `lex-openai` `lex-xai`
434
+ `legion-llm` `lex-llm` `lex-llm-anthropic` `lex-llm-azure-foundry` `lex-llm-bedrock` `lex-llm-gemini` `lex-llm-ledger` `lex-llm-mlx` `lex-llm-ollama` `lex-llm-openai` `lex-llm-vertex` `lex-llm-vllm`
435
435
 
436
- Powered by [legion-llm](https://github.com/LegionIO/legion-llm) with three-tier routing (local Ollama, fleet GPU servers, cloud APIs), intent-based dispatch, health tracking, and automatic model discovery.
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
+
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.
437
439
 
438
440
  ### Service Integrations (8 common + 15 additional)
439
441
 
@@ -465,7 +467,7 @@ Control which extensions load at startup via `settings/legion.json`:
465
467
  | `core` | 14 core operational extensions only |
466
468
  | `cognitive` | core + all agentic extensions |
467
469
  | `service` | core + service + other integrations |
468
- | `dev` | core + AI + essential agentic (~20 extensions) |
470
+ | `dev` | core + native LLM providers + essential agentic (~20 extensions) |
469
471
  | `custom` | only what's listed in `role.extensions` |
470
472
 
471
473
  Faster boot and lower memory footprint for dedicated worker roles.
data/legionio.gemspec CHANGED
@@ -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.5.8'
66
- spec.add_dependency 'legion-tty', '>= 0.4.35'
65
+ spec.add_dependency 'legion-llm', '>= 0.8.47'
66
+ spec.add_dependency 'legion-tty', '>= 0.5.4'
67
67
  spec.add_dependency 'lex-node'
68
68
  end
@@ -5,6 +5,7 @@ module Legion
5
5
  module Routes
6
6
  module Extensions
7
7
  def self.registered(app)
8
+ register_loaded_summary_route(app)
8
9
  register_available_route(app)
9
10
  register_extension_routes(app)
10
11
  register_runner_routes(app)
@@ -12,6 +13,22 @@ module Legion
12
13
  register_invoke_route(app)
13
14
  end
14
15
 
16
+ def self.register_loaded_summary_route(app)
17
+ app.get '/api/extensions' do
18
+ items = Legion::Extensions.loaded_extension_modules.map do |mod|
19
+ version = mod.const_get(:VERSION, false).to_s if mod.const_defined?(:VERSION, false)
20
+ name = if mod.respond_to?(:lex_name)
21
+ mod.lex_name
22
+ else
23
+ mod.name.to_s.split('::').last.to_s.downcase
24
+ end
25
+ { name: name, module: mod.name, version: version }.compact
26
+ end
27
+
28
+ json_response(items)
29
+ end
30
+ end
31
+
15
32
  def self.register_available_route(app)
16
33
  app.get '/api/extension_catalog/available' do
17
34
  entries = Legion::Extensions::Catalog::Available.all
@@ -188,7 +205,7 @@ module Legion
188
205
  started_at: entry[:started_at]&.iso8601 }
189
206
  end
190
207
 
191
- private :register_available_route, :register_extension_routes,
208
+ private :register_loaded_summary_route, :register_available_route, :register_extension_routes,
192
209
  :register_runner_routes, :register_function_routes, :register_invoke_route
193
210
  end
194
211
  end
@@ -1,10 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
- require 'open3'
5
- require 'resolv'
6
- require 'ipaddr'
7
- require 'uri'
8
4
 
9
5
  module Legion
10
6
  class API < Sinatra::Base
@@ -29,89 +25,86 @@ module Legion
29
25
  end
30
26
 
31
27
  define_method(:gateway_available?) do
32
- defined?(Legion::Extensions::LLM::Gateway::Runners::Inference)
28
+ defined?(Legion::Extensions::Llm::Gateway::Runners::Inference)
33
29
  end
34
30
 
35
- define_method(:build_client_tool_class) do |tname, tdesc, tschema|
36
- klass = Class.new(RubyLLM::Tool) do
37
- description tdesc
38
- define_method(:name) { tname }
39
- tool_ref = tname
40
- define_method(:execute) do |**kwargs|
41
- case tool_ref
42
- when 'sh'
43
- cmd = kwargs[:command] || kwargs[:cmd] || kwargs.values.first.to_s
44
- output, status = ::Open3.capture2e(cmd, chdir: Dir.pwd)
45
- "exit=#{status.exitstatus}\n#{output}"
46
- when 'file_read'
47
- path = kwargs[:path] || kwargs[:file_path] || kwargs.values.first.to_s
48
- ::File.exist?(path) ? ::File.read(path, encoding: 'utf-8') : "File not found: #{path}"
49
- when 'file_write'
50
- path = kwargs[:path] || kwargs[:file_path]
51
- content = kwargs[:content] || kwargs[:contents]
52
- ::File.write(path, content)
53
- "Written #{content.to_s.bytesize} bytes to #{path}"
54
- when 'file_edit'
55
- path = kwargs[:path] || kwargs[:file_path]
56
- old_text = kwargs[:old_text] || kwargs[:search]
57
- new_text = kwargs[:new_text] || kwargs[:replace]
58
- content = ::File.read(path, encoding: 'utf-8')
59
- content.sub!(old_text, new_text)
60
- ::File.write(path, content)
61
- "Edited #{path}"
62
- when 'list_directory'
63
- path = kwargs[:path] || kwargs[:dir] || Dir.pwd
64
- Dir.entries(path).reject { |e| e.start_with?('.') }.sort.join("\n")
65
- when 'grep'
66
- pattern = kwargs[:pattern] || kwargs[:query] || kwargs.values.first.to_s
67
- path = kwargs[:path] || Dir.pwd
68
- output, = ::Open3.capture2e('grep', '-rn', '--include=*.rb', pattern, path)
69
- output.lines.first(50).join
70
- when 'glob'
71
- pattern = kwargs[:pattern] || kwargs.values.first.to_s
72
- Dir.glob(pattern).first(100).join("\n")
73
- when 'web_fetch'
74
- url = kwargs[:url] || kwargs.values.first.to_s
75
- raw_length = (kwargs[:maxLength] || kwargs[:max_length])&.to_i
76
- max_length = raw_length&.positive? ? raw_length : nil
77
- parsed = begin
78
- URI.parse(url)
79
- rescue StandardError
80
- nil
81
- end
82
- raise 'Invalid or non-HTTP URL' unless parsed.is_a?(URI::HTTP)
83
-
84
- addr = begin
85
- ::Resolv.getaddress(parsed.host)
86
- rescue StandardError
87
- nil
88
- end
89
- if addr
90
- ip = ::IPAddr.new(addr)
91
- raise 'SSRF: private/loopback targets are not permitted' if
92
- ip.loopback? || ip.private? || ip.link_local?
93
- end
94
- require 'legion/cli/chat/web_fetch'
95
- content = Legion::CLI::Chat::WebFetch.fetch(url)
96
- max_length ? content[0, max_length] : content
97
- when 'web_search'
98
- query = kwargs[:query] || kwargs.values.first.to_s
99
- raw_results = (kwargs[:max_results] || kwargs[:maxResults]).to_i
100
- max_results = raw_results.positive? ? [raw_results, 50].min : 5
101
- require 'legion/cli/chat/web_search'
102
- results = Legion::CLI::Chat::WebSearch.search(query, max_results: max_results,
103
- auto_fetch: false)
104
- results[:results].map { |r| "### #{r[:title]}\n#{r[:url]}\n#{r[:snippet]}" }.join("\n\n")
105
- else
106
- "Tool #{tool_ref} is not executable server-side. Use a legion_ prefixed tool instead."
107
- end
108
- rescue StandardError => e
109
- Legion::Logging.log_exception(e, payload_summary: "client tool #{tool_ref} failed", component_type: :api)
110
- "Tool error: #{e.message}"
31
+ define_method(:native_provider_stats_available?) do
32
+ defined?(Legion::LLM::Inventory) && Legion::LLM::Inventory.respond_to?(:providers)
33
+ end
34
+
35
+ 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
+ }
111
57
  end
58
+ elsif defined?(Legion::Extensions::Llm::Gateway::Runners::ProviderStats)
59
+ Legion::Extensions::Llm::Gateway::Runners::ProviderStats.health_report
60
+ else
61
+ []
62
+ end
63
+ end
64
+
65
+ define_method(:provider_circuit_summary) do
66
+ 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
+ circuits = report.map { |entry| entry[:circuit].to_s }
71
+ {
72
+ total: report.size,
73
+ closed: circuits.count('closed'),
74
+ open: circuits.count('open'),
75
+ half_open: circuits.count('half_open')
76
+ }
77
+ end
78
+
79
+ define_method(:provider_detail) do |provider|
80
+ 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)
112
90
  end
113
- klass.params(tschema) if tschema.is_a?(Hash) && tschema[:properties]
114
- klass
91
+ end
92
+
93
+ define_method(:offering_value) do |offering, key|
94
+ next unless offering.respond_to?(:[])
95
+
96
+ offering[key] || offering[key.to_s]
97
+ end
98
+
99
+ define_method(:build_client_tool_class) do |tname, tdesc, tschema|
100
+ require 'legion/llm/types/tool_definition' unless defined?(Legion::LLM::Types::ToolDefinition)
101
+
102
+ Legion::LLM::Types::ToolDefinition.build(
103
+ name: tname,
104
+ description: tdesc,
105
+ parameters: tschema || {},
106
+ source: { type: :client, executable: true }
107
+ )
115
108
  rescue StandardError => e
116
109
  Legion::Logging.log_exception(e, payload_summary: "build_client_tool_class failed for #{tname}", component_type: :api)
117
110
  nil
@@ -168,12 +161,12 @@ module Legion
168
161
  model = body[:model]
169
162
  provider = body[:provider]
170
163
 
171
- # Route through full Legion pipeline when gateway is available
172
- if gateway_available?
164
+ # Compatibility fallback for legacy gateway installs. Native legion-llm handles routing first.
165
+ if !Legion::LLM.respond_to?(:chat) && gateway_available?
173
166
  ingress_result = Legion::Ingress.run(
174
167
  payload: { message: message, model: model, provider: provider,
175
168
  request_id: request_id },
176
- runner_class: 'Legion::Extensions::LLM::Gateway::Runners::Inference',
169
+ runner_class: 'Legion::Extensions::Llm::Gateway::Runners::Inference',
177
170
  function: 'chat',
178
171
  source: 'api'
179
172
  )
@@ -307,8 +300,8 @@ module Legion
307
300
  streaming = body[:stream] == true && env['HTTP_ACCEPT']&.include?('text/event-stream')
308
301
 
309
302
  # Executor handles all registry tool injection — API only passes client-defined tools
310
- require 'legion/llm/pipeline/request' unless defined?(Legion::LLM::Pipeline::Request)
311
- require 'legion/llm/pipeline/executor' unless defined?(Legion::LLM::Pipeline::Executor)
303
+ require 'legion/llm/inference' unless defined?(Legion::LLM::Inference::Request) &&
304
+ defined?(Legion::LLM::Inference::Executor)
312
305
 
313
306
  principal = defined?(Legion::Identity::Request) && env['legion.principal']
314
307
  caller_ctx = if principal
@@ -318,7 +311,7 @@ module Legion
318
311
  end
319
312
 
320
313
  caller_metadata = body[:metadata].is_a?(Hash) ? body[:metadata] : {}
321
- req = Legion::LLM::Pipeline::Request.build(
314
+ req = Legion::LLM::Inference::Request.build(
322
315
  messages: messages,
323
316
  system: body[:system],
324
317
  routing: { provider: provider, model: model },
@@ -329,7 +322,7 @@ module Legion
329
322
  stream: streaming,
330
323
  cache: { strategy: :default, cacheable: true }
331
324
  )
332
- executor = Legion::LLM::Pipeline::Executor.new(req)
325
+ executor = Legion::LLM::Inference::Executor.new(req)
333
326
 
334
327
  if streaming
335
328
  content_type 'text/event-stream'
@@ -476,26 +469,17 @@ module Legion
476
469
  def self.register_providers(app)
477
470
  app.get '/api/llm/providers' do
478
471
  require_llm!
479
- unless gateway_available? && defined?(Legion::Extensions::LLM::Gateway::Runners::ProviderStats)
480
- halt 503, json_error('gateway_unavailable', 'LLM gateway is not loaded', status_code: 503)
481
- end
482
472
 
483
- stats = Legion::Extensions::LLM::Gateway::Runners::ProviderStats
484
473
  json_response({
485
- providers: stats.health_report,
486
- summary: stats.circuit_summary
474
+ providers: provider_health_report,
475
+ summary: provider_circuit_summary
487
476
  })
488
477
  end
489
478
 
490
479
  app.get '/api/llm/providers/:name' do
491
480
  require_llm!
492
- unless gateway_available? && defined?(Legion::Extensions::LLM::Gateway::Runners::ProviderStats)
493
- halt 503, json_error('gateway_unavailable', 'LLM gateway is not loaded', status_code: 503)
494
- end
495
481
 
496
- stats = Legion::Extensions::LLM::Gateway::Runners::ProviderStats
497
- detail = stats.provider_detail(provider: params[:name])
498
- json_response(detail)
482
+ json_response(provider_detail(params[:name]))
499
483
  end
500
484
  end
501
485
 
@@ -5,6 +5,13 @@ module Legion
5
5
  module Routes
6
6
  module Metering
7
7
  def self.registered(app)
8
+ register_helpers(app)
9
+ register_summary_route(app)
10
+ register_rollup_route(app)
11
+ register_by_model_route(app)
12
+ end
13
+
14
+ def self.register_helpers(app)
8
15
  app.helpers do
9
16
  define_method(:require_metering!) do
10
17
  return if defined?(Legion::Extensions::Metering::Runners::Metering)
@@ -17,18 +24,29 @@ module Legion
17
24
  Legion::Data.connected? && Legion::Data.connection.table_exists?(:metering_records)
18
25
  end
19
26
  end
27
+ end
20
28
 
29
+ def self.register_summary_route(app)
21
30
  app.get '/api/metering' do
22
31
  require_metering!
23
- return json_response({ records: [], total: 0, note: 'metering_records table not available' }) unless metering_table?
32
+ unless metering_table?
33
+ return json_response({ total_cost_usd: 0.0, total_tokens: 0, total_requests: 0,
34
+ note: 'metering_records table not available' })
35
+ end
24
36
 
25
- result = Legion::Extensions::Metering::Runners::Metering.routing_stats
26
- json_response(result)
37
+ ds = Legion::Data.connection[:metering_records]
38
+ json_response({
39
+ total_cost_usd: (ds.sum(:cost_usd) || 0).to_f,
40
+ total_tokens: (ds.sum(:total_tokens) || 0).to_i,
41
+ total_requests: ds.count
42
+ })
27
43
  rescue StandardError => e
28
44
  Legion::Logging.log_exception(e, payload_summary: 'GET /api/metering', component_type: :api)
29
- json_response({ records: [], total: 0, error: e.message })
45
+ json_response({ total_cost_usd: 0.0, total_tokens: 0, total_requests: 0, error: e.message })
30
46
  end
47
+ end
31
48
 
49
+ def self.register_rollup_route(app)
32
50
  app.get '/api/metering/rollup' do
33
51
  require_metering!
34
52
  return json_response({ rollup: [], period: 'hourly', note: 'metering_records table not available' }) unless metering_table?
@@ -41,7 +59,9 @@ module Legion
41
59
  Legion::Logging.log_exception(e, payload_summary: 'GET /api/metering/rollup', component_type: :api)
42
60
  json_response({ rollup: [], period: 'hourly', error: e.message })
43
61
  end
62
+ end
44
63
 
64
+ def self.register_by_model_route(app)
45
65
  app.get '/api/metering/by_model' do
46
66
  require_metering!
47
67
  return json_response({ models: [], note: 'metering_records table not available' }) unless metering_table?
@@ -60,6 +80,8 @@ module Legion
60
80
  json_response({ models: [], error: e.message })
61
81
  end
62
82
  end
83
+
84
+ private_class_method :register_helpers, :register_summary_route, :register_rollup_route, :register_by_model_route
63
85
  end
64
86
  end
65
87
  end
@@ -64,13 +64,16 @@ module Legion
64
64
  conv_id = body[:conversation_id] || "conv_#{SecureRandom.hex(8)}"
65
65
  begin
66
66
  Legion::LLM::ConversationStore.set_skill_state(conv_id, skill_key: skill_name, resume_at: 0)
67
- req = Legion::LLM::Pipeline::Request.build(
67
+ require 'legion/llm/inference' unless defined?(Legion::LLM::Inference::Request) &&
68
+ defined?(Legion::LLM::Inference::Executor)
69
+
70
+ req = Legion::LLM::Inference::Request.build(
68
71
  messages: [{ role: :user, content: body[:initial_message] || 'start skill' }],
69
72
  conversation_id: conv_id,
70
73
  metadata: (body[:metadata].is_a?(Hash) ? body[:metadata] : {}).merge(skill_invoke: true),
71
74
  stream: false
72
75
  )
73
- result = Legion::LLM::Pipeline::Executor.new(req).call
76
+ result = Legion::LLM::Inference::Executor.new(req).call
74
77
  json_response({ conversation_id: conv_id, content: result.message[:content],
75
78
  skill_name: skill_name })
76
79
  rescue StandardError => e
@@ -9,7 +9,7 @@ module Legion
9
9
  def self.registered(app)
10
10
  app.get '/api/tenants' do
11
11
  tenants = Legion::Tenants.list
12
- json_response(data: tenants)
12
+ json_response(tenants)
13
13
  end
14
14
 
15
15
  app.post '/api/tenants' do
@@ -24,13 +24,13 @@ module Legion
24
24
 
25
25
  app.get '/api/tenants/:tenant_id' do
26
26
  tenant = Legion::Tenants.find(params[:tenant_id])
27
- halt 404, json_response(error: 'not_found') unless tenant
28
- json_response(data: tenant)
27
+ halt 404, json_error('not_found', 'Tenant not found', status_code: 404) unless tenant
28
+ json_response(tenant)
29
29
  end
30
30
 
31
31
  app.post '/api/tenants/:tenant_id/suspend' do
32
32
  result = Legion::Tenants.suspend(tenant_id: params[:tenant_id])
33
- json_response(data: result)
33
+ json_response(result)
34
34
  end
35
35
 
36
36
  app.get '/api/tenants/:tenant_id/quota/:resource' do
@@ -38,7 +38,7 @@ module Legion
38
38
  tenant_id: params[:tenant_id],
39
39
  resource: params[:resource].to_sym
40
40
  )
41
- json_response(data: result)
41
+ json_response(result)
42
42
  end
43
43
  end
44
44
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../webhooks'
4
+
3
5
  module Legion
4
6
  class API < Sinatra::Base
5
7
  module Routes
data/lib/legion/api.rb CHANGED
@@ -67,6 +67,8 @@ require_relative 'api/graphql' if defined?(GraphQL)
67
67
 
68
68
  module Legion
69
69
  class API < Sinatra::Base
70
+ START_TIME = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
71
+
70
72
  helpers Legion::API::Helpers
71
73
  helpers Legion::API::Validators
72
74
 
@@ -105,7 +107,8 @@ module Legion
105
107
 
106
108
  # Health and readiness
107
109
  get '/api/health' do
108
- json_response({ status: 'ok', version: Legion::VERSION })
110
+ uptime_seconds = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - START_TIME).to_i
111
+ json_response({ status: 'ok', version: Legion::VERSION, uptime_seconds: uptime_seconds, uptime: uptime_seconds })
109
112
  end
110
113
 
111
114
  get '/api/ready' do
@@ -19,7 +19,7 @@ module Legion
19
19
  param :provider, type: 'string', desc: 'Specific provider to check (optional)', required: false
20
20
 
21
21
  def execute(provider: nil)
22
- return 'LLM gateway not available.' unless gateway_stats_available?
22
+ return 'LLM provider inventory not available.' unless provider_stats_available?
23
23
 
24
24
  if provider
25
25
  format_detail(provider.strip)
@@ -34,11 +34,11 @@ module Legion
34
34
  private
35
35
 
36
36
  def format_report
37
- report = stats_module.health_report
37
+ report = provider_health_report
38
38
  return "Router not available: #{report[:error]}" if report.is_a?(Hash) && report[:error]
39
39
  return 'No providers configured.' if report.empty?
40
40
 
41
- summary = stats_module.circuit_summary
41
+ summary = provider_circuit_summary(report)
42
42
  lines = ["Provider Health Report:\n"]
43
43
  lines << format_circuit_summary(summary) if summary.is_a?(Hash) && !summary[:error]
44
44
  lines << ''
@@ -47,8 +47,9 @@ module Legion
47
47
  end
48
48
 
49
49
  def format_detail(provider)
50
- entry = stats_module.provider_detail(provider: provider.to_sym)
50
+ entry = provider_detail(provider)
51
51
  return "Router not available: #{entry[:error]}" if entry[:error]
52
+ return "Provider not found: #{provider}" if entry.empty?
52
53
 
53
54
  lines = ["Provider: #{entry[:provider]}\n"]
54
55
  lines << " Circuit: #{entry[:circuit]}"
@@ -65,17 +66,83 @@ module Legion
65
66
 
66
67
  def format_entry(entry)
67
68
  icon = entry[:healthy] ? '+' : '!'
68
- format(' [%<icon>s] %<name>-15s circuit=%<circuit>s adj=%<adj>d',
69
+ suffix = +''
70
+ suffix << " offerings=#{entry[:offerings]}" if entry.key?(:offerings)
71
+ suffix << " models=#{entry[:models].length}" if entry[:models].respond_to?(:length)
72
+ format(' [%<icon>s] %<name>-15s circuit=%<circuit>s adj=%<adj>d%<suffix>s',
69
73
  icon: icon, name: entry[:provider],
70
- circuit: entry[:circuit], adj: entry[:adjustment])
74
+ circuit: entry[:circuit], adj: entry[:adjustment], suffix: suffix)
75
+ end
76
+
77
+ def provider_stats_available?
78
+ native_provider_stats_available? || gateway_stats_available?
79
+ end
80
+
81
+ def native_provider_stats_available?
82
+ defined?(Legion::LLM::Inventory) && Legion::LLM::Inventory.respond_to?(:providers)
83
+ end
84
+
85
+ def provider_health_report
86
+ return native_provider_health_report if native_provider_stats_available?
87
+
88
+ stats_module.health_report
89
+ end
90
+
91
+ def native_provider_health_report
92
+ groups = Legion::LLM::Inventory.providers
93
+ return [] unless groups.respond_to?(:map)
94
+
95
+ groups.map do |provider, offerings|
96
+ provider_offerings = Array(offerings)
97
+ health = provider_offerings.map { |offering| offering_value(offering, :health) }
98
+ .find { |entry| entry.is_a?(Hash) } || {}
99
+ circuit = health[:circuit_state] || health['circuit_state'] || 'unknown'
100
+ {
101
+ provider: provider.to_s,
102
+ circuit: circuit,
103
+ adjustment: health[:adjustment] || health['adjustment'] || 0,
104
+ healthy: circuit.to_s != 'open',
105
+ offerings: provider_offerings.size,
106
+ models: provider_offerings.map { |offering| offering_value(offering, :model) }.compact.uniq,
107
+ types: provider_offerings.map { |offering| offering_value(offering, :type) }.compact.uniq,
108
+ instances: provider_offerings.map do |offering|
109
+ offering_value(offering, :provider_instance) || offering_value(offering, :instance_id)
110
+ end.compact.uniq
111
+ }
112
+ end
113
+ end
114
+
115
+ def provider_circuit_summary(report)
116
+ return stats_module.circuit_summary unless native_provider_stats_available?
117
+
118
+ circuits = report.map { |entry| entry[:circuit].to_s }
119
+ {
120
+ total: report.size,
121
+ closed: circuits.count('closed'),
122
+ open: circuits.count('open'),
123
+ half_open: circuits.count('half_open')
124
+ }
125
+ end
126
+
127
+ def provider_detail(provider)
128
+ provider_name = provider.to_s
129
+ return stats_module.provider_detail(provider: provider_name.to_sym) unless native_provider_stats_available?
130
+
131
+ provider_health_report.find { |entry| entry[:provider] == provider_name } || {}
132
+ end
133
+
134
+ def offering_value(offering, key)
135
+ return unless offering.respond_to?(:[])
136
+
137
+ offering[key] || offering[key.to_s]
71
138
  end
72
139
 
73
140
  def gateway_stats_available?
74
- defined?(Legion::Extensions::LLM::Gateway::Runners::ProviderStats)
141
+ defined?(Legion::Extensions::Llm::Gateway::Runners::ProviderStats)
75
142
  end
76
143
 
77
144
  def stats_module
78
- Legion::Extensions::LLM::Gateway::Runners::ProviderStats
145
+ Legion::Extensions::Llm::Gateway::Runners::ProviderStats
79
146
  end
80
147
  end
81
148
  end
@@ -4,6 +4,11 @@ module Legion
4
4
  module CLI
5
5
  class Doctor
6
6
  class ExtensionsCheck
7
+ LOADER_CONFIG_KEYS = %w[
8
+ agentic ai auto_install blocked categories core gaia identity
9
+ parallel_pool_size reserved_prefixes reserved_words
10
+ ].freeze
11
+
7
12
  def name
8
13
  'Extensions'
9
14
  end
@@ -38,7 +43,11 @@ module Legion
38
43
  exts = Legion::Settings[:extensions]
39
44
  return [] unless exts.is_a?(Hash) || exts.is_a?(Array)
40
45
 
41
- exts.is_a?(Array) ? exts.map(&:to_s) : exts.keys.map(&:to_s)
46
+ if exts.is_a?(Array)
47
+ exts.map(&:to_s)
48
+ else
49
+ exts.keys.map(&:to_s).reject { |key| LOADER_CONFIG_KEYS.include?(key) }
50
+ end
42
51
  rescue StandardError => e
43
52
  Legion::Logging.warn("ExtensionsCheck#configured_extensions failed: #{e.message}") if defined?(Legion::Logging)
44
53
  []
@@ -234,10 +234,15 @@ module Legion
234
234
  out.header(" Pinging #{name} (#{model})...") unless options[:json]
235
235
  t0 = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
236
236
 
237
- response = RubyLLM.chat(model: model, provider: name).ask('Respond with only the word: pong')
237
+ response = Legion::LLM.ask_direct(
238
+ message: 'Respond with only the word: pong',
239
+ model: model,
240
+ provider: name,
241
+ caller: { source: 'cli', command: 'llm ping' }
242
+ )
238
243
  elapsed = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - t0) * 1000).round
239
244
 
240
- content = response.content.to_s.strip
245
+ content = response_content(response)
241
246
  success = content.downcase.include?('pong')
242
247
 
243
248
  if success
@@ -255,6 +260,16 @@ module Legion
255
260
  { provider: name, status: 'error', message: e.message, model: model, latency_ms: elapsed }
256
261
  end
257
262
 
263
+ def response_content(response)
264
+ if response.respond_to?(:content)
265
+ response.content.to_s.strip
266
+ elsif response.is_a?(Hash)
267
+ (response[:content] || response['content'] || response[:response] || response['response']).to_s.strip
268
+ else
269
+ response.to_s.strip
270
+ end
271
+ end
272
+
258
273
  def show_status(out, data)
259
274
  out.header('LLM Status')
260
275
  out.detail({
@@ -37,20 +37,27 @@ module Legion
37
37
  lex-agentic-imagination lex-agentic-inference lex-agentic-integration
38
38
  lex-agentic-language lex-agentic-learning lex-agentic-memory
39
39
  lex-agentic-self lex-agentic-social lex-apollo lex-audit lex-autofix
40
- lex-azure-ai lex-bedrock lex-claude lex-codegen lex-coldstart
40
+ lex-codegen lex-coldstart
41
41
  lex-conditioner lex-cost-scanner lex-dataset lex-detect
42
- lex-eval lex-exec lex-extinction lex-factory lex-finops lex-foundry
43
- lex-gemini lex-governance lex-kerberos lex-knowledge lex-llm-gateway
42
+ lex-eval lex-exec lex-extinction lex-factory lex-finops
43
+ lex-governance lex-kerberos lex-knowledge lex-llm
44
+ lex-llm-anthropic lex-llm-azure-foundry lex-llm-bedrock
45
+ lex-llm-gemini lex-llm-ledger lex-llm-mlx
46
+ lex-llm-ollama lex-llm-openai lex-llm-vertex lex-llm-vllm
44
47
  lex-metering lex-mesh lex-microsoft_teams lex-mind-growth lex-node
45
- lex-onboard lex-openai lex-pilot-infra-monitor
48
+ lex-onboard lex-pilot-infra-monitor
46
49
  lex-pilot-knowledge-assist lex-privatecore lex-prompt lex-react
47
50
  lex-swarm lex-swarm-github lex-synapse lex-telemetry lex-tick
48
- lex-transformer lex-xai
51
+ lex-transformer
49
52
  ]
50
53
  },
51
54
  llm: {
52
55
  description: 'LLM routing and provider integration (no cognitive stack)',
53
- gems: %w[legion-llm]
56
+ gems: %w[
57
+ legion-llm lex-llm lex-llm-anthropic lex-llm-azure-foundry
58
+ lex-llm-bedrock lex-llm-gemini lex-llm-ledger lex-llm-mlx
59
+ lex-llm-ollama lex-llm-openai lex-llm-vertex lex-llm-vllm
60
+ ]
54
61
  },
55
62
  channels: {
56
63
  description: 'Channel adapters for chat platforms',
@@ -14,7 +14,7 @@ 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: 'core', description: 'LLM gateway and routing' },
17
+ { name: 'lex-llm-gateway', category: 'legacy', description: 'Legacy LLM gateway compatibility' },
18
18
  { name: 'lex-llm-ledger', category: 'core', description: 'LLM cost and usage ledger' },
19
19
  { name: 'lex-log', category: 'core', description: 'Log shipping and aggregation' },
20
20
  { name: 'lex-metering', category: 'core', description: 'Resource metering and accounting' },
@@ -36,6 +36,16 @@ module Legion
36
36
  { name: 'lex-ollama', category: 'ai', description: 'Ollama local LLM provider integration' },
37
37
  { name: 'lex-openai', category: 'ai', description: 'OpenAI provider integration' },
38
38
  { name: 'lex-xai', category: 'ai', description: 'xAI Grok provider integration' },
39
+ { name: 'lex-llm', category: 'ai', description: 'Common LLM provider base and routing metadata' },
40
+ { name: 'lex-llm-anthropic', category: 'ai', description: 'Anthropic LLM provider integration' },
41
+ { name: 'lex-llm-azure-foundry', category: 'ai', description: 'Azure AI Foundry hosted LLM provider integration' },
42
+ { name: 'lex-llm-bedrock', category: 'ai', description: 'AWS Bedrock hosted LLM provider integration' },
43
+ { name: 'lex-llm-gemini', category: 'ai', description: 'Google Gemini LLM provider integration' },
44
+ { name: 'lex-llm-mlx', category: 'ai', description: 'Apple MLX local LLM provider integration' },
45
+ { name: 'lex-llm-ollama', category: 'ai', description: 'Ollama LLM provider integration' },
46
+ { name: 'lex-llm-openai', category: 'ai', description: 'OpenAI LLM provider integration' },
47
+ { name: 'lex-llm-vertex', category: 'ai', description: 'Google Vertex AI hosted LLM provider integration' },
48
+ { name: 'lex-llm-vllm', category: 'ai', description: 'vLLM OpenAI-compatible provider integration' },
39
49
  # agentic
40
50
  { name: 'lex-agentic-affect', category: 'agentic', description: 'Affective state modeling' },
41
51
  { name: 'lex-agentic-attention', category: 'agentic', description: 'Attentional focus and salience' },
@@ -85,6 +85,8 @@ module Legion
85
85
  pending.each do |lex_name, new_state|
86
86
  existing = model.where(lex_name: lex_name).first
87
87
  if existing
88
+ next if existing.respond_to?(:state) && existing.state == new_state.to_s
89
+
88
90
  existing.update(state: new_state.to_s, updated_at: now)
89
91
  else
90
92
  model.insert(lex_name: lex_name, state: new_state.to_s, created_at: now, updated_at: now)
@@ -72,7 +72,7 @@ module Legion
72
72
  @messages = {}
73
73
  build_settings
74
74
  build_transport
75
- if Legion::Settings[:data][:connected] && data_required?
75
+ if Legion::Settings[:data][:connected] && (data_required? || data_migrations_available?)
76
76
  Legion::Logging.debug "[Core] building data for #{name}" if defined?(Legion::Logging)
77
77
  build_data
78
78
  end
@@ -91,6 +91,13 @@ module Legion
91
91
  false
92
92
  end
93
93
 
94
+ def data_migrations_available?
95
+ Dir[File.join(extension_path.to_s, 'data', 'migrations', '*.rb')].any?
96
+ rescue StandardError => e
97
+ log.debug "[Core] data migration discovery failed for #{name}: #{e.message}" if defined?(log)
98
+ false
99
+ end
100
+
94
101
  def transport_required?
95
102
  true
96
103
  end
@@ -6,8 +6,13 @@ module Legion
6
6
  module Segments
7
7
  module_function
8
8
 
9
+ COMPOUND_SUFFIXES = {
10
+ %w[llm azure foundry] => %w[llm azure_foundry]
11
+ }.freeze
12
+
9
13
  def derive_segments(gem_name)
10
- gem_name.delete_prefix('lex-').split('-')
14
+ segments = gem_name.delete_prefix('lex-').split('-')
15
+ COMPOUND_SUFFIXES.fetch(segments, segments)
11
16
  end
12
17
 
13
18
  def derive_namespace(gem_name)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/logging'
3
4
  require 'legion/extensions/core'
4
5
  require 'legion/extensions/catalog'
5
6
  require 'legion/extensions/handle_registry'
@@ -29,11 +30,19 @@ module Legion
29
30
  find_extensions
30
31
 
31
32
  phases = group_by_phase
33
+ llm_base_entries, llm_extension_entries = extract_llm_extension_entries!(phases)
34
+ llm_phases_loaded = false
32
35
  phases.each do |phase_num, entries|
36
+ unless llm_phases_loaded || before_llm_extension_phase?(phase_num)
37
+ load_llm_extension_phases(llm_base_entries, llm_extension_entries)
38
+ llm_phases_loaded = true
39
+ end
40
+
33
41
  @pending_actors = Concurrent::Array.new
34
42
  load_phase_extensions(phase_num, entries)
35
43
  hook_phase_actors(phase_num)
36
44
  end
45
+ load_llm_extension_phases(llm_base_entries, llm_extension_entries) unless llm_phases_loaded
37
46
 
38
47
  transition_loaded_extensions(:running)
39
48
  Catalog.flush_persisted_transitions
@@ -350,6 +359,55 @@ module Legion
350
359
  end.sort_by(&:first)
351
360
  end
352
361
 
362
+ def load_llm_extension_phases(base_entries, extension_entries)
363
+ run_extension_phase(:llm_base, base_entries)
364
+
365
+ Legion::Logging.warn 'lex-llm-* extensions discovered without lex-llm; provider loading may fail' if base_entries.empty? && extension_entries.any?
366
+
367
+ run_extension_phase(:llm_extensions, extension_entries.sort_by { |entry| entry[:gem_name] })
368
+ end
369
+
370
+ def before_llm_extension_phase?(phase_num)
371
+ phase_num.is_a?(Numeric) && phase_num < 1
372
+ end
373
+
374
+ def run_extension_phase(phase_num, entries)
375
+ return if entries.empty?
376
+
377
+ @pending_actors = Concurrent::Array.new
378
+ load_phase_extensions(phase_num, entries)
379
+ hook_phase_actors(phase_num)
380
+ end
381
+
382
+ def extract_llm_extension_entries!(phases)
383
+ base_entries = []
384
+ extension_entries = []
385
+
386
+ phases.each do |(_, entries)|
387
+ entries.delete_if do |entry|
388
+ next false unless llm_extension_entry?(entry)
389
+
390
+ if llm_base_extension_entry?(entry)
391
+ base_entries << entry
392
+ else
393
+ extension_entries << entry
394
+ end
395
+ true
396
+ end
397
+ end
398
+ phases.reject! { |_, entries| entries.empty? }
399
+
400
+ [base_entries, extension_entries]
401
+ end
402
+
403
+ def llm_extension_entry?(entry)
404
+ llm_base_extension_entry?(entry) || entry[:gem_name].start_with?('lex-llm-')
405
+ end
406
+
407
+ def llm_base_extension_entry?(entry)
408
+ entry[:gem_name] == 'lex-llm'
409
+ end
410
+
353
411
  def group_pending_actors
354
412
  groups = { once: [], poll: [], every: [], loop: [], subscription: [] }
355
413
  @pending_actors.each do |actor|
@@ -677,12 +735,32 @@ module Legion
677
735
  end
678
736
 
679
737
  def core_extension_names
680
- %w[codegen conditioner exec health lex llm-gateway log metering node ping scheduler tasker task_pruner telemetry
738
+ %w[codegen conditioner exec health lex log metering node ping scheduler tasker task_pruner telemetry
681
739
  transformer].freeze
682
740
  end
683
741
 
684
742
  def ai_extension_names
685
- %w[claude gemini openai].freeze
743
+ native_llm_extension_names
744
+ end
745
+
746
+ def native_llm_extension_names
747
+ %w[
748
+ llm
749
+ llm-anthropic
750
+ llm-azure-foundry
751
+ llm-bedrock
752
+ llm-gemini
753
+ llm-ledger
754
+ llm-mlx
755
+ llm-ollama
756
+ llm-openai
757
+ llm-vertex
758
+ llm-vllm
759
+ ].freeze
760
+ end
761
+
762
+ def legacy_ai_extension_names
763
+ %w[azure-ai bedrock claude foundry gemini llm-gateway ollama openai xai].freeze
686
764
  end
687
765
 
688
766
  def service_extension_names
@@ -700,7 +778,10 @@ module Legion
700
778
  end
701
779
 
702
780
  def agentic_extension_names
703
- known_gem_names = (core_extension_names + service_extension_names + other_extension_names + ai_extension_names).map { |n| "lex-#{n}" }
781
+ known_gem_names = (
782
+ core_extension_names + service_extension_names + other_extension_names +
783
+ ai_extension_names + legacy_ai_extension_names
784
+ ).map { |n| "lex-#{n}" }
704
785
  Array(@extensions).reject { |entry| known_gem_names.include?(entry[:gem_name]) }.map { |entry| entry[:gem_name] }
705
786
  end
706
787
 
@@ -7,7 +7,7 @@ module Legion
7
7
  require_airb_approval: false,
8
8
  auto_approve_risk_tiers: %w[low],
9
9
  review_required_risk_tiers: %w[medium high critical],
10
- naming_convention: 'lex-[a-z][a-z0-9_]*',
10
+ naming_convention: 'lex-[a-z][a-z0-9_]*(?:-[a-z][a-z0-9_]*)*',
11
11
  deprecation_notice_days: 30
12
12
  }.freeze
13
13
 
@@ -39,10 +39,11 @@ module Legion
39
39
  def naming_convention(name:, **_)
40
40
  return { check: :naming_convention, status: :skip, details: 'no name' } unless name
41
41
 
42
- if name.match?(/\Alex-[a-z][a-z0-9_]*\z/)
42
+ if name.match?(/\Alex-[a-z][a-z0-9_]*(?:-[a-z][a-z0-9_]*)*\z/)
43
43
  { check: :naming_convention, status: :pass, details: name }
44
44
  else
45
- { check: :naming_convention, status: :fail, details: "#{name} does not match lex-[a-z][a-z0-9_]*" }
45
+ { check: :naming_convention, status: :fail,
46
+ details: "#{name} does not match lex-[a-z][a-z0-9_]*(?:-[a-z][a-z0-9_]*)*" }
46
47
  end
47
48
  end
48
49
 
@@ -451,6 +451,7 @@ module Legion
451
451
  log.info 'Setting up Legion::LLM'
452
452
  require 'legion/llm'
453
453
  Legion::Settings.merge_settings('llm', Legion::LLM::Settings.default)
454
+ preload_llm_providers
454
455
  Legion::LLM.start
455
456
  log.info 'Legion::LLM started'
456
457
  rescue LoadError => e
@@ -460,6 +461,40 @@ module Legion
460
461
  handle_exception(e, level: :warn, operation: 'service.setup_llm')
461
462
  end
462
463
 
464
+ def preload_llm_providers
465
+ require 'legion/extensions/llm'
466
+ gems = llm_provider_gems
467
+ gems.each do |gem_name, require_path|
468
+ require require_path
469
+ log.debug "[service] loaded #{gem_name}"
470
+ rescue LoadError => e
471
+ log.warn "[service] #{gem_name} failed to load: #{e.message}"
472
+ rescue StandardError => e
473
+ handle_exception(e, level: :warn, operation: "service.preload_llm_provider.#{gem_name}")
474
+ end
475
+ registered = defined?(Legion::LLM::Call::Registry) ? Legion::LLM::Call::Registry.all_instances : []
476
+ log.info "[service] llm providers preloaded gems=#{gems.size} instances=#{registered.size}"
477
+ rescue LoadError => e
478
+ handle_exception(e, level: :warn, operation: 'service.preload_llm_providers', availability: 'lex-llm not installed')
479
+ rescue StandardError => e
480
+ handle_exception(e, level: :warn, operation: 'service.preload_llm_providers')
481
+ end
482
+
483
+ def llm_provider_gems
484
+ specs = if defined?(Bundler)
485
+ Bundler.load.specs.map { |s| s.respond_to?(:name) ? s.name : s[:name].to_s }
486
+ else
487
+ Gem::Specification.latest_specs.map(&:name)
488
+ end
489
+ specs.filter_map do |name|
490
+ next unless name.start_with?('lex-llm-') && name != 'lex-llm-ledger'
491
+
492
+ provider_name = name.delete_prefix('lex-llm-').tr('-', '_')
493
+ require_path = "legion/extensions/llm/#{provider_name}"
494
+ [name, require_path]
495
+ end
496
+ end
497
+
463
498
  def setup_gaia
464
499
  log.info 'Setting up Legion::Gaia'
465
500
  require 'legion/gaia'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.9.2'
4
+ VERSION = '1.9.18'
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.2
4
+ version: 1.9.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -351,28 +351,28 @@ dependencies:
351
351
  requirements:
352
352
  - - ">="
353
353
  - !ruby/object:Gem::Version
354
- version: 0.5.8
354
+ version: 0.8.47
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.5.8
361
+ version: 0.8.47
362
362
  - !ruby/object:Gem::Dependency
363
363
  name: legion-tty
364
364
  requirement: !ruby/object:Gem::Requirement
365
365
  requirements:
366
366
  - - ">="
367
367
  - !ruby/object:Gem::Version
368
- version: 0.4.35
368
+ version: 0.5.4
369
369
  type: :runtime
370
370
  prerelease: false
371
371
  version_requirements: !ruby/object:Gem::Requirement
372
372
  requirements:
373
373
  - - ">="
374
374
  - !ruby/object:Gem::Version
375
- version: 0.4.35
375
+ version: 0.5.4
376
376
  - !ruby/object:Gem::Dependency
377
377
  name: lex-node
378
378
  requirement: !ruby/object:Gem::Requirement