ruby_llm-agents 3.7.2 → 3.9.0

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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -10
  3. data/app/controllers/ruby_llm/agents/agents_controller.rb +14 -141
  4. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +12 -166
  5. data/app/controllers/ruby_llm/agents/executions_controller.rb +1 -1
  6. data/app/controllers/ruby_llm/agents/requests_controller.rb +117 -0
  7. data/app/helpers/ruby_llm/agents/application_helper.rb +38 -0
  8. data/app/models/ruby_llm/agents/execution/analytics.rb +302 -103
  9. data/app/models/ruby_llm/agents/execution.rb +76 -54
  10. data/app/models/ruby_llm/agents/execution_detail.rb +2 -0
  11. data/app/models/ruby_llm/agents/tenant.rb +39 -0
  12. data/app/services/ruby_llm/agents/agent_registry.rb +98 -0
  13. data/app/views/layouts/ruby_llm/agents/application.html.erb +4 -2
  14. data/app/views/ruby_llm/agents/executions/_list.html.erb +3 -17
  15. data/app/views/ruby_llm/agents/requests/index.html.erb +153 -0
  16. data/app/views/ruby_llm/agents/requests/show.html.erb +136 -0
  17. data/config/routes.rb +2 -0
  18. data/lib/generators/ruby_llm_agents/agent_generator.rb +2 -2
  19. data/lib/generators/ruby_llm_agents/demo_generator.rb +102 -0
  20. data/lib/generators/ruby_llm_agents/doctor_generator.rb +196 -0
  21. data/lib/generators/ruby_llm_agents/install_generator.rb +7 -19
  22. data/lib/generators/ruby_llm_agents/templates/add_dashboard_performance_indexes_migration.rb.tt +23 -0
  23. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +27 -80
  24. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +18 -51
  25. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +19 -17
  26. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +3 -0
  27. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +25 -0
  28. data/lib/ruby_llm/agents/base_agent.rb +71 -4
  29. data/lib/ruby_llm/agents/core/base.rb +4 -0
  30. data/lib/ruby_llm/agents/core/configuration.rb +11 -0
  31. data/lib/ruby_llm/agents/core/instrumentation.rb +15 -19
  32. data/lib/ruby_llm/agents/core/version.rb +1 -1
  33. data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +4 -4
  34. data/lib/ruby_llm/agents/infrastructure/budget_tracker.rb +19 -11
  35. data/lib/ruby_llm/agents/pipeline/builder.rb +8 -4
  36. data/lib/ruby_llm/agents/pipeline/context.rb +69 -1
  37. data/lib/ruby_llm/agents/pipeline/middleware/base.rb +58 -4
  38. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +21 -17
  39. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +40 -26
  40. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +126 -120
  41. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +13 -11
  42. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +29 -31
  43. data/lib/ruby_llm/agents/providers/inception/capabilities.rb +107 -0
  44. data/lib/ruby_llm/agents/providers/inception/chat.rb +17 -0
  45. data/lib/ruby_llm/agents/providers/inception/configuration.rb +9 -0
  46. data/lib/ruby_llm/agents/providers/inception/models.rb +38 -0
  47. data/lib/ruby_llm/agents/providers/inception/registry.rb +45 -0
  48. data/lib/ruby_llm/agents/providers/inception.rb +50 -0
  49. data/lib/ruby_llm/agents/rails/engine.rb +11 -0
  50. data/lib/ruby_llm/agents/results/background_removal_result.rb +7 -1
  51. data/lib/ruby_llm/agents/results/base.rb +28 -4
  52. data/lib/ruby_llm/agents/results/embedding_result.rb +4 -0
  53. data/lib/ruby_llm/agents/results/image_analysis_result.rb +11 -3
  54. data/lib/ruby_llm/agents/results/image_edit_result.rb +7 -1
  55. data/lib/ruby_llm/agents/results/image_generation_result.rb +7 -1
  56. data/lib/ruby_llm/agents/results/image_pipeline_result.rb +7 -1
  57. data/lib/ruby_llm/agents/results/image_transform_result.rb +7 -1
  58. data/lib/ruby_llm/agents/results/image_upscale_result.rb +7 -1
  59. data/lib/ruby_llm/agents/results/image_variation_result.rb +7 -1
  60. data/lib/ruby_llm/agents/results/speech_result.rb +6 -0
  61. data/lib/ruby_llm/agents/results/trackable.rb +25 -0
  62. data/lib/ruby_llm/agents/results/transcription_result.rb +6 -0
  63. data/lib/ruby_llm/agents/text/embedder.rb +8 -1
  64. data/lib/ruby_llm/agents/track_report.rb +127 -0
  65. data/lib/ruby_llm/agents/tracker.rb +32 -0
  66. data/lib/ruby_llm/agents.rb +212 -0
  67. data/lib/tasks/ruby_llm_agents.rake +6 -0
  68. metadata +17 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 891034cc430fa52a5b9d1b07bd9cf06558414b91396f4a1521ebd0f8618f2a68
