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
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Database-backed API configuration for LLM providers
6
+ #
7
+ # Stores API keys (encrypted at rest) and configuration options that can be
8
+ # managed via the dashboard UI. Supports both global settings and per-tenant
9
+ # overrides.
10
+ #
11
+ # Resolution priority: per-tenant DB > global DB > config file (RubyLLM.configure)
12
+ #
13
+ # @!attribute [rw] scope_type
14
+ # @return [String] Either 'global' or 'tenant'
15
+ # @!attribute [rw] scope_id
16
+ # @return [String, nil] Tenant ID when scope_type='tenant'
17
+ #
18
+ # @example Setting global API keys
19
+ # config = ApiConfiguration.global
20
+ # config.update!(
21
+ # openai_api_key: "sk-...",
22
+ # anthropic_api_key: "sk-ant-..."
23
+ # )
24
+ #
25
+ # @example Setting tenant-specific configuration
26
+ # tenant_config = ApiConfiguration.for_tenant!("acme_corp")
27
+ # tenant_config.update!(
28
+ # anthropic_api_key: "sk-ant-tenant-specific...",
29
+ # default_model: "claude-sonnet-4-20250514"
30
+ # )
31
+ #
32
+ # @example Resolving configuration for a tenant
33
+ # resolved = ApiConfiguration.resolve(tenant_id: "acme_corp")
34
+ # resolved.apply_to_ruby_llm! # Apply to RubyLLM.configuration
35
+ #
36
+ # @see ResolvedConfig
37
+ # @api public
38
+ class ApiConfiguration < ::ActiveRecord::Base
39
+ self.table_name = "ruby_llm_agents_api_configurations"
40
+
41
+ # Valid scope types
42
+ SCOPE_TYPES = %w[global tenant].freeze
43
+
44
+ # All API key attributes that should be encrypted
45
+ API_KEY_ATTRIBUTES = %i[
46
+ openai_api_key
47
+ anthropic_api_key
48
+ gemini_api_key
49
+ deepseek_api_key
50
+ mistral_api_key
51
+ perplexity_api_key
52
+ openrouter_api_key
53
+ gpustack_api_key
54
+ xai_api_key
55
+ ollama_api_key
56
+ bedrock_api_key
57
+ bedrock_secret_key
58
+ bedrock_session_token
59
+ vertexai_credentials
60
+ ].freeze
61
+
62
+ # All endpoint attributes
63
+ ENDPOINT_ATTRIBUTES = %i[
64
+ openai_api_base
65
+ gemini_api_base
66
+ ollama_api_base
67
+ gpustack_api_base
68
+ xai_api_base
69
+ ].freeze
70
+
71
+ # All default model attributes
72
+ MODEL_ATTRIBUTES = %i[
73
+ default_model
74
+ default_embedding_model
75
+ default_image_model
76
+ default_moderation_model
77
+ ].freeze
78
+
79
+ # Connection settings attributes
80
+ CONNECTION_ATTRIBUTES = %i[
81
+ request_timeout
82
+ max_retries
83
+ retry_interval
84
+ retry_backoff_factor
85
+ retry_interval_randomness
86
+ http_proxy
87
+ ].freeze
88
+
89
+ # All configurable attributes (excluding API keys)
90
+ NON_KEY_ATTRIBUTES = (
91
+ ENDPOINT_ATTRIBUTES +
92
+ MODEL_ATTRIBUTES +
93
+ CONNECTION_ATTRIBUTES +
94
+ %i[
95
+ openai_organization_id
96
+ openai_project_id
97
+ bedrock_region
98
+ vertexai_project_id
99
+ vertexai_location
100
+ ]
101
+ ).freeze
102
+
103
+ # Encrypt all API keys using Rails encrypted attributes
104
+ # Requires Rails encryption to be configured (rails credentials:edit)
105
+ API_KEY_ATTRIBUTES.each do |key_attr|
106
+ encrypts key_attr, deterministic: false
107
+ end
108
+
109
+ # Validations
110
+ validates :scope_type, presence: true, inclusion: { in: SCOPE_TYPES }
111
+ validates :scope_id, uniqueness: { scope: :scope_type }, allow_nil: true
112
+ validate :validate_scope_consistency
113
+
114
+ # Scopes
115
+ scope :global_config, -> { where(scope_type: "global", scope_id: nil) }
116
+ scope :for_scope, ->(type, id) { where(scope_type: type, scope_id: id) }
117
+ scope :tenant_configs, -> { where(scope_type: "tenant") }
118
+
119
+ # Provider configuration mappings for display
120
+ PROVIDERS = {
121
+ openai: {
122
+ name: "OpenAI",
123
+ key_attr: :openai_api_key,
124
+ base_attr: :openai_api_base,
125
+ extra_attrs: [:openai_organization_id, :openai_project_id],
126
+ capabilities: ["Chat", "Embeddings", "Images", "Moderation"]
127
+ },
128
+ anthropic: {
129
+ name: "Anthropic",
130
+ key_attr: :anthropic_api_key,
131
+ capabilities: ["Chat"]
132
+ },
133
+ gemini: {
134
+ name: "Google Gemini",
135
+ key_attr: :gemini_api_key,
136
+ base_attr: :gemini_api_base,
137
+ capabilities: ["Chat", "Embeddings", "Images"]
138
+ },
139
+ deepseek: {
140
+ name: "DeepSeek",
141
+ key_attr: :deepseek_api_key,
142
+ capabilities: ["Chat"]
143
+ },
144
+ mistral: {
145
+ name: "Mistral",
146
+ key_attr: :mistral_api_key,
147
+ capabilities: ["Chat", "Embeddings"]
148
+ },
149
+ perplexity: {
150
+ name: "Perplexity",
151
+ key_attr: :perplexity_api_key,
152
+ capabilities: ["Chat"]
153
+ },
154
+ openrouter: {
155
+ name: "OpenRouter",
156
+ key_attr: :openrouter_api_key,
157
+ capabilities: ["Chat"]
158
+ },
159
+ gpustack: {
160
+ name: "GPUStack",
161
+ key_attr: :gpustack_api_key,
162
+ base_attr: :gpustack_api_base,
163
+ capabilities: ["Chat"]
164
+ },
165
+ xai: {
166
+ name: "xAI",
167
+ key_attr: :xai_api_key,
168
+ base_attr: :xai_api_base,
169
+ capabilities: ["Chat"]
170
+ },
171
+ ollama: {
172
+ name: "Ollama",
173
+ key_attr: :ollama_api_key,
174
+ base_attr: :ollama_api_base,
175
+ capabilities: ["Chat", "Embeddings"]
176
+ },
177
+ bedrock: {
178
+ name: "AWS Bedrock",
179
+ key_attr: :bedrock_api_key,
180
+ extra_attrs: [:bedrock_secret_key, :bedrock_session_token, :bedrock_region],
181
+ capabilities: ["Chat", "Embeddings"]
182
+ },
183
+ vertexai: {
184
+ name: "Google Vertex AI",
185
+ key_attr: :vertexai_credentials,
186
+ extra_attrs: [:vertexai_project_id, :vertexai_location],
187
+ capabilities: ["Chat", "Embeddings"]
188
+ }
189
+ }.freeze
190
+
191
+ class << self
192
+ # Finds or creates the global configuration
193
+ #
194
+ # @return [ApiConfiguration] The global configuration record
195
+ def global
196
+ global_config.first_or_create!
197
+ end
198
+
199
+ # Finds a tenant-specific configuration
200
+ #
201
+ # @param tenant_id [String] The tenant identifier
202
+ # @return [ApiConfiguration, nil] The tenant configuration or nil
203
+ def for_tenant(tenant_id)
204
+ return nil if tenant_id.blank?
205
+
206
+ for_scope("tenant", tenant_id).first
207
+ end
208
+
209
+ # Finds or creates a tenant-specific configuration
210
+ #
211
+ # @param tenant_id [String] The tenant identifier
212
+ # @return [ApiConfiguration] The tenant configuration record
213
+ def for_tenant!(tenant_id)
214
+ raise ArgumentError, "tenant_id cannot be blank" if tenant_id.blank?
215
+
216
+ for_scope("tenant", tenant_id).first_or_create!(
217
+ scope_type: "tenant",
218
+ scope_id: tenant_id
219
+ )
220
+ end
221
+
222
+ # Resolves the effective configuration for a given tenant
223
+ #
224
+ # Creates a ResolvedConfig that combines tenant config > global DB > RubyLLM config
225
+ #
226
+ # @param tenant_id [String, nil] Optional tenant identifier
227
+ # @return [ResolvedConfig] The resolved configuration
228
+ def resolve(tenant_id: nil)
229
+ tenant_config = tenant_id.present? ? for_tenant(tenant_id) : nil
230
+ global = global_config.first
231
+
232
+ RubyLLM::Agents::ResolvedConfig.new(
233
+ tenant_config: tenant_config,
234
+ global_config: global,
235
+ ruby_llm_config: ruby_llm_current_config
236
+ )
237
+ end
238
+
239
+ # Attempts to get the current RubyLLM configuration object
240
+ # Gets the current RubyLLM configuration object
241
+ #
242
+ # @return [Object, nil] The RubyLLM config object or nil
243
+ def ruby_llm_current_config
244
+ return nil unless defined?(::RubyLLM)
245
+ return nil unless RubyLLM.respond_to?(:config)
246
+
247
+ RubyLLM.config
248
+ rescue StandardError
249
+ nil
250
+ end
251
+
252
+ # Checks if the table exists (for graceful degradation)
253
+ #
254
+ # @return [Boolean]
255
+ def table_exists?
256
+ connection.table_exists?(table_name)
257
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
258
+ false
259
+ end
260
+ end
261
+
262
+ # Checks if a specific attribute has a value set
263
+ #
264
+ # @param attr_name [Symbol, String] The attribute name
265
+ # @return [Boolean]
266
+ def has_value?(attr_name)
267
+ value = send(attr_name)
268
+ value.present?
269
+ rescue NoMethodError
270
+ false
271
+ end
272
+
273
+ # Returns a masked version of an API key for display
274
+ #
275
+ # @param attr_name [Symbol, String] The API key attribute name
276
+ # @return [String, nil] Masked key like "sk-ab****wxyz" or nil
277
+ def masked_key(attr_name)
278
+ value = send(attr_name)
279
+ return nil if value.blank?
280
+
281
+ mask_string(value)
282
+ end
283
+
284
+ # Returns the source of this configuration
285
+ #
286
+ # @return [String] "global" or "tenant:ID"
287
+ def source_label
288
+ scope_type == "global" ? "Global" : "Tenant: #{scope_id}"
289
+ end
290
+
291
+ # Converts this configuration to a hash suitable for RubyLLM
292
+ #
293
+ # @return [Hash] Configuration hash with non-nil values
294
+ def to_ruby_llm_config
295
+ {}.tap do |config|
296
+ # API keys
297
+ config[:openai_api_key] = openai_api_key if openai_api_key.present?
298
+ config[:anthropic_api_key] = anthropic_api_key if anthropic_api_key.present?
299
+ config[:gemini_api_key] = gemini_api_key if gemini_api_key.present?
300
+ config[:deepseek_api_key] = deepseek_api_key if deepseek_api_key.present?
301
+ config[:mistral_api_key] = mistral_api_key if mistral_api_key.present?
302
+ config[:perplexity_api_key] = perplexity_api_key if perplexity_api_key.present?
303
+ config[:openrouter_api_key] = openrouter_api_key if openrouter_api_key.present?
304
+ config[:gpustack_api_key] = gpustack_api_key if gpustack_api_key.present?
305
+ config[:xai_api_key] = xai_api_key if xai_api_key.present?
306
+ config[:ollama_api_key] = ollama_api_key if ollama_api_key.present?
307
+
308
+ # Bedrock
309
+ config[:bedrock_api_key] = bedrock_api_key if bedrock_api_key.present?
310
+ config[:bedrock_secret_key] = bedrock_secret_key if bedrock_secret_key.present?
311
+ config[:bedrock_session_token] = bedrock_session_token if bedrock_session_token.present?
312
+ config[:bedrock_region] = bedrock_region if bedrock_region.present?
313
+
314
+ # Vertex AI
315
+ config[:vertexai_credentials] = vertexai_credentials if vertexai_credentials.present?
316
+ config[:vertexai_project_id] = vertexai_project_id if vertexai_project_id.present?
317
+ config[:vertexai_location] = vertexai_location if vertexai_location.present?
318
+
319
+ # Endpoints
320
+ config[:openai_api_base] = openai_api_base if openai_api_base.present?
321
+ config[:gemini_api_base] = gemini_api_base if gemini_api_base.present?
322
+ config[:ollama_api_base] = ollama_api_base if ollama_api_base.present?
323
+ config[:gpustack_api_base] = gpustack_api_base if gpustack_api_base.present?
324
+ config[:xai_api_base] = xai_api_base if xai_api_base.present?
325
+
326
+ # OpenAI options
327
+ config[:openai_organization_id] = openai_organization_id if openai_organization_id.present?
328
+ config[:openai_project_id] = openai_project_id if openai_project_id.present?
329
+
330
+ # Default models
331
+ config[:default_model] = default_model if default_model.present?
332
+ config[:default_embedding_model] = default_embedding_model if default_embedding_model.present?
333
+ config[:default_image_model] = default_image_model if default_image_model.present?
334
+ config[:default_moderation_model] = default_moderation_model if default_moderation_model.present?
335
+
336
+ # Connection settings
337
+ config[:request_timeout] = request_timeout if request_timeout.present?
338
+ config[:max_retries] = max_retries if max_retries.present?
339
+ config[:retry_interval] = retry_interval if retry_interval.present?
340
+ config[:retry_backoff_factor] = retry_backoff_factor if retry_backoff_factor.present?
341
+ config[:retry_interval_randomness] = retry_interval_randomness if retry_interval_randomness.present?
342
+ config[:http_proxy] = http_proxy if http_proxy.present?
343
+ end
344
+ end
345
+
346
+ # Returns provider status information for display
347
+ #
348
+ # @return [Array<Hash>] Array of provider status hashes
349
+ def provider_statuses
350
+ PROVIDERS.map do |key, info|
351
+ key_value = send(info[:key_attr])
352
+ {
353
+ key: key,
354
+ name: info[:name],
355
+ configured: key_value.present?,
356
+ masked_key: key_value.present? ? mask_string(key_value) : nil,
357
+ capabilities: info[:capabilities],
358
+ has_base_url: info[:base_attr].present? && send(info[:base_attr]).present?
359
+ }
360
+ end
361
+ end
362
+
363
+ private
364
+
365
+ # Validates scope consistency
366
+ def validate_scope_consistency
367
+ if scope_type == "global" && scope_id.present?
368
+ errors.add(:scope_id, "must be nil for global scope")
369
+ elsif scope_type == "tenant" && scope_id.blank?
370
+ errors.add(:scope_id, "must be present for tenant scope")
371
+ end
372
+ end
373
+
374
+ # Masks a string for display (shows first 2 and last 4 chars)
375
+ #
376
+ # @param value [String] The string to mask
377
+ # @return [String] Masked string
378
+ def mask_string(value)
379
+ return nil if value.blank?
380
+ return "****" if value.length <= 8
381
+
382
+ "#{value[0..1]}****#{value[-4..]}"
383
+ end
384
+ end
385
+ end
386
+ end
@@ -72,6 +72,9 @@ module RubyLLM
72
72
  has_many :child_executions, class_name: "RubyLLM::Agents::Execution",
