ruby_llm-agents 0.3.4 → 0.3.5

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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +132 -1263
  3. data/app/controllers/concerns/ruby_llm/agents/filterable.rb +5 -1
  4. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +2 -1
  5. data/app/controllers/ruby_llm/agents/agents_controller.rb +21 -2
  6. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +80 -16
  7. data/app/controllers/ruby_llm/agents/executions_controller.rb +83 -5
  8. data/app/models/ruby_llm/agents/execution/analytics.rb +17 -27
  9. data/app/models/ruby_llm/agents/execution/scopes.rb +25 -0
  10. data/app/models/ruby_llm/agents/execution/workflow.rb +299 -0
  11. data/app/models/ruby_llm/agents/execution.rb +9 -1
  12. data/app/models/ruby_llm/agents/tenant_budget.rb +165 -0
  13. data/app/services/ruby_llm/agents/agent_registry.rb +118 -7
  14. data/app/views/layouts/{rubyllm → ruby_llm}/agents/application.html.erb +91 -29
  15. data/app/views/ruby_llm/agents/agents/_empty_state.html.erb +23 -0
  16. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +125 -0
  17. data/app/views/ruby_llm/agents/agents/index.html.erb +93 -0
  18. data/app/views/{rubyllm → ruby_llm}/agents/agents/show.html.erb +77 -20
  19. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +112 -0
  20. data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_execution_item.html.erb +7 -4
  21. data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +115 -0
  22. data/app/views/{rubyllm → ruby_llm}/agents/dashboard/index.html.erb +9 -6
  23. data/app/views/{rubyllm → ruby_llm}/agents/executions/_execution.html.erb +1 -1
  24. data/app/views/{rubyllm → ruby_llm}/agents/executions/_filters.html.erb +39 -11
  25. data/app/views/{rubyllm → ruby_llm}/agents/executions/_list.html.erb +19 -9
  26. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +101 -0
  27. data/app/views/ruby_llm/agents/executions/index.html.erb +88 -0
  28. data/app/views/{rubyllm → ruby_llm}/agents/executions/show.html.erb +137 -126
  29. data/app/views/{rubyllm → ruby_llm}/agents/shared/_breadcrumbs.html.erb +2 -2
  30. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +251 -0
  31. data/app/views/{rubyllm → ruby_llm}/agents/shared/_filter_dropdown.html.erb +1 -1
  32. data/app/views/{rubyllm → ruby_llm}/agents/shared/_select_dropdown.html.erb +1 -1
  33. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_badge.html.erb +1 -1
  34. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_dot.html.erb +1 -1
  35. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +26 -0
  36. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +61 -0
  37. data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +97 -0
  38. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +3 -3
  39. data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +23 -0
  40. data/lib/generators/ruby_llm_agents/templates/add_tool_calls_migration.rb.tt +2 -2
  41. data/lib/generators/ruby_llm_agents/templates/add_workflow_migration.rb.tt +38 -0
  42. data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +45 -0
  43. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +17 -5
  44. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +13 -0
  45. data/lib/ruby_llm/agents/alert_manager.rb +20 -16
  46. data/lib/ruby_llm/agents/base/caching.rb +4 -7
  47. data/lib/ruby_llm/agents/base/cost_calculation.rb +5 -3
  48. data/lib/ruby_llm/agents/base/execution.rb +61 -9
  49. data/lib/ruby_llm/agents/base/reliability_execution.rb +14 -9
  50. data/lib/ruby_llm/agents/base.rb +26 -0
  51. data/lib/ruby_llm/agents/budget_tracker.rb +250 -139
  52. data/lib/ruby_llm/agents/cache_helper.rb +98 -0
  53. data/lib/ruby_llm/agents/circuit_breaker.rb +48 -30
  54. data/lib/ruby_llm/agents/configuration.rb +40 -1
  55. data/lib/ruby_llm/agents/engine.rb +65 -1
  56. data/lib/ruby_llm/agents/inflections.rb +14 -0
  57. data/lib/ruby_llm/agents/instrumentation.rb +66 -0
  58. data/lib/ruby_llm/agents/reliability.rb +8 -2
  59. data/lib/ruby_llm/agents/version.rb +1 -1
  60. data/lib/ruby_llm/agents/workflow/instrumentation.rb +254 -0
  61. data/lib/ruby_llm/agents/workflow/parallel.rb +282 -0
  62. data/lib/ruby_llm/agents/workflow/pipeline.rb +306 -0
  63. data/lib/ruby_llm/agents/workflow/result.rb +390 -0
  64. data/lib/ruby_llm/agents/workflow/router.rb +429 -0
  65. data/lib/ruby_llm/agents/workflow.rb +232 -0
  66. data/lib/ruby_llm/agents.rb +1 -0
  67. metadata +50 -60
  68. data/app/views/rubyllm/agents/agents/index.html.erb +0 -20
  69. data/app/views/rubyllm/agents/dashboard/_agent_comparison.html.erb +0 -46
  70. data/app/views/rubyllm/agents/executions/index.html.erb +0 -28
  71. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +0 -18
  72. data/app/views/rubyllm/agents/shared/_executions_table.html.erb +0 -193
  73. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_agent.html.erb +0 -0
  74. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_version_comparison.html.erb +0 -0
  75. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_action_center.html.erb +0 -0
  76. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_alerts_feed.html.erb +0 -0
  77. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_breaker_strip.html.erb +0 -0
  78. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_budgets_bar.html.erb +0 -0
  79. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_now_strip.html.erb +0 -0
  80. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_top_errors.html.erb +0 -0
  81. /data/app/views/{rubyllm → ruby_llm}/agents/executions/dry_run.html.erb +0 -0
  82. /data/app/views/{rubyllm → ruby_llm}/agents/settings/show.html.erb +0 -0
  83. /data/app/views/{rubyllm → ruby_llm}/agents/shared/_nav_link.html.erb +0 -0
  84. /data/app/views/{rubyllm → ruby_llm}/agents/shared/_stat_card.html.erb +0 -0
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "cache_helper"
4
+
3
5
  module RubyLLM
