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.
- checksums.yaml +4 -4
- data/README.md +132 -1263
- data/app/controllers/concerns/ruby_llm/agents/filterable.rb +5 -1
- data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +2 -1
- data/app/controllers/ruby_llm/agents/agents_controller.rb +21 -2
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +80 -16
- data/app/controllers/ruby_llm/agents/executions_controller.rb +83 -5
- data/app/models/ruby_llm/agents/execution/analytics.rb +17 -27
- data/app/models/ruby_llm/agents/execution/scopes.rb +25 -0
- data/app/models/ruby_llm/agents/execution/workflow.rb +299 -0
- data/app/models/ruby_llm/agents/execution.rb +9 -1
- data/app/models/ruby_llm/agents/tenant_budget.rb +165 -0
- data/app/services/ruby_llm/agents/agent_registry.rb +118 -7
- data/app/views/layouts/{rubyllm → ruby_llm}/agents/application.html.erb +91 -29
- data/app/views/ruby_llm/agents/agents/_empty_state.html.erb +23 -0
- data/app/views/ruby_llm/agents/agents/_workflow.html.erb +125 -0
- data/app/views/ruby_llm/agents/agents/index.html.erb +93 -0
- data/app/views/{rubyllm → ruby_llm}/agents/agents/show.html.erb +77 -20
- data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +112 -0
- data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_execution_item.html.erb +7 -4
- data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +115 -0
- data/app/views/{rubyllm → ruby_llm}/agents/dashboard/index.html.erb +9 -6
- data/app/views/{rubyllm → ruby_llm}/agents/executions/_execution.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/executions/_filters.html.erb +39 -11
- data/app/views/{rubyllm → ruby_llm}/agents/executions/_list.html.erb +19 -9
- data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +101 -0
- data/app/views/ruby_llm/agents/executions/index.html.erb +88 -0
- data/app/views/{rubyllm → ruby_llm}/agents/executions/show.html.erb +137 -126
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_breadcrumbs.html.erb +2 -2
- data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +251 -0
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_filter_dropdown.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_select_dropdown.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_badge.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_dot.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +26 -0
- data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +61 -0
- data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +97 -0
- data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +3 -3
- data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +23 -0
- data/lib/generators/ruby_llm_agents/templates/add_tool_calls_migration.rb.tt +2 -2
- data/lib/generators/ruby_llm_agents/templates/add_workflow_migration.rb.tt +38 -0
- data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +45 -0
- data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +17 -5
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +13 -0
- data/lib/ruby_llm/agents/alert_manager.rb +20 -16
- data/lib/ruby_llm/agents/base/caching.rb +4 -7
- data/lib/ruby_llm/agents/base/cost_calculation.rb +5 -3
- data/lib/ruby_llm/agents/base/execution.rb +61 -9
- data/lib/ruby_llm/agents/base/reliability_execution.rb +14 -9
- data/lib/ruby_llm/agents/base.rb +26 -0
- data/lib/ruby_llm/agents/budget_tracker.rb +250 -139
- data/lib/ruby_llm/agents/cache_helper.rb +98 -0
- data/lib/ruby_llm/agents/circuit_breaker.rb +48 -30
- data/lib/ruby_llm/agents/configuration.rb +40 -1
- data/lib/ruby_llm/agents/engine.rb +65 -1
- data/lib/ruby_llm/agents/inflections.rb +14 -0
- data/lib/ruby_llm/agents/instrumentation.rb +66 -0
- data/lib/ruby_llm/agents/reliability.rb +8 -2
- data/lib/ruby_llm/agents/version.rb +1 -1
- data/lib/ruby_llm/agents/workflow/instrumentation.rb +254 -0
- data/lib/ruby_llm/agents/workflow/parallel.rb +282 -0
- data/lib/ruby_llm/agents/workflow/pipeline.rb +306 -0
- data/lib/ruby_llm/agents/workflow/result.rb +390 -0
- data/lib/ruby_llm/agents/workflow/router.rb +429 -0
- data/lib/ruby_llm/agents/workflow.rb +232 -0
- data/lib/ruby_llm/agents.rb +1 -0
- metadata +50 -60
- data/app/views/rubyllm/agents/agents/index.html.erb +0 -20
- data/app/views/rubyllm/agents/dashboard/_agent_comparison.html.erb +0 -46
- data/app/views/rubyllm/agents/executions/index.html.erb +0 -28
- data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +0 -18
- data/app/views/rubyllm/agents/shared/_executions_table.html.erb +0 -193
- /data/app/views/{rubyllm → ruby_llm}/agents/agents/_agent.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/agents/_version_comparison.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_action_center.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_alerts_feed.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_breaker_strip.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_budgets_bar.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_now_strip.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_top_errors.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/executions/dry_run.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/settings/show.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/shared/_nav_link.html.erb +0 -0
- /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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
93
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
99
|
+
budget_config[:global_daily]
|
|
123
100
|
when [:global, :monthly]
|
|
124
|
-
|
|
101
|
+
budget_config[:global_monthly]
|
|
125
102
|
when [:agent, :daily]
|
|
126
|
-
|
|
103
|
+
budget_config[:per_agent_daily]&.dig(agent_type)
|
|
127
104
|
when [:agent, :monthly]
|
|
128
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
|
163
|
-
return nil unless
|
|
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
|
|
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:
|
|
186
|
-
on_track: projected_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
|
|
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:
|
|
201
|
-
on_track: projected_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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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 =
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
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
|
-
"
|
|
369
|
+
BudgetTracker.cache_key("budget", tenant_part, date_part)
|
|
266
370
|
when :agent
|
|
267
|
-
"
|
|
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
|
|
296
|
-
# @param
|
|
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,
|
|
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,
|
|
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,
|
|
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 =
|
|
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,
|
|
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 =
|
|
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,
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|