73
73
  foreign_key: :parent_execution_id, dependent: :nullify, inverse_of: :parent_execution
74
74
 
75
+ # Polymorphic association to tenant model (for llm_tenant DSL)
76
+ belongs_to :tenant_record, polymorphic: true, optional: true
77
+
75
78
  # Validations
76
79
  validates :agent_type, :model_id, :started_at, presence: true
77
80
  validates :status, inclusion: { in: statuses.keys }
@@ -6,36 +6,56 @@ module RubyLLM
6
6
  #
7
7
  # Stores per-tenant budget limits that override the global configuration.
8
8
  # Supports runtime updates without application restarts.
9
+ # Supports cost-based (USD), token-based, and execution-based limits.
9
10
  #
10
11
  # @!attribute [rw] tenant_id
11
12
  # @return [String] Unique identifier for the tenant
13
+ # @!attribute [rw] name
14
+ # @return [String, nil] Human-readable name for the tenant
12
15
  # @!attribute [rw] daily_limit
13
16
  # @return [BigDecimal, nil] Daily budget limit in USD
14
17
  # @!attribute [rw] monthly_limit
15
18
  # @return [BigDecimal, nil] Monthly budget limit in USD
19
+ # @!attribute [rw] daily_token_limit
20
+ # @return [Integer, nil] Daily token limit (across all models)
21
+ # @!attribute [rw] monthly_token_limit
22
+ # @return [Integer, nil] Monthly token limit (across all models)
23
+ # @!attribute [rw] daily_execution_limit
24
+ # @return [Integer, nil] Daily execution/call limit
25
+ # @!attribute [rw] monthly_execution_limit
26
+ # @return [Integer, nil] Monthly execution/call limit
16
27
  # @!attribute [rw] per_agent_daily