4
6
  module Agents
5
7
  # Cache-based budget tracking for cost governance
6
8
  #
7
9
  # Tracks spending against configured budget limits using cache counters.
8
10
  # Supports daily and monthly budgets at both global and per-agent levels.
11
+ # In multi-tenant mode, budgets are tracked separately per tenant.
9
12
  #
10
13
  # Note: Uses best-effort enforcement with cache counters. In high-concurrency
11
14
  # scenarios, slight overruns may occur due to race conditions. This is an
@@ -17,82 +20,54 @@ module RubyLLM
17
20
  # @example Recording spend after execution
18
21
  # BudgetTracker.record_spend!("MyAgent", 0.05)
19
22
  #
23
+ # @example Multi-tenant usage
24
+ # BudgetTracker.check_budget!("MyAgent", tenant_id: "acme_corp")
25
+ # BudgetTracker.record_spend!("MyAgent", 0.05, tenant_id: "acme_corp")
26
+ #
20
27
  # @see RubyLLM::Agents::Configuration
21
28
  # @see RubyLLM::Agents::Reliability::BudgetExceededError
29
+ # @see RubyLLM::Agents::TenantBudget
22
30
  # @api public
23
31
  module BudgetTracker
32
+ extend CacheHelper
33
+
24
34
  class << self
25
35
  # Checks if the current spend exceeds budget limits
26
36
  #
27
37
  # @param agent_type [String] The agent class name
38
+ # @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
28
39
  # @raise [Reliability::BudgetExceededError] If hard cap is exceeded
29
40
  # @return [void]
30
- def check_budget!(agent_type)
31
- config = RubyLLM::Agents.configuration
32
- return unless config.budgets_enabled?
33
-
34
- budgets = config.budgets
35
- enforcement = config.budget_enforcement
36
-
37
- # Only block on hard enforcement
38
- return unless enforcement == :hard
39
-
40
- # Check global daily budget
41
- if budgets[:global_daily]
42
- current = current_spend(:global, :daily)
43
- if current >= budgets[:global_daily]
44
- raise Reliability::BudgetExceededError.new(:global_daily, budgets[:global_daily], current)
45
- end
46
- end
47
-
48
- # Check global monthly budget
49
- if budgets[:global_monthly]
50
- current = current_spend(:global, :monthly)
51
- if current >= budgets[:global_monthly]
52
- raise Reliability::BudgetExceededError.new(:global_monthly, budgets[:global_monthly], current)
53
- end
54
- end
41
+ def check_budget!(agent_type, tenant_id: nil)
42
+ tenant_id = resolve_tenant_id(tenant_id)
43
+ budget_config = resolve_budget_config(tenant_id)
55
44
 
