ruby_llm-agents 0.4.0 → 0.5.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.
- checksums.yaml +4 -4
- data/README.md +46 -13
- data/app/controllers/ruby_llm/agents/api_configurations_controller.rb +214 -0
- data/app/controllers/ruby_llm/agents/{settings_controller.rb → system_config_controller.rb} +3 -3
- data/app/controllers/ruby_llm/agents/tenants_controller.rb +109 -0
- data/app/models/ruby_llm/agents/api_configuration.rb +386 -0
- data/app/models/ruby_llm/agents/tenant_budget.rb +62 -7
- data/app/views/layouts/ruby_llm/agents/application.html.erb +3 -1
- data/app/views/ruby_llm/agents/api_configurations/_api_key_field.html.erb +34 -0
- data/app/views/ruby_llm/agents/api_configurations/_form.html.erb +288 -0
- data/app/views/ruby_llm/agents/api_configurations/edit.html.erb +95 -0
- data/app/views/ruby_llm/agents/api_configurations/edit_tenant.html.erb +97 -0
- data/app/views/ruby_llm/agents/api_configurations/show.html.erb +211 -0
- data/app/views/ruby_llm/agents/api_configurations/tenant.html.erb +179 -0
- data/app/views/ruby_llm/agents/dashboard/_action_center.html.erb +1 -1
- data/app/views/ruby_llm/agents/executions/show.html.erb +82 -0
- data/app/views/ruby_llm/agents/{settings → system_config}/show.html.erb +1 -1
- data/app/views/ruby_llm/agents/tenants/_form.html.erb +150 -0
- data/app/views/ruby_llm/agents/tenants/edit.html.erb +13 -0
- data/app/views/ruby_llm/agents/tenants/index.html.erb +129 -0
- data/app/views/ruby_llm/agents/tenants/show.html.erb +374 -0
- data/config/routes.rb +12 -1
- data/lib/generators/ruby_llm_agents/api_configuration_generator.rb +100 -0
- data/lib/generators/ruby_llm_agents/templates/create_api_configurations_migration.rb.tt +90 -0
- data/lib/ruby_llm/agents/base/execution.rb +83 -0
- data/lib/ruby_llm/agents/base.rb +1 -0
- data/lib/ruby_llm/agents/budget_tracker.rb +285 -23
- data/lib/ruby_llm/agents/configuration.rb +38 -1
- data/lib/ruby_llm/agents/engine.rb +1 -0
- data/lib/ruby_llm/agents/instrumentation.rb +71 -3
- data/lib/ruby_llm/agents/resolved_config.rb +348 -0
- data/lib/ruby_llm/agents/version.rb +1 -1
- metadata +19 -3
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Migration to create the api_configurations table
|
|
4
|
+
#
|
|
5
|
+
# This table stores API key configurations that can be managed via the dashboard.
|
|
6
|
+
# Supports both global settings and per-tenant overrides.
|
|
7
|
+
#
|
|
8
|
+
# Resolution priority: per-tenant DB > global DB > config file (RubyLLM.configure)
|
|
9
|
+
#
|
|
10
|
+
# Features:
|
|
11
|
+
# - Encrypted storage for all API keys (using Rails encrypted attributes)
|
|
12
|
+
# - Support for all major LLM providers
|
|
13
|
+
# - Custom endpoint configuration
|
|
14
|
+
# - Connection settings
|
|
15
|
+
# - Default model configuration
|
|
16
|
+
#
|
|
17
|
+
# Run with: rails db:migrate
|
|
18
|
+
class CreateRubyLLMAgentsApiConfigurations < ActiveRecord::Migration<%= migration_version %>
|
|
19
|
+
def change
|
|
20
|
+
create_table :ruby_llm_agents_api_configurations do |t|
|
|
21
|
+
# Scope type: 'global' or 'tenant'
|
|
22
|
+
t.string :scope_type, null: false, default: 'global'
|
|
23
|
+
# Tenant ID when scope_type='tenant'
|
|
24
|
+
t.string :scope_id
|
|
25
|
+
|
|
26
|
+
# === Encrypted API Keys ===
|
|
27
|
+
# Rails encrypts stores encrypted data in the same-named column
|
|
28
|
+
# Primary providers
|
|
29
|
+
t.text :openai_api_key
|
|
30
|
+
t.text :anthropic_api_key
|
|
31
|
+
t.text :gemini_api_key
|
|
32
|
+
|
|
33
|
+
# Additional providers
|
|
34
|
+
t.text :deepseek_api_key
|
|
35
|
+
t.text :mistral_api_key
|
|
36
|
+
t.text :perplexity_api_key
|
|
37
|
+
t.text :openrouter_api_key
|
|
38
|
+
t.text :gpustack_api_key
|
|
39
|
+
t.text :xai_api_key
|
|
40
|
+
t.text :ollama_api_key
|
|
41
|
+
|
|
42
|
+
# AWS Bedrock
|
|
43
|
+
t.text :bedrock_api_key
|
|
44
|
+
t.text :bedrock_secret_key
|
|
45
|
+
t.text :bedrock_session_token
|
|
46
|
+
t.string :bedrock_region
|
|
47
|
+
|
|
48
|
+
# Google Vertex AI
|
|
49
|
+
t.text :vertexai_credentials
|
|
50
|
+
t.string :vertexai_project_id
|
|
51
|
+
t.string :vertexai_location
|
|
52
|
+
|
|
53
|
+
# === Custom Endpoints ===
|
|
54
|
+
t.string :openai_api_base
|
|
55
|
+
t.string :gemini_api_base
|
|
56
|
+
t.string :ollama_api_base
|
|
57
|
+
t.string :gpustack_api_base
|
|
58
|
+
t.string :xai_api_base
|
|
59
|
+
|
|
60
|
+
# === OpenAI Options ===
|
|
61
|
+
t.string :openai_organization_id
|
|
62
|
+
t.string :openai_project_id
|
|
63
|
+
|
|
64
|
+
# === Default Models ===
|
|
65
|
+
t.string :default_model
|
|
66
|
+
t.string :default_embedding_model
|
|
67
|
+
t.string :default_image_model
|
|
68
|
+
t.string :default_moderation_model
|
|
69
|
+
|
|
70
|
+
# === Connection Settings ===
|
|
71
|
+
t.integer :request_timeout
|
|
72
|
+
t.integer :max_retries
|
|
73
|
+
t.decimal :retry_interval, precision: 5, scale: 2
|
|
74
|
+
t.decimal :retry_backoff_factor, precision: 5, scale: 2
|
|
75
|
+
t.decimal :retry_interval_randomness, precision: 5, scale: 2
|
|
76
|
+
t.string :http_proxy
|
|
77
|
+
|
|
78
|
+
# Whether to inherit from global config for unset values
|
|
79
|
+
t.boolean :inherit_global_defaults, default: true
|
|
80
|
+
|
|
81
|
+
t.timestamps
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Ensure unique scope_type + scope_id combinations
|
|
85
|
+
add_index :ruby_llm_agents_api_configurations, [:scope_type, :scope_id], unique: true, name: 'idx_api_configs_scope'
|
|
86
|
+
|
|
87
|
+
# Index for faster tenant lookups
|
|
88
|
+
add_index :ruby_llm_agents_api_configurations, :scope_id, name: 'idx_api_configs_scope_id'
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -17,6 +17,9 @@ module RubyLLM
|
|
|
17
17
|
# @yieldparam chunk [RubyLLM::Chunk] A streaming chunk with content
|
|
18
18
|
# @return [Object] The processed LLM response
|
|
19
19
|
def call(&block)
|
|
20
|
+
# Resolve tenant configuration before execution
|
|
21
|
+
resolve_tenant_context!
|
|
22
|
+
|
|
20
23
|
return dry_run_response if @options[:dry_run]
|
|
21
24
|
return uncached_call(&block) if @options[:skip_cache] || !self.class.cache_enabled?
|
|
22
25
|
|
|
@@ -36,6 +39,52 @@ module RubyLLM
|
|
|
36
39
|
end
|
|
37
40
|
end
|
|
38
41
|
|
|
42
|
+
# Resolves tenant context from the :tenant option
|
|
43
|
+
#
|
|
44
|
+
# The tenant option can be:
|
|
45
|
+
# - String: Just the tenant_id (uses resolver or DB for config)
|
|
46
|
+
# - Hash: Full config { id:, name:, daily_limit:, daily_token_limit:, ... }
|
|
47
|
+
#
|
|
48
|
+
# @return [void]
|
|
49
|
+
def resolve_tenant_context!
|
|
50
|
+
# Idempotency guard - only resolve once
|
|
51
|
+
return if defined?(@tenant_context_resolved) && @tenant_context_resolved
|
|
52
|
+
|
|
53
|
+
tenant_option = @options[:tenant]
|
|
54
|
+
return unless tenant_option
|
|
55
|
+
|
|
56
|
+
if tenant_option.is_a?(Hash)
|
|
57
|
+
# Full config passed - extract id and store config
|
|
58
|
+
@tenant_id = tenant_option[:id]&.to_s
|
|
59
|
+
@tenant_config = tenant_option.except(:id)
|
|
60
|
+
else
|
|
61
|
+
# Just tenant_id passed
|
|
62
|
+
@tenant_id = tenant_option.to_s
|
|
63
|
+
@tenant_config = nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
@tenant_context_resolved = true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Returns the resolved tenant ID
|
|
70
|
+
#
|
|
71
|
+
# @return [String, nil] The tenant identifier
|
|
72
|
+
def resolved_tenant_id
|
|
73
|
+
return @tenant_id if defined?(@tenant_id) && @tenant_id.present?
|
|
74
|
+
|
|
75
|
+
config = RubyLLM::Agents.configuration
|
|
76
|
+
return nil unless config.multi_tenancy_enabled?
|
|
77
|
+
|
|
78
|
+
config.current_tenant_id
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Returns the runtime tenant config (if passed via :tenant option)
|
|
82
|
+
#
|
|
83
|
+
# @return [Hash, nil] Runtime tenant configuration
|
|
84
|
+
def runtime_tenant_config
|
|
85
|
+
@tenant_config if defined?(@tenant_config)
|
|
86
|
+
end
|
|
87
|
+
|
|
39
88
|
# Executes the agent without caching
|
|
40
89
|
#
|
|
41
90
|
# Routes to reliability-enabled execution if configured, otherwise
|
|
@@ -226,6 +275,9 @@ module RubyLLM
|
|
|
226
275
|
#
|
|
227
276
|
# @return [RubyLLM::Chat] Configured chat client
|
|
228
277
|
def build_client
|
|
278
|
+
# Apply database-backed API configuration if available
|
|
279
|
+
apply_api_configuration!
|
|
280
|
+
|
|
229
281
|
client = RubyLLM.chat
|
|
230
282
|
.with_model(model)
|
|
231
283
|
.with_temperature(temperature)
|
|
@@ -236,11 +288,42 @@ module RubyLLM
|
|
|
236
288
|
client
|
|
237
289
|
end
|
|
238
290
|
|
|
291
|
+
# Applies database-backed API configuration to RubyLLM
|
|
292
|
+
#
|
|
293
|
+
# Resolution priority: per-tenant DB > global DB > RubyLLM.configure
|
|
294
|
+
# Only applies if the api_configurations table exists.
|
|
295
|
+
#
|
|
296
|
+
# @return [void]
|
|
297
|
+
def apply_api_configuration!
|
|
298
|
+
return unless api_configuration_available?
|
|
299
|
+
|
|
300
|
+
resolved_config = ApiConfiguration.resolve(tenant_id: resolved_tenant_id)
|
|
301
|
+
resolved_config.apply_to_ruby_llm!
|
|
302
|
+
rescue StandardError => e
|
|
303
|
+
Rails.logger.warn("[RubyLLM::Agents] Failed to apply API config: #{e.message}")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Checks if API configuration table is available
|
|
307
|
+
#
|
|
308
|
+
# @return [Boolean] true if table exists and is accessible
|
|
309
|
+
def api_configuration_available?
|
|
310
|
+
return @api_config_available if defined?(@api_config_available)
|
|
311
|
+
|
|
312
|
+
@api_config_available = begin
|
|
313
|
+
ApiConfiguration.table_exists?
|
|
314
|
+
rescue StandardError
|
|
315
|
+
false
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
239
319
|
# Builds a client with a specific model
|
|
240
320
|
#
|
|
241
321
|
# @param model_id [String] The model identifier
|
|
242
322
|
# @return [RubyLLM::Chat] Configured chat client
|
|
243
323
|
def build_client_with_model(model_id)
|
|
324
|
+
# Apply database-backed API configuration if available
|
|
325
|
+
apply_api_configuration!
|
|
326
|
+
|
|
244
327
|
client = RubyLLM.chat
|
|
245
328
|
.with_model(model_id)
|
|
246
329
|
.with_temperature(temperature)
|
data/lib/ruby_llm/agents/base.rb
CHANGED
|
@@ -36,11 +36,12 @@ module RubyLLM
|
|
|
36
36
|
#
|
|
37
37
|
# @param agent_type [String] The agent class name
|
|
38
38
|
# @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
|
|
39
|
+
# @param tenant_config [Hash, nil] Optional runtime tenant config (takes priority over resolver/DB)
|
|
39
40
|
# @raise [Reliability::BudgetExceededError] If hard cap is exceeded
|
|
40
41
|
# @return [void]
|
|
41
|
-
def check_budget!(agent_type, tenant_id: nil)
|
|
42
|
+
def check_budget!(agent_type, tenant_id: nil, tenant_config: nil)
|
|
42
43
|
tenant_id = resolve_tenant_id(tenant_id)
|
|
43
|
-
budget_config = resolve_budget_config(tenant_id)
|
|
44
|
+
budget_config = resolve_budget_config(tenant_id, runtime_config: tenant_config)
|
|
44
45
|
|
|
45
46
|
return unless budget_config[:enabled]
|
|
46
47
|
return unless budget_config[:enforcement] == :hard
|
|
@@ -48,13 +49,31 @@ module RubyLLM
|
|
|
48
49
|
check_budget_limits!(agent_type, tenant_id, budget_config)
|
|
49
50
|
end
|
|
50
51
|
|
|
52
|
+
# Checks if the current token usage exceeds budget limits
|
|
53
|
+
#
|
|
54
|
+
# @param agent_type [String] The agent class name
|
|
55
|
+
# @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
|
|
56
|
+
# @param tenant_config [Hash, nil] Optional runtime tenant config (takes priority over resolver/DB)
|
|
57
|
+
# @raise [Reliability::BudgetExceededError] If hard cap is exceeded
|
|
58
|
+
# @return [void]
|
|
59
|
+
def check_token_budget!(agent_type, tenant_id: nil, tenant_config: nil)
|
|
60
|
+
tenant_id = resolve_tenant_id(tenant_id)
|
|
61
|
+
budget_config = resolve_budget_config(tenant_id, runtime_config: tenant_config)
|
|
62
|
+
|
|
63
|
+
return unless budget_config[:enabled]
|
|
64
|
+
return unless budget_config[:enforcement] == :hard
|
|
65
|
+
|
|
66
|
+
check_token_limits!(agent_type, tenant_id, budget_config)
|
|
67
|
+
end
|
|
68
|
+
|
|
51
69
|
# Records spend and checks for soft cap alerts
|
|
52
70
|
#
|
|
53
71
|
# @param agent_type [String] The agent class name
|
|
54
72
|
# @param amount [Float] The amount spent in USD
|
|
55
73
|
# @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
|
|
74
|
+
# @param tenant_config [Hash, nil] Optional runtime tenant config (takes priority over resolver/DB)
|
|
56
75
|
# @return [void]
|
|
57
|
-
def record_spend!(agent_type, amount, tenant_id: nil)
|
|
76
|
+
def record_spend!(agent_type, amount, tenant_id: nil, tenant_config: nil)
|
|
58
77
|
return if amount.nil? || amount <= 0
|
|
59
78
|
|
|
60
79
|
tenant_id = resolve_tenant_id(tenant_id)
|
|
@@ -66,10 +85,33 @@ module RubyLLM
|
|
|
66
85
|
increment_spend(:agent, :monthly, amount, agent_type: agent_type, tenant_id: tenant_id)
|
|
67
86
|
|
|
68
87
|
# Check for soft cap alerts
|
|
69
|
-
budget_config = resolve_budget_config(tenant_id)
|
|
88
|
+
budget_config = resolve_budget_config(tenant_id, runtime_config: tenant_config)
|
|
70
89
|
check_soft_cap_alerts(agent_type, tenant_id, budget_config) if budget_config[:enabled]
|
|
71
90
|
end
|
|
72
91
|
|
|
92
|
+
# Records token usage and checks for soft cap alerts
|
|
93
|
+
#
|
|
94
|
+
# @param agent_type [String] The agent class name
|
|
95
|
+
# @param tokens [Integer] The number of tokens used
|
|
96
|
+
# @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
|
|
97
|
+
# @param tenant_config [Hash, nil] Optional runtime tenant config (takes priority over resolver/DB)
|
|
98
|
+
# @return [void]
|
|
99
|
+
def record_tokens!(agent_type, tokens, tenant_id: nil, tenant_config: nil)
|
|
100
|
+
return if tokens.nil? || tokens <= 0
|
|
101
|
+
|
|
102
|
+
tenant_id = resolve_tenant_id(tenant_id)
|
|
103
|
+
|
|
104
|
+
# Increment all relevant token counters
|
|
105
|
+
increment_tokens(:global, :daily, tokens, tenant_id: tenant_id)
|
|
106
|
+
increment_tokens(:global, :monthly, tokens, tenant_id: tenant_id)
|
|
107
|
+
increment_tokens(:agent, :daily, tokens, agent_type: agent_type, tenant_id: tenant_id)
|
|
108
|
+
increment_tokens(:agent, :monthly, tokens, agent_type: agent_type, tenant_id: tenant_id)
|
|
109
|
+
|
|
110
|
+
# Check for soft cap alerts
|
|
111
|
+
budget_config = resolve_budget_config(tenant_id, runtime_config: tenant_config)
|
|
112
|
+
check_soft_token_alerts(agent_type, tenant_id, budget_config) if budget_config[:enabled]
|
|
113
|
+
end
|
|
114
|
+
|
|
73
115
|
# Returns the current spend for a scope and period
|
|
74
116
|
#
|
|
75
117
|
# @param scope [Symbol] :global or :agent
|
|
@@ -83,6 +125,17 @@ module RubyLLM
|
|
|
83
125
|
(BudgetTracker.cache_read(key) || 0).to_f
|
|
84
126
|
end
|
|
85
127
|
|
|
128
|
+
# Returns the current token usage for a period (global only)
|
|
129
|
+
#
|
|
130
|
+
# @param period [Symbol] :daily or :monthly
|
|
131
|
+
# @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
|
|
132
|
+
# @return [Integer] Current token usage
|
|
133
|
+
def current_tokens(period, tenant_id: nil)
|
|
134
|
+
tenant_id = resolve_tenant_id(tenant_id)
|
|
135
|
+
key = token_cache_key(period, tenant_id: tenant_id)
|
|
136
|
+
(BudgetTracker.cache_read(key) || 0).to_i
|
|
137
|
+
end
|
|
138
|
+
|
|
86
139
|
# Returns the remaining budget for a scope and period
|
|
87
140
|
#
|
|
88
141
|
# @param scope [Symbol] :global or :agent
|
|
@@ -110,6 +163,27 @@ module RubyLLM
|
|
|
110
163
|
[limit - current_spend(scope, period, agent_type: agent_type, tenant_id: tenant_id), 0].max
|
|
111
164
|
end
|
|
112
165
|
|
|
166
|
+
# Returns the remaining token budget for a period (global only)
|
|
167
|
+
#
|
|
168
|
+
# @param period [Symbol] :daily or :monthly
|
|
169
|
+
# @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
|
|
170
|
+
# @return [Integer, nil] Remaining token budget, or nil if no limit configured
|
|
171
|
+
def remaining_token_budget(period, tenant_id: nil)
|
|
172
|
+
tenant_id = resolve_tenant_id(tenant_id)
|
|
173
|
+
budget_config = resolve_budget_config(tenant_id)
|
|
174
|
+
|
|
175
|
+
limit = case period
|
|
176
|
+
when :daily
|
|
177
|
+
budget_config[:global_daily_tokens]
|
|
178
|
+
when :monthly
|
|
179
|
+
budget_config[:global_monthly_tokens]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
return nil unless limit
|
|
183
|
+
|
|
184
|
+
[limit - current_tokens(period, tenant_id: tenant_id), 0].max
|
|
185
|
+
end
|
|
186
|
+
|
|
113
187
|
# Returns a summary of all budget statuses
|
|
114
188
|
#
|
|
115
189
|
# @param agent_type [String, nil] Optional agent type for per-agent budgets
|
|
@@ -123,10 +197,14 @@ module RubyLLM
|
|
|
123
197
|
tenant_id: tenant_id,
|
|
124
198
|
enabled: budget_config[:enabled],
|
|
125
199
|
enforcement: budget_config[:enforcement],
|
|
200
|
+
# Cost budgets
|
|
126
201
|
global_daily: budget_status(:global, :daily, budget_config[:global_daily], tenant_id: tenant_id),
|
|
127
202
|
global_monthly: budget_status(:global, :monthly, budget_config[:global_monthly], tenant_id: tenant_id),
|
|
128
203
|
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
204
|
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,
|
|
205
|
+
# Token budgets (global only)
|
|
206
|
+
global_daily_tokens: token_status(:daily, budget_config[:global_daily_tokens], tenant_id: tenant_id),
|
|
207
|
+
global_monthly_tokens: token_status(:monthly, budget_config[:global_monthly_tokens], tenant_id: tenant_id),
|
|
130
208
|
forecast: calculate_forecast(tenant_id: tenant_id)
|
|
131
209
|
}.compact
|
|
132
210
|
end
|
|
@@ -225,41 +303,86 @@ module RubyLLM
|
|
|
225
303
|
|
|
226
304
|
# Resolves budget configuration for a tenant
|
|
227
305
|
#
|
|
306
|
+
# Priority order:
|
|
307
|
+
# 1. runtime_config (passed to run())
|
|
308
|
+
# 2. tenant_config_resolver (configured lambda)
|
|
309
|
+
# 3. TenantBudget database record
|
|
310
|
+
# 4. Global configuration
|
|
311
|
+
#
|
|
228
312
|
# @param tenant_id [String, nil] The tenant identifier
|
|
313
|
+
# @param runtime_config [Hash, nil] Runtime config passed to run()
|
|
229
314
|
# @return [Hash] Budget configuration
|
|
230
|
-
def resolve_budget_config(tenant_id)
|
|
315
|
+
def resolve_budget_config(tenant_id, runtime_config: nil)
|
|
231
316
|
config = RubyLLM::Agents.configuration
|
|
232
317
|
|
|
318
|
+
# Priority 1: Runtime config passed directly to run()
|
|
319
|
+
if runtime_config.present?
|
|
320
|
+
return normalize_budget_config(runtime_config, config)
|
|
321
|
+
end
|
|
322
|
+
|
|
233
323
|
# If multi-tenancy is disabled or no tenant, use global config
|
|
234
324
|
if tenant_id.nil? || !config.multi_tenancy_enabled?
|
|
235
|
-
return
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
325
|
+
return global_budget_config(config)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Priority 2: tenant_config_resolver lambda
|
|
329
|
+
if config.tenant_config_resolver.present?
|
|
330
|
+
resolved_config = config.tenant_config_resolver.call(tenant_id)
|
|
331
|
+
if resolved_config.present?
|
|
332
|
+
return normalize_budget_config(resolved_config, config)
|
|
333
|
+
end
|
|
243
334
|
end
|
|
244
335
|
|
|
245
|
-
# Look up tenant-specific budget from database
|
|
336
|
+
# Priority 3: Look up tenant-specific budget from database
|
|
246
337
|
tenant_budget = lookup_tenant_budget(tenant_id)
|
|
247
338
|
|
|
248
339
|
if tenant_budget
|
|
249
340
|
tenant_budget.to_budget_config
|
|
250
341
|
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
|
-
}
|
|
342
|
+
# Priority 4: Fall back to global config for unknown tenants
|
|
343
|
+
global_budget_config(config)
|
|
260
344
|
end
|
|
261
345
|
end
|
|
262
346
|
|
|
347
|
+
# Builds global budget config from configuration
|
|
348
|
+
#
|
|
349
|
+
# @param config [Configuration] The configuration object
|
|
350
|
+
# @return [Hash] Budget configuration
|
|
351
|
+
def global_budget_config(config)
|
|
352
|
+
{
|
|
353
|
+
enabled: config.budgets_enabled?,
|
|
354
|
+
enforcement: config.budget_enforcement,
|
|
355
|
+
global_daily: config.budgets&.dig(:global_daily),
|
|
356
|
+
global_monthly: config.budgets&.dig(:global_monthly),
|
|
357
|
+
per_agent_daily: config.budgets&.dig(:per_agent_daily),
|
|
358
|
+
per_agent_monthly: config.budgets&.dig(:per_agent_monthly),
|
|
359
|
+
global_daily_tokens: config.budgets&.dig(:global_daily_tokens),
|
|
360
|
+
global_monthly_tokens: config.budgets&.dig(:global_monthly_tokens)
|
|
361
|
+
}
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Normalizes runtime/resolver config to standard budget config format
|
|
365
|
+
#
|
|
366
|
+
# @param raw_config [Hash] Raw config from runtime or resolver
|
|
367
|
+
# @param global_config [Configuration] Global config for fallbacks
|
|
368
|
+
# @return [Hash] Normalized budget configuration
|
|
369
|
+
def normalize_budget_config(raw_config, global_config)
|
|
370
|
+
enforcement = raw_config[:enforcement]&.to_sym || global_config.budget_enforcement
|
|
371
|
+
|
|
372
|
+
{
|
|
373
|
+
enabled: enforcement != :none,
|
|
374
|
+
enforcement: enforcement,
|
|
375
|
+
# Cost/budget limits (USD)
|
|
376
|
+
global_daily: raw_config[:daily_budget_limit],
|
|
377
|
+
global_monthly: raw_config[:monthly_budget_limit],
|
|
378
|
+
per_agent_daily: raw_config[:per_agent_daily] || {},
|
|
379
|
+
per_agent_monthly: raw_config[:per_agent_monthly] || {},
|
|
380
|
+
# Token limits
|
|
381
|
+
global_daily_tokens: raw_config[:daily_token_limit],
|
|
382
|
+
global_monthly_tokens: raw_config[:monthly_token_limit]
|
|
383
|
+
}
|
|
384
|
+
end
|
|
385
|
+
|
|
263
386
|
# Safely looks up tenant budget, handling missing table
|
|
264
387
|
#
|
|
265
388
|
# @param tenant_id [String] The tenant identifier
|
|
@@ -465,6 +588,145 @@ module RubyLLM
|
|
|
465
588
|
timestamp: Date.current.to_s
|
|
466
589
|
})
|
|
467
590
|
end
|
|
591
|
+
|
|
592
|
+
# Increments the token counter for a period
|
|
593
|
+
#
|
|
594
|
+
# @param scope [Symbol] :global (only global supported for tokens)
|
|
595
|
+
# @param period [Symbol] :daily or :monthly
|
|
596
|
+
# @param tokens [Integer] Tokens to add
|
|
597
|
+
# @param tenant_id [String, nil] The tenant identifier
|
|
598
|
+
# @return [Integer] New total
|
|
599
|
+
def increment_tokens(scope, period, tokens, agent_type: nil, tenant_id: nil)
|
|
600
|
+
# For now, we only track global token usage (not per-agent)
|
|
601
|
+
key = token_cache_key(period, tenant_id: tenant_id)
|
|
602
|
+
ttl = period == :daily ? 1.day : 31.days
|
|
603
|
+
|
|
604
|
+
current = (BudgetTracker.cache_read(key) || 0).to_i
|
|
605
|
+
new_total = current + tokens
|
|
606
|
+
BudgetTracker.cache_write(key, new_total, expires_in: ttl)
|
|
607
|
+
new_total
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# Generates a cache key for token tracking
|
|
611
|
+
#
|
|
612
|
+
# @param period [Symbol] :daily or :monthly
|
|
613
|
+
# @param tenant_id [String, nil] The tenant identifier
|
|
614
|
+
# @return [String] Cache key
|
|
615
|
+
def token_cache_key(period, tenant_id: nil)
|
|
616
|
+
date_part = period == :daily ? Date.current.to_s : Date.current.strftime("%Y-%m")
|
|
617
|
+
tenant_part = tenant_id.present? ? "tenant:#{tenant_id}" : "global"
|
|
618
|
+
|
|
619
|
+
BudgetTracker.cache_key("tokens", tenant_part, date_part)
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# Checks token limits and raises error if exceeded
|
|
623
|
+
#
|
|
624
|
+
# @param agent_type [String] The agent class name
|
|
625
|
+
# @param tenant_id [String, nil] The tenant identifier
|
|
626
|
+
# @param budget_config [Hash] The budget configuration
|
|
627
|
+
# @raise [Reliability::BudgetExceededError] If limit exceeded
|
|
628
|
+
# @return [void]
|
|
629
|
+
def check_token_limits!(agent_type, tenant_id, budget_config)
|
|
630
|
+
# Check global daily token budget
|
|
631
|
+
if budget_config[:global_daily_tokens]
|
|
632
|
+
current = current_tokens(:daily, tenant_id: tenant_id)
|
|
633
|
+
if current >= budget_config[:global_daily_tokens]
|
|
634
|
+
raise Reliability::BudgetExceededError.new(
|
|
635
|
+
:global_daily_tokens,
|
|
636
|
+
budget_config[:global_daily_tokens],
|
|
637
|
+
current,
|
|
638
|
+
tenant_id: tenant_id
|
|
639
|
+
)
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Check global monthly token budget
|
|
644
|
+
if budget_config[:global_monthly_tokens]
|
|
645
|
+
current = current_tokens(:monthly, tenant_id: tenant_id)
|
|
646
|
+
if current >= budget_config[:global_monthly_tokens]
|
|
647
|
+
raise Reliability::BudgetExceededError.new(
|
|
648
|
+
:global_monthly_tokens,
|
|
649
|
+
budget_config[:global_monthly_tokens],
|
|
650
|
+
current,
|
|
651
|
+
tenant_id: tenant_id
|
|
652
|
+
)
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# Checks for soft cap token alerts after recording usage
|
|
658
|
+
#
|
|
659
|
+
# @param agent_type [String] The agent class name
|
|
660
|
+
# @param tenant_id [String, nil] The tenant identifier
|
|
661
|
+
# @param budget_config [Hash] Budget configuration
|
|
662
|
+
# @return [void]
|
|
663
|
+
def check_soft_token_alerts(agent_type, tenant_id, budget_config)
|
|
664
|
+
config = RubyLLM::Agents.configuration
|
|
665
|
+
return unless config.alerts_enabled?
|
|
666
|
+
return unless config.alert_events.include?(:token_soft_cap) || config.alert_events.include?(:token_hard_cap)
|
|
667
|
+
|
|
668
|
+
# Check global daily tokens
|
|
669
|
+
check_token_alert(:global_daily_tokens, budget_config[:global_daily_tokens],
|
|
670
|
+
current_tokens(:daily, tenant_id: tenant_id),
|
|
671
|
+
agent_type, tenant_id, budget_config)
|
|
672
|
+
|
|
673
|
+
# Check global monthly tokens
|
|
674
|
+
check_token_alert(:global_monthly_tokens, budget_config[:global_monthly_tokens],
|
|
675
|
+
current_tokens(:monthly, tenant_id: tenant_id),
|
|
676
|
+
agent_type, tenant_id, budget_config)
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# Checks if a token alert should be fired
|
|
680
|
+
#
|
|
681
|
+
# @param scope [Symbol] Token scope
|
|
682
|
+
# @param limit [Integer, nil] Token limit
|
|
683
|
+
# @param current [Integer] Current token usage
|
|
684
|
+
# @param agent_type [String] Agent type
|
|
685
|
+
# @param tenant_id [String, nil] The tenant identifier
|
|
686
|
+
# @param budget_config [Hash] Budget configuration
|
|
687
|
+
# @return [void]
|
|
688
|
+
def check_token_alert(scope, limit, current, agent_type, tenant_id, budget_config)
|
|
689
|
+
return unless limit
|
|
690
|
+
return if current <= limit
|
|
691
|
+
|
|
692
|
+
event = budget_config[:enforcement] == :hard ? :token_hard_cap : :token_soft_cap
|
|
693
|
+
config = RubyLLM::Agents.configuration
|
|
694
|
+
return unless config.alert_events.include?(event)
|
|
695
|
+
|
|
696
|
+
# Prevent duplicate alerts
|
|
697
|
+
tenant_part = tenant_id.present? ? "tenant:#{tenant_id}" : "global"
|
|
698
|
+
alert_key = BudgetTracker.cache_key("token_alert", tenant_part, scope, Date.current.to_s)
|
|
699
|
+
return if BudgetTracker.cache_exist?(alert_key)
|
|
700
|
+
|
|
701
|
+
BudgetTracker.cache_write(alert_key, true, expires_in: 1.hour)
|
|
702
|
+
|
|
703
|
+
AlertManager.notify(event, {
|
|
704
|
+
scope: scope,
|
|
705
|
+
limit: limit,
|
|
706
|
+
total: current,
|
|
707
|
+
agent_type: agent_type,
|
|
708
|
+
tenant_id: tenant_id,
|
|
709
|
+
timestamp: Date.current.to_s
|
|
710
|
+
})
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# Returns token status for a period
|
|
714
|
+
#
|
|
715
|
+
# @param period [Symbol] :daily or :monthly
|
|
716
|
+
# @param limit [Integer, nil] The token limit
|
|
717
|
+
# @param tenant_id [String, nil] The tenant identifier
|
|
718
|
+
# @return [Hash, nil] Status hash or nil if no limit
|
|
719
|
+
def token_status(period, limit, tenant_id: nil)
|
|
720
|
+
return nil unless limit
|
|
721
|
+
|
|
722
|
+
current = current_tokens(period, tenant_id: tenant_id)
|
|
723
|
+
{
|
|
724
|
+
limit: limit,
|
|
725
|
+
current: current,
|
|
726
|
+
remaining: [limit - current, 0].max,
|
|
727
|
+
percentage_used: ((current.to_f / limit) * 100).round(2)
|
|
728
|
+
}
|
|
729
|
+
end
|
|
468
730
|
end
|
|
469
731
|
end
|
|
470
732
|
end
|
|
@@ -195,6 +195,35 @@ module RubyLLM
|
|
|
195
195
|
# @example Using request store
|
|
196
196
|
# config.tenant_resolver = -> { RequestStore[:tenant_id] }
|
|
197
197
|
|
|
198
|
+
# @!attribute [rw] tenant_config_resolver
|
|
199
|
+
# Lambda that returns tenant configuration without querying the database.
|
|
200
|
+
# Called when resolving tenant budget config. If set, this takes priority
|
|
201
|
+
# over the TenantBudget database lookup.
|
|
202
|
+
# @return [Proc, nil] Tenant config resolver lambda (default: nil)
|
|
203
|
+
# @example Using an external tenant service
|
|
204
|
+
# config.tenant_config_resolver = ->(tenant_id) {
|
|
205
|
+
# tenant = Tenant.find(tenant_id)
|
|
206
|
+
# {
|
|
207
|
+
# name: tenant.name,
|
|
208
|
+
# daily_limit: tenant.subscription.daily_budget,
|
|
209
|
+
# monthly_limit: tenant.subscription.monthly_budget,
|
|
210
|
+
# daily_token_limit: tenant.subscription.daily_tokens,
|
|
211
|
+
# monthly_token_limit: tenant.subscription.monthly_tokens,
|
|
212
|
+
# enforcement: tenant.subscription.hard_limits? ? :hard : :soft
|
|
213
|
+
# }
|
|
214
|
+
# }
|
|
215
|
+
|
|
216
|
+
# @!attribute [rw] persist_messages_summary
|
|
217
|
+
# Whether to persist a summary of conversation messages in execution records.
|
|
218
|
+
# When true, stores message count and first/last messages (truncated).
|
|
219
|
+
# Set to false to disable message summary persistence.
|
|
220
|
+
# @return [Boolean] Enable messages summary persistence (default: true)
|
|
221
|
+
|
|
222
|
+
# @!attribute [rw] messages_summary_max_length
|
|
223
|
+
# Maximum character length for message content in the summary.
|
|
224
|
+
# Content exceeding this length will be truncated with "...".
|
|
225
|
+
# @return [Integer] Max length for message content (default: 500)
|
|
226
|
+
|
|
198
227
|
attr_accessor :default_model,
|
|
199
228
|
:default_temperature,
|
|
200
229
|
:default_timeout,
|
|
@@ -220,7 +249,10 @@ module RubyLLM
|
|
|
220
249
|
:persist_responses,
|
|
221
250
|
:redaction,
|
|
222
251
|
:multi_tenancy_enabled,
|
|
223
|
-
:tenant_resolver
|
|
252
|
+
:tenant_resolver,
|
|
253
|
+
:tenant_config_resolver,
|
|
254
|
+
:persist_messages_summary,
|
|
255
|
+
:messages_summary_max_length
|
|
224
256
|
|
|
225
257
|
attr_writer :cache_store
|
|
226
258
|
|
|
@@ -264,6 +296,11 @@ module RubyLLM
|
|
|
264
296
|
# Multi-tenancy defaults (disabled for backward compatibility)
|
|
265
297
|
@multi_tenancy_enabled = false
|
|
266
298
|
@tenant_resolver = -> { nil }
|
|
299
|
+
@tenant_config_resolver = nil
|
|
300
|
+
|
|
301
|
+
# Messages summary defaults
|
|
302
|
+
@persist_messages_summary = true
|
|
303
|
+
@messages_summary_max_length = 500
|
|
267
304
|
end
|
|
268
305
|
|
|
269
306
|
# Returns the configured cache store, falling back to Rails.cache
|