ruby_llm-agents 0.4.0 → 1.0.0.beta.1

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 (208) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +225 -34
  3. data/app/controllers/ruby_llm/agents/agents_controller.rb +136 -16
  4. data/app/controllers/ruby_llm/agents/api_configurations_controller.rb +214 -0
  5. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +29 -9
  6. data/app/controllers/ruby_llm/agents/{settings_controller.rb → system_config_controller.rb} +3 -3
  7. data/app/controllers/ruby_llm/agents/tenants_controller.rb +109 -0
  8. data/app/controllers/ruby_llm/agents/workflows_controller.rb +355 -0
  9. data/app/helpers/ruby_llm/agents/application_helper.rb +25 -0
  10. data/app/models/ruby_llm/agents/api_configuration.rb +386 -0
  11. data/app/models/ruby_llm/agents/execution.rb +3 -0
  12. data/app/models/ruby_llm/agents/tenant_budget.rb +112 -14
  13. data/app/services/ruby_llm/agents/agent_registry.rb +51 -12
  14. data/app/views/layouts/ruby_llm/agents/application.html.erb +5 -30
  15. data/app/views/ruby_llm/agents/agents/_agent.html.erb +13 -1
  16. data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +235 -0
  17. data/app/views/ruby_llm/agents/agents/_config_embedder.html.erb +70 -0
  18. data/app/views/ruby_llm/agents/agents/_config_image_generator.html.erb +152 -0
  19. data/app/views/ruby_llm/agents/agents/_config_moderator.html.erb +63 -0
  20. data/app/views/ruby_llm/agents/agents/_config_speaker.html.erb +108 -0
  21. data/app/views/ruby_llm/agents/agents/_config_transcriber.html.erb +91 -0
  22. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +1 -1
  23. data/app/views/ruby_llm/agents/agents/index.html.erb +74 -9
  24. data/app/views/ruby_llm/agents/agents/show.html.erb +18 -378
  25. data/app/views/ruby_llm/agents/api_configurations/_api_key_field.html.erb +34 -0
  26. data/app/views/ruby_llm/agents/api_configurations/_form.html.erb +288 -0
  27. data/app/views/ruby_llm/agents/api_configurations/edit.html.erb +95 -0
  28. data/app/views/ruby_llm/agents/api_configurations/edit_tenant.html.erb +97 -0
  29. data/app/views/ruby_llm/agents/api_configurations/show.html.erb +211 -0
  30. data/app/views/ruby_llm/agents/api_configurations/tenant.html.erb +179 -0
  31. data/app/views/ruby_llm/agents/dashboard/_action_center.html.erb +1 -1
  32. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +269 -15
  33. data/app/views/ruby_llm/agents/executions/show.html.erb +98 -0
  34. data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +93 -0
  35. data/app/views/ruby_llm/agents/{settings → system_config}/show.html.erb +1 -1
  36. data/app/views/ruby_llm/agents/tenants/_form.html.erb +150 -0
  37. data/app/views/ruby_llm/agents/tenants/edit.html.erb +13 -0
  38. data/app/views/ruby_llm/agents/tenants/index.html.erb +129 -0
  39. data/app/views/ruby_llm/agents/tenants/show.html.erb +374 -0
  40. data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +236 -0
  41. data/app/views/ruby_llm/agents/workflows/_structure_parallel.html.erb +76 -0
  42. data/app/views/ruby_llm/agents/workflows/_structure_pipeline.html.erb +74 -0
  43. data/app/views/ruby_llm/agents/workflows/_structure_router.html.erb +108 -0
  44. data/app/views/ruby_llm/agents/workflows/show.html.erb +442 -0
  45. data/config/routes.rb +13 -1
  46. data/lib/generators/ruby_llm_agents/agent_generator.rb +56 -7
  47. data/lib/generators/ruby_llm_agents/api_configuration_generator.rb +100 -0
  48. data/lib/generators/ruby_llm_agents/background_remover_generator.rb +110 -0
  49. data/lib/generators/ruby_llm_agents/embedder_generator.rb +107 -0
  50. data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +115 -0
  51. data/lib/generators/ruby_llm_agents/image_editor_generator.rb +108 -0
  52. data/lib/generators/ruby_llm_agents/image_generator_generator.rb +116 -0
  53. data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +178 -0
  54. data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +109 -0
  55. data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +103 -0
  56. data/lib/generators/ruby_llm_agents/image_variator_generator.rb +102 -0
  57. data/lib/generators/ruby_llm_agents/install_generator.rb +76 -4
  58. data/lib/generators/ruby_llm_agents/restructure_generator.rb +292 -0
  59. data/lib/generators/ruby_llm_agents/speaker_generator.rb +121 -0
  60. data/lib/generators/ruby_llm_agents/templates/add_execution_type_migration.rb.tt +8 -0
  61. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +99 -84
  62. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +42 -40
  63. data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +26 -0
  64. data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +50 -0
  65. data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +26 -0
  66. data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +20 -0
  67. data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +38 -0
  68. data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +139 -0
  69. data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +21 -0
  70. data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +20 -0
  71. data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +20 -0
  72. data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +49 -0
  73. data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +53 -0
  74. data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +44 -0
  75. data/lib/generators/ruby_llm_agents/templates/create_api_configurations_migration.rb.tt +90 -0
  76. data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +41 -0
  77. data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +45 -0
  78. data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +35 -0
  79. data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +47 -0
  80. data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +50 -0
  81. data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +44 -0
  82. data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +38 -0
  83. data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +33 -0
  84. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +228 -0
  85. data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +131 -0
  86. data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +255 -0
  87. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +120 -0
  88. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +102 -0
  89. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +282 -0
  90. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +228 -0
  91. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +120 -0
  92. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +110 -0
  93. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +120 -0
  94. data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +212 -0
  95. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +227 -0
  96. data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +251 -0
  97. data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +300 -0
  98. data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +56 -0
  99. data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +51 -0
  100. data/lib/generators/ruby_llm_agents/transcriber_generator.rb +107 -0
  101. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +152 -1
  102. data/lib/ruby_llm/agents/audio/speaker.rb +553 -0
  103. data/lib/ruby_llm/agents/audio/transcriber.rb +669 -0
  104. data/lib/ruby_llm/agents/base_agent.rb +675 -0
  105. data/lib/ruby_llm/agents/core/base/moderation_dsl.rb +181 -0
  106. data/lib/ruby_llm/agents/core/base/moderation_execution.rb +274 -0
  107. data/lib/ruby_llm/agents/core/base.rb +135 -0
  108. data/lib/ruby_llm/agents/core/configuration.rb +981 -0
  109. data/lib/ruby_llm/agents/core/errors.rb +150 -0
  110. data/lib/ruby_llm/agents/{instrumentation.rb → core/instrumentation.rb} +93 -4
  111. data/lib/ruby_llm/agents/core/llm_tenant.rb +358 -0
  112. data/lib/ruby_llm/agents/core/resolved_config.rb +348 -0
  113. data/lib/ruby_llm/agents/{version.rb → core/version.rb} +1 -1
  114. data/lib/ruby_llm/agents/dsl/base.rb +110 -0
  115. data/lib/ruby_llm/agents/dsl/caching.rb +142 -0
  116. data/lib/ruby_llm/agents/dsl/reliability.rb +307 -0
  117. data/lib/ruby_llm/agents/dsl.rb +41 -0
  118. data/lib/ruby_llm/agents/image/analyzer/dsl.rb +130 -0
  119. data/lib/ruby_llm/agents/image/analyzer/execution.rb +402 -0
  120. data/lib/ruby_llm/agents/image/analyzer.rb +90 -0
  121. data/lib/ruby_llm/agents/image/background_remover/dsl.rb +154 -0
  122. data/lib/ruby_llm/agents/image/background_remover/execution.rb +240 -0
  123. data/lib/ruby_llm/agents/image/background_remover.rb +89 -0
  124. data/lib/ruby_llm/agents/image/concerns/image_operation_dsl.rb +91 -0
  125. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +165 -0
  126. data/lib/ruby_llm/agents/image/editor/dsl.rb +56 -0
  127. data/lib/ruby_llm/agents/image/editor/execution.rb +207 -0
  128. data/lib/ruby_llm/agents/image/editor.rb +92 -0
  129. data/lib/ruby_llm/agents/image/generator/active_storage_support.rb +127 -0
  130. data/lib/ruby_llm/agents/image/generator/content_policy.rb +95 -0
  131. data/lib/ruby_llm/agents/image/generator/pricing.rb +353 -0
  132. data/lib/ruby_llm/agents/image/generator/templates.rb +124 -0
  133. data/lib/ruby_llm/agents/image/generator.rb +455 -0
  134. data/lib/ruby_llm/agents/image/pipeline/dsl.rb +213 -0
  135. data/lib/ruby_llm/agents/image/pipeline/execution.rb +382 -0
  136. data/lib/ruby_llm/agents/image/pipeline.rb +97 -0
  137. data/lib/ruby_llm/agents/image/transformer/dsl.rb +148 -0
  138. data/lib/ruby_llm/agents/image/transformer/execution.rb +223 -0
  139. data/lib/ruby_llm/agents/image/transformer.rb +95 -0
  140. data/lib/ruby_llm/agents/image/upscaler/dsl.rb +83 -0
  141. data/lib/ruby_llm/agents/image/upscaler/execution.rb +219 -0
  142. data/lib/ruby_llm/agents/image/upscaler.rb +81 -0
  143. data/lib/ruby_llm/agents/image/variator/dsl.rb +62 -0
  144. data/lib/ruby_llm/agents/image/variator/execution.rb +189 -0
  145. data/lib/ruby_llm/agents/image/variator.rb +80 -0
  146. data/lib/ruby_llm/agents/{alert_manager.rb → infrastructure/alert_manager.rb} +17 -22
  147. data/lib/ruby_llm/agents/infrastructure/budget/budget_query.rb +145 -0
  148. data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +149 -0
  149. data/lib/ruby_llm/agents/infrastructure/budget/forecaster.rb +68 -0
  150. data/lib/ruby_llm/agents/infrastructure/budget/spend_recorder.rb +279 -0
  151. data/lib/ruby_llm/agents/infrastructure/budget_tracker.rb +275 -0
  152. data/lib/ruby_llm/agents/{execution_logger_job.rb → infrastructure/execution_logger_job.rb} +17 -1
  153. data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/executor.rb +2 -1
  154. data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/retry_strategy.rb +9 -3
  155. data/lib/ruby_llm/agents/{reliability.rb → infrastructure/reliability.rb} +11 -21
  156. data/lib/ruby_llm/agents/pipeline/builder.rb +215 -0
  157. data/lib/ruby_llm/agents/pipeline/context.rb +255 -0
  158. data/lib/ruby_llm/agents/pipeline/executor.rb +86 -0
  159. data/lib/ruby_llm/agents/pipeline/middleware/base.rb +124 -0
  160. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +95 -0
  161. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +171 -0
  162. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +415 -0
  163. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +276 -0
  164. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +196 -0
  165. data/lib/ruby_llm/agents/pipeline.rb +68 -0
  166. data/lib/ruby_llm/agents/{engine.rb → rails/engine.rb} +79 -10
  167. data/lib/ruby_llm/agents/results/background_removal_result.rb +286 -0
  168. data/lib/ruby_llm/agents/{result.rb → results/base.rb} +73 -1
  169. data/lib/ruby_llm/agents/results/embedding_result.rb +243 -0
  170. data/lib/ruby_llm/agents/results/image_analysis_result.rb +314 -0
  171. data/lib/ruby_llm/agents/results/image_edit_result.rb +250 -0
  172. data/lib/ruby_llm/agents/results/image_generation_result.rb +346 -0
  173. data/lib/ruby_llm/agents/results/image_pipeline_result.rb +399 -0
  174. data/lib/ruby_llm/agents/results/image_transform_result.rb +251 -0
  175. data/lib/ruby_llm/agents/results/image_upscale_result.rb +255 -0
  176. data/lib/ruby_llm/agents/results/image_variation_result.rb +237 -0
  177. data/lib/ruby_llm/agents/results/moderation_result.rb +158 -0
  178. data/lib/ruby_llm/agents/results/speech_result.rb +338 -0
  179. data/lib/ruby_llm/agents/results/transcription_result.rb +408 -0
  180. data/lib/ruby_llm/agents/text/embedder.rb +444 -0
  181. data/lib/ruby_llm/agents/text/moderator.rb +237 -0
  182. data/lib/ruby_llm/agents/workflow/async.rb +220 -0
  183. data/lib/ruby_llm/agents/workflow/async_executor.rb +156 -0
  184. data/lib/ruby_llm/agents/{workflow.rb → workflow/orchestrator.rb} +6 -5
  185. data/lib/ruby_llm/agents/workflow/parallel.rb +34 -17
  186. data/lib/ruby_llm/agents/workflow/thread_pool.rb +185 -0
  187. data/lib/ruby_llm/agents.rb +86 -20
  188. metadata +189 -35
  189. data/lib/ruby_llm/agents/base/caching.rb +0 -40
  190. data/lib/ruby_llm/agents/base/cost_calculation.rb +0 -105
  191. data/lib/ruby_llm/agents/base/dsl.rb +0 -324
  192. data/lib/ruby_llm/agents/base/execution.rb +0 -283
  193. data/lib/ruby_llm/agents/base/reliability_dsl.rb +0 -82
  194. data/lib/ruby_llm/agents/base/reliability_execution.rb +0 -136
  195. data/lib/ruby_llm/agents/base/response_building.rb +0 -86
  196. data/lib/ruby_llm/agents/base/tool_tracking.rb +0 -57
  197. data/lib/ruby_llm/agents/base.rb +0 -209
  198. data/lib/ruby_llm/agents/budget_tracker.rb +0 -471
  199. data/lib/ruby_llm/agents/configuration.rb +0 -357
  200. /data/lib/ruby_llm/agents/{deprecations.rb → core/deprecations.rb} +0 -0
  201. /data/lib/ruby_llm/agents/{inflections.rb → core/inflections.rb} +0 -0
  202. /data/lib/ruby_llm/agents/{attempt_tracker.rb → infrastructure/attempt_tracker.rb} +0 -0
  203. /data/lib/ruby_llm/agents/{cache_helper.rb → infrastructure/cache_helper.rb} +0 -0
  204. /data/lib/ruby_llm/agents/{circuit_breaker.rb → infrastructure/circuit_breaker.rb} +0 -0
  205. /data/lib/ruby_llm/agents/{redactor.rb → infrastructure/redactor.rb} +0 -0
  206. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/breaker_manager.rb +0 -0
  207. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/execution_constraints.rb +0 -0
  208. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/fallback_routing.rb +0 -0