56
- # Check per-agent daily budget
57
- agent_daily_limit = budgets[:per_agent_daily]&.dig(agent_type)
58
- if agent_daily_limit
59
- current = current_spend(:agent, :daily, agent_type: agent_type)
60
- if current >= agent_daily_limit
61
- raise Reliability::BudgetExceededError.new(:per_agent_daily, agent_daily_limit, current, agent_type: agent_type)
62
- end
63
- end
45
+ return unless budget_config[:enabled]
46
+ return unless budget_config[:enforcement] == :hard
64
47
 
65
- # Check per-agent monthly budget
66
- agent_monthly_limit = budgets[:per_agent_monthly]&.dig(agent_type)
67
- if agent_monthly_limit
68
- current = current_spend(:agent, :monthly, agent_type: agent_type)
69
- if current >= agent_monthly_limit
70
- raise Reliability::BudgetExceededError.new(:per_agent_monthly, agent_monthly_limit, current, agent_type: agent_type)
71
- end
72
- end
48
+ check_budget_limits!(agent_type, tenant_id, budget_config)
73
49
  end
74
50
 
75
51
  # Records spend and checks for soft cap alerts
76
52
  #
77
53
  # @param agent_type [String] The agent class name
78
54
  # @param amount [Float] The amount spent in USD
55
+ # @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
79
56
  # @return [void]
80
- def record_spend!(agent_type, amount)
57
+ def record_spend!(agent_type, amount, tenant_id: nil)
81
58
  return if amount.nil? || amount <= 0
82
59
 
83
- config = RubyLLM::Agents.configuration
84
- budgets = config.budgets
60
+ tenant_id = resolve_tenant_id(tenant_id)
85
61
 
86
62
  # Increment all relevant counters
87
- increment_spend(:global, :daily, amount)
88
- increment_spend(:global, :monthly, amount)
89
- increment_spend(:agent, :daily, amount, agent_type: agent_type)
90
- increment_spend(:agent, :monthly, amount, agent_type: agent_type)
91
-
92
- # Check for soft cap alerts if budgets are configured
93
- return unless budgets.is_a?(Hash)
94
-
95
- check_soft_cap_alerts(agent_type, budgets, config)
63
+ increment_spend(:global, :daily, amount, tenant_id: tenant_id)
64
+ increment_spend(:global, :monthly, amount, tenant_id: tenant_id)
65
+ increment_spend(:agent, :daily, amount, agent_type: agent_type, tenant_id: tenant_id)
66
+ increment_spend(:agent, :monthly, amount, agent_type: agent_type, tenant_id: tenant_id)
67
+
68
+ # Check for soft cap alerts
69
+ budget_config = resolve_budget_config(tenant_id)
70
+ check_soft_cap_alerts(agent_type, tenant_id, budget_config) if budget_config[:enabled]
96
71
  end
97
72
 
98
73
  # Returns the current spend for a scope and period
@@ -100,10 +75,12 @@ module RubyLLM
100
75
  # @param scope [Symbol] :global or :agent
101
76
  # @param period [Symbol] :daily or :monthly
102
77
  # @param agent_type [String, nil] Required when scope is :agent
78
+ # @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
103
79
  # @return [Float] Current spend in USD
104
- def current_spend(scope, period, agent_type: nil)
105
- key = cache_key(scope, period, agent_type: agent_type)
106
- (cache_store.read(key) || 0).to_f
80
+ def current_spend(scope, period, agent_type: nil, tenant_id: nil)
81
+ tenant_id = resolve_tenant_id(tenant_id)
82
+ key = budget_cache_key(scope, period, agent_type: agent_type, tenant_id: tenant_id)
83
+ (BudgetTracker.cache_read(key) || 0).to_f
107
84
  end
108
85
 
109
86
  # Returns the remaining budget for a scope and period
@@ -111,59 +88,62 @@ module RubyLLM
111
88
  # @param scope [Symbol] :global or :agent
112
89
  # @param period [Symbol] :daily or :monthly
113
90
  # @param agent_type [String, nil] Required when scope is :agent
91
+ # @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
114
92
  # @return [Float, nil] Remaining budget in USD, or nil if no limit configured
115
- def remaining_budget(scope, period, agent_type: nil)
116
- config = RubyLLM::Agents.configuration
117
- budgets = config.budgets
118
- return nil unless budgets.is_a?(Hash)
93
+ def remaining_budget(scope, period, agent_type: nil, tenant_id: nil)
94
+ tenant_id = resolve_tenant_id(tenant_id)
95
+ budget_config = resolve_budget_config(tenant_id)
119
96
 
120
97
  limit = case [scope, period]
121
98
  when [:global, :daily]
122
- budgets[:global_daily]
99
+ budget_config[:global_daily]
123
100
  when [:global, :monthly]