17
- # @return [Hash] Per-agent daily limits: { "AgentName" => limit }
28
+ # @return [Hash] Per-agent daily cost limits: { "AgentName" => limit }
18
29
  # @!attribute [rw] per_agent_monthly
19
- # @return [Hash] Per-agent monthly limits: { "AgentName" => limit }
30
+ # @return [Hash] Per-agent monthly cost limits: { "AgentName" => limit }
20
31
  # @!attribute [rw] enforcement
21
32
  # @return [String] Enforcement mode: "none", "soft", or "hard"
22
33
  # @!attribute [rw] inherit_global_defaults
23
34
  # @return [Boolean] Whether to fall back to global config for unset limits
35
+ # @!attribute [rw] tenant_record
36
+ # @return [ActiveRecord::Base, nil] Polymorphic association to tenant model
24
37
  #
25
- # @example Creating a tenant budget
38
+ # @example Creating a tenant budget with cost, token, and execution limits
26
39
  # TenantBudget.create!(
27
40
  # tenant_id: "acme_corp",
28
- # daily_limit: 50.0,
29
- # monthly_limit: 500.0,
30
- # per_agent_daily: { "ContentAgent" => 10.0 },
41
+ # name: "Acme Corporation",
42
+ # daily_limit: 50.0, # USD
43
+ # monthly_limit: 500.0, # USD
44
+ # daily_token_limit: 1_000_000,
45
+ # monthly_token_limit: 10_000_000,
46
+ # daily_execution_limit: 500,
47
+ # monthly_execution_limit: 10_000,
31
48
  # enforcement: "hard"
