legionio 1.7.4 → 1.7.8
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/.rubocop.yml +5 -0
- data/CHANGELOG.md +39 -2
- data/lib/legion/api/default_settings.rb +48 -0
- data/lib/legion/api/knowledge.rb +18 -3
- data/lib/legion/api/llm.rb +244 -50
- data/lib/legion/api/mesh.rb +37 -5
- data/lib/legion/api/metering.rb +66 -0
- data/lib/legion/api/prompts.rb +8 -4
- data/lib/legion/api.rb +7 -0
- data/lib/legion/cli/check_command.rb +4 -2
- data/lib/legion/context.rb +18 -0
- data/lib/legion/extensions/actors/base.rb +6 -3
- data/lib/legion/extensions/actors/every.rb +4 -3
- data/lib/legion/extensions/actors/loop.rb +1 -1
- data/lib/legion/extensions/actors/poll.rb +4 -4
- data/lib/legion/extensions/actors/subscription.rb +12 -9
- data/lib/legion/extensions/core.rb +1 -1
- data/lib/legion/extensions/helpers/logger.rb +3 -62
- data/lib/legion/extensions/helpers/task.rb +4 -2
- data/lib/legion/extensions/transport.rb +3 -2
- data/lib/legion/ingress.rb +12 -8
- data/lib/legion/runner.rb +34 -19
- data/lib/legion/service.rb +22 -10
- data/lib/legion/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6741515579e2c24f64b301136e023fb6ddd73b27f41779caace7df02cd15f667
|
|
4
|
+
data.tar.gz: c90f66af48bd6705b58c6c31344dd0a9486b8519cc48b98f3b1965d2571527db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5409680125318327c866c9f59dde8ce2b7b5a33cea5082bf2d654c763184c25e4658a24ef43d4b292f12546b314692da1b5d71ca6e25ca55e6095ea376d66d5f
|
|
7
|
+
data.tar.gz: 68a9358035224f600dfc673272d2cc82ecdfc7236a7225a2974721ad61b081571dbb190c77d25c8442bc3ff13c7cea44274b8331fd2942213a714167c56017c4
|
data/.rubocop.yml
CHANGED
|
@@ -18,6 +18,7 @@ Metrics/MethodLength:
|
|
|
18
18
|
Exclude:
|
|
19
19
|
- 'lib/legion/cli/chat_command.rb'
|
|
20
20
|
- 'lib/legion/api/openapi.rb'
|
|
21
|
+
- 'lib/legion/api/llm.rb'
|
|
21
22
|
- 'lib/legion/digital_worker/lifecycle.rb'
|
|
22
23
|
|
|
23
24
|
Metrics/ClassLength:
|
|
@@ -53,6 +54,7 @@ Metrics/BlockLength:
|
|
|
53
54
|
- 'lib/legion/cli/prompt_command.rb'
|
|
54
55
|
- 'lib/legion/cli/image_command.rb'
|
|
55
56
|
- 'lib/legion/cli/notebook_command.rb'
|
|
57
|
+
- 'lib/legion/api/llm.rb'
|
|
56
58
|
- 'lib/legion/api/acp.rb'
|
|
57
59
|
- 'lib/legion/api/auth_saml.rb'
|
|
58
60
|
- 'lib/legion/cli/failover_command.rb'
|
|
@@ -66,6 +68,7 @@ Metrics/AbcSize:
|
|
|
66
68
|
Max: 60
|
|
67
69
|
Exclude:
|
|
68
70
|
- 'lib/legion/cli/chat_command.rb'
|
|
71
|
+
- 'lib/legion/api/llm.rb'
|
|
69
72
|
- 'lib/legion/digital_worker/lifecycle.rb'
|
|
70
73
|
|
|
71
74
|
Metrics/CyclomaticComplexity:
|
|
@@ -73,12 +76,14 @@ Metrics/CyclomaticComplexity:
|
|
|
73
76
|
Exclude:
|
|
74
77
|
- 'lib/legion/cli/chat_command.rb'
|
|
75
78
|
- 'lib/legion/api/auth_human.rb'
|
|
79
|
+
- 'lib/legion/api/llm.rb'
|
|
76
80
|
- 'lib/legion/digital_worker/lifecycle.rb'
|
|
77
81
|
|
|
78
82
|
Metrics/PerceivedComplexity:
|
|
79
83
|
Max: 17
|
|
80
84
|
Exclude:
|
|
81
85
|
- 'lib/legion/api/auth_human.rb'
|
|
86
|
+
- 'lib/legion/api/llm.rb'
|
|
82
87
|
- 'lib/legion/digital_worker/lifecycle.rb'
|
|
83
88
|
|
|
84
89
|
Style/Documentation:
|
data/CHANGELOG.md
CHANGED
|
@@ -2,15 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
## [1.7.
|
|
5
|
+
## [1.7.8] - 2026-04-01
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `Legion::API::Settings` module with registered defaults via `merge_settings('api', ...)`, matching the pattern used by all other LegionIO gems
|
|
9
|
+
- Puma `persistent_timeout` (20s) and `first_data_timeout` (30s) now configurable via `Settings[:api][:puma]`
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- Removed all inline `||` and `.fetch(..., default)` fallbacks for API settings in `service.rb` and `check_command.rb` — defaults now guaranteed by `merge_settings`
|
|
13
|
+
|
|
14
|
+
## [1.7.7] - 2026-04-01
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- Integrated legion-logging 1.4.3 Helper refactor: all log output now uses structured segment tagging, colored exception output, and thread-local task context
|
|
18
|
+
- Slimmed `Extensions::Helpers::Logger` to thin override; `derive_component_type`, `lex_gem_name`, `gem_spec_for_lex`, `log_lex_name` now live in legion-logging gem
|
|
19
|
+
- Added `handle_runner_exception` for runner-specific exception handling (TaskLog publish + HandledTask raise)
|
|
20
|
+
- Added `Legion::Context.with_task_context` and `.current_task_context` for thread-local task propagation
|
|
21
|
+
- Wrapped all 5 dispatch paths (Runner.run, Subscription#dispatch_runner, Base#runner, Ingress local/remote) with context propagation
|
|
22
|
+
- Migrated 13 `log.log_exception` call sites to `handle_exception` across actors, core, transport, and task helpers
|
|
23
|
+
|
|
24
|
+
## [1.7.6] - 2026-04-01
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- `POST /api/llm/inference` now routes through `Legion::LLM::Pipeline::Executor` instead of raw `Legion::LLM.chat` session, enabling the full 18-step pipeline (RBAC, RAG context, MCP discovery, metering, audit, knowledge capture)
|
|
28
|
+
- GAIA bridge added: user prompt from `/api/llm/inference` is pushed as an `InputFrame` to the GAIA sensory buffer when GAIA is started
|
|
29
|
+
- SSE streaming support added: `stream: true` + `Accept: text/event-stream` returns `text/event-stream` with `text-delta`, `tool-call`, `enrichment`, and `done` events
|
|
30
|
+
- `build_client_tool` renamed to `build_client_tool_class`; now returns a `Class` (not an instance) so the pipeline can inject it correctly via `tool.is_a?(Class)` check
|
|
31
|
+
- Typed error mapping added: `AuthError` → 401, `RateLimitError` → 429, `TokenBudgetExceeded` → 413, `ProviderDown`/`ProviderError` → 502
|
|
32
|
+
|
|
33
|
+
## [1.7.5] - 2026-04-01
|
|
6
34
|
|
|
7
35
|
### Added
|
|
8
36
|
- `POST /api/reload` endpoint to trigger daemon reload from CLI mode command
|
|
9
|
-
- `GET /api/mesh/status` and `GET /api/mesh/peers` endpoints
|
|
37
|
+
- `GET /api/mesh/status` and `GET /api/mesh/peers` endpoints with 10s cache
|
|
38
|
+
- `GET /api/metering`, `/api/metering/rollup`, `/api/metering/by_model` endpoints wired to lex-metering
|
|
39
|
+
- `GET /api/webhooks` and `GET /api/tenants` routes registered (were defined but never mounted)
|
|
40
|
+
- Knowledge monitor v2/v3 route aliases for Interlink compatibility
|
|
41
|
+
- Server-side MCP tool injection into `/api/llm/inference` via `McpToolAdapter` (64 tools)
|
|
42
|
+
- Deferred tool loading: 18 always-loaded tools, ~46 on-demand (cuts inference from 24s to 6-9s)
|
|
43
|
+
- Client-side tools (`sh`, `file_read`, `list_directory`, etc.) now execute server-side in the inference endpoint
|
|
10
44
|
|
|
11
45
|
### Fixed
|
|
12
46
|
- Knowledge ingest API route calls `ingest_content` instead of `ingest_file` when `content` body param is present
|
|
13
47
|
- Catalog API queries `extensions.name` instead of non-existent `gem_name` column
|
|
48
|
+
- Inference endpoint tool declarations use `RubyLLM::Tool` subclass with proper `name` instance method
|
|
49
|
+
- Prompts API guards against missing `prompts` table (returns 503 instead of 500)
|
|
50
|
+
- All API rescue blocks use `Legion::Logging.log_exception` instead of swallowing errors
|
|
14
51
|
|
|
15
52
|
## [1.7.0] - 2026-03-31
|
|
16
53
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sinatra/base'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
class API < Sinatra::Base
|
|
7
|
+
module Settings
|
|
8
|
+
def self.default
|
|
9
|
+
{
|
|
10
|
+
enabled: true,
|
|
11
|
+
port: 4567,
|
|
12
|
+
bind: '0.0.0.0',
|
|
13
|
+
puma: puma_defaults,
|
|
14
|
+
bind_retries: 3,
|
|
15
|
+
bind_retry_wait: 2,
|
|
16
|
+
tls: tls_defaults
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.puma_defaults
|
|
21
|
+
{
|
|
22
|
+
min_threads: 10,
|
|
23
|
+
max_threads: 16,
|
|
24
|
+
persistent_timeout: 20,
|
|
25
|
+
first_data_timeout: 30
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.tls_defaults
|
|
30
|
+
{
|
|
31
|
+
enabled: false
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
Legion::Settings.merge_settings('api', Legion::API::Settings.default) if Legion.const_defined?('Settings', false)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
if Legion.const_defined?('Logging', false) && Legion::Logging.respond_to?(:fatal)
|
|
42
|
+
Legion::Logging.fatal(e.message)
|
|
43
|
+
Legion::Logging.fatal(e.backtrace)
|
|
44
|
+
else
|
|
45
|
+
puts e.message
|
|
46
|
+
puts e.backtrace
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/legion/api/knowledge.rb
CHANGED
|
@@ -103,13 +103,13 @@ module Legion
|
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
def self.register_monitor_routes(app)
|
|
106
|
-
|
|
106
|
+
monitor_list = lambda do
|
|
107
107
|
require_knowledge_monitor!
|
|
108
108
|
monitors = Legion::Extensions::Knowledge::Runners::Monitor.list_monitors
|
|
109
109
|
json_response(monitors)
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
-
|
|
112
|
+
monitor_add = lambda do
|
|
113
113
|
require_knowledge_monitor!
|
|
114
114
|
body = parse_request_body
|
|
115
115
|
result = Legion::Extensions::Knowledge::Runners::Monitor.add_monitor(
|
|
@@ -120,7 +120,7 @@ module Legion
|
|
|
120
120
|
json_response(result, status_code: 201)
|
|
121
121
|
end
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
monitor_remove = lambda do
|
|
124
124
|
require_knowledge_monitor!
|
|
125
125
|
body = parse_request_body
|
|
126
126
|
result = Legion::Extensions::Knowledge::Runners::Monitor.remove_monitor(
|
|
@@ -129,6 +129,21 @@ module Legion
|
|
|
129
129
|
json_response(result)
|
|
130
130
|
end
|
|
131
131
|
|
|
132
|
+
# Primary routes
|
|
133
|
+
app.get('/api/knowledge/monitors', &monitor_list)
|
|
134
|
+
app.post('/api/knowledge/monitors', &monitor_add)
|
|
135
|
+
app.delete('/api/knowledge/monitors', &monitor_remove)
|
|
136
|
+
|
|
137
|
+
# Interlink v3 aliases
|
|
138
|
+
app.get('/api/extensions/knowledge/runners/monitors/list', &monitor_list)
|
|
139
|
+
app.post('/api/extensions/knowledge/runners/monitors/create', &monitor_add)
|
|
140
|
+
app.delete('/api/extensions/knowledge/runners/monitors/delete', &monitor_remove)
|
|
141
|
+
|
|
142
|
+
# Interlink v2 aliases
|
|
143
|
+
app.get('/api/lex/knowledge/monitors', &monitor_list)
|
|
144
|
+
app.post('/api/lex/knowledge/monitors', &monitor_add)
|
|
145
|
+
app.delete('/api/lex/knowledge/monitors', &monitor_remove)
|
|
146
|
+
|
|
132
147
|
app.get '/api/knowledge/monitors/status' do
|
|
133
148
|
require_knowledge_monitor!
|
|
134
149
|
result = Legion::Extensions::Knowledge::Runners::Monitor.monitor_status
|
data/lib/legion/api/llm.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'securerandom'
|
|
4
|
+
require 'open3'
|
|
4
5
|
|
|
5
6
|
begin
|
|
6
7
|
require 'legion/cli/chat/tools/search_traces'
|
|
@@ -8,9 +9,30 @@ begin
|
|
|
8
9
|
Legion::LLM::ToolRegistry.register(Legion::CLI::Chat::Tools::SearchTraces)
|
|
9
10
|
end
|
|
10
11
|
rescue LoadError => e
|
|
11
|
-
Legion::Logging.
|
|
12
|
+
Legion::Logging.log_exception(e, payload_summary: 'SearchTraces not available for API', component_type: :api) if defined?(Legion::Logging)
|
|
12
13
|
end
|
|
13
14
|
|
|
15
|
+
ALWAYS_LOADED_TOOLS = %w[
|
|
16
|
+
legion_do
|
|
17
|
+
legion_get_status
|
|
18
|
+
legion_run_task
|
|
19
|
+
legion_describe_runner
|
|
20
|
+
legion_list_extensions
|
|
21
|
+
legion_get_extension
|
|
22
|
+
legion_list_tasks
|
|
23
|
+
legion_get_task
|
|
24
|
+
legion_get_task_logs
|
|
25
|
+
legion_query_knowledge
|
|
26
|
+
legion_knowledge_health
|
|
27
|
+
legion_knowledge_context
|
|
28
|
+
legion_list_workers
|
|
29
|
+
legion_show_worker
|
|
30
|
+
legion_mesh_status
|
|
31
|
+
legion_list_peers
|
|
32
|
+
legion_tools
|
|
33
|
+
legion_search_sessions
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
14
36
|
module Legion
|
|
15
37
|
class API < Sinatra::Base
|
|
16
38
|
module Routes
|
|
@@ -36,16 +58,123 @@ module Legion
|
|
|
36
58
|
define_method(:gateway_available?) do
|
|
37
59
|
defined?(Legion::Extensions::LLM::Gateway::Runners::Inference)
|
|
38
60
|
end
|
|
61
|
+
|
|
62
|
+
define_method(:cached_mcp_tools) do
|
|
63
|
+
@cached_mcp_tools ||= begin
|
|
64
|
+
all = []
|
|
65
|
+
begin
|
|
66
|
+
require 'legion/mcp' unless defined?(Legion::MCP) && Legion::MCP.respond_to?(:server)
|
|
67
|
+
rescue LoadError => e
|
|
68
|
+
Legion::Logging.log_exception(e, payload_summary: 'cached_mcp_tools: failed to require legion/mcp', component_type: :api)
|
|
69
|
+
end
|
|
70
|
+
if defined?(Legion::MCP::Server) && Legion::MCP::Server.respond_to?(:tool_registry)
|
|
71
|
+
require 'legion/llm/pipeline/mcp_tool_adapter' unless defined?(Legion::LLM::Pipeline::McpToolAdapter)
|
|
72
|
+
Legion::MCP::Server.tool_registry.each do |tc|
|
|
73
|
+
all << Legion::LLM::Pipeline::McpToolAdapter.new(tc)
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
Legion::Logging.log_exception(e, payload_summary: "cached_mcp_tools: failed to adapt #{tc}", component_type: :api)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
{
|
|
79
|
+
always: all.select { |t| ALWAYS_LOADED_TOOLS.include?(t.name) }.freeze,
|
|
80
|
+
deferred: all.reject { |t| ALWAYS_LOADED_TOOLS.include?(t.name) }.freeze,
|
|
81
|
+
all: all.freeze
|
|
82
|
+
}.freeze
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
define_method(:inject_mcp_tools) do |session, requested_tools: []|
|
|
87
|
+
cache = cached_mcp_tools
|
|
88
|
+
cache[:always].each { |t| session.with_tool(t) }
|
|
89
|
+
|
|
90
|
+
return if requested_tools.empty?
|
|
91
|
+
|
|
92
|
+
requested = requested_tools.map { |n| n.to_s.tr('.', '_') }
|
|
93
|
+
cache[:deferred].each do |t|
|
|
94
|
+
session.with_tool(t) if requested.include?(t.name)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
define_method(:build_client_tool_class) do |tname, tdesc, tschema|
|
|
99
|
+
klass = Class.new(RubyLLM::Tool) do
|
|
100
|
+
description tdesc
|
|
101
|
+
define_method(:name) { tname }
|
|
102
|
+
tool_ref = tname
|
|
103
|
+
define_method(:execute) do |**kwargs|
|
|
104
|
+
case tool_ref
|
|
105
|
+
when 'sh'
|
|
106
|
+
cmd = kwargs[:command] || kwargs[:cmd] || kwargs.values.first.to_s
|
|
107
|
+
output, status = ::Open3.capture2e(cmd, chdir: Dir.pwd)
|
|
108
|
+
"exit=#{status.exitstatus}\n#{output}"
|
|
109
|
+
when 'file_read'
|
|
110
|
+
path = kwargs[:path] || kwargs[:file_path] || kwargs.values.first.to_s
|
|
111
|
+
::File.exist?(path) ? ::File.read(path, encoding: 'utf-8') : "File not found: #{path}"
|
|
112
|
+
when 'file_write'
|
|
113
|
+
path = kwargs[:path] || kwargs[:file_path]
|
|
114
|
+
content = kwargs[:content] || kwargs[:contents]
|
|
115
|
+
::File.write(path, content)
|
|
116
|
+
"Written #{content.to_s.bytesize} bytes to #{path}"
|
|
117
|
+
when 'file_edit'
|
|
118
|
+
path = kwargs[:path] || kwargs[:file_path]
|
|
119
|
+
old_text = kwargs[:old_text] || kwargs[:search]
|
|
120
|
+
new_text = kwargs[:new_text] || kwargs[:replace]
|
|
121
|
+
content = ::File.read(path, encoding: 'utf-8')
|
|
122
|
+
content.sub!(old_text, new_text)
|
|
123
|
+
::File.write(path, content)
|
|
124
|
+
"Edited #{path}"
|
|
125
|
+
when 'list_directory'
|
|
126
|
+
path = kwargs[:path] || kwargs[:dir] || Dir.pwd
|
|
127
|
+
Dir.entries(path).reject { |e| e.start_with?('.') }.sort.join("\n")
|
|
128
|
+
when 'grep'
|
|
129
|
+
pattern = kwargs[:pattern] || kwargs[:query] || kwargs.values.first.to_s
|
|
130
|
+
path = kwargs[:path] || Dir.pwd
|
|
131
|
+
output, = ::Open3.capture2e('grep', '-rn', '--include=*.rb', pattern, path)
|
|
132
|
+
output.lines.first(50).join
|
|
133
|
+
when 'glob'
|
|
134
|
+
pattern = kwargs[:pattern] || kwargs.values.first.to_s
|
|
135
|
+
Dir.glob(pattern).first(100).join("\n")
|
|
136
|
+
when 'web_fetch'
|
|
137
|
+
url = kwargs[:url] || kwargs.values.first.to_s
|
|
138
|
+
require 'net/http'
|
|
139
|
+
uri = URI(url)
|
|
140
|
+
Net::HTTP.get(uri)
|
|
141
|
+
else
|
|
142
|
+
"Tool #{tool_ref} is not executable server-side. Use a legion_ prefixed tool instead."
|
|
143
|
+
end
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
Legion::Logging.log_exception(e, payload_summary: "client tool #{tool_ref} failed", component_type: :api)
|
|
146
|
+
"Tool error: #{e.message}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
klass.params(tschema) if tschema.is_a?(Hash) && tschema[:properties]
|
|
150
|
+
klass
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
Legion::Logging.log_exception(e, payload_summary: "build_client_tool_class failed for #{tname}", component_type: :api)
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
define_method(:extract_tool_calls) do |pipeline_response|
|
|
157
|
+
tools_data = pipeline_response.tools
|
|
158
|
+
return nil unless tools_data.is_a?(Array) && !tools_data.empty?
|
|
159
|
+
|
|
160
|
+
tools_data.map do |tc|
|
|
161
|
+
{
|
|
162
|
+
id: tc.respond_to?(:id) ? tc.id : nil,
|
|
163
|
+
name: tc.respond_to?(:name) ? tc.name : tc.to_s,
|
|
164
|
+
arguments: tc.respond_to?(:arguments) ? tc.arguments : {}
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
end
|
|
39
168
|
end
|
|
40
169
|
|
|
41
170
|
register_chat(app)
|
|
42
171
|
register_providers(app)
|
|
43
172
|
end
|
|
44
173
|
|
|
45
|
-
def self.register_chat(app)
|
|
174
|
+
def self.register_chat(app)
|
|
46
175
|
register_inference(app)
|
|
47
176
|
|
|
48
|
-
app.post '/api/llm/chat' do
|
|
177
|
+
app.post '/api/llm/chat' do
|
|
49
178
|
Legion::Logging.debug "API: POST /api/llm/chat params=#{params.keys}"
|
|
50
179
|
require_llm!
|
|
51
180
|
|
|
@@ -138,7 +267,7 @@ module Legion
|
|
|
138
267
|
}
|
|
139
268
|
)
|
|
140
269
|
rescue StandardError => e
|
|
141
|
-
Legion::Logging.
|
|
270
|
+
Legion::Logging.log_exception(e, payload_summary: 'api/llm/chat async failed', component_type: :api)
|
|
142
271
|
rc.fail_request(request_id, code: 'llm_error', message: e.message)
|
|
143
272
|
end
|
|
144
273
|
|
|
@@ -165,71 +294,136 @@ module Legion
|
|
|
165
294
|
end
|
|
166
295
|
end
|
|
167
296
|
|
|
168
|
-
def self.register_inference(app)
|
|
169
|
-
app.post '/api/llm/inference' do
|
|
297
|
+
def self.register_inference(app)
|
|
298
|
+
app.post '/api/llm/inference' do
|
|
170
299
|
require_llm!
|
|
171
300
|
body = parse_request_body
|
|
172
301
|
validate_required!(body, :messages)
|
|
173
302
|
|
|
174
|
-
messages
|
|
175
|
-
tools
|
|
176
|
-
model
|
|
177
|
-
provider
|
|
303
|
+
messages = body[:messages]
|
|
304
|
+
tools = body[:tools] || []
|
|
305
|
+
model = body[:model]
|
|
306
|
+
provider = body[:provider]
|
|
307
|
+
requested_tools = body[:requested_tools] || []
|
|
178
308
|
|
|
179
309
|
unless messages.is_a?(Array)
|
|
180
310
|
halt 400, { 'Content-Type' => 'application/json' },
|
|
181
311
|
Legion::JSON.dump({ error: { code: 'invalid_messages', message: 'messages must be an array' } })
|
|
182
312
|
end
|
|
183
313
|
|
|
184
|
-
|
|
185
|
-
model: model,
|
|
186
|
-
provider: provider,
|
|
187
|
-
caller: { source: 'api', path: request.path }
|
|
188
|
-
)
|
|
314
|
+
caller_identity = env['legion.tenant_id'] || 'api:inference'
|
|
189
315
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
316
|
+
# GAIA bridge — push InputFrame to sensory buffer
|
|
317
|
+
last_user = messages.select { |m| (m[:role] || m['role']).to_s == 'user' }.last
|
|
318
|
+
prompt = (last_user || {})[:content] || (last_user || {})['content'] || ''
|
|
319
|
+
|
|
320
|
+
if defined?(Legion::Gaia) && Legion::Gaia.respond_to?(:started?) && Legion::Gaia.started? && prompt.length.positive?
|
|
321
|
+
begin
|
|
322
|
+
frame = Legion::Gaia::InputFrame.new(
|
|
323
|
+
content: prompt,
|
|
324
|
+
channel_id: :api,
|
|
325
|
+
content_type: :text,
|
|
326
|
+
auth_context: { identity: caller_identity },
|
|
327
|
+
metadata: { source_type: :human_direct, salience: 0.5 }
|
|
328
|
+
)
|
|
329
|
+
Legion::Gaia.ingest(frame)
|
|
330
|
+
rescue StandardError => e
|
|
331
|
+
Legion::Logging.log_exception(e, payload_summary: 'gaia ingest failed in inference', component_type: :api)
|
|
202
332
|
end
|
|
203
|
-
session.with_tools(*tool_declarations)
|
|
204
333
|
end
|
|
205
334
|
|
|
206
|
-
|
|
335
|
+
# Build client-side tool classes from Interlink definitions
|
|
336
|
+
tool_classes = tools.filter_map do |t|
|
|
337
|
+
ts = t.respond_to?(:transform_keys) ? t.transform_keys(&:to_sym) : t
|
|
338
|
+
build_client_tool_class(ts[:name].to_s, ts[:description].to_s, ts[:parameters] || ts[:input_schema])
|
|
339
|
+
end
|
|
207
340
|
|
|
208
|
-
|
|
209
|
-
|
|
341
|
+
# Detect streaming mode
|
|
342
|
+
streaming = body[:stream] == true && env['HTTP_ACCEPT']&.include?('text/event-stream')
|
|
343
|
+
|
|
344
|
+
# Build pipeline request
|
|
345
|
+
require 'legion/llm/pipeline/request' unless defined?(Legion::LLM::Pipeline::Request)
|
|
346
|
+
require 'legion/llm/pipeline/executor' unless defined?(Legion::LLM::Pipeline::Executor)
|
|
347
|
+
|
|
348
|
+
req = Legion::LLM::Pipeline::Request.build(
|
|
349
|
+
messages: messages,
|
|
350
|
+
system: body[:system],
|
|
351
|
+
routing: { provider: provider, model: model },
|
|
352
|
+
tools: tool_classes,
|
|
353
|
+
caller: { requested_by: { identity: caller_identity, type: :user, credential: :api } },
|
|
354
|
+
conversation_id: body[:conversation_id],
|
|
355
|
+
metadata: { requested_tools: requested_tools },
|
|
356
|
+
stream: streaming,
|
|
357
|
+
cache: { strategy: :default, cacheable: true }
|
|
358
|
+
)
|
|
359
|
+
executor = Legion::LLM::Pipeline::Executor.new(req)
|
|
210
360
|
|
|
211
|
-
|
|
361
|
+
if streaming
|
|
362
|
+
content_type 'text/event-stream'
|
|
363
|
+
headers 'Cache-Control' => 'no-cache', 'Connection' => 'keep-alive',
|
|
364
|
+
'X-Accel-Buffering' => 'no'
|
|
212
365
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
arguments: tc.respond_to?(:arguments) ? tc.arguments : {}
|
|
219
|
-
}
|
|
220
|
-
end
|
|
221
|
-
end
|
|
366
|
+
stream do |out|
|
|
367
|
+
full_text = +''
|
|
368
|
+
pipeline_response = executor.call_stream do |chunk|
|
|
369
|
+
text = chunk.respond_to?(:content) ? chunk.content.to_s : chunk.to_s
|
|
370
|
+
next if text.empty?
|
|
222
371
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
372
|
+
full_text << text
|
|
373
|
+
out << "event: text-delta\ndata: #{Legion::JSON.dump({ delta: text })}\n\n"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
if pipeline_response.tools.is_a?(Array) && !pipeline_response.tools.empty?
|
|
377
|
+
pipeline_response.tools.each do |tc|
|
|
378
|
+
out << "event: tool-call\ndata: #{Legion::JSON.dump({
|
|
379
|
+
id: tc.respond_to?(:id) ? tc.id : nil,
|
|
380
|
+
name: tc.respond_to?(:name) ? tc.name : tc.to_s,
|
|
381
|
+
arguments: tc.respond_to?(:arguments) ? tc.arguments : {}
|
|
382
|
+
})}\n\n"
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
enrichments = pipeline_response.enrichments
|
|
387
|
+
out << "event: enrichment\ndata: #{Legion::JSON.dump(enrichments)}\n\n" if enrichments.is_a?(Hash) && !enrichments.empty?
|
|
388
|
+
|
|
389
|
+
tokens = pipeline_response.tokens
|
|
390
|
+
out << "event: done\ndata: #{Legion::JSON.dump({
|
|
391
|
+
content: full_text,
|
|
392
|
+
model: pipeline_response.routing&.dig(:model),
|
|
393
|
+
input_tokens: tokens.respond_to?(:input_tokens) ? tokens.input_tokens : nil,
|
|
394
|
+
output_tokens: tokens.respond_to?(:output_tokens) ? tokens.output_tokens : nil
|
|
395
|
+
})}\n\n"
|
|
396
|
+
rescue StandardError => e
|
|
397
|
+
Legion::Logging.log_exception(e, payload_summary: 'api/llm/inference stream failed', component_type: :api)
|
|
398
|
+
out << "event: error\ndata: #{Legion::JSON.dump({ code: 'stream_error', message: e.message })}\n\n"
|
|
399
|
+
end
|
|
400
|
+
else
|
|
401
|
+
pipeline_response = executor.call
|
|
402
|
+
tokens = pipeline_response.tokens
|
|
403
|
+
|
|
404
|
+
json_response({
|
|
405
|
+
content: pipeline_response.message&.dig(:content),
|
|
406
|
+
tool_calls: extract_tool_calls(pipeline_response),
|
|
407
|
+
stop_reason: pipeline_response.stop&.dig(:reason),
|
|
408
|
+
model: pipeline_response.routing&.dig(:model) || model,
|
|
409
|
+
input_tokens: tokens.respond_to?(:input_tokens) ? tokens.input_tokens : nil,
|
|
410
|
+
output_tokens: tokens.respond_to?(:output_tokens) ? tokens.output_tokens : nil
|
|
411
|
+
}, status_code: 200)
|
|
412
|
+
end
|
|
413
|
+
rescue Legion::LLM::AuthError => e
|
|
414
|
+
Legion::Logging.log_exception(e, payload_summary: 'api/llm/inference auth failed', component_type: :api)
|
|
415
|
+
json_response({ error: { code: 'auth_error', message: e.message } }, status_code: 401)
|
|
416
|
+
rescue Legion::LLM::RateLimitError => e
|
|
417
|
+
Legion::Logging.log_exception(e, payload_summary: 'api/llm/inference rate limited', component_type: :api)
|
|
418
|
+
json_response({ error: { code: 'rate_limit', message: e.message } }, status_code: 429)
|
|
419
|
+
rescue Legion::LLM::TokenBudgetExceeded => e
|
|
420
|
+
Legion::Logging.log_exception(e, payload_summary: 'api/llm/inference token budget exceeded', component_type: :api)
|
|
421
|
+
json_response({ error: { code: 'token_budget_exceeded', message: e.message } }, status_code: 413)
|
|
422
|
+
rescue Legion::LLM::ProviderDown, Legion::LLM::ProviderError => e
|
|
423
|
+
Legion::Logging.log_exception(e, payload_summary: 'api/llm/inference provider error', component_type: :api)
|
|
424
|
+
json_response({ error: { code: 'provider_error', message: e.message } }, status_code: 502)
|
|
231
425
|
rescue StandardError => e
|
|
232
|
-
Legion::Logging.
|
|
426
|
+
Legion::Logging.log_exception(e, payload_summary: 'api/llm/inference failed', component_type: :api)
|
|
233
427
|
json_response({ error: { code: 'inference_error', message: e.message } }, status_code: 500)
|
|
234
428
|
end
|
|
235
429
|
end
|
data/lib/legion/api/mesh.rb
CHANGED
|
@@ -4,20 +4,52 @@ module Legion
|
|
|
4
4
|
class API < Sinatra::Base
|
|
5
5
|
module Routes
|
|
6
6
|
module Mesh
|
|
7
|
+
@cache = {}
|
|
8
|
+
@cache_mutex = Mutex.new
|
|
9
|
+
MESH_CACHE_TTL = 10
|
|
10
|
+
|
|
11
|
+
def self.cached_fetch(key)
|
|
12
|
+
@cache_mutex.synchronize do
|
|
13
|
+
entry = @cache[key]
|
|
14
|
+
return entry[:data] if entry && (Time.now - entry[:at]) < MESH_CACHE_TTL
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
data = yield
|
|
18
|
+
@cache_mutex.synchronize { @cache[key] = { data: data, at: Time.now } }
|
|
19
|
+
data
|
|
20
|
+
end
|
|
21
|
+
|
|
7
22
|
def self.registered(app)
|
|
8
23
|
app.get '/api/mesh/status' do
|
|
9
24
|
require_mesh!
|
|
10
|
-
result =
|
|
25
|
+
result = Mesh.cached_fetch(:status) do
|
|
26
|
+
Legion::Ingress.run(
|
|
27
|
+
runner_class: 'Legion::Extensions::Mesh::Runners::Mesh',
|
|
28
|
+
function: 'mesh_status',
|
|
29
|
+
source: :api,
|
|
30
|
+
payload: {}
|
|
31
|
+
)
|
|
32
|
+
end
|
|
11
33
|
json_response(result)
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
Legion::Logging.log_exception(e, payload_summary: 'GET /api/mesh/status', component_type: :api)
|
|
36
|
+
json_error('mesh_error', e.message, status_code: 500)
|
|
12
37
|
end
|
|
13
38
|
|
|
14
39
|
app.get '/api/mesh/peers' do
|
|
15
40
|
require_mesh!
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
41
|
+
result = Mesh.cached_fetch(:peers) do
|
|
42
|
+
Legion::Ingress.run(
|
|
43
|
+
runner_class: 'Legion::Extensions::Mesh::Runners::Mesh',
|
|
44
|
+
function: 'find_agents',
|
|
45
|
+
source: :api,
|
|
46
|
+
payload: { capability: nil }
|
|
47
|
+
)
|
|
19
48
|
end
|
|
20
|
-
json_response(
|
|
49
|
+
json_response(result)
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
Legion::Logging.log_exception(e, payload_summary: 'GET /api/mesh/peers', component_type: :api)
|
|
52
|
+
json_error('mesh_error', e.message, status_code: 500)
|
|
21
53
|
end
|
|
22
54
|
end
|
|
23
55
|
end
|