124
- budgets[:global_monthly]
101
+ budget_config[:global_monthly]
125
102
  when [:agent, :daily]
126
- budgets[:per_agent_daily]&.dig(agent_type)
103
+ budget_config[:per_agent_daily]&.dig(agent_type)
127
104
  when [:agent, :monthly]
128
- budgets[:per_agent_monthly]&.dig(agent_type)
105
+ budget_config[:per_agent_monthly]&.dig(agent_type)
129
106
  end
130
107
 
131
108
  return nil unless limit
132
109
 
133
- [limit - current_spend(scope, period, agent_type: agent_type), 0].max
110
+ [limit - current_spend(scope, period, agent_type: agent_type, tenant_id: tenant_id), 0].max
134
111
  end
135
112
 
136
113
  # Returns a summary of all budget statuses
137
114
  #
138
115
  # @param agent_type [String, nil] Optional agent type for per-agent budgets
116
+ # @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
139
117
  # @return [Hash] Budget status information
140
- def status(agent_type: nil)
141
- config = RubyLLM::Agents.configuration
142
- budgets = config.budgets || {}
118
+ def status(agent_type: nil, tenant_id: nil)
119
+ tenant_id = resolve_tenant_id(tenant_id)
120
+ budget_config = resolve_budget_config(tenant_id)
143
121
 
144
122
  {
145
- enabled: config.budgets_enabled?,
146
- enforcement: config.budget_enforcement,
147
- global_daily: budget_status(:global, :daily, budgets[:global_daily]),
148
- global_monthly: budget_status(:global, :monthly, budgets[:global_monthly]),
149
- per_agent_daily: agent_type ? budget_status(:agent, :daily, budgets[:per_agent_daily]&.dig(agent_type), agent_type: agent_type) : nil,
150
- per_agent_monthly: agent_type ? budget_status(:agent, :monthly, budgets[:per_agent_monthly]&.dig(agent_type), agent_type: agent_type) : nil,
151
- forecast: calculate_forecast
123
+ tenant_id: tenant_id,
124
+ enabled: budget_config[:enabled],
125
+ enforcement: budget_config[:enforcement],
126
+ global_daily: budget_status(:global, :daily, budget_config[:global_daily], tenant_id: tenant_id),
127
+ global_monthly: budget_status(:global, :monthly, budget_config[:global_monthly], tenant_id: tenant_id),
128
+ per_agent_daily: agent_type ? budget_status(:agent, :daily, budget_config[:per_agent_daily]&.dig(agent_type), agent_type: agent_type, tenant_id: tenant_id) : nil,
129
+ per_agent_monthly: agent_type ? budget_status(:agent, :monthly, budget_config[:per_agent_monthly]&.dig(agent_type), agent_type: agent_type, tenant_id: tenant_id) : nil,
130
+ forecast: calculate_forecast(tenant_id: tenant_id)
152
131
  }.compact
153
132
  end
154
133
 
155
134
  # Calculates budget forecasts based on current spending trends
156
135
  #
136
+ # @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
157
137
  # @return [Hash, nil] Forecast information
158
- def calculate_forecast
159
- config = RubyLLM::Agents.configuration
160
- budgets = config.budgets || {}
138
+ def calculate_forecast(tenant_id: nil)
139
+ tenant_id = resolve_tenant_id(tenant_id)
140
+ budget_config = resolve_budget_config(tenant_id)
161
141
 
162
- return nil unless config.budgets_enabled?
163
- return nil unless budgets[:global_daily] || budgets[:global_monthly]
142
+ return nil unless budget_config[:enabled]
143
+ return nil unless budget_config[:global_daily] || budget_config[:global_monthly]
164
144
 
165
- daily_current = current_spend(:global, :daily)
166
- monthly_current = current_spend(:global, :monthly)
145
+ daily_current = current_spend(:global, :daily, tenant_id: tenant_id)
146
+ monthly_current = current_spend(:global, :monthly, tenant_id: tenant_id)
167
147
 
168
148
  # Calculate hours elapsed today and days elapsed this month
169
149
  hours_elapsed = Time.current.hour + (Time.current.min / 60.0)
@@ -176,29 +156,29 @@ module RubyLLM
176
156
  forecast = {}
177
157
 
178
158
  # Daily forecast
179
- if budgets[:global_daily]
159
+ if budget_config[:global_daily]
180
160
  daily_rate = daily_current / hours_elapsed
181
161
  projected_daily = daily_rate * 24