4
- data.tar.gz: fc6775ed017277076bec9c0931cfe4c34d4ef1023815f4898458f40e55b61855
3
+ metadata.gz: 6925b14509a50c3bbf5efb8d0e37f5a38b4d2f52f13f3c7ecd7af588f832c5c1
4
+ data.tar.gz: 4332d03ebaf4bd94f3e314656e7f6755475c5ad45661a8c1e76e6e324d37ec2e
5
5
  SHA512:
6
- metadata.gz: 9de47e331e3f0b48465fa12c9f91f79045dfec64d76f242308184a36c41a67be7d324d9b358c96450cde053dbb8f69fe326a1d3a6078a3ea93136b8c327edc30
7
- data.tar.gz: d7ba9f35cf90656e7618189141d7291d704dd7199c54ced508d9da1cb72aa417f1988cd15e884cd42f2ae235e3149c2c31ca0d4d3567db9e04132d890a11e517
6
+ metadata.gz: e12ea68a14b7c9ec683ae7b07a14d9d422922fc8e5233d7296fd42bc6100097bb929da87b6c52fe55929779a17c04e59253fa490c40147558392a32a79023fa6
7
+ data.tar.gz: 9d0d5d249ee0bb6bac741d5c1087ade27ba0a6d032184bb01e1ecfc0d43f79453fbd12cd22e9e4f3eeca7b457bfb242ebca7ebc4e010f44387b9ee8a13bf0865
data/README.md CHANGED
@@ -206,7 +206,7 @@ puts run.summary
206
206
 
207
207
  ## Quick Start
208
208
 
209
- ### Installation
209
+ ### 1. Install
210
210
 
211
211
  ```ruby
212
212
  # Gemfile
@@ -219,29 +219,49 @@ rails generate ruby_llm_agents:install
219
219
  rails db:migrate
220
220
  ```
221
221
 
222
- ### Configure API Keys
222
+ ### 2. Set one API key
223
223
 
224
- Configure all provider API keys in one place (v2.1+):
224
+ Uncomment one line in `config/initializers/ruby_llm_agents.rb`:
225
225
 
226
226
  ```ruby
227
- # config/initializers/ruby_llm_agents.rb
228
227
  RubyLLM::Agents.configure do |config|
229
228
  config.openai_api_key = ENV["OPENAI_API_KEY"]
230
- config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
231
- config.gemini_api_key = ENV["GOOGLE_API_KEY"]
232
229
  end
233
230
  ```
234
231
 
235
- Or use environment variables directly (auto-detected by RubyLLM):
232
+ Then set the environment variable (e.g., in `.env` or Rails credentials).
233
+
234
+ <details>
235
+ <summary>Other providers</summary>
236
236
 
237
+ ```ruby
238
+ config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
239
+ config.gemini_api_key = ENV["GOOGLE_API_KEY"]
240
+ config.deepseek_api_key = ENV["DEEPSEEK_API_KEY"]
241
+ # ... see initializer for full list
242
+ ```
243
+
244
+ Or use environment variables directly (auto-detected by RubyLLM):
237
245
  ```bash
238
- # .env
239
246
  OPENAI_API_KEY=sk-...
240
247
  ANTHROPIC_API_KEY=sk-ant-...
241
- GOOGLE_API_KEY=...
248
+ ```
249
+ </details>
250
+
251
+ ### 3. Verify setup
252
+
253
+ ```bash
254
+ rails ruby_llm_agents:doctor
255
+ ```
256
+
257
+ ### 4. Try it
258
+
259
+ ```bash
260
+ rails generate ruby_llm_agents:demo
261
+ bin/rails runner bin/smoke_test_agent
242
262
  ```