@@ -1,471 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "cache_helper"
4
-
5
- module RubyLLM
6
- module Agents
7
- # Cache-based budget tracking for cost governance
8
- #
9
- # Tracks spending against configured budget limits using cache counters.
10
- # Supports daily and monthly budgets at both global and per-agent levels.
11
- # In multi-tenant mode, budgets are tracked separately per tenant.
12
- #
13
- # Note: Uses best-effort enforcement with cache counters. In high-concurrency
14
- # scenarios, slight overruns may occur due to race conditions. This is an
15
- # acceptable trade-off for performance.
16
- #
17
- # @example Checking budget before execution
18
- # BudgetTracker.check_budget!("MyAgent") # raises BudgetExceededError if over limit
19
- #
20
- # @example Recording spend after execution
21
- # BudgetTracker.record_spend!("MyAgent", 0.05)
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
- #
27
- # @see RubyLLM::Agents::Configuration
28
- # @see RubyLLM::Agents::Reliability::BudgetExceededError
29
- # @see RubyLLM::Agents::TenantBudget
30
- # @api public
31
- module BudgetTracker
32
- extend CacheHelper
33
-
34
- class << self
35
- # Checks if the current spend exceeds budget limits
36
- #
37
- # @param agent_type [String] The agent class name
38
- # @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
39
- # @raise [Reliability::BudgetExceededError] If hard cap is exceeded
40
- # @return [void]
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)
44
-
45
- return unless budget_config[:enabled]
46
- return unless budget_config[:enforcement] == :hard
47
-
48
- check_budget_limits!(agent_type, tenant_id, budget_config)
49
- end
50
-
51
- # Records spend and checks for soft cap alerts
52
- #
53
- # @param agent_type [String] The agent class name
54
- # @param amount [Float] The amount spent in USD
55
- # @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
56
- # @return [void]
57
- def record_spend!(agent_type, amount, tenant_id: nil)
58
- return if amount.nil? || amount <= 0
59
-
60
- tenant_id = resolve_tenant_id(tenant_id)
61
-
62
- # Increment all relevant counters
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]
71
- end
72
-
73
- # Returns the current spend for a scope and period
74
- #
75
- # @param scope [Symbol] :global or :agent
76
- # @param period [Symbol] :daily or :monthly
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)
79
- # @return [Float] Current spend in USD
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
84
- end
85
-
86
- # Returns the remaining budget for a scope and period
87
- #
88
- # @param scope [Symbol] :global or :agent
89
- # @param period [Symbol] :daily or :monthly
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)
92
- # @return [Float, nil] Remaining budget in USD, or nil if no limit configured
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)
96
-
97
- limit = case [scope, period]
98
- when [:global, :daily]
99
- budget_config[:global_daily]
100
- when [:global, :monthly]
101
- budget_config[:global_monthly]
102
- when [:agent, :daily]
103
- budget_config[:per_agent_daily]&.dig(agent_type)
104
- when [:agent, :monthly]
105
- budget_config[:per_agent_monthly]&.dig(agent_type)
106
- end
107
-
108
- return nil unless limit
109
-
110
- [limit - current_spend(scope, period, agent_type: agent_type, tenant_id: tenant_id), 0].max
111
- end
112
-
113
- # Returns a summary of all budget statuses
114
- #
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)
117
- # @return [Hash] Budget status information
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)
121
-
122
- {
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)
131
- }.compact
132
- end
133
-
134
- # Calculates budget forecasts based on current spending trends
135
- #
136
- # @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
137
- # @return [Hash, nil] Forecast information
138
- def calculate_forecast(tenant_id: nil)
139
- tenant_id = resolve_tenant_id(tenant_id)
140
- budget_config = resolve_budget_config(tenant_id)
141
-
142
- return nil unless budget_config[:enabled]
143
- return nil unless budget_config[:global_daily] || budget_config[:global_monthly]
144
-
145
- daily_current = current_spend(:global, :daily, tenant_id: tenant_id)
146
- monthly_current = current_spend(:global, :monthly, tenant_id: tenant_id)
147
-
148
- # Calculate hours elapsed today and days elapsed this month
149
- hours_elapsed = Time.current.hour + (Time.current.min / 60.0)
150
- hours_elapsed = [hours_elapsed, 1].max # Avoid division by zero
151
- days_in_month = Time.current.end_of_month.day
152
- day_of_month = Time.current.day
153
- days_elapsed = day_of_month - 1 + (hours_elapsed / 24.0)
154
- days_elapsed = [days_elapsed, 1].max
155
-
156
- forecast = {}
157
-
158
- # Daily forecast
159
- if budget_config[:global_daily]
160
- daily_rate = daily_current / hours_elapsed
161
- projected_daily = daily_rate * 24
162
- forecast[:daily] = {
163
- current: daily_current.round(4),
164
- projected: projected_daily.round(4),
165
- limit: budget_config[:global_daily],
166
- on_track: projected_daily <= budget_config[:global_daily],
167
- hours_remaining: (24 - hours_elapsed).round(1),
168
- rate_per_hour: daily_rate.round(6)
169
- }
170
- end
171
-
172
- # Monthly forecast
173
- if budget_config[:global_monthly]
174
- monthly_rate = monthly_current / days_elapsed
175
- projected_monthly = monthly_rate * days_in_month
176
- days_remaining = days_in_month - day_of_month
177
- forecast[:monthly] = {
178
- current: monthly_current.round(4),
179
- projected: projected_monthly.round(4),
180
- limit: budget_config[:global_monthly],
181
- on_track: projected_monthly <= budget_config[:global_monthly],
182
- days_remaining: days_remaining,
183
- rate_per_day: monthly_rate.round(4)
184
- }
185
- end
186
-
187
- forecast.presence
188
- end
189
-
190
- # Resets all budget counters (useful for testing)
191
- #
192
- # @param tenant_id [String, nil] Optional tenant identifier to reset only that tenant's counters
193
- # @return [void]
194
- def reset!(tenant_id: nil)
195
- tenant_id = resolve_tenant_id(tenant_id)
196
- today = Date.current.to_s
197
- month = Date.current.strftime("%Y-%m")
198
-
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)
206
- end
207
-
208
- private
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
-
337
- # Increments the spend counter for a scope and period
338
- #
339
- # @param scope [Symbol] :global or :agent
340
- # @param period [Symbol] :daily or :monthly
341
- # @param amount [Float] Amount to add
342
- # @param agent_type [String, nil] Required when scope is :agent
343
- # @param tenant_id [String, nil] The tenant identifier
344
- # @return [Float] New total
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)
347
- ttl = period == :daily ? 1.day : 31.days
348
-
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
354
- end
355
-
356
- # Generates a cache key for budget tracking
357
- #
358
- # @param scope [Symbol] :global or :agent
359
- # @param period [Symbol] :daily or :monthly
360
- # @param agent_type [String, nil] Required when scope is :agent
361
- # @param tenant_id [String, nil] The tenant identifier
362
- # @return [String] Cache key
363
- def budget_cache_key(scope, period, agent_type: nil, tenant_id: nil)
364
- date_part = period == :daily ? Date.current.to_s : Date.current.strftime("%Y-%m")
365
- tenant_part = tenant_id.present? ? "tenant:#{tenant_id}" : "global"
366
-
367
- case scope
368
- when :global
369
- BudgetTracker.cache_key("budget", tenant_part, date_part)
370
- when :agent
371
- BudgetTracker.cache_key("budget", tenant_part, "agent", agent_type, date_part)
372
- else
373
- raise ArgumentError, "Unknown scope: #{scope}"
374
- end
375
- end
376
-
377
- # Returns budget status for a scope/period
378
- #
379
- # @param scope [Symbol] :global or :agent
380
- # @param period [Symbol] :daily or :monthly
381
- # @param limit [Float, nil] The budget limit
382
- # @param agent_type [String, nil] Required when scope is :agent
383
- # @param tenant_id [String, nil] The tenant identifier
384
- # @return [Hash, nil] Status hash or nil if no limit
385
- def budget_status(scope, period, limit, agent_type: nil, tenant_id: nil)
386
- return nil unless limit
387
-
388
- current = current_spend(scope, period, agent_type: agent_type, tenant_id: tenant_id)
389
- {
390
- limit: limit,
391
- current: current.round(6),
392
- remaining: [limit - current, 0].max.round(6),
393
- percentage_used: ((current / limit) * 100).round(2)
394
- }
395
- end
396
-
397
- # Checks for soft cap alerts after recording spend
398
- #
399
- # @param agent_type [String] The agent class name
400
- # @param tenant_id [String, nil] The tenant identifier
401
- # @param budget_config [Hash] Budget configuration
402
- # @return [void]
403
- def check_soft_cap_alerts(agent_type, tenant_id, budget_config)
404
- config = RubyLLM::Agents.configuration
405
- return unless config.alerts_enabled?
406
- return unless config.alert_events.include?(:budget_soft_cap) || config.alert_events.include?(:budget_hard_cap)
407
-
408
- # Check 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)
412
-
413
- # Check 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)
417
-
418
- # Check per-agent daily
419
- agent_daily_limit = budget_config[:per_agent_daily]&.dig(agent_type)
420
- if 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)
424
- end
425
-
426
- # Check per-agent monthly
427
- agent_monthly_limit = budget_config[:per_agent_monthly]&.dig(agent_type)
428
- if 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)
432
- end
433
- end
434
-
435
- # Checks if an alert should be fired for a budget
436
- #
437
- # @param scope [Symbol] Budget scope
438
- # @param limit [Float, nil] Budget limit
439
- # @param current [Float] Current spend
440
- # @param agent_type [String] Agent type
441
- # @param tenant_id [String, nil] The tenant identifier
442
- # @param budget_config [Hash] Budget configuration
443
- # @return [void]
444
- def check_budget_alert(scope, limit, current, agent_type, tenant_id, budget_config)
445
- return unless limit
446
- return if current <= limit
447
-
448
- event = budget_config[:enforcement] == :hard ? :budget_hard_cap : :budget_soft_cap
449
- config = RubyLLM::Agents.configuration
450
- return unless config.alert_events.include?(event)
451
-
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)
456
-
457
- BudgetTracker.cache_write(alert_key, true, expires_in: 1.hour)
458
-
459
- AlertManager.notify(event, {
460
- scope: scope,
461
- limit: limit,
462
- total: current.round(6),
463
- agent_type: agent_type,
464
- tenant_id: tenant_id,
465
- timestamp: Date.current.to_s
466
- })
467
- end
468
- end
469
- end
470
- end
471
- end