182
162
  forecast[:daily] = {
183
163
  current: daily_current.round(4),
184
164
  projected: projected_daily.round(4),
185
- limit: budgets[:global_daily],
186
- on_track: projected_daily <= budgets[:global_daily],
165
+ limit: budget_config[:global_daily],
166
+ on_track: projected_daily <= budget_config[:global_daily],
187
167
  hours_remaining: (24 - hours_elapsed).round(1),
188
168
  rate_per_hour: daily_rate.round(6)
189
169
  }
190
170
  end
191
171
 
192
172
  # Monthly forecast
193
- if budgets[:global_monthly]
173
+ if budget_config[:global_monthly]
194
174
  monthly_rate = monthly_current / days_elapsed
195
175
  projected_monthly = monthly_rate * days_in_month
196
176
  days_remaining = days_in_month - day_of_month
197
177
  forecast[:monthly] = {
198
178
  current: monthly_current.round(4),
199
179
  projected: projected_monthly.round(4),
200
- limit: budgets[:global_monthly],
201
- on_track: projected_monthly <= budgets[:global_monthly],
180
+ limit: budget_config[:global_monthly],
181
+ on_track: projected_monthly <= budget_config[:global_monthly],
202
182
  days_remaining: days_remaining,
203
183
  rate_per_day: monthly_rate.round(4)
204
184
  }
@@ -209,46 +189,168 @@ module RubyLLM
209
189
 
210
190
  # Resets all budget counters (useful for testing)
211
191
  #
192
+ # @param tenant_id [String, nil] Optional tenant identifier to reset only that tenant's counters
212
193
  # @return [void]
213
- def reset!
214
- # Note: This is a simple implementation. In production, you might want
215
- # to iterate over all known keys or use cache namespacing.
194
+ def reset!(tenant_id: nil)
195
+ tenant_id = resolve_tenant_id(tenant_id)
216
196
  today = Date.current.to_s
217
197
  month = Date.current.strftime("%Y-%m")
218
198
 
219
- cache_store.delete("ruby_llm_agents:budget:global:#{today}")
220
- cache_store.delete("ruby_llm_agents:budget:global:#{month}")
199
+ tenant_part = tenant_id.present? ? "tenant:#{tenant_id}" : "global"
200
+
201
+ BudgetTracker.cache_delete(BudgetTracker.cache_key("budget", tenant_part, today))
202
+ BudgetTracker.cache_delete(BudgetTracker.cache_key("budget", tenant_part, month))
203
+
204
+ # Reset memoized table existence check (useful for testing)
205
+ remove_instance_variable(:@tenant_budget_table_exists) if defined?(@tenant_budget_table_exists)
221
206
  end
222
207
 
223
208
  private
224
209
 