32
49
  # )
33
50
  #
34
- # @example Fetching budget for a tenant
35
- # budget = TenantBudget.for_tenant("acme_corp")
36
- # budget.effective_daily_limit # => 50.0
51
+ # @example Fetching budget for a tenant object
52
+ # budget = TenantBudget.for_tenant(organization)
53
+ # budget.effective_daily_limit # => 50.0 (cost)
54
+ # budget.effective_daily_token_limit # => 1_000_000 (tokens)
55
+ # budget.effective_daily_execution_limit # => 500 (executions)
37
56
  #
38
57
  # @see RubyLLM::Agents::BudgetTracker
58
+ # @see RubyLLM::Agents::LLMTenant
39
59
  # @api public
40
60
  class TenantBudget < ::ActiveRecord::Base
41
61
  self.table_name = "ruby_llm_agents_tenant_budgets"
@@ -43,20 +63,51 @@ module RubyLLM
43
63
  # Valid enforcement modes
44
64
  ENFORCEMENT_MODES = %w[none soft hard].freeze
45
65
 
66
+ # Polymorphic association to the tenant model (e.g., Organization, Account)
67
+ belongs_to :tenant_record, polymorphic: true, optional: true
68
+
46
69
  # Validations
47
70
  validates :tenant_id, presence: true, uniqueness: true
