legionio 1.7.4 → 1.7.6
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 +20 -2
- data/lib/legion/api/knowledge.rb +18 -3
- data/lib/legion/api/llm.rb +241 -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 +6 -0
- data/lib/legion/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 30ac0f16cae1d42de41e7ed922e2fd75d96093017f7d562e2596b9c20bea99b6
|
|
4
|
+
data.tar.gz: 23fe67ca7f6171ea6e0ed13d50b382e78e1a7df4402cf1fe8174fcfea7d92ceb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ada4b04b1a274b66d60ab44ae9abe4b8ea998576d7d4a5b679ab3099ad91615924fe4646f671b544820d0b1db6e2bbcc0ceea84f2e5ed762e3f1321500017d99
|
|
7
|
+
data.tar.gz: b1a5d03f53e03039321f54529b0eae7abdc82462a20ee0dd0c1cb88927b15f606a07472b69cdeacc488217fc941d62074f83e567c09d3d26f21c821328309f14
|
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,33 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
## [1.7.
|
|
5
|
+
## [1.7.6] - 2026-04-01
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- `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)
|
|
9
|
+
- GAIA bridge added: user prompt from `/api/llm/inference` is pushed as an `InputFrame` to the GAIA sensory buffer when GAIA is started
|
|
10
|
+
- SSE streaming support added: `stream: true` + `Accept: text/event-stream` returns `text/event-stream` with `text-delta`, `tool-call`, `enrichment`, and `done` events
|
|
11
|
+
- `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
|
|
12
|
+
- Typed error mapping added: `AuthError` → 401, `RateLimitError` → 429, `TokenBudgetExceeded` → 413, `ProviderDown`/`ProviderError` → 502
|
|
13
|
+
|
|
14
|
+
## [1.7.5] - 2026-04-01
|
|
6
15
|
|
|
7
16
|
### Added
|
|
8
17
|
- `POST /api/reload` endpoint to trigger daemon reload from CLI mode command
|
|
9
|
-
- `GET /api/mesh/status` and `GET /api/mesh/peers` endpoints
|
|
18
|
+
- `GET /api/mesh/status` and `GET /api/mesh/peers` endpoints with 10s cache
|
|
19
|
+
- `GET /api/metering`, `/api/metering/rollup`, `/api/metering/by_model` endpoints wired to lex-metering
|
|
20
|
+
- `GET /api/webhooks` and `GET /api/tenants` routes registered (were defined but never mounted)
|
|
21
|
+
- Knowledge monitor v2/v3 route aliases for Interlink compatibility
|
|
22
|
+
- Server-side MCP tool injection into `/api/llm/inference` via `McpToolAdapter` (64 tools)
|
|
23
|
+
- Deferred tool loading: 18 always-loaded tools, ~46 on-demand (cuts inference from 24s to 6-9s)
|
|
24
|
+
- Client-side tools (`sh`, `file_read`, `list_directory`, etc.) now execute server-side in the inference endpoint
|
|
10
25
|
|
|
11
26
|
### Fixed
|
|
12
27
|
- Knowledge ingest API route calls `ingest_content` instead of `ingest_file` when `content` body param is present
|
|
13
28
|
- Catalog API queries `extensions.name` instead of non-existent `gem_name` column
|
|
29
|
+
- Inference endpoint tool declarations use `RubyLLM::Tool` subclass with proper `name` instance method
|
|
30
|
+
- Prompts API guards against missing `prompts` table (returns 503 instead of 500)
|
|
31
|
+
- All API rescue blocks use `Legion::Logging.log_exception` instead of swallowing errors
|
|
14
32
|
|
|
15
33
|
## [1.7.0] - 2026-03-31
|
|
16
34
|
|
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,133 @@ 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)
|
|
360
|
+
|
|
361
|
+
if streaming
|
|
362
|
+
content_type 'text/event-stream'
|
|
363
|
+
headers 'Cache-Control' => 'no-cache', 'Connection' => 'keep-alive',
|
|
364
|
+
'X-Accel-Buffering' => 'no'
|
|
365
|
+
|
|
366
|
+
stream do |out|
|
|
367
|
+
full_text = +''
|
|
368
|
+
pipeline_response = executor.call_stream do |chunk|
|
|
369
|
+
full_text << chunk
|
|
370
|
+
out << "event: text-delta\ndata: #{Legion::JSON.dump({ delta: chunk })}\n\n"
|
|
371
|
+
end
|
|
210
372
|
|
|
211
|
-
|
|
373
|
+
if pipeline_response.tools.is_a?(Array) && !pipeline_response.tools.empty?
|
|
374
|
+
pipeline_response.tools.each do |tc|
|
|
375
|
+
out << "event: tool-call\ndata: #{Legion::JSON.dump({
|
|
376
|
+
id: tc.respond_to?(:id) ? tc.id : nil,
|
|
377
|
+
name: tc.respond_to?(:name) ? tc.name : tc.to_s,
|
|
378
|
+
arguments: tc.respond_to?(:arguments) ? tc.arguments : {}
|
|
379
|
+
})}\n\n"
|
|
380
|
+
end
|
|
381
|
+
end
|
|
212
382
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
{
|
|
216
|
-
id: tc.respond_to?(:id) ? tc.id : nil,
|
|
217
|
-
name: tc.respond_to?(:name) ? tc.name : tc.to_s,
|
|
218
|
-
arguments: tc.respond_to?(:arguments) ? tc.arguments : {}
|
|
219
|
-
}
|
|
220
|
-
end
|
|
221
|
-
end
|
|
383
|
+
enrichments = pipeline_response.enrichments
|
|
384
|
+
out << "event: enrichment\ndata: #{Legion::JSON.dump(enrichments)}\n\n" if enrichments.is_a?(Hash) && !enrichments.empty?
|
|
222
385
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
386
|
+
tokens = pipeline_response.tokens
|
|
387
|
+
out << "event: done\ndata: #{Legion::JSON.dump({
|
|
388
|
+
content: full_text,
|
|
389
|
+
model: pipeline_response.routing&.dig(:model),
|
|
390
|
+
input_tokens: tokens.respond_to?(:input_tokens) ? tokens.input_tokens : nil,
|
|
391
|
+
output_tokens: tokens.respond_to?(:output_tokens) ? tokens.output_tokens : nil
|
|
392
|
+
})}\n\n"
|
|
393
|
+
rescue StandardError => e
|
|
394
|
+
Legion::Logging.log_exception(e, payload_summary: 'api/llm/inference stream failed', component_type: :api)
|
|
395
|
+
out << "event: error\ndata: #{Legion::JSON.dump({ code: 'stream_error', message: e.message })}\n\n"
|
|
396
|
+
end
|
|
397
|
+
else
|
|
398
|
+
pipeline_response = executor.call
|
|
399
|
+
tokens = pipeline_response.tokens
|
|
400
|
+
|
|
401
|
+
json_response({
|
|
402
|
+
content: pipeline_response.message&.dig(:content),
|
|
403
|
+
tool_calls: extract_tool_calls(pipeline_response),
|
|
404
|
+
stop_reason: pipeline_response.stop&.dig(:reason),
|
|
405
|
+
model: pipeline_response.routing&.dig(:model) || model,
|
|
406
|
+
input_tokens: tokens.respond_to?(:input_tokens) ? tokens.input_tokens : nil,
|
|
407
|
+
output_tokens: tokens.respond_to?(:output_tokens) ? tokens.output_tokens : nil
|
|
408
|
+
}, status_code: 200)
|
|
409
|
+
end
|
|
410
|
+
rescue Legion::LLM::AuthError => e
|
|
411
|
+
Legion::Logging.log_exception(e, payload_summary: 'api/llm/inference auth failed', component_type: :api)
|
|
412
|
+
json_response({ error: { code: 'auth_error', message: e.message } }, status_code: 401)
|
|
413
|
+
rescue Legion::LLM::RateLimitError => e
|
|
414
|
+
Legion::Logging.log_exception(e, payload_summary: 'api/llm/inference rate limited', component_type: :api)
|
|
415
|
+
json_response({ error: { code: 'rate_limit', message: e.message } }, status_code: 429)
|
|
416
|
+
rescue Legion::LLM::TokenBudgetExceeded => e
|
|
417
|
+
Legion::Logging.log_exception(e, payload_summary: 'api/llm/inference token budget exceeded', component_type: :api)
|
|
418
|
+
json_response({ error: { code: 'token_budget_exceeded', message: e.message } }, status_code: 413)
|
|
419
|
+
rescue Legion::LLM::ProviderDown, Legion::LLM::ProviderError => e
|
|
420
|
+
Legion::Logging.log_exception(e, payload_summary: 'api/llm/inference provider error', component_type: :api)
|
|
421
|
+
json_response({ error: { code: 'provider_error', message: e.message } }, status_code: 502)
|
|
231
422
|
rescue StandardError => e
|
|
232
|
-
Legion::Logging.
|
|
423
|
+
Legion::Logging.log_exception(e, payload_summary: 'api/llm/inference failed', component_type: :api)
|
|
233
424
|
json_response({ error: { code: 'inference_error', message: e.message } }, status_code: 500)
|
|
234
425
|
end
|
|
235
426
|
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
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
class API < Sinatra::Base
|
|
5
|
+
module Routes
|
|
6
|
+
module Metering
|
|
7
|
+
def self.registered(app)
|
|
8
|
+
app.helpers do
|
|
9
|
+
define_method(:require_metering!) do
|
|
10
|
+
return if defined?(Legion::Extensions::Metering::Runners::Metering)
|
|
11
|
+
|
|
12
|
+
halt 503, json_error('metering_unavailable', 'lex-metering is not loaded', status_code: 503)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
define_method(:metering_table?) do
|
|
16
|
+
defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) &&
|
|
17
|
+
Legion::Data.connected? && Legion::Data.connection.table_exists?(:metering_records)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
app.get '/api/metering' do
|
|
22
|
+
require_metering!
|
|
23
|
+
return json_response({ records: [], total: 0, note: 'metering_records table not available' }) unless metering_table?
|
|
24
|
+
|
|
25
|
+
result = Legion::Extensions::Metering::Runners::Metering.routing_stats
|
|
26
|
+
json_response(result)
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
Legion::Logging.log_exception(e, payload_summary: 'GET /api/metering', component_type: :api)
|
|
29
|
+
json_response({ records: [], total: 0, error: e.message })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
app.get '/api/metering/rollup' do
|
|
33
|
+
require_metering!
|
|
34
|
+
return json_response({ rollup: [], period: 'hourly', note: 'metering_records table not available' }) unless metering_table?
|
|
35
|
+
|
|
36
|
+
return json_response({ rollup: [], period: 'hourly' }) unless defined?(Legion::Extensions::Metering::Runners::Rollup)
|
|
37
|
+
|
|
38
|
+
result = Legion::Extensions::Metering::Runners::Rollup.rollup_hour
|
|
39
|
+
json_response(result)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
Legion::Logging.log_exception(e, payload_summary: 'GET /api/metering/rollup', component_type: :api)
|
|
42
|
+
json_response({ rollup: [], period: 'hourly', error: e.message })
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
app.get '/api/metering/by_model' do
|
|
46
|
+
require_metering!
|
|
47
|
+
return json_response({ models: [], note: 'metering_records table not available' }) unless metering_table?
|
|
48
|
+
|
|
49
|
+
ds = Legion::Data.connection[:metering_records]
|
|
50
|
+
models = ds.group(:model_id).select_append do
|
|
51
|
+
[count.as(:call_count),
|
|
52
|
+
sum(total_tokens).as(:total_tokens),
|
|
53
|
+
sum(cost_usd).as(:total_cost),
|
|
54
|
+
avg(latency_ms).as(:avg_latency_ms)]
|
|
55
|
+
end.all
|
|
56
|
+
|
|
57
|
+
json_response({ models: models })
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
Legion::Logging.log_exception(e, payload_summary: 'GET /api/metering/by_model', component_type: :api)
|
|
60
|
+
json_response({ models: [], error: e.message })
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/legion/api/prompts.rb
CHANGED
|
@@ -16,7 +16,11 @@ module Legion
|
|
|
16
16
|
|
|
17
17
|
define_method(:prompt_client) do
|
|
18
18
|
require 'legion/extensions/prompt/client'
|
|
19
|
-
Legion::
|
|
19
|
+
db = Legion::Data.connection
|
|
20
|
+
unless db.table_exists?(:prompts)
|
|
21
|
+
halt 503, json_error('prompt_unavailable', 'prompts table does not exist — run lex-prompt migrations', status_code: 503)
|
|
22
|
+
end
|
|
23
|
+
Legion::Extensions::Prompt::Client.new(db: db)
|
|
20
24
|
rescue LoadError => e
|
|
21
25
|
Legion::Logging.warn "Prompts#prompt_client failed to load lex-prompt: #{e.message}" if defined?(Legion::Logging)
|
|
22
26
|
halt 503, json_error('prompt_unavailable', 'lex-prompt is not loaded', status_code: 503)
|
|
@@ -34,7 +38,7 @@ module Legion
|
|
|
34
38
|
result = client.list_prompts
|
|
35
39
|
json_response(result)
|
|
36
40
|
rescue StandardError => e
|
|
37
|
-
Legion::Logging.
|
|
41
|
+
Legion::Logging.log_exception(e, payload_summary: 'API GET /api/prompts', component_type: :api)
|
|
38
42
|
json_error('execution_error', e.message, status_code: 500)
|
|
39
43
|
end
|
|
40
44
|
end
|
|
@@ -52,7 +56,7 @@ module Legion
|
|
|
52
56
|
|
|
53
57
|
json_response(result)
|
|
54
58
|
rescue StandardError => e
|
|
55
|
-
Legion::Logging.
|
|
59
|
+
Legion::Logging.log_exception(e, payload_summary: "API GET /api/prompts/#{params[:name]}", component_type: :api)
|
|
56
60
|
json_error('execution_error', e.message, status_code: 500)
|
|
57
61
|
end
|
|
58
62
|
end
|
|
@@ -100,7 +104,7 @@ module Legion
|
|
|
100
104
|
provider: provider
|
|
101
105
|
})
|
|
102
106
|
rescue StandardError => e
|
|
103
|
-
Legion::Logging.
|
|
107
|
+
Legion::Logging.log_exception(e, payload_summary: "API POST /api/prompts/#{params[:name]}/run", component_type: :api)
|
|
104
108
|
json_error('execution_error', e.message, status_code: 500)
|
|
105
109
|
end
|
|
106
110
|
end
|
data/lib/legion/api.rb
CHANGED
|
@@ -49,12 +49,15 @@ require_relative 'api/absorbers'
|
|
|
49
49
|
require_relative 'api/codegen'
|
|
50
50
|
require_relative 'api/knowledge'
|
|
51
51
|
require_relative 'api/mesh'
|
|
52
|
+
require_relative 'api/metering'
|
|
52
53
|
require_relative 'api/logs'
|
|
53
54
|
require_relative 'api/router'
|
|
54
55
|
require_relative 'api/library_routes'
|
|
55
56
|
require_relative 'api/sync_dispatch'
|
|
56
57
|
require_relative 'api/lex_dispatch'
|
|
57
58
|
require_relative 'api/tbi_patterns'
|
|
59
|
+
require_relative 'api/webhooks'
|
|
60
|
+
require_relative 'api/tenants'
|
|
58
61
|
require_relative 'api/inbound_webhooks'
|
|
59
62
|
require_relative 'api/graphql' if defined?(GraphQL)
|
|
60
63
|
|
|
@@ -186,8 +189,11 @@ module Legion
|
|
|
186
189
|
register Routes::Codegen
|
|
187
190
|
register Routes::Knowledge
|
|
188
191
|
register Routes::Mesh
|
|
192
|
+
register Routes::Metering
|
|
189
193
|
register Routes::Logs
|
|
190
194
|
register Routes::TbiPatterns
|
|
195
|
+
register Routes::Webhooks
|
|
196
|
+
register Routes::Tenants
|
|
191
197
|
register Routes::InboundWebhooks
|
|
192
198
|
register Routes::GraphQL if defined?(Routes::GraphQL)
|
|
193
199
|
|
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.7.
|
|
4
|
+
version: 1.7.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -497,6 +497,7 @@ files:
|
|
|
497
497
|
- lib/legion/api/logs.rb
|
|
498
498
|
- lib/legion/api/marketplace.rb
|
|
499
499
|
- lib/legion/api/mesh.rb
|
|
500
|
+
- lib/legion/api/metering.rb
|
|
500
501
|
- lib/legion/api/metrics.rb
|
|
501
502
|
- lib/legion/api/middleware/api_version.rb
|
|
502
503
|
- lib/legion/api/middleware/auth.rb
|