243
263
 
244
- ### Generate an Agent
264
+ Or generate a custom agent:
245
265
 
246
266
  ```bash
247
267
  rails generate ruby_llm_agents:agent SearchIntent query:required
@@ -100,11 +100,12 @@ module RubyLLM
100
100
  #
101
101
  # @return [void]
102
102
  def load_agent_stats
103
- @stats = Execution.stats_for(@agent_type, period: :all_time)
104
- @stats_today = Execution.stats_for(@agent_type, period: :today)
103
+ base = tenant_scoped_executions
104
+ @stats = base.stats_for(@agent_type, period: :all_time)
105
+ @stats_today = base.stats_for(@agent_type, period: :today)
105
106
 
106
107
  # Additional stats for new schema fields
107
- agent_scope = Execution.by_agent(@agent_type)
108
+ agent_scope = base.by_agent(@agent_type)
108
109
  @cache_hit_rate = agent_scope.cache_hit_rate
109
110
  @streaming_rate = agent_scope.streaming_rate
110
111
  @avg_ttft = agent_scope.avg_time_to_first_token
@@ -118,9 +119,10 @@ module RubyLLM
118
119
  # @return [void]
119
120
  def load_filter_options
120
121
  # Single query to get all filter options (fixes N+1)
121
- filter_data = Execution.by_agent(@agent_type)
122
+ base = tenant_scoped_executions.by_agent(@agent_type)
123
+ filter_data = base
122
124
  .where.not(model_id: nil)
123
- .or(Execution.by_agent(@agent_type).where.not(temperature: nil))
125
+ .or(base.where.not(temperature: nil))
124
126
  .pluck(:model_id, :temperature)
125
127
 
126
128
  @models = filter_data.map(&:first).compact.uniq.sort
@@ -152,7 +154,7 @@ module RubyLLM
152
154
  #
153
155
  # @return [ActiveRecord::Relation] Filtered execution scope
154
156
  def build_filtered_scope
155
- scope = Execution.by_agent(@agent_type)
157
+ scope = tenant_scoped_executions.by_agent(@agent_type)
156
158
 
157
159
  # Apply status filter with validation
158
160
  statuses = parse_array_param(:statuses)
@@ -177,9 +179,10 @@ module RubyLLM
177
179
  #
178
180
  # @return [void]
179
181
  def load_chart_data
180
- @trend_data = Execution.trend_analysis(agent_type: @agent_type, days: 30)
181
- @status_distribution = Execution.by_agent(@agent_type).group(:status).count
182
- @finish_reason_distribution = Execution.by_agent(@agent_type).finish_reason_distribution
182
+ base = tenant_scoped_executions
183
+ @trend_data = base.trend_analysis(agent_type: @agent_type, days: 30)
184
+ @status_distribution = base.by_agent(@agent_type).group(:status).count
185
+ @finish_reason_distribution = base.by_agent(@agent_type).finish_reason_distribution
183
186
  end
184
187
 
185
188
  # Loads the current agent class configuration
@@ -189,140 +192,10 @@ module RubyLLM
189
192
  # Detects agent type and loads appropriate config.
190
193
  #
191
194
  # @return [void]
195
+ # Loads agent configuration using AgentRegistry
192
196
  def load_agent_config
193
197
  @agent_type_kind = AgentRegistry.send(:detect_agent_type, @agent_class)
194
-
195
- # Common config for all types
196
- @config = {
197
- model: safe_config_call(:model),
198
- description: safe_config_call(:description)
199
- }
200
-
201
- # Type-specific config
202
- case @agent_type_kind
203
- when "embedder"
204
- load_embedder_config
205
- when "speaker"
206
- load_speaker_config
207
- when "transcriber"
208
- load_transcriber_config
209
- when "image_generator"
210
- load_image_generator_config
211
- when "router"
212
- load_router_config
213
- else
214
- load_base_agent_config
215
- end
216
- end
217
-
218
- # Loads configuration specific to Base agents
219
- #
220
- # @return [void]
221
- def load_base_agent_config
222
- @config.merge!(
223
- temperature: safe_config_call(:temperature),
224
- timeout: safe_config_call(:timeout),
225
- cache_enabled: safe_config_call(:cache_enabled?) || false,
226
- cache_ttl: safe_config_call(:cache_ttl),
227
- params: safe_config_call(:params) || {},
228
- retries: safe_config_call(:retries),
229
- fallback_models: safe_config_call(:fallback_models),
230
- total_timeout: safe_config_call(:total_timeout),
231
- circuit_breaker: safe_config_call(:circuit_breaker_config)
232
- )
233
- end
234
-
235
- # Loads configuration specific to Embedders
236
- #
237
- # @return [void]
238
- def load_embedder_config
239
- @config.merge!(
240
- dimensions: safe_config_call(:dimensions),
241
- batch_size: safe_config_call(:batch_size),
242
- cache_enabled: safe_config_call(:cache_enabled?) || false,
243
- cache_ttl: safe_config_call(:cache_ttl)
244
- )
245
- end
246
-
247
- # Loads configuration specific to Speakers
248
- #
249
- # @return [void]
250
- def load_speaker_config
251
- @config.merge!(
252
- provider: safe_config_call(:provider),
253
- voice: safe_config_call(:voice),
254
- voice_id: safe_config_call(:voice_id),
255
- speed: safe_config_call(:speed),
256
- output_format: safe_config_call(:output_format),
257
- streaming: safe_config_call(:streaming?),
258
- ssml_enabled: safe_config_call(:ssml_enabled?),
259
- cache_enabled: safe_config_call(:cache_enabled?) || false,
260
- cache_ttl: safe_config_call(:cache_ttl)
261
- )
262
- end
263
-
264
- # Loads configuration specific to Transcribers
265
- #
266
- # @return [void]
267
- def load_transcriber_config
268
- @config.merge!(
269
- language: safe_config_call(:language),
270
- output_format: safe_config_call(:output_format),
271
- include_timestamps: safe_config_call(:include_timestamps),
272
- cache_enabled: safe_config_call(:cache_enabled?) || false,
273
- cache_ttl: safe_config_call(:cache_ttl),
274
- fallback_models: safe_config_call(:fallback_models)
275
- )
276
- end
277
-
278
- # Loads configuration specific to ImageGenerators
279
- #
280
- # @return [void]
281
- def load_image_generator_config
282
- @config.merge!(
283
- size: safe_config_call(:size),
284
- quality: safe_config_call(:quality),
285
- style: safe_config_call(:style),
286
- content_policy: safe_config_call(:content_policy),
287
- template: safe_config_call(:template_string),
288
- negative_prompt: safe_config_call(:negative_prompt),
289
- seed: safe_config_call(:seed),
290
- guidance_scale: safe_config_call(:guidance_scale),
291
- steps: safe_config_call(:steps),
292
- cache_enabled: safe_config_call(:cache_enabled?) || false,
293
- cache_ttl: safe_config_call(:cache_ttl)
294
- )
295
- end
296
-
297
- # Loads configuration specific to Router agents
298
- #
299
- # @return [void]
300
- def load_router_config
301
- routes = safe_config_call(:routes) || {}
302
- @config.merge!(
303
- temperature: safe_config_call(:temperature),
304
- timeout: safe_config_call(:timeout),
305
- cache_enabled: safe_config_call(:cache_enabled?) || false,
306
- cache_ttl: safe_config_call(:cache_ttl),
307
- default_route: safe_config_call(:default_route_name),
308
- routes: routes.transform_values { |v| v[:description] },
309
- route_count: routes.size,
310
- retries: safe_config_call(:retries),
311
- fallback_models: safe_config_call(:fallback_models),
312
- total_timeout: safe_config_call(:total_timeout),
313
- circuit_breaker: safe_config_call(:circuit_breaker_config)
314
- )
315
- end
316
-
317
- # Safely calls a method on the agent class, returning nil on error
318
- #
319
- # @param method [Symbol] The method to call
320
- # @return [Object, nil] The result or nil if error
321
- def safe_config_call(method)
322
- return nil unless @agent_class&.respond_to?(method)
323
- @agent_class.public_send(method)
324
- rescue
325
- nil
198
+ @config = AgentRegistry.config_for(@agent_class)
326
199
  end
327
200
 
328
201
  # Loads circuit breaker status for the agent's models
@@ -24,7 +24,7 @@ module RubyLLM
24
24
  base_scope = tenant_scoped_executions
25
25
  @now_strip = build_now_strip(base_scope)
26
26
  @critical_alerts = load_critical_alerts(base_scope)
27
- @recent_executions = base_scope.recent(10)
27
+ @recent_executions = base_scope.includes(:detail).recent(10)
28
28
  @agent_stats = build_agent_comparison(base_scope)
29
29
  @top_errors = build_top_errors(base_scope)
30
30
  @tenant_budget = load_tenant_budget(base_scope)
@@ -194,100 +194,14 @@ module RubyLLM
194
194
  @agent_stats
195
195
  end
196
196
 
197
- # Builds per-model statistics for model comparison and cost breakdown
198
- #
199
- # @param base_scope [ActiveRecord::Relation] Base scope to filter from
200
- # @return [Array<Hash>] Array of model stats sorted by total cost descending
197
+ # Delegates to Execution.model_stats with time scoping
201
198
  def build_model_stats(base_scope = Execution)
202
- scope = time_scoped(base_scope).where.not(model_id: nil)
203
-
204
- # Batch fetch stats grouped by model
205
- counts = scope.group(:model_id).count
206
- costs = scope.group(:model_id).sum(:total_cost)
207
- tokens = scope.group(:model_id).sum(:total_tokens)
208
- durations = scope.group(:model_id).average(:duration_ms)
209
- success_counts = scope.successful.group(:model_id).count
210
-
211
- total_cost = costs.values.sum
212
-
213
- model_ids = counts.keys
214
- model_ids.map do |model_id|
215
- count = counts[model_id] || 0
216
- model_cost = costs[model_id] || 0
217
- model_tokens = tokens[model_id] || 0
218
- successful = success_counts[model_id] || 0
219
-
220
- {
221
- model_id: model_id,
222
- executions: count,
223
- total_cost: model_cost,
224
- total_tokens: model_tokens,
225
- avg_duration_ms: durations[model_id]&.round || 0,
226
- success_rate: (count > 0) ? (successful.to_f / count * 100).round(1) : 0,
227
- cost_per_1k_tokens: (model_tokens > 0) ? (model_cost / model_tokens * 1000).round(4) : 0,
228
- cost_percentage: (total_cost > 0) ? (model_cost / total_cost * 100).round(1) : 0
229
- }
230
- end.sort_by { |m| -(m[:total_cost] || 0) }
199
+ Execution.model_stats(scope: time_scoped(base_scope))
231
200
  end
232
201
 
233
- # Builds top errors list
234
- #
235
- # @param base_scope [ActiveRecord::Relation] Base scope to filter from
236
- # @return [Array<Hash>] Top 5 error classes with counts
202
+ # Delegates to Execution.top_errors with time scoping
237
203
  def build_top_errors(base_scope = Execution)
238
- scope = time_scoped(base_scope).where(status: "error")
239
- total_errors = scope.count
240
-
241
- scope.group(:error_class)
242
- .select("error_class, COUNT(*) as count, MAX(created_at) as last_seen")
243
- .order("count DESC")
244
- .limit(5)
245
- .map do |row|
246
- {
247
- error_class: row.error_class || "Unknown Error",
248
- count: row.count,
249
- percentage: (total_errors > 0) ? (row.count.to_f / total_errors * 100).round(1) : 0,
250
- last_seen: row.last_seen
251
- }
252
- end
253
- end
254
-
255
- # Fetches cached daily statistics for the dashboard
256
- #
257
- # Results are cached for 1 minute to reduce database load while
258
- # keeping the dashboard reasonably up-to-date.
259
- #
260
- # @return [Hash] Daily statistics
261
- # @option return [Integer] :total_executions Total execution count today
262
- # @option return [Integer] :successful Successful execution count
263
- # @option return [Integer] :failed Failed execution count
264
- # @option return [Float] :total_cost Combined cost of all executions
265
- # @option return [Integer] :total_tokens Combined token usage
266
- # @option return [Integer] :avg_duration_ms Average execution duration
267
- # @option return [Float] :success_rate Percentage of successful executions
268
- def daily_stats
269
- Rails.cache.fetch("ruby_llm_agents/daily_stats/#{Date.current}", expires_in: 1.minute) do
270
- scope = Execution.today
271
- {
272
- total_executions: scope.count,
273
- successful: scope.successful.count,
274
- failed: scope.failed.count,
275
- total_cost: scope.total_cost_sum || 0,
276
- total_tokens: scope.total_tokens_sum || 0,
277
- avg_duration_ms: scope.avg_duration&.round || 0,
278
- success_rate: calculate_success_rate(scope)
279
- }
280
- end
281
- end
282
-
283
- # Calculates the success rate percentage for a scope
284
- #
285
- # @param scope [ActiveRecord::Relation] The execution scope to calculate from
286
- # @return [Float] Success rate as a percentage (0.0-100.0)
287
- def calculate_success_rate(scope)
288
- total = scope.count
289
- return 0.0 if total.zero?
290
- (scope.successful.count.to_f / total * 100).round(1)
204
+ Execution.top_errors(scope: time_scoped(base_scope))
291
205
  end
292
206
 
293
207
  # Loads budget status for display on dashboard
@@ -304,7 +218,7 @@ module RubyLLM
304
218
  open_breakers = []
305
219
 
306
220
  # Get all agents from execution history
307
- agent_types = Execution.distinct.pluck(:agent_type)
221
+ agent_types = tenant_scoped_executions.distinct.pluck(:agent_type)
308
222
 
309
223
  agent_types.each do |agent_type|
310
224
  # Get the agent class if available
@@ -435,87 +349,19 @@ module RubyLLM
435
349
  alerts.take(3)
436
350
  end
437
351
 
438
- # Builds cache savings statistics for the dashboard
439
- #
440
- # @param base_scope [ActiveRecord::Relation] Base scope to filter from
441
- # @return [Hash] Cache savings data with count, estimated savings, and hit rate
352
+ # Delegates to Execution.cache_savings with time scoping
442
353
  def build_cache_savings(base_scope)
443
- scope = time_scoped(base_scope)
444
- total_count = scope.count
445
- return {count: 0, estimated_savings: 0, hit_rate: 0, total_executions: 0} if total_count.zero?
446
-
447
- cached_scope = scope.cached
448
- cache_count = cached_scope.count
449
- estimated_savings = cached_scope.sum(:total_cost)
450
-
451
- {
452
- count: cache_count,
453
- estimated_savings: estimated_savings,
454
- hit_rate: (cache_count.to_f / total_count * 100).round(1),
455
- total_executions: total_count
456
- }
354
+ Execution.cache_savings(scope: time_scoped(base_scope))
457
355
  end
458
356
 
459
- # Builds top tenants list for the dashboard overview
460
- #
461
- # @return [Array<Hash>, nil] Top 5 tenants by monthly spend, or nil if none
357
+ # Delegates to Tenant.top_by_spend
462
358
  def build_top_tenants
463
- return nil unless Tenant.table_exists?
464
-
465
- tenants = Tenant.active
466
- .where("monthly_cost_spent > 0 OR monthly_executions_count > 0")
467
- .order(monthly_cost_spent: :desc)
468
- .limit(5)
469
-
470
- return nil if tenants.empty?
471
-
472
- tenants.map do |tenant|
473
- tenant.ensure_daily_reset!
474
- tenant.ensure_monthly_reset!
475
-
476
- monthly_limit = tenant.effective_monthly_limit
477
- daily_limit = tenant.effective_daily_limit
478
-
479
- {
480
- id: tenant.id,
481
- tenant_id: tenant.tenant_id,
482
- name: tenant.display_name,
483
- enforcement: tenant.effective_enforcement,
484
- monthly_spend: tenant.monthly_cost_spent,
485
- monthly_limit: monthly_limit,
486
- monthly_percentage: (monthly_limit.to_f > 0) ? (tenant.monthly_cost_spent / monthly_limit * 100).round(1) : 0,
487
- daily_spend: tenant.daily_cost_spent,
488
- daily_limit: daily_limit,
489
- daily_percentage: (daily_limit.to_f > 0) ? (tenant.daily_cost_spent / daily_limit * 100).round(1) : 0,
490
- monthly_executions: tenant.monthly_executions_count
491
- }
492
- end
359
+ Tenant.top_by_spend(limit: 5)
493
360
  end
494
361
 
495
- # Batch fetches execution stats for all agents in a time period
496
- #
497
- # @param scope [ActiveRecord::Relation] Base scope with time filter
498
- # @return [Hash<String, Hash>] Agent type => stats hash
362
+ # Delegates to Execution.batch_agent_stats with pre-filtered scope
499
363
  def batch_fetch_agent_stats(scope)
500
- counts = scope.group(:agent_type).count
501
- costs = scope.group(:agent_type).sum(:total_cost)
502
- success_counts = scope.successful.group(:agent_type).count
503
- durations = scope.group(:agent_type).average(:duration_ms)
504
-
505
- agent_types = (counts.keys + costs.keys).uniq
506
- agent_types.each_with_object({}) do |agent_type, hash|
507
- count = counts[agent_type] || 0
508
- total_cost = costs[agent_type] || 0
509
- successful = success_counts[agent_type] || 0
510
-
511
- hash[agent_type] = {
512
- count: count,
513
- total_cost: total_cost,
514
- avg_cost: (count > 0) ? (total_cost / count).round(6) : 0,
515
- avg_duration_ms: durations[agent_type]&.round || 0,
516
- success_rate: (count > 0) ? (successful.to_f / count * 100).round(1) : 0
517
- }
518
- end
364
+ Execution.batch_agent_stats(scope: scope)
519
365
  end
520
366
  end
521
367
  end
@@ -39,7 +39,7 @@ module RubyLLM
39
39
  #
40
40
  # @return [void]
41
41
  def show
42
- @execution = Execution.find(params[:id])
42
+ @execution = tenant_scoped_executions.includes(:detail, :child_executions).find(params[:id])
43
43
  end
44
44
 
45
45
  # Handles filter search requests via Turbo Stream
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Controller for browsing tracked request groups
6
+ #
7
+ # Provides listing and detail views for executions grouped by
8
+ # request_id, as set by RubyLLM::Agents.track blocks.
9
+ #
10
+ # @api private
11
+ class RequestsController < ApplicationController
12
+ include Paginatable
13
+
14
+ # Lists all tracked requests with aggregated stats
15
+ #
16
+ # @return [void]
17
+ def index
18
+ @sort_column = sanitize_sort_column(params[:sort])
19
+ @sort_direction = (params[:direction] == "asc") ? "asc" : "desc"
20
+
21
+ scope = Execution
22
+ .where.not(request_id: [nil, ""])
23
+ .select(
24
+ "request_id",
25
+ "COUNT(*) AS call_count",
26
+ "SUM(total_cost) AS total_cost",
27
+ "SUM(total_tokens) AS total_tokens",
28
+ "MIN(started_at) AS started_at",
29
+ "MAX(completed_at) AS completed_at",
30
+ "SUM(duration_ms) AS total_duration_ms",
31
+ "GROUP_CONCAT(DISTINCT agent_type) AS agent_types_list",
32
+ "GROUP_CONCAT(DISTINCT status) AS statuses_list",
33
+ "MAX(created_at) AS latest_created_at"
34
+ )
35
+ .group(:request_id)
36
+
37
+ # Apply time filter
38
+ days = params[:days].to_i
39
+ scope = scope.where("created_at >= ?", days.days.ago) if days > 0
40
+
41
+ result = paginate_requests(scope)
42
+ @requests = result[:records]
43
+ @pagination = result[:pagination]
44
+
45
+ # Stats
46
+ total_scope = Execution.where.not(request_id: [nil, ""])
47
+ @stats = {
48
+ total_requests: total_scope.distinct.count(:request_id),
49
+ total_cost: total_scope.sum(:total_cost) || 0
50
+ }
51
+ end
52
+
53
+ # Shows a single tracked request with all its executions
54
+ #
55
+ # @return [void]
56
+ def show
57
+ @request_id = params[:id]
58
+ @executions = Execution
59
+ .where(request_id: @request_id)
60
+ .order(started_at: :asc)
61
+
62
+ if @executions.empty?
63
+ redirect_to ruby_llm_agents.requests_path,
64
+ alert: "Request not found: #{@request_id}"
65
+ return
66
+ end
67
+
68
+ @summary = {
69
+ call_count: @executions.count,
70
+ total_cost: @executions.sum(:total_cost) || 0,
71
+ total_tokens: @executions.sum(:total_tokens) || 0,
72
+ started_at: @executions.minimum(:started_at),
73
+ completed_at: @executions.maximum(:completed_at),
74
+ agent_types: @executions.distinct.pluck(:agent_type),
75
+ models_used: @executions.distinct.pluck(:model_id),
76
+ all_successful: @executions.where.not(status: "success").count.zero?,
77
+ error_count: @executions.where(status: "error").count
78
+ }
79
+
80
+ if @summary[:started_at] && @summary[:completed_at]
81
+ @summary[:duration_ms] = ((@summary[:completed_at] - @summary[:started_at]) * 1000).to_i
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ ALLOWED_SORT_COLUMNS = %w[latest_created_at call_count total_cost total_tokens total_duration_ms].freeze
88
+
89
+ def sanitize_sort_column(column)
90
+ ALLOWED_SORT_COLUMNS.include?(column) ? column : "latest_created_at"
91
+ end
92
+
93
+ def paginate_requests(scope)
94
+ page = [(params[:page] || 1).to_i, 1].max
95
+ per_page = RubyLLM::Agents.configuration.per_page
96
+
97
+ total_count = Execution
98
+ .where.not(request_id: [nil, ""])
99
+ .distinct
100
+ .count(:request_id)
101
+
102
+ sorted = scope.order("#{@sort_column} #{@sort_direction.upcase}")
103
+ offset = (page - 1) * per_page
104
+
105
+ {
106
+ records: sorted.offset(offset).limit(per_page),
107
+ pagination: {
108
+ current_page: page,
109
+ per_page: per_page,
110
+ total_count: total_count,
111
+ total_pages: (total_count.to_f / per_page).ceil
112
+ }
113
+ }
114
+ end
115
+ end
116
+ end
117
+ end
@@ -120,6 +120,44 @@ module RubyLLM
120
120
  end
121
121
  end
122
122
 
123
+ # Renders a sortable column header link with arrow indicator
124
+ #
125
+ # Replaces inline `raw()` calls in views with safe content_tag usage.
126
+ #
127
+ # @param column [String] The sort column name
128
+ # @param label [String] Display label for the header
129
+ # @param current_column [String] Currently active sort column
130
+ # @param current_direction [String] Current sort direction ("asc"/"desc")
131
+ # @param extra_class [String] Additional CSS classes
132
+ # @return [ActiveSupport::SafeBuffer] HTML link element
133
+ def sort_header_link(column, label, current_column:, current_direction:, extra_class: "")
134
+ is_active = column == current_column
135
+ next_dir = (is_active && current_direction == "asc") ? "desc" : "asc"
136
+ url = url_for(request.query_parameters.merge(sort: column, direction: next_dir, page: 1))
137
+
138
+ arrow = if is_active && current_direction == "asc"
139
+ content_tag(:svg, class: "w-2.5 h-2.5 inline", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
140
+ content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M5 15l7-7 7 7")
141
+ end
142
+ elsif is_active
143
+ content_tag(:svg, class: "w-2.5 h-2.5 inline", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
144
+ content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M19 9l-7 7-7-7")
145
+ end
146
+ else
147
+ "".html_safe
148
+ end
149
+
150
+ active_class = is_active ? "text-gray-700 dark:text-gray-300" : ""
151
+ opacity_class = is_active ? "opacity-100" : "opacity-0 group-hover:opacity-50"
152
+
153
+ link_to url, class: "group inline-flex items-center gap-0.5 hover:text-gray-700 dark:hover:text-gray-300 #{active_class} #{extra_class}" do
154
+ safe_join([
155
+ content_tag(:span, label),
156
+ content_tag(:span, arrow, class: "#{opacity_class} transition-opacity")
157
+ ])
158
+ end
159
+ end
160
+
123
161
  # Syntax-highlights a Ruby object as pretty-printed JSON
124
162
  #
125
163
  # Converts the object to JSON and applies color highlighting