48
71
  validates :enforcement, inclusion: { in: ENFORCEMENT_MODES }, allow_nil: true
49
72
  validates :daily_limit, :monthly_limit,
50
73
  numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
74
+ validates :daily_token_limit, :monthly_token_limit,
75
+ numericality: { greater_than_or_equal_to: 0, only_integer: true }, allow_nil: true
76
+ validates :daily_execution_limit, :monthly_execution_limit,
77
+ numericality: { greater_than_or_equal_to: 0, only_integer: true }, allow_nil: true
51
78
 
52
79
  # Finds a budget for the given tenant
53
80
  #
54
- # @param tenant_id [String] The tenant identifier
81
+ # @param tenant [String, Object] The tenant identifier string or object with llm_tenant_id
55
82
  # @return [TenantBudget, nil] The budget record or nil if not found
56
- def self.for_tenant(tenant_id)
57
- return nil if tenant_id.blank?
83
+ def self.for_tenant(tenant)
84
+ return nil if tenant.blank?
85
+
86
+ if tenant.respond_to?(:llm_tenant_id)
87
+ # Object with llm_tenant DSL - try polymorphic first, then tenant_id
88
+ find_by(tenant_record: tenant) || find_by(tenant_id: tenant.llm_tenant_id)
89
+ else
90
+ # String tenant_id
91
+ find_by(tenant_id: tenant.to_s)
92
+ end
93
+ end
58
94
 
