ruby_llm-agents 0.5.0 → 1.0.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 (190) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +189 -31
  3. data/app/controllers/ruby_llm/agents/agents_controller.rb +136 -16
  4. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +29 -9
  5. data/app/controllers/ruby_llm/agents/workflows_controller.rb +355 -0
  6. data/app/helpers/ruby_llm/agents/application_helper.rb +25 -0
  7. data/app/models/ruby_llm/agents/execution.rb +3 -0
  8. data/app/models/ruby_llm/agents/tenant_budget.rb +58 -15
  9. data/app/services/ruby_llm/agents/agent_registry.rb +51 -12
  10. data/app/views/layouts/ruby_llm/agents/application.html.erb +2 -29
  11. data/app/views/ruby_llm/agents/agents/_agent.html.erb +13 -1
  12. data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +235 -0
  13. data/app/views/ruby_llm/agents/agents/_config_embedder.html.erb +70 -0
  14. data/app/views/ruby_llm/agents/agents/_config_image_generator.html.erb +152 -0
  15. data/app/views/ruby_llm/agents/agents/_config_moderator.html.erb +63 -0
  16. data/app/views/ruby_llm/agents/agents/_config_speaker.html.erb +108 -0
  17. data/app/views/ruby_llm/agents/agents/_config_transcriber.html.erb +91 -0
  18. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +1 -1
  19. data/app/views/ruby_llm/agents/agents/index.html.erb +74 -9
  20. data/app/views/ruby_llm/agents/agents/show.html.erb +18 -378
  21. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +269 -15
  22. data/app/views/ruby_llm/agents/executions/show.html.erb +16 -0
  23. data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +93 -0
  24. data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +236 -0
  25. data/app/views/ruby_llm/agents/workflows/_structure_parallel.html.erb +76 -0
  26. data/app/views/ruby_llm/agents/workflows/_structure_pipeline.html.erb +74 -0
  27. data/app/views/ruby_llm/agents/workflows/_structure_router.html.erb +108 -0
  28. data/app/views/ruby_llm/agents/workflows/show.html.erb +442 -0
  29. data/config/routes.rb +1 -0
  30. data/lib/generators/ruby_llm_agents/agent_generator.rb +56 -7
  31. data/lib/generators/ruby_llm_agents/background_remover_generator.rb +110 -0
  32. data/lib/generators/ruby_llm_agents/embedder_generator.rb +107 -0
  33. data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +115 -0
  34. data/lib/generators/ruby_llm_agents/image_editor_generator.rb +108 -0
  35. data/lib/generators/ruby_llm_agents/image_generator_generator.rb +116 -0
  36. data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +178 -0
  37. data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +109 -0
  38. data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +103 -0
  39. data/lib/generators/ruby_llm_agents/image_variator_generator.rb +102 -0
  40. data/lib/generators/ruby_llm_agents/install_generator.rb +76 -4
  41. data/lib/generators/ruby_llm_agents/restructure_generator.rb +292 -0
  42. data/lib/generators/ruby_llm_agents/speaker_generator.rb +121 -0
  43. data/lib/generators/ruby_llm_agents/templates/add_execution_type_migration.rb.tt +8 -0
  44. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +99 -84
  45. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +42 -40
  46. data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +26 -0
  47. data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +50 -0
  48. data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +26 -0
  49. data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +20 -0
  50. data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +38 -0
  51. data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +139 -0
  52. data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +21 -0
  53. data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +20 -0
  54. data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +20 -0
  55. data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +49 -0
  56. data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +53 -0
  57. data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +44 -0
  58. data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +41 -0
  59. data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +45 -0
  60. data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +35 -0
  61. data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +47 -0
  62. data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +50 -0
  63. data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +44 -0
  64. data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +38 -0
  65. data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +33 -0
  66. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +228 -0
  67. data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +131 -0
  68. data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +255 -0
  69. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +120 -0
  70. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +102 -0
  71. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +282 -0
  72. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +228 -0
  73. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +120 -0
  74. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +110 -0
  75. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +120 -0
  76. data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +212 -0
  77. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +227 -0
  78. data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +251 -0
  79. data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +300 -0
  80. data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +56 -0
  81. data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +51 -0
  82. data/lib/generators/ruby_llm_agents/transcriber_generator.rb +107 -0
  83. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +152 -1
  84. data/lib/ruby_llm/agents/audio/speaker.rb +553 -0
  85. data/lib/ruby_llm/agents/audio/transcriber.rb +669 -0
  86. data/lib/ruby_llm/agents/base_agent.rb +675 -0
  87. data/lib/ruby_llm/agents/core/base/moderation_dsl.rb +181 -0
  88. data/lib/ruby_llm/agents/core/base/moderation_execution.rb +274 -0
  89. data/lib/ruby_llm/agents/core/base.rb +135 -0
  90. data/lib/ruby_llm/agents/core/configuration.rb +981 -0
  91. data/lib/ruby_llm/agents/core/errors.rb +150 -0
  92. data/lib/ruby_llm/agents/{instrumentation.rb → core/instrumentation.rb} +22 -1
  93. data/lib/ruby_llm/agents/core/llm_tenant.rb +358 -0
  94. data/lib/ruby_llm/agents/{version.rb → core/version.rb} +1 -1
  95. data/lib/ruby_llm/agents/dsl/base.rb +110 -0
  96. data/lib/ruby_llm/agents/dsl/caching.rb +142 -0
  97. data/lib/ruby_llm/agents/dsl/reliability.rb +307 -0
  98. data/lib/ruby_llm/agents/dsl.rb +41 -0
  99. data/lib/ruby_llm/agents/image/analyzer/dsl.rb +130 -0
  100. data/lib/ruby_llm/agents/image/analyzer/execution.rb +402 -0
  101. data/lib/ruby_llm/agents/image/analyzer.rb +90 -0
  102. data/lib/ruby_llm/agents/image/background_remover/dsl.rb +154 -0
  103. data/lib/ruby_llm/agents/image/background_remover/execution.rb +240 -0
  104. data/lib/ruby_llm/agents/image/background_remover.rb +89 -0
  105. data/lib/ruby_llm/agents/image/concerns/image_operation_dsl.rb +91 -0
  106. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +165 -0
  107. data/lib/ruby_llm/agents/image/editor/dsl.rb +56 -0
  108. data/lib/ruby_llm/agents/image/editor/execution.rb +207 -0
  109. data/lib/ruby_llm/agents/image/editor.rb +92 -0
  110. data/lib/ruby_llm/agents/image/generator/active_storage_support.rb +127 -0
  111. data/lib/ruby_llm/agents/image/generator/content_policy.rb +95 -0
  112. data/lib/ruby_llm/agents/image/generator/pricing.rb +353 -0
  113. data/lib/ruby_llm/agents/image/generator/templates.rb +124 -0
  114. data/lib/ruby_llm/agents/image/generator.rb +455 -0
  115. data/lib/ruby_llm/agents/image/pipeline/dsl.rb +213 -0
  116. data/lib/ruby_llm/agents/image/pipeline/execution.rb +382 -0
  117. data/lib/ruby_llm/agents/image/pipeline.rb +97 -0
  118. data/lib/ruby_llm/agents/image/transformer/dsl.rb +148 -0
  119. data/lib/ruby_llm/agents/image/transformer/execution.rb +223 -0
  120. data/lib/ruby_llm/agents/image/transformer.rb +95 -0
  121. data/lib/ruby_llm/agents/image/upscaler/dsl.rb +83 -0
  122. data/lib/ruby_llm/agents/image/upscaler/execution.rb +219 -0
  123. data/lib/ruby_llm/agents/image/upscaler.rb +81 -0
  124. data/lib/ruby_llm/agents/image/variator/dsl.rb +62 -0
  125. data/lib/ruby_llm/agents/image/variator/execution.rb +189 -0
  126. data/lib/ruby_llm/agents/image/variator.rb +80 -0
  127. data/lib/ruby_llm/agents/{alert_manager.rb → infrastructure/alert_manager.rb} +17 -22
  128. data/lib/ruby_llm/agents/infrastructure/budget/budget_query.rb +145 -0
  129. data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +149 -0
  130. data/lib/ruby_llm/agents/infrastructure/budget/forecaster.rb +68 -0
  131. data/lib/ruby_llm/agents/infrastructure/budget/spend_recorder.rb +279 -0
  132. data/lib/ruby_llm/agents/infrastructure/budget_tracker.rb +275 -0
  133. data/lib/ruby_llm/agents/{execution_logger_job.rb → infrastructure/execution_logger_job.rb} +17 -1
  134. data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/executor.rb +2 -1
  135. data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/retry_strategy.rb +9 -3
  136. data/lib/ruby_llm/agents/{reliability.rb → infrastructure/reliability.rb} +11 -21
  137. data/lib/ruby_llm/agents/pipeline/builder.rb +215 -0
  138. data/lib/ruby_llm/agents/pipeline/context.rb +255 -0
  139. data/lib/ruby_llm/agents/pipeline/executor.rb +86 -0
  140. data/lib/ruby_llm/agents/pipeline/middleware/base.rb +124 -0
  141. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +95 -0
  142. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +171 -0
  143. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +415 -0
  144. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +276 -0
  145. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +196 -0
  146. data/lib/ruby_llm/agents/pipeline.rb +68 -0
  147. data/lib/ruby_llm/agents/{engine.rb → rails/engine.rb} +79 -11
  148. data/lib/ruby_llm/agents/results/background_removal_result.rb +286 -0
  149. data/lib/ruby_llm/agents/{result.rb → results/base.rb} +73 -1
  150. data/lib/ruby_llm/agents/results/embedding_result.rb +243 -0
  151. data/lib/ruby_llm/agents/results/image_analysis_result.rb +314 -0
  152. data/lib/ruby_llm/agents/results/image_edit_result.rb +250 -0
  153. data/lib/ruby_llm/agents/results/image_generation_result.rb +346 -0
  154. data/lib/ruby_llm/agents/results/image_pipeline_result.rb +399 -0
  155. data/lib/ruby_llm/agents/results/image_transform_result.rb +251 -0
  156. data/lib/ruby_llm/agents/results/image_upscale_result.rb +255 -0
  157. data/lib/ruby_llm/agents/results/image_variation_result.rb +237 -0
  158. data/lib/ruby_llm/agents/results/moderation_result.rb +158 -0
  159. data/lib/ruby_llm/agents/results/speech_result.rb +338 -0
  160. data/lib/ruby_llm/agents/results/transcription_result.rb +408 -0
  161. data/lib/ruby_llm/agents/text/embedder.rb +444 -0
  162. data/lib/ruby_llm/agents/text/moderator.rb +237 -0
  163. data/lib/ruby_llm/agents/workflow/async.rb +220 -0
  164. data/lib/ruby_llm/agents/workflow/async_executor.rb +156 -0
  165. data/lib/ruby_llm/agents/{workflow.rb → workflow/orchestrator.rb} +6 -5
  166. data/lib/ruby_llm/agents/workflow/parallel.rb +34 -17
  167. data/lib/ruby_llm/agents/workflow/thread_pool.rb +185 -0
  168. data/lib/ruby_llm/agents.rb +86 -20
  169. metadata +172 -34
  170. data/lib/ruby_llm/agents/base/caching.rb +0 -40
  171. data/lib/ruby_llm/agents/base/cost_calculation.rb +0 -105
  172. data/lib/ruby_llm/agents/base/dsl.rb +0 -324
  173. data/lib/ruby_llm/agents/base/execution.rb +0 -366
  174. data/lib/ruby_llm/agents/base/reliability_dsl.rb +0 -82
  175. data/lib/ruby_llm/agents/base/reliability_execution.rb +0 -136
  176. data/lib/ruby_llm/agents/base/response_building.rb +0 -86
  177. data/lib/ruby_llm/agents/base/tool_tracking.rb +0 -57
  178. data/lib/ruby_llm/agents/base.rb +0 -210
  179. data/lib/ruby_llm/agents/budget_tracker.rb +0 -733
  180. data/lib/ruby_llm/agents/configuration.rb +0 -394
  181. /data/lib/ruby_llm/agents/{deprecations.rb → core/deprecations.rb} +0 -0
  182. /data/lib/ruby_llm/agents/{inflections.rb → core/inflections.rb} +0 -0
  183. /data/lib/ruby_llm/agents/{resolved_config.rb → core/resolved_config.rb} +0 -0
  184. /data/lib/ruby_llm/agents/{attempt_tracker.rb → infrastructure/attempt_tracker.rb} +0 -0
  185. /data/lib/ruby_llm/agents/{cache_helper.rb → infrastructure/cache_helper.rb} +0 -0
  186. /data/lib/ruby_llm/agents/{circuit_breaker.rb → infrastructure/circuit_breaker.rb} +0 -0
  187. /data/lib/ruby_llm/agents/{redactor.rb → infrastructure/redactor.rb} +0 -0
  188. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/breaker_manager.rb +0 -0
  189. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/execution_constraints.rb +0 -0
  190. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/fallback_routing.rb +0 -0
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Base error class for RubyLLM::Agents
6
+ class Error < StandardError; end
7
+
8
+ # ============================================================
9
+ # Pipeline Errors
10
+ # ============================================================
11
+
12
+ # Base class for pipeline-related errors
13
+ class PipelineError < Error; end
14
+
15
+ # ============================================================
16
+ # Reliability Errors
17
+ # ============================================================
18
+
19
+ # Base class for reliability-related errors
20
+ class ReliabilityError < Error; end
21
+
22
+ # Raised when an error is retryable (transient)
23
+ class RetryableError < ReliabilityError; end
24
+
25
+ # Raised when a circuit breaker is open
26
+ class CircuitOpenError < ReliabilityError
27
+ # @return [String] The model that has an open circuit
28
+ attr_reader :model
29
+
30
+ def initialize(message = nil, model: nil)
31
+ @model = model
32
+ super(message || "Circuit breaker is open#{model ? " for #{model}" : ""}")
33
+ end
34
+ end
35
+
36
+ # Raised when total timeout is exceeded across all attempts
37
+ class TotalTimeoutError < ReliabilityError
38
+ # @return [Float] The timeout that was exceeded
39
+ attr_reader :timeout
40
+
41
+ # @return [Float] The elapsed time
42
+ attr_reader :elapsed
43
+
44
+ def initialize(message = nil, timeout: nil, elapsed: nil)
45
+ @timeout = timeout
46
+ @elapsed = elapsed
47
+ super(message || "Total timeout of #{timeout}s exceeded (elapsed: #{elapsed&.round(2)}s)")
48
+ end
49
+ end
50
+
51
+ # Raised when all models (primary + fallbacks) fail
52
+ class AllModelsFailedError < ReliabilityError
53
+ # @return [Array<Hash>] Details of each failed attempt
54
+ attr_reader :attempts
55
+
56
+ def initialize(message = nil, attempts: [])
57
+ @attempts = attempts
58
+ models = attempts.map { |a| a[:model] }.compact.join(", ")
59
+ super(message || "All models failed: #{models}")
60
+ end
61
+ end
62
+
63
+ # ============================================================
64
+ # Budget Errors
65
+ # ============================================================
66
+
67
+ # Base class for budget-related errors
68
+ class BudgetError < Error; end
69
+
70
+ # Raised when budget is exceeded
71
+ class BudgetExceededError < BudgetError
72
+ # @return [String, nil] The tenant ID
73
+ attr_reader :tenant_id
74
+
75
+ # @return [String, nil] The budget type (daily, monthly, etc.)
76
+ attr_reader :budget_type
77
+
78
+ def initialize(message = nil, tenant_id: nil, budget_type: nil)
79
+ @tenant_id = tenant_id
80
+ @budget_type = budget_type
81
+ super(message || "Budget exceeded#{tenant_id ? " for tenant #{tenant_id}" : ""}")
82
+ end
83
+ end
84
+
85
+ # ============================================================
86
+ # Configuration Errors
87
+ # ============================================================
88
+
89
+ # Raised for configuration issues
90
+ class ConfigurationError < Error; end
91
+
92
+ # Raised when content is flagged during moderation
93
+ #
94
+ # Contains the full moderation result and the phase where
95
+ # the content was flagged.
96
+ #
97
+ # @example Handling moderation errors
98
+ # begin
99
+ # result = MyAgent.call(message: user_input)
100
+ # rescue RubyLLM::Agents::ModerationError => e
101
+ # puts "Content blocked: #{e.flagged_categories.join(', ')}"
102
+ # puts "Phase: #{e.phase}"
103
+ # puts "Scores: #{e.category_scores}"
104
+ # end
105
+ #
106
+ # @api public
107
+ class ModerationError < Error
108
+ # @return [Object] The raw moderation result from RubyLLM
109
+ attr_reader :moderation_result
110
+
111
+ # @return [Symbol] The phase where content was flagged (:input or :output)
112
+ attr_reader :phase
113
+
114
+ # Creates a new ModerationError
115
+ #
116
+ # @param moderation_result [Object] The moderation result from RubyLLM
117
+ # @param phase [Symbol] The phase where content was flagged
118
+ def initialize(moderation_result, phase)
119
+ @moderation_result = moderation_result
120
+ @phase = phase
121
+
122
+ categories = moderation_result.flagged_categories
123
+ category_list = categories.respond_to?(:join) ? categories.join(", ") : categories.to_s
124
+
125
+ super("Content flagged during #{phase} moderation: #{category_list}")
126
+ end
127
+
128
+ # Returns the flagged categories from the moderation result
129
+ #
130
+ # @return [Array<String, Symbol>] List of flagged categories
131
+ def flagged_categories
132
+ moderation_result.flagged_categories
133
+ end
134
+
135
+ # Returns the category scores from the moderation result
136
+ #
137
+ # @return [Hash{String, Symbol => Float}] Category to score mapping
138
+ def category_scores
139
+ moderation_result.category_scores
140
+ end
141
+
142
+ # Returns whether the moderation result was flagged
143
+ #
144
+ # @return [Boolean] Always true for ModerationError
145
+ def flagged?
146
+ true
147
+ end
148
+ end
149
+ end
150
+ end
@@ -627,6 +627,9 @@ module RubyLLM
627
627
  # during multi-turn conversations (when tools are used)
628
628
  tool_calls_data = respond_to?(:accumulated_tool_calls) ? accumulated_tool_calls : []
629
629
 
630
+ # Extract thinking data if present
631
+ thinking_data = safe_extract_thinking_data(response)
632
+
630
633
  {
631
634
  input_tokens: safe_response_value(response, :input_tokens),
632
635
  output_tokens: safe_response_value(response, :output_tokens),
@@ -637,7 +640,7 @@ module RubyLLM
637
640
  response: safe_serialize_response(response),
638
641
  tool_calls: tool_calls_data || [],
639
642
  tool_calls_count: tool_calls_data&.size || 0
640
- }.compact
643
+ }.merge(thinking_data).compact
641
644
  end
642
645
 
643
646
  # Extracts finish reason from response, normalizing to standard values
@@ -665,6 +668,24 @@ module RubyLLM
665
668
  end
666
669
  end
667
670
 
671
+ # Extracts thinking data from response
672
+ #
673
+ # Handles different response structures from various providers.
674
+ # The thinking object typically has text, signature, and tokens.
675
+ #
676
+ # @param response [RubyLLM::Message] The LLM response
677
+ # @return [Hash] Thinking data (empty if none present)
678
+ def safe_extract_thinking_data(response)
679
+ thinking = safe_response_value(response, :thinking)
680
+ return {} unless thinking
681
+
682
+ {
683
+ thinking_text: thinking.respond_to?(:text) ? thinking.text : thinking[:text],
684
+ thinking_signature: thinking.respond_to?(:signature) ? thinking.signature : thinking[:signature],
685
+ thinking_tokens: thinking.respond_to?(:tokens) ? thinking.tokens : thinking[:tokens]
686
+ }.compact
687
+ end
688
+
668
689
  # Extracts routing/retry tracking data from attempt tracker
669
690
  #
670
691
  # Analyzes the execution attempts to determine:
@@ -0,0 +1,358 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module RubyLLM
6
+ module Agents
7
+ # DSL for declaring Rails models as LLM tenants
8
+ #
9
+ # Provides automatic budget management and usage tracking when included
10
+ # in ActiveRecord models. Models using this concern can be passed as
11
+ # the `tenant:` parameter to agents.
12
+ #
13
+ # @example Basic usage
14
+ # class Organization < ApplicationRecord
15
+ # llm_tenant
16
+ # end
17
+ #
18
+ # @example With custom ID method
19
+ # class Organization < ApplicationRecord
20
+ # llm_tenant id: :slug
21
+ # end
22
+ #
23
+ # @example With auto-created budget
24
+ # class Organization < ApplicationRecord
25
+ # llm_tenant id: :slug, budget: true
26
+ # end
27
+ #
28
+ # @example With limits (auto-creates budget)
29
+ # class Organization < ApplicationRecord
30
+ # llm_tenant(
31
+ # id: :slug,
32
+ # name: :company_name,
33
+ # limits: {
34
+ # daily_cost: 100,
35
+ # monthly_cost: 1000,
36
+ # daily_executions: 500
37
+ # },
38
+ # enforcement: :hard
39
+ # )
40
+ # end
41
+ #
42
+ # @example With API keys from model columns/methods
43
+ # class Organization < ApplicationRecord
44
+ # encrypts :openai_api_key, :anthropic_api_key # Rails 7+ encryption
45
+ #
46
+ # llm_tenant(
47
+ # id: :slug,
48
+ # api_keys: {
49
+ # openai: :openai_api_key, # column name
50
+ # anthropic: :anthropic_api_key, # column name
51
+ # gemini: :fetch_gemini_key # custom method
52
+ # }
53
+ # )
54
+ #
55
+ # def fetch_gemini_key
56
+ # Vault.read("secret/#{slug}/gemini")
57
+ # end
58
+ # end
59
+ #
60
+ # @see RubyLLM::Agents::TenantBudget
61
+ # @api public
62
+ module LLMTenant
63
+ extend ActiveSupport::Concern
64
+
65
+ included do
66
+ # Executions tracked for this tenant
67
+ has_many :llm_executions,
68
+ class_name: "RubyLLM::Agents::Execution",
69
+ as: :tenant_record,
70
+ dependent: :nullify
71
+
72
+ # Budget association (optional)
73
+ has_one :llm_budget,
74
+ class_name: "RubyLLM::Agents::TenantBudget",
75
+ as: :tenant_record,
76
+ dependent: :destroy
77
+
78
+ # Store options at class level
79
+ class_attribute :llm_tenant_options, default: {}
80
+ end
81
+
82
+ class_methods do
83
+ # Declares this model as an LLM tenant
84
+ #
85
+ # @param id [Symbol] Method to call for tenant_id string (default: :id)
86
+ # @param name [Symbol] Method for budget display name (default: :to_s)
87
+ # @param budget [Boolean] Auto-create TenantBudget on model creation (default: false)
88
+ # @param limits [Hash] Default budget limits (implies budget: true)
89
+ # @param enforcement [Symbol] Budget enforcement mode (:none, :soft, :hard)
90
+ # @param inherit_global [Boolean] Inherit from global config (default: true)
91
+ # @param api_keys [Hash] Provider API keys mapping (e.g., { openai: :openai_api_key })
92
+ # @return [void]
93
+ def llm_tenant(id: :id, name: :to_s, budget: false, limits: nil, enforcement: nil, inherit_global: true, api_keys: nil)
94
+ self.llm_tenant_options = {
95
+ id: id,
96
+ name: name,
97
+ budget: budget || limits.present?,
98
+ limits: normalize_limits(limits),
99
+ enforcement: enforcement,
100
+ inherit_global: inherit_global,
101
+ api_keys: api_keys
102
+ }
103
+
104
+ # Auto-create budget callback
105
+ after_create :create_default_llm_budget if llm_tenant_options[:budget]
106
+ end
107
+
108
+ private
109
+
110
+ # Normalizes the limits hash to internal column names
111
+ #
112
+ # @param limits [Hash, nil] User-provided limits
113
+ # @return [Hash] Normalized limits
114
+ def normalize_limits(limits)
115
+ return {} if limits.blank?
116
+
117
+ {
118
+ daily_cost: limits[:daily_cost],
119
+ monthly_cost: limits[:monthly_cost],
120
+ daily_tokens: limits[:daily_tokens],
121
+ monthly_tokens: limits[:monthly_tokens],
122
+ daily_executions: limits[:daily_executions],
123
+ monthly_executions: limits[:monthly_executions]
124
+ }.compact
125
+ end
126
+ end
127
+
128
+ # Returns the tenant_id string for this model
129
+ #
130
+ # @return [String] The tenant identifier
131
+ def llm_tenant_id
132
+ id_method = self.class.llm_tenant_options[:id] || :id
133
+ send(id_method).to_s
134
+ end
135
+
136
+ # Returns API keys resolved from the DSL configuration
137
+ #
138
+ # Maps provider names (e.g., :openai, :anthropic) to their resolved values
139
+ # by calling the configured method/column on this model instance.
140
+ #
141
+ # @return [Hash] Provider to API key mapping (e.g., { openai: "sk-..." })
142
+ # @example
143
+ # org.llm_api_keys
144
+ # # => { openai: "sk-abc123", anthropic: "sk-ant-xyz789" }
145
+ def llm_api_keys
146
+ api_keys_config = self.class.llm_tenant_options[:api_keys]
147
+ return {} if api_keys_config.blank?
148
+
149
+ api_keys_config.transform_values do |method_name|
150
+ value = send(method_name)
151
+ value.presence
152
+ end.compact
153
+ end
154
+
155
+ # Returns cost for a given period
156
+ #
157
+ # @param period [Symbol, Range, nil] Time period (:today, :this_month, etc.)
158
+ # @return [BigDecimal] Total cost
159
+ def llm_cost(period: nil)
160
+ scope = llm_executions
161
+ scope = apply_period_scope(scope, period) if period
162
+ scope.sum(:total_cost) || 0
163
+ end
164
+
165
+ # Returns cost for today
166
+ #
167
+ # @return [BigDecimal] Today's cost
168
+ def llm_cost_today
169
+ llm_cost(period: :today)
170
+ end
171
+
172
+ # Returns cost for this month
173
+ #
174
+ # @return [BigDecimal] This month's cost
175
+ def llm_cost_this_month
176
+ llm_cost(period: :this_month)
177
+ end
178
+
179
+ # Returns token count for a given period
180
+ #
181
+ # @param period [Symbol, Range, nil] Time period
182
+ # @return [Integer] Total tokens
183
+ def llm_tokens(period: nil)
184
+ scope = llm_executions
185
+ scope = apply_period_scope(scope, period) if period
186
+ scope.sum(:total_tokens) || 0
187
+ end
188
+
189
+ # Returns tokens for today
190
+ #
191
+ # @return [Integer] Today's tokens
192
+ def llm_tokens_today
193
+ llm_tokens(period: :today)
194
+ end
195
+
196
+ # Returns tokens for this month
197
+ #
198
+ # @return [Integer] This month's tokens
199
+ def llm_tokens_this_month
200
+ llm_tokens(period: :this_month)
201
+ end
202
+
203
+ # Returns execution count for a given period
204
+ #
205
+ # @param period [Symbol, Range, nil] Time period
206
+ # @return [Integer] Execution count
207
+ def llm_execution_count(period: nil)
208
+ scope = llm_executions
209
+ scope = apply_period_scope(scope, period) if period
210
+ scope.count
211
+ end
212
+
213
+ # Returns executions for today
214
+ #
215
+ # @return [Integer] Today's execution count
216
+ def llm_executions_today
217
+ llm_execution_count(period: :today)
218
+ end
219
+
220
+ # Returns executions for this month
221
+ #
222
+ # @return [Integer] This month's execution count
223
+ def llm_executions_this_month
224
+ llm_execution_count(period: :this_month)
225
+ end
226
+
227
+ # Returns a usage summary for a given period
228
+ #
229
+ # @param period [Symbol] Time period (default: :this_month)
230
+ # @return [Hash] Usage summary with cost, tokens, and executions
231
+ def llm_usage_summary(period: :this_month)
232
+ {
233
+ cost: llm_cost(period: period),
234
+ tokens: llm_tokens(period: period),
235
+ executions: llm_execution_count(period: period),
236
+ period: period
237
+ }
238
+ end
239
+
240
+ # Returns or builds the associated TenantBudget
241
+ #
242
+ # @return [TenantBudget] The budget record
243
+ def llm_budget
244
+ super || build_llm_budget(tenant_id: llm_tenant_id)
245
+ end
246
+
247
+ # Configure budget with a block
248
+ #
249
+ # @yield [budget] The budget to configure
250
+ # @return [TenantBudget] The saved budget
251
+ def llm_configure_budget
252
+ budget = llm_budget
253
+ yield(budget) if block_given?
254
+ budget.save!
255
+ budget
256
+ end
257
+
258
+ # Returns the budget status from BudgetTracker
259
+ #
260
+ # @return [Hash] Budget status
261
+ def llm_budget_status
262
+ BudgetTracker.status(tenant_id: llm_tenant_id)
263
+ end
264
+
265
+ # Checks if within budget for a given limit type
266
+ #
267
+ # @param type [Symbol] Limit type (:daily_cost, :monthly_cost, :daily_tokens, etc.)
268
+ # @return [Boolean] true if within budget
269
+ def llm_within_budget?(type: :daily_cost)
270
+ status = llm_budget_status
271
+ return true unless status[:enabled]
272
+
273
+ key = budget_status_key(type)
274
+ status.dig(key, :percentage_used).to_f < 100
275
+ end
276
+
277
+ # Returns remaining budget for a given limit type
278
+ #
279
+ # @param type [Symbol] Limit type
280
+ # @return [Numeric, nil] Remaining amount
281
+ def llm_remaining_budget(type: :daily_cost)
282
+ status = llm_budget_status
283
+ key = budget_status_key(type)
284
+ status.dig(key, :remaining)
285
+ end
286
+
287
+ # Raises an error if over budget
288
+ #
289
+ # @raise [BudgetExceededError] if budget is exceeded
290
+ # @return [void]
291
+ def llm_check_budget!
292
+ BudgetTracker.check_budget!(self.class.name, tenant_id: llm_tenant_id)
293
+ end
294
+
295
+ private
296
+
297
+ # Applies a period scope to an execution query
298
+ #
299
+ # @param scope [ActiveRecord::Relation] The query scope
300
+ # @param period [Symbol, Range] The period to filter by
301
+ # @return [ActiveRecord::Relation] Filtered scope
302
+ def apply_period_scope(scope, period)
303
+ case period
304
+ when :today then scope.where(created_at: Time.current.all_day)
305
+ when :yesterday then scope.where(created_at: 1.day.ago.all_day)
306
+ when :this_week then scope.where(created_at: Time.current.all_week)
307
+ when :this_month then scope.where(created_at: Time.current.all_month)
308
+ when Range then scope.where(created_at: period)
309
+ else scope
310
+ end
311
+ end
312
+
313
+ # Maps user-friendly type to budget status key
314
+ #
315
+ # @param type [Symbol] User-friendly type
316
+ # @return [Symbol] Status key
317
+ def budget_status_key(type)
318
+ case type
319
+ when :daily_cost then :global_daily
320
+ when :monthly_cost then :global_monthly
321
+ when :daily_tokens then :global_daily_tokens
322
+ when :monthly_tokens then :global_monthly_tokens
323
+ when :daily_executions then :global_daily_executions
324
+ when :monthly_executions then :global_monthly_executions
325
+ else :global_daily
326
+ end
327
+ end
328
+
329
+ # Creates the default budget on model creation
330
+ #
331
+ # @return [void]
332
+ def create_default_llm_budget
333
+ return if self.class.llm_tenant_options.blank?
334
+ return if llm_budget&.persisted?
335
+
336
+ options = self.class.llm_tenant_options
337
+ limits = options[:limits] || {}
338
+ name_method = options[:name] || :to_s
339
+
340
+ budget = build_llm_budget(
341
+ tenant_id: llm_tenant_id,
342
+ name: send(name_method).to_s,
343
+ daily_limit: limits[:daily_cost],
344
+ monthly_limit: limits[:monthly_cost],
345
+ daily_token_limit: limits[:daily_tokens],
346
+ monthly_token_limit: limits[:monthly_tokens],
347
+ daily_execution_limit: limits[:daily_executions],
348
+ monthly_execution_limit: limits[:monthly_executions],
349
+ enforcement: options[:enforcement]&.to_s || "soft",
350
+ inherit_global_defaults: options.fetch(:inherit_global, true)
351
+ )
352
+
353
+ budget.tenant_record = self
354
+ budget.save!
355
+ end
356
+ end
357
+ end
358
+ end
@@ -4,6 +4,6 @@ module RubyLLM
4
4
  module Agents
5
5
  # Current version of the RubyLLM::Agents gem
6
6
  # @return [String] Semantic version string
7
- VERSION = "0.5.0"
7
+ VERSION = "1.0.0"
8
8
  end
9
9
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ module DSL
6
+ # Base DSL available to all agents.
7
+ #
8
+ # Provides common configuration methods that every agent type needs:
9
+ # - model: The LLM model to use
10
+ # - version: Cache invalidation version
11
+ # - description: Human-readable description
12
+ # - timeout: Request timeout
13
+ #
14
+ # @example Basic usage
15
+ # class MyAgent < RubyLLM::Agents::BaseAgent
16
+ # extend DSL::Base
17
+ #
18
+ # model "gpt-4o"
19
+ # version "2.0"
20
+ # description "A helpful agent"
21
+ # end
22
+ #
23
+ module Base
24
+ # @!group Configuration DSL
25
+
26
+ # Sets or returns the LLM model for this agent class
27
+ #
28
+ # @param value [String, nil] The model identifier to set
29
+ # @return [String] The current model setting
30
+ # @example
31
+ # model "gpt-4o"
32
+ def model(value = nil)
33
+ @model = value if value
34
+ @model || inherited_or_default(:model, default_model)
35
+ end
36
+
37
+ # Sets or returns the version string for cache invalidation
38
+ #
39
+ # Change this when you want to invalidate cached results
40
+ # (e.g., after changing prompts or behavior).
41
+ #
42
+ # @param value [String, nil] Version string
43
+ # @return [String] The current version
44
+ # @example
45
+ # version "2.0"
46
+ def version(value = nil)
47
+ @version = value if value
48
+ @version || inherited_or_default(:version, "1.0")
49
+ end
50
+
51
+ # Sets or returns the description for this agent class
52
+ #
53
+ # Useful for documentation and tool registration.
54
+ #
55
+ # @param value [String, nil] The description text
56
+ # @return [String, nil] The current description
57
+ # @example
58
+ # description "Searches the knowledge base for relevant documents"
59
+ def description(value = nil)
60
+ @description = value if value
61
+ @description || inherited_or_default(:description, nil)
62
+ end
63
+
64
+ # Sets or returns the timeout in seconds for LLM requests
65
+ #
66
+ # @param value [Integer, nil] Timeout in seconds
67
+ # @return [Integer] The current timeout setting
68
+ # @example
69
+ # timeout 30
70
+ def timeout(value = nil)
71
+ @timeout = value if value
72
+ @timeout || inherited_or_default(:timeout, default_timeout)
73
+ end
74
+
75
+ # @!endgroup
76
+
77
+ private
78
+
79
+ # Looks up setting from superclass or uses default
80
+ #
81
+ # @param method [Symbol] The method to call on superclass
82
+ # @param default [Object] Default value if not found
83
+ # @return [Object] The resolved value
84
+ def inherited_or_default(method, default)
85
+ return default unless superclass.respond_to?(method)
86
+
87
+ superclass.send(method)
88
+ end
89
+
90
+ # Returns the default model from configuration
91
+ #
92
+ # @return [String] The default model
93
+ def default_model
94
+ RubyLLM::Agents.configuration.default_model
95
+ rescue StandardError
96
+ "gpt-4o"
97
+ end
98
+
99
+ # Returns the default timeout from configuration
100
+ #
101
+ # @return [Integer] The default timeout
102
+ def default_timeout
103
+ RubyLLM::Agents.configuration.default_timeout
104
+ rescue StandardError
105
+ 120
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end