210
+ # Resolves the current tenant ID
211
+ #
212
+ # @param explicit_tenant_id [String, nil] Explicitly passed tenant ID
213
+ # @return [String, nil] Resolved tenant ID or nil if multi-tenancy disabled
214
+ def resolve_tenant_id(explicit_tenant_id)
215
+ config = RubyLLM::Agents.configuration
216
+
217
+ # Ignore tenant_id entirely when multi-tenancy is disabled
218
+ return nil unless config.multi_tenancy_enabled?
219
+
220
+ # Use explicit tenant_id if provided, otherwise use resolver
221
+ return explicit_tenant_id if explicit_tenant_id.present?
222
+
223
+ config.tenant_resolver&.call
224
+ end
225
+
226
+ # Resolves budget configuration for a tenant
227
+ #
228
+ # @param tenant_id [String, nil] The tenant identifier
229
+ # @return [Hash] Budget configuration
230
+ def resolve_budget_config(tenant_id)
231
+ config = RubyLLM::Agents.configuration
232
+
233
+ # If multi-tenancy is disabled or no tenant, use global config
234
+ if tenant_id.nil? || !config.multi_tenancy_enabled?
235
+ return {
236
+ enabled: config.budgets_enabled?,
237
+ enforcement: config.budget_enforcement,
238
+ global_daily: config.budgets&.dig(:global_daily),
239
+ global_monthly: config.budgets&.dig(:global_monthly),
240
+ per_agent_daily: config.budgets&.dig(:per_agent_daily),
241
+ per_agent_monthly: config.budgets&.dig(:per_agent_monthly)
242
+ }
243
+ end
244
+
245
+ # Look up tenant-specific budget from database (if table exists)
246
+ tenant_budget = lookup_tenant_budget(tenant_id)
247
+
248
+ if tenant_budget
249
+ tenant_budget.to_budget_config
250
+ else
251
+ # Fall back to global config for unknown tenants
252
+ {
253
+ enabled: config.budgets_enabled?,
254
+ enforcement: config.budget_enforcement,
255
+ global_daily: config.budgets&.dig(:global_daily),
256
+ global_monthly: config.budgets&.dig(:global_monthly),
257
+ per_agent_daily: config.budgets&.dig(:per_agent_daily),
258
+ per_agent_monthly: config.budgets&.dig(:per_agent_monthly)
259
+ }
260
+ end
261
+ end
262
+
263
+ # Safely looks up tenant budget, handling missing table
264
+ #
265
+ # @param tenant_id [String] The tenant identifier
266
+ # @return [TenantBudget, nil] The tenant budget or nil
267
+ def lookup_tenant_budget(tenant_id)
268
+ return nil unless tenant_budget_table_exists?
269
+
270
+ TenantBudget.for_tenant(tenant_id)
271
+ rescue StandardError => e
272
+ Rails.logger.warn("[RubyLLM::Agents] Failed to lookup tenant budget: #{e.message}")
273
+ nil
274
+ end
275
+
276
+ # Checks if the tenant_budgets table exists
277
+ #
278
+ # @return [Boolean] true if table exists
279
+ def tenant_budget_table_exists?
280
+ return @tenant_budget_table_exists if defined?(@tenant_budget_table_exists)
281
+
282
+ @tenant_budget_table_exists = ::ActiveRecord::Base.connection.table_exists?(:ruby_llm_agents_tenant_budgets)
283
+ rescue StandardError
284
+ @tenant_budget_table_exists = false
285
+ end
286
+
287
+ # Resets the memoized tenant budget table existence check (useful for testing)
288
+ #
289
+ # @return [void]
290
+ def reset_tenant_budget_table_check!
291
+ remove_instance_variable(:@tenant_budget_table_exists) if defined?(@tenant_budget_table_exists)
292
+ end
293
+
294
+ # Checks budget limits and raises error if exceeded
295
+ #
296
+ # @param agent_type [String] The agent class name
297
+ # @param tenant_id [String, nil] The tenant identifier
298
+ # @param budget_config [Hash] The budget configuration
299
+ # @raise [Reliability::BudgetExceededError] If limit exceeded
300
+ # @return [void]
301
+ def check_budget_limits!(agent_type, tenant_id, budget_config)
302
+ # Check global daily budget
303
+ if budget_config[:global_daily]
304
+ current = current_spend(:global, :daily, tenant_id: tenant_id)
305
+ if current >= budget_config[:global_daily]
306
+ raise Reliability::BudgetExceededError.new(:global_daily, budget_config[:global_daily], current, tenant_id: tenant_id)
307
+ end
308
+ end
309
+
310
+ # Check global monthly budget
311
+ if budget_config[:global_monthly]
312
+ current = current_spend(:global, :monthly, tenant_id: tenant_id)
313
+ if current >= budget_config[:global_monthly]
314
+ raise Reliability::BudgetExceededError.new(:global_monthly, budget_config[:global_monthly], current, tenant_id: tenant_id)
315
+ end
316
+ end
317
+
318
+ # Check per-agent daily budget
319
+ agent_daily_limit = budget_config[:per_agent_daily]&.dig(agent_type)
320
+ if agent_daily_limit
321
+ current = current_spend(:agent, :daily, agent_type: agent_type, tenant_id: tenant_id)
322
+ if current >= agent_daily_limit
323
+ raise Reliability::BudgetExceededError.new(:per_agent_daily, agent_daily_limit, current, agent_type: agent_type, tenant_id: tenant_id)
324
+ end
325
+ end
326
+
327
+ # Check per-agent monthly budget
328
+ agent_monthly_limit = budget_config[:per_agent_monthly]&.dig(agent_type)
329
+ if agent_monthly_limit
330
+ current = current_spend(:agent, :monthly, agent_type: agent_type, tenant_id: tenant_id)
331
+ if current >= agent_monthly_limit
332
+ raise Reliability::BudgetExceededError.new(:per_agent_monthly, agent_monthly_limit, current, agent_type: agent_type, tenant_id: tenant_id)
333
+ end
334
+ end
335
+ end
336
+
225
337
  # Increments the spend counter for a scope and period
226
338
  #
227
339
  # @param scope [Symbol] :global or :agent
228
340
  # @param period [Symbol] :daily or :monthly
229
341
  # @param amount [Float] Amount to add
