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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e258eb5ccc6e84f396bfd5ea054003c9f12f39eae03bf799a48fd56126e9754
4
- data.tar.gz: 4a6e13b1061f3b2dd5e382a67d350f0880e6dad06510f1dfce95bc44483dbe45
3
+ metadata.gz: 6741515579e2c24f64b301136e023fb6ddd73b27f41779caace7df02cd15f667
4
+ data.tar.gz: c90f66af48bd6705b58c6c31344dd0a9486b8519cc48b98f3b1965d2571527db
5
5
  SHA512:
6
- metadata.gz: 819e3c3bc599a5d47e189e36151eef39cac69d48eb64ef8e482a37c013bfcd8ed07e54b24685cef736b61066d7e86a4004cae91e7b3a7423ff11f0ca2b89eb8b
7
- data.tar.gz: a936e7fd4f0f8b72b732419029c3e3b2634947077281c1e1ece84c20038068d4ade021a575a163e07246584597f1a4d643bf22553dabf339f570d4e410bc18f2
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.3] - 2026-03-31
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 for mesh topology visibility
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
@@ -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,136 @@ 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)
210
360
 
211
- response = session.ask(prompt)
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
- 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
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
- 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)
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.error "[api/llm/inference] #{e.class}: #{e.message}" if defined?(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
@@ -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