59
- find_by(tenant_id: tenant_id)
95
+ # Finds or creates a budget for the given tenant
96
+ #
97
+ # @param tenant_id [String] The tenant identifier
98
+ # @param name [String, nil] Optional human-readable name
99
+ # @return [TenantBudget] The budget record
100
+ def self.for_tenant!(tenant_id, name: nil)
101
+ find_or_create_by!(tenant_id: tenant_id) do |budget|
102
+ budget.name = name
103
+ end
104
+ end
105
+
106
+ # Returns the display name (name or tenant_id fallback)
107
+ #
108
+ # @return [String] The name to display
109
+ def display_name
110
+ name.presence || tenant_id
60
111
  end
61
112
 
62
113
  # Returns the effective daily limit, considering inheritance
@@ -103,6 +154,46 @@ module RubyLLM
103
154
  global_config&.dig(:per_agent_monthly, agent_type)
104
155
  end
105
156
 
157
+ # Returns the effective daily token limit, considering inheritance
158
+ #
159
+ # @return [Integer, nil] The daily token limit or nil if not set
160
+ def effective_daily_token_limit
161
+ return daily_token_limit if daily_token_limit.present?
162
+ return nil unless inherit_global_defaults
163
+
164
+ global_config&.dig(:global_daily_tokens)
165
+ end
166
+
167
+ # Returns the effective monthly token limit, considering inheritance
168
+ #
169
+ # @return [Integer, nil] The monthly token limit or nil if not set
170
+ def effective_monthly_token_limit
171
+ return monthly_token_limit if monthly_token_limit.present?
172
+ return nil unless inherit_global_defaults
173
+
174
+ global_config&.dig(:global_monthly_tokens)
175
+ end
176
+
177
+ # Returns the effective daily execution limit, considering inheritance
178
+ #
179
+ # @return [Integer, nil] The daily execution limit or nil if not set
180
+ def effective_daily_execution_limit
181
+ return daily_execution_limit if daily_execution_limit.present?
182
+ return nil unless inherit_global_defaults
183
+
184
+ global_config&.dig(:global_daily_executions)
185
+ end
186
+
187
+ # Returns the effective monthly execution limit, considering inheritance
188
+ #
189
+ # @return [Integer, nil] The monthly execution limit or nil if not set
190
+ def effective_monthly_execution_limit
191
+ return monthly_execution_limit if monthly_execution_limit.present?
192
+ return nil unless inherit_global_defaults
193
+
194
+ global_config&.dig(:global_monthly_executions)
195
+ end
196
+
106
197
  # Returns the effective enforcement mode
107
198
  #
108
199
  # @return [Symbol] :none, :soft, or :hard
@@ -127,10 +218,17 @@ module RubyLLM
127
218
  {
128
219
  enabled: budgets_enabled?,
129
220
  enforcement: effective_enforcement,
221
+ # Cost limits
130
222
  global_daily: effective_daily_limit,
131
223
  global_monthly: effective_monthly_limit,
132
224
  per_agent_daily: merged_per_agent_daily,
133
- per_agent_monthly: merged_per_agent_monthly
225
+ per_agent_monthly: merged_per_agent_monthly,
226
+ # Token limits
227
+ global_daily_tokens: effective_daily_token_limit,
228
+ global_monthly_tokens: effective_monthly_token_limit,
229
+ # Execution limits
230
+ global_daily_executions: effective_daily_execution_limit,
231
+ global_monthly_executions: effective_monthly_execution_limit
134
232
  }
135
233
  end
136
234
 
@@ -60,12 +60,19 @@ module RubyLLM
60
60
  #
61
61
  # @return [Array<String>] Agent class names
62
62
  def file_system_agents
63
- # Ensure all agent classes are loaded
63
+ # Ensure all agent and workflow classes are loaded
64
64
  eager_load_agents!
65
65
 