230
342
  # @param agent_type [String, nil] Required when scope is :agent
343
+ # @param tenant_id [String, nil] The tenant identifier
231
344
  # @return [Float] New total
232
- def increment_spend(scope, period, amount, agent_type: nil)
233
- key = cache_key(scope, period, agent_type: agent_type)
345
+ def increment_spend(scope, period, amount, agent_type: nil, tenant_id: nil)
346
+ key = budget_cache_key(scope, period, agent_type: agent_type, tenant_id: tenant_id)
234
347
  ttl = period == :daily ? 1.day : 31.days
235
348
 
236
- if cache_store.respond_to?(:increment)
237
- # Ensure key exists with TTL
238
- cache_store.write(key, 0, expires_in: ttl, unless_exist: true)
239
- # Note: increment typically works with integers, so we multiply by 1000000
240
- # to store as cents of a cent, then divide when reading
241
- # For simplicity, we use read-modify-write here
242
- current = (cache_store.read(key) || 0).to_f
243
- new_total = current + amount
244
- cache_store.write(key, new_total, expires_in: ttl)
245
- new_total
246
- else
247
- current = (cache_store.read(key) || 0).to_f
248
- new_total = current + amount
249
- cache_store.write(key, new_total, expires_in: ttl)
250
- new_total
251
- end
349
+ # Read-modify-write for float values (cache increment is for integers)
350
+ current = (BudgetTracker.cache_read(key) || 0).to_f
351
+ new_total = current + amount
352
+ BudgetTracker.cache_write(key, new_total, expires_in: ttl)
353
+ new_total
252
354
  end
253
355
 
254
356
  # Generates a cache key for budget tracking
@@ -256,15 +358,17 @@ module RubyLLM
256
358
  # @param scope [Symbol] :global or :agent
257
359
  # @param period [Symbol] :daily or :monthly
258
360
  # @param agent_type [String, nil] Required when scope is :agent
361
+ # @param tenant_id [String, nil] The tenant identifier
259
362
  # @return [String] Cache key
260
- def cache_key(scope, period, agent_type: nil)
363
+ def budget_cache_key(scope, period, agent_type: nil, tenant_id: nil)
261
364
  date_part = period == :daily ? Date.current.to_s : Date.current.strftime("%Y-%m")
365
+ tenant_part = tenant_id.present? ? "tenant:#{tenant_id}" : "global"
262
366
 
263
367
  case scope
264
368
  when :global
265
- "ruby_llm_agents:budget:global:#{date_part}"
369
+ BudgetTracker.cache_key("budget", tenant_part, date_part)
266
370
  when :agent
267
- "ruby_llm_agents:budget:agent:#{agent_type}:#{date_part}"
371
+ BudgetTracker.cache_key("budget", tenant_part, "agent", agent_type, date_part)
268
372
  else
269
373
  raise ArgumentError, "Unknown scope: #{scope}"
270
374
  end
@@ -276,11 +380,12 @@ module RubyLLM
276
380
  # @param period [Symbol] :daily or :monthly
277
381
  # @param limit [Float, nil] The budget limit
278
382
  # @param agent_type [String, nil] Required when scope is :agent
383
+ # @param tenant_id [String, nil] The tenant identifier
279
384
  # @return [Hash, nil] Status hash or nil if no limit
280
- def budget_status(scope, period, limit, agent_type: nil)
385
+ def budget_status(scope, period, limit, agent_type: nil, tenant_id: nil)
281
386
  return nil unless limit
282
387
 
