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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +46 -13
  3. data/app/controllers/ruby_llm/agents/api_configurations_controller.rb +214 -0
  4. data/app/controllers/ruby_llm/agents/{settings_controller.rb → system_config_controller.rb} +3 -3
  5. data/app/controllers/ruby_llm/agents/tenants_controller.rb +109 -0
  6. data/app/models/ruby_llm/agents/api_configuration.rb +386 -0
  7. data/app/models/ruby_llm/agents/tenant_budget.rb +62 -7
  8. data/app/views/layouts/ruby_llm/agents/application.html.erb +3 -1
  9. data/app/views/ruby_llm/agents/api_configurations/_api_key_field.html.erb +34 -0
  10. data/app/views/ruby_llm/agents/api_configurations/_form.html.erb +288 -0
  11. data/app/views/ruby_llm/agents/api_configurations/edit.html.erb +95 -0
  12. data/app/views/ruby_llm/agents/api_configurations/edit_tenant.html.erb +97 -0
  13. data/app/views/ruby_llm/agents/api_configurations/show.html.erb +211 -0
  14. data/app/views/ruby_llm/agents/api_configurations/tenant.html.erb +179 -0
  15. data/app/views/ruby_llm/agents/dashboard/_action_center.html.erb +1 -1
  16. data/app/views/ruby_llm/agents/executions/show.html.erb +82 -0
  17. data/app/views/ruby_llm/agents/{settings → system_config}/show.html.erb +1 -1
  18. data/app/views/ruby_llm/agents/tenants/_form.html.erb +150 -0
  19. data/app/views/ruby_llm/agents/tenants/edit.html.erb +13 -0
  20. data/app/views/ruby_llm/agents/tenants/index.html.erb +129 -0
  21. data/app/views/ruby_llm/agents/tenants/show.html.erb +374 -0
  22. data/config/routes.rb +12 -1
  23. data/lib/generators/ruby_llm_agents/api_configuration_generator.rb +100 -0
  24. data/lib/generators/ruby_llm_agents/templates/create_api_configurations_migration.rb.tt +90 -0
  25. data/lib/ruby_llm/agents/base/execution.rb +83 -0
  26. data/lib/ruby_llm/agents/base.rb +1 -0
  27. data/lib/ruby_llm/agents/budget_tracker.rb +285 -23
  28. data/lib/ruby_llm/agents/configuration.rb +38 -1
  29. data/lib/ruby_llm/agents/engine.rb +1 -0
  30. data/lib/ruby_llm/agents/instrumentation.rb +71 -3
  31. data/lib/ruby_llm/agents/resolved_config.rb +348 -0
  32. data/lib/ruby_llm/agents/version.rb +1 -1
  33. 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)
@@ -136,6 +136,7 @@ module RubyLLM
136
136
  @options = options
137
137
  @accumulated_tool_calls = []
138
138
  validate_required_params!
139
+ resolve_tenant_context! # Resolve tenant before building client for API key resolution
139
140
  @client = build_client
140
141
  end
141
142
 
@@ -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
- 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
- }
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 (if table exists)
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
@@ -34,6 +34,7 @@ module RubyLLM
34
34
  config.to_prepare do
35
35
  require_relative "execution_logger_job"
36
36
  require_relative "instrumentation"
37
+ require_relative "resolved_config"
37
38
  require_relative "base"
38
39
  require_relative "workflow"
39
40