66
- # Find all descendants of the base class
67
- base_class = RubyLLM::Agents::Base
68
- base_class.descendants.map(&:name).compact
66
+ # Find all descendants of all base classes
67
+ agents = RubyLLM::Agents::Base.descendants.map(&:name).compact
68
+ workflows = RubyLLM::Agents::Workflow.descendants.map(&:name).compact
69
+ embedders = RubyLLM::Agents::Embedder.descendants.map(&:name).compact
70
+ moderators = RubyLLM::Agents::Moderator.descendants.map(&:name).compact
71
+ speakers = RubyLLM::Agents::Speaker.descendants.map(&:name).compact
72
+ transcribers = RubyLLM::Agents::Transcriber.descendants.map(&:name).compact
73
+ image_generators = RubyLLM::Agents::ImageGenerator.descendants.map(&:name).compact
74
+
75
+ (agents + workflows + embedders + moderators + speakers + transcribers + image_generators).uniq
69
76
  rescue StandardError => e
70
77
  Rails.logger.error("[RubyLLM::Agents] Error loading agents from file system: #{e.message}")
71
78
  []
@@ -81,17 +88,19 @@ module RubyLLM
81
88
  []
82
89
  end
83
90
 
84
- # Eager loads all agent files to register descendants
91
+ # Eager loads all agent and workflow files to register descendants
85
92
  #
86
93
  # @return [void]
87
94
  def eager_load_agents!
88
- agents_path = Rails.root.join("app", "agents")
89
- return unless agents_path.exist?
95
+ %w[agents workflows embedders moderators speakers transcribers image_generators].each do |dir|
96
+ path = Rails.root.join("app", dir)
97
+ next unless path.exist?
90
98
 
91
- Dir.glob(agents_path.join("**", "*.rb")).each do |file|
92
- require_dependency file
93
- rescue LoadError, StandardError => e
94
- Rails.logger.error("[RubyLLM::Agents] Failed to load agent file #{file}: #{e.message}")
99
+ Dir.glob(path.join("**", "*.rb")).each do |file|
100
+ require_dependency file
101
+ rescue LoadError, StandardError => e
102
+ Rails.logger.error("[RubyLLM::Agents] Failed to load file #{file}: #{e.message}")
103
+ end
95
104
  end
96
105
  end
97
106
 
@@ -125,8 +134,11 @@ module RubyLLM
125
134
  agent_class = find(agent_type)
126
135
  stats = fetch_stats(agent_type)
127
136
 
137
+ # Detect the agent type (agent, workflow, embedder, moderator, speaker, transcriber)
138
+ detected_type = detect_agent_type(agent_class)
139
+
128
140
  # Check if this is a workflow class vs a regular agent
129
- is_workflow = agent_class&.ancestors&.any? { |a| a.name&.include?("Workflow") }
141
+ is_workflow = detected_type == "workflow"
130
142
 
131
143
  # Determine specific workflow type and children
132
144
  workflow_type = is_workflow ? detect_workflow_type(agent_class) : nil
@@ -136,6 +148,7 @@ module RubyLLM
136
148
  name: agent_type,
137
149
  class: agent_class,
138
150
  active: agent_class.present?,
151
+ agent_type: detected_type,
139
152
  is_workflow: is_workflow,
140
153
  workflow_type: workflow_type,
141
154
  workflow_children: workflow_children,
@@ -209,6 +222,32 @@ module RubyLLM
209
222
  end
210
223
  end
211
224
 
225
+ # Detects the agent type from class hierarchy
226
+ #
227
+ # @param agent_class [Class, nil] The agent class
228
+ # @return [String] "agent", "workflow", "embedder", "moderator", "speaker", "transcriber", or "image_generator"
229
+ def detect_agent_type(agent_class)
230
+ return "agent" unless agent_class
231
+
232
+ ancestors = agent_class.ancestors.map { |a| a.name.to_s }
233
+
234
+ if ancestors.include?("RubyLLM::Agents::Embedder")
235
+ "embedder"
236
+ elsif ancestors.include?("RubyLLM::Agents::Moderator")
237
+ "moderator"
238
+ elsif ancestors.include?("RubyLLM::Agents::Speaker")
239
+ "speaker"
240
+ elsif ancestors.include?("RubyLLM::Agents::Transcriber")
241
+ "transcriber"
242
+ elsif ancestors.include?("RubyLLM::Agents::ImageGenerator")
243
+ "image_generator"
244
+ elsif ancestors.include?("RubyLLM::Agents::Workflow")
245
+ "workflow"
246
+ else
247
+ "agent"
248
+ end
249
+ end
250
+
212
251
  # Extracts child agents from workflow DSL configuration
213
252
  #
214
253
  # @param agent_class [Class, nil] The workflow class