283
- current = current_spend(scope, period, agent_type: agent_type)
388
+ current = current_spend(scope, period, agent_type: agent_type, tenant_id: tenant_id)
284
389
  {
285
390
  limit: limit,
286
391
  current: current.round(6),
@@ -292,29 +397,38 @@ module RubyLLM
292
397
  # Checks for soft cap alerts after recording spend
293
398
  #
294
399
  # @param agent_type [String] The agent class name
295
- # @param budgets [Hash] Budget configuration
296
- # @param config [Configuration] The configuration
400
+ # @param tenant_id [String, nil] The tenant identifier
401
+ # @param budget_config [Hash] Budget configuration
297
402
  # @return [void]
298
- def check_soft_cap_alerts(agent_type, budgets, config)
403
+ def check_soft_cap_alerts(agent_type, tenant_id, budget_config)
404
+ config = RubyLLM::Agents.configuration
299
405
  return unless config.alerts_enabled?
300
406
  return unless config.alert_events.include?(:budget_soft_cap) || config.alert_events.include?(:budget_hard_cap)
301
407
 
302
408
  # Check global daily
303
- check_budget_alert(:global_daily, budgets[:global_daily], current_spend(:global, :daily), agent_type, config)
409
+ check_budget_alert(:global_daily, budget_config[:global_daily],
410
+ current_spend(:global, :daily, tenant_id: tenant_id),
411
+ agent_type, tenant_id, budget_config)
304
412
 
305
413
  # Check global monthly
306
- check_budget_alert(:global_monthly, budgets[:global_monthly], current_spend(:global, :monthly), agent_type, config)
414
+ check_budget_alert(:global_monthly, budget_config[:global_monthly],
415
+ current_spend(:global, :monthly, tenant_id: tenant_id),
416
+ agent_type, tenant_id, budget_config)
307
417
 
308
418
  # Check per-agent daily
309
- agent_daily_limit = budgets[:per_agent_daily]&.dig(agent_type)
419
+ agent_daily_limit = budget_config[:per_agent_daily]&.dig(agent_type)
310
420
  if agent_daily_limit
311
- check_budget_alert(:per_agent_daily, agent_daily_limit, current_spend(:agent, :daily, agent_type: agent_type), agent_type, config)
421
+ check_budget_alert(:per_agent_daily, agent_daily_limit,
422
+ current_spend(:agent, :daily, agent_type: agent_type, tenant_id: tenant_id),
423
+ agent_type, tenant_id, budget_config)
312
424
  end
313
425
 
314
426
  # Check per-agent monthly
315
- agent_monthly_limit = budgets[:per_agent_monthly]&.dig(agent_type)
427
+ agent_monthly_limit = budget_config[:per_agent_monthly]&.dig(agent_type)
316
428
  if agent_monthly_limit
317
- check_budget_alert(:per_agent_monthly, agent_monthly_limit, current_spend(:agent, :monthly, agent_type: agent_type), agent_type, config)
429
+ check_budget_alert(:per_agent_monthly, agent_monthly_limit,
430
+ current_spend(:agent, :monthly, agent_type: agent_type, tenant_id: tenant_id),
431
+ agent_type, tenant_id, budget_config)
318
432
  end
319
433
  end
320
434
 
@@ -324,36 +438,33 @@ module RubyLLM
324
438
  # @param limit [Float, nil] Budget limit
325
439
  # @param current [Float] Current spend
326
440
  # @param agent_type [String] Agent type
327
- # @param config [Configuration] Configuration
441
+ # @param tenant_id [String, nil] The tenant identifier
442
+ # @param budget_config [Hash] Budget configuration
328
443
  # @return [void]
329
- def check_budget_alert(scope, limit, current, agent_type, config)
444
+ def check_budget_alert(scope, limit, current, agent_type, tenant_id, budget_config)
330
445
  return unless limit
331
446
  return if current <= limit
332
447
 
333
- event = config.budget_enforcement == :hard ? :budget_hard_cap : :budget_soft_cap
448
+ event = budget_config[:enforcement] == :hard ? :budget_hard_cap : :budget_soft_cap
449
+ config = RubyLLM::Agents.configuration
334
450
  return unless config.alert_events.include?(event)
335
451
 
336
- # Prevent duplicate alerts by using a cache key
337
- alert_key = "ruby_llm_agents:budget_alert:#{scope}:#{Date.current}"
338
- return if cache_store.exist?(alert_key)
452
+ # Prevent duplicate alerts by using a cache key (include tenant for isolation)
453
+ tenant_part = tenant_id.present? ? "tenant:#{tenant_id}" : "global"
454
+ alert_key = BudgetTracker.cache_key("budget_alert", tenant_part, scope, Date.current.to_s)
455
+ return if BudgetTracker.cache_exist?(alert_key)
339
456
 
340
- cache_store.write(alert_key, true, expires_in: 1.hour)
457
+ BudgetTracker.cache_write(alert_key, true, expires_in: 1.hour)
341
458
 
342
459
  AlertManager.notify(event, {
343
460
  scope: scope,
344
461
  limit: limit,
345
462
  total: current.round(6),
346
463
  agent_type: agent_type,
464
+ tenant_id: tenant_id,
347
465
  timestamp: Date.current.to_s
348
466
  })
349
467
  end
350
-
351
- # Returns the cache store
352
- #
353
- # @return [ActiveSupport::Cache::Store]
354
- def cache_store
355
- RubyLLM::Agents.configuration.cache_store
356
- end
357
468
  end
358
469
  end
359
470
  end