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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e258eb5ccc6e84f396bfd5ea054003c9f12f39eae03bf799a48fd56126e9754
4
- data.tar.gz: 4a6e13b1061f3b2dd5e382a67d350f0880e6dad06510f1dfce95bc44483dbe45
3
+ metadata.gz: 30ac0f16cae1d42de41e7ed922e2fd75d96093017f7d562e2596b9c20bea99b6
4
+ data.tar.gz: 23fe67ca7f6171ea6e0ed13d50b382e78e1a7df4402cf1fe8174fcfea7d92ceb
5
5
  SHA512:
6
- metadata.gz: 819e3c3bc599a5d47e189e36151eef39cac69d48eb64ef8e482a37c013bfcd8ed07e54b24685cef736b61066d7e86a4004cae91e7b3a7423ff11f0ca2b89eb8b
7
- data.tar.gz: a936e7fd4f0f8b72b732419029c3e3b2634947077281c1e1ece84c20038068d4ade021a575a163e07246584597f1a4d643bf22553dabf339f570d4e410bc18f2
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.3] - 2026-03-31
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 for mesh topology visibility
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
 
@@ -103,13 +103,13 @@ module Legion
103
103
  end
104
104
 
105
105
  def self.register_monitor_routes(app)
106
- app.get '/api/knowledge/monitors' do
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
- app.post '/api/knowledge/monitors' do
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
- app.delete '/api/knowledge/monitors' do
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
@@ -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.debug("SearchTraces not available for API: #{e.message}") if defined?(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) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
174
+ def self.register_chat(app)
46
175
  register_inference(app)
47
176
 
48
- app.post '/api/llm/chat' do # rubocop:disable Metrics/BlockLength
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.error "API POST /api/llm/chat async: #{e.class} #{e.message}"
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) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
169
- app.post '/api/llm/inference' do # rubocop:disable Metrics/BlockLength
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 = body[:messages]
175
- tools = body[:tools] || []
176
- model = body[:model]
177
- provider = body[: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
- session = Legion::LLM.chat(
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
- unless tools.empty?
191
- tool_declarations = tools.map do |t|
192
- ts = t.respond_to?(:transform_keys) ? t.transform_keys(&:to_sym) : t
193
- tname = ts[:name].to_s
194
- tdesc = ts[:description].to_s
195
- tparams = ts[:parameters] || {}
196
- Class.new do
197
- define_singleton_method(:tool_name) { tname }
198
- define_singleton_method(:description) { tdesc }
199
- define_singleton_method(:parameters) { tparams }
200
- define_method(:call) { |**_| raise NotImplementedError, "#{tname} executes client-side only" }
201
- end
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
- messages.each { |m| session.add_message(m) }
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
- last_user = messages.select { |m| (m[:role] || m['role']).to_s == 'user' }.last
209
- prompt = (last_user || {})[:content] || (last_user || {})['content'] || ''
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
- response = session.ask(prompt)
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
- tc_list = if response.respond_to?(:tool_calls) && response.tool_calls
214
- Array(response.tool_calls).map do |tc|
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
- json_response({
224
- content: response.content,
225
- tool_calls: tc_list,
226
- stop_reason: response.respond_to?(:stop_reason) ? response.stop_reason : nil,
227
- model: session.model.to_s,
228
- input_tokens: response.respond_to?(:input_tokens) ? response.input_tokens : nil,
229
- output_tokens: response.respond_to?(:output_tokens) ? response.output_tokens : nil
230
- }, status_code: 200)
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.error "[api/llm/inference] #{e.class}: #{e.message}" if defined?(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
@@ -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 = Legion::Extensions::Mesh::Runners::Mesh.mesh_status
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
- registry = Legion::Extensions::Mesh.mesh_registry
17
- agents = registry.all_agents.map do |agent|
18
- agent.slice(:agent_id, :capabilities, :endpoint, :status, :last_seen, :registered_at)
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(agents)
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
@@ -16,7 +16,11 @@ module Legion
16
16
 
17
17
  define_method(:prompt_client) do
18
18
  require 'legion/extensions/prompt/client'
19
- Legion::Extensions::Prompt::Client.new
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.error "API GET /api/prompts: #{e.class} — #{e.message}"
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.error "API GET /api/prompts/#{params[:name]}: #{e.class} — #{e.message}"
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.error "API POST /api/prompts/#{params[:name]}/run: #{e.class} — #{e.message}"
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.7.4'
4
+ VERSION = '1.7.6